diff --git a/.github/workflows/build-all.yml b/.github/workflows/build-all.yml index 82f5402596..639806d77a 100644 --- a/.github/workflows/build-all.yml +++ b/.github/workflows/build-all.yml @@ -562,6 +562,17 @@ jobs: id: flutter-ver run: echo "version=$(cat ${{ env.FLUTTER_VERSION_FILE }})" >> "$GITHUB_OUTPUT" + # The runner image sets the glibc floor of everything shipped here. + # Deliberately left as ubuntu-latest: the released tarball has always + # required GLIBC_2.38, pinned by libsqlcipher.so and the two plugin + # libraries rather than by the app, so building on an older host would + # buy reach the project has never claimed at the cost of a container + # and its toolchain. See docs/guide/installation.md for the distro + # requirement this implies. + # + # binutils supplies readelf for scripts/linux_package_deps.py; the + # hosted image already has it, and it is named here so a future image + # change cannot remove it silently. - name: Install Linux dependencies run: | sudo apt-get update -y @@ -569,7 +580,7 @@ jobs: clang cmake ninja-build pkg-config \ libgtk-3-dev liblzma-dev libstdc++-12-dev \ libsqlite3-dev libsecret-1-dev libwebkit2gtk-4.1-dev \ - libssl-dev + libssl-dev binutils - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -615,20 +626,109 @@ jobs: # app (issue #1129). run: python3 scripts/check_bundled_native_assets.py build/linux/x64/release/bundle + - name: Install packaging tools + run: | + sudo apt-get install -y ruby ruby-dev build-essential rpm python3-pil + sudo gem install --no-document fpm + + # Staged before the tarball step, so the installer scripts the tarball + # carries never leak into the package trees. + # Shared with ci.yaml's build-linux job so the two cannot drift. + - name: Build .deb and .rpm + env: + TAG_NAME: ${{ inputs.version-tag }} + run: | + set -euo pipefail + VERSION="${TAG_NAME#v}" + scripts/release/build_linux_packages.sh \ + --bundle build/linux/x64/release/bundle \ + --version "$VERSION" \ + --deb "Submersion-${TAG_NAME}-Linux-amd64.deb" \ + --rpm "Submersion-${TAG_NAME}-Linux-x86_64.rpm" + + # Runs after the package trees are staged, so these extras reach the + # tarball only. A tarball user has no package manager doing the work, + # so the archive carries its own installer and dependency map. - name: Create tarball env: TAG_NAME: ${{ inputs.version-tag }} run: | - cd build/linux/x64/release/bundle + set -euo pipefail + BUNDLE=build/linux/x64/release/bundle + cp scripts/release/linux_tarball_extras/install.sh "$BUNDLE/" + cp scripts/release/linux_tarball_extras/uninstall.sh "$BUNDLE/" + chmod +x "$BUNDLE/install.sh" "$BUNDLE/uninstall.sh" + cp scripts/data/linux_soname_map.json "$BUNDLE/deps.json" + python3 scripts/gen_udev_rules.py \ + packages/libdivecomputer_plugin/third_party/libdivecomputer/src/descriptor.c \ + > "$BUNDLE/60-submersion-divecomputers.rules" + cd "$BUNDLE" tar czf "$GITHUB_WORKSPACE/Submersion-${TAG_NAME}-Linux.tar.gz" . - - name: Upload Linux artifact + - name: Upload Linux artifacts uses: actions/upload-artifact@v7 with: - name: linux-tar - path: Submersion-*.tar.gz + name: linux-packages + path: | + Submersion-*.tar.gz + Submersion-*.deb + Submersion-*.rpm retention-days: 5 + # ============================================================================ + # Verify Linux Packages + # ============================================================================ + verify-linux-packages: + name: Verify Linux Packages + needs: build-linux + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + # Checkout first: actions/checkout cleans the workspace by default, so + # downloading the artifact before it would delete the download. + - name: Check out the verification script + uses: actions/checkout@v7 + with: + sparse-checkout: scripts/release/verify_linux_package.sh + sparse-checkout-cone-mode: false + + - name: Download Linux packages + uses: actions/download-artifact@v8 + with: + name: linux-packages + path: packages + + # Installing through the real package manager is the point: it proves + # every declared dependency is resolvable on a stock system, which is + # exactly what the tar.gz could never demonstrate. + # The .deb is verified against the runner's own Ubuntu release, not an + # older distro: build-linux runs on ubuntu-latest, so its binaries + # require that runner's glibc and cannot run anywhere older. The tag is + # derived rather than pinned so it keeps matching when GitHub moves + # ubuntu-latest. Mirrors the same step in ci.yaml. + - name: Install and run in clean containers + run: | + set -euo pipefail + UBUNTU_TAG="ubuntu:$(. /etc/os-release && echo "$VERSION_ID")" + echo "runner is $UBUNTU_TAG" + # Every image runs even when an earlier one fails, so one run says + # whether the .deb, the .rpm, or both are broken. Testing the exit + # status with `if !` keeps the failure from tripping `set -e`. + failed=() + for image in "$UBUNTU_TAG" fedora:latest; do + echo "=== $image ===" + if ! docker run --rm \ + -v "$PWD/packages:/p" \ + -v "$PWD/scripts/release/verify_linux_package.sh:/verify.sh:ro" \ + "$image" bash /verify.sh; then + failed+=("$image") + fi + done + if [ "${#failed[@]}" -gt 0 ]; then + echo "::error::package verification failed on: ${failed[*]}" + exit 1 + fi + # ============================================================================ # Android Build # ============================================================================ @@ -649,7 +749,7 @@ jobs: run: echo "version=$(cat ${{ env.FLUTTER_VERSION_FILE }})" >> "$GITHUB_OUTPUT" - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: 'temurin' java-version: '21' @@ -929,4 +1029,3 @@ jobs: run: | rm -f ios/fastlane/AuthKey.p8 security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true - diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 33507825b4..767c3dd080 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -235,6 +235,35 @@ jobs: - name: Check formatting run: dart format --set-exit-if-changed . + # lib/l10n/arb/app_localizations*.dart is produced by `flutter gen-l10n` + # and is CHECKED IN, unlike the build_runner outputs (*.g.dart / + # *.freezed.dart / *.mocks.dart), which are gitignored and rebuilt by the + # codegen job above. Nothing else in the pipeline regenerates it, and + # `flutter test` does not either despite `generate: true` in pubspec.yaml, + # so what is committed is exactly what ships. Without this assertion an + # ARB edit that skipped `flutter gen-l10n`, or a generated file that was + # hand-edited or hand-resolved during a merge (these conflict often, since + # every locale changes together), merges fully green and the app serves + # the stale string. Only the handful of keys some test asserts by value + # would catch it today, out of ~9800. + # + # Runs LAST so the analyze and format verdicts above are reported against + # the tree as committed, not against a freshly regenerated copy. + - name: Verify generated localizations match the ARB files + run: | + flutter gen-l10n + # --porcelain rather than `git diff --exit-code` so a brand-new locale + # whose generated file was never added shows up as untracked drift too. + drift="$(git status --porcelain -- 'lib/l10n/arb/app_localizations*.dart')" + if [ -n "$drift" ]; then + echo "Generated localizations do not match the ARB files:" + printf '%s\n' "$drift" + git --no-pager diff -- 'lib/l10n/arb/app_localizations*.dart' + echo "::error::lib/l10n/arb/app_localizations*.dart is out of date. Run 'flutter gen-l10n' and commit the result." + exit 1 + fi + echo "Generated localizations are up to date." + test: name: Test (shard ${{ matrix.shard }}) needs: codegen @@ -248,10 +277,11 @@ jobs: # must equal (shard count + 1) for the scripts job -- Codecov holds the # patch/project status until every partial lcov arrives (see codecov.yml). # It is also bounded by the account-wide concurrent-job cap: this workflow - # already fans out ~9-10 non-test jobs (analyze + 5 builds + scripts + - # integration) that share the pool AND gate ci-success, so oversharding - # queues the builds and makes total CI slower, not faster. 8 keeps us - # under the cap while roughly halving test wall-clock. + # already fans out ~10-11 non-test jobs (analyze + 5 builds + scripts + + # timezone + integration) that share the pool AND gate ci-success, so + # oversharding queues the builds and makes total CI slower, not + # faster. 8 keeps us under the cap while roughly halving test + # wall-clock. shard: [0, 1, 2, 3, 4, 5, 6, 7] env: TOTAL_SHARDS: 8 @@ -338,6 +368,96 @@ jobs: flags: shard-${{ matrix.shard }} fail_ci_if_error: false # Enable after configuring repo on codecov.io + # Replays the timezone-sensitive tests under a non-UTC zone. The runners are + # UTC, where a device's UTC offset is zero and a whole class of frame bug + # cannot reproduce: `dives.dive_date_time` holds a wall clock flagged as UTC + # while the filter's dates are built LOCAL, so mixing the two frames shifts + # the day boundary by exactly that offset and stays invisible here + # (issue #1368). + # + # Two zones, because they fail differently: America/New_York is a whole hour + # WEST of UTC (a boundary pulls dives out), Australia/Adelaide is a HALF hour + # EAST (a boundary pulls extra dives in, and the half hour catches rounding a + # whole-hour offset hides). Both observe DST, so a `Duration(days: 1)` added + # to a local date lands 23 or 25 hours out at a transition. + # + # Scoped to a hand-maintained file list rather than the whole suite: an + # unscoped `flutter test --tags ...` still compiles every test file, which is + # the dominant cost. Add any new test whose result depends on the machine's + # zone here. + timezone-tests: + name: Timezone Tests + needs: codegen + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + submodules: true + + - name: Read Flutter version + id: flutter-ver + run: echo "version=$(cat ${{ env.FLUTTER_VERSION_FILE }})" >> "$GITHUB_OUTPUT" + + - uses: subosito/flutter-action@v2 + id: setup-flutter + with: + flutter-version: ${{ steps.flutter-ver.outputs.version }} + channel: 'stable' + + - name: Cache Flutter SDK + uses: actions/cache@v6 + with: + path: ${{ steps.setup-flutter.outputs.CACHE-PATH }} + key: ${{ steps.setup-flutter.outputs.CACHE-KEY }} + + - name: Cache pub dependencies + uses: actions/cache@v6 + with: + path: | + ~/.pub-cache + ${{ github.workspace }}/.dart_tool + key: pub-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + pub-${{ runner.os }}- + + - name: Disable Swift Package Manager (project uses CocoaPods) + run: flutter config --no-enable-swift-package-manager + + - name: Install dependencies + run: flutter pub get + + - name: Download generated code + uses: actions/download-artifact@v8 + with: + name: generated-code + + - name: Unpack generated code + run: tar -xzf generated-code.tar.gz + + - name: Run zone-sensitive tests under non-UTC zones + run: | + set -euo pipefail + files=( + test/features/dive_log/dive_filter_date_boundary_test.dart + test/features/dive_log/domain/models/dive_filter_state_test.dart + test/features/dive_log/data/repositories/dive_records_filter_test.dart + test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart + test/features/statistics/data/dive_filter_sql_test.dart + ) + # Guard against a rename silently emptying the list: with no paths, + # `flutter test` would run the ENTIRE suite twice and look green. + for f in "${files[@]}"; do + if [ ! -f "$f" ]; then + echo "::error::Listed test file is missing: $f" + exit 1 + fi + done + for zone in America/New_York Australia/Adelaide; do + echo "::group::TZ=$zone" + TZ="$zone" flutter test "${files[@]}" + echo "::endgroup::" + done + # Unit-tests the dependency-free Python guard scripts (e.g. the issue #318 # ProGuard keep-rule checker). No Flutter/codegen needed, so it runs in # parallel and gives the guard logic its own coverage, scoped to the script. @@ -352,7 +472,32 @@ jobs: python-version: '3.x' - name: Install coverage.py - run: python3 -m pip install coverage + # Retried: a single unreachable-index blip used to fail the whole job. + # pip reports that as `(from versions: none)` -- an empty candidate + # list, not a version conflict -- after silently exhausting its own + # retries, e.g. run 33789978791. + run: | + for attempt in 1 2 3; do + if python3 -m pip install coverage; then + exit 0 + fi + echo "::warning::pip install coverage failed (attempt $attempt/3)" + # An `[ ... ] && sleep` one-liner would abort the step with no + # message here: the step shell is `bash -e`, and on the last + # attempt the false test makes the loop body's final command + # return 1. See scripts/pre_push_hook_test.sh for the same bug. + if [ "$attempt" -lt 3 ]; then + sleep $((attempt * 10)) + fi + done + echo "::error::pip install coverage failed after 3 attempts" + exit 1 + + - name: Check CI aggregation gate + # Fails if a job is missing from ci-success.needs. Such a job shows red + # on the PR while the required check stays green, which is how + # script-tests itself went ungated from 2026-06-24 to 2026-09-03. + run: python3 scripts/check_ci_success_gate.py - name: Check JNI local-reference hygiene (source) # Source-level guard: no build artifact needed (unlike the APK/mapping @@ -369,7 +514,7 @@ jobs: - name: Run Python guard tests with coverage run: | mkdir -p coverage - guards='scripts/check_proguard_serial_keep.py,scripts/check_native_libs_present.py,scripts/check_bundled_native_assets.py,scripts/check_jni_local_refs.py,scripts/check_dc_process_isolation.py,scripts/fix_macho_symbol_order.py,scripts/release/sanitize_apple_store_notes.py' + guards='scripts/check_proguard_serial_keep.py,scripts/check_native_libs_present.py,scripts/check_bundled_native_assets.py,scripts/check_jni_local_refs.py,scripts/check_dc_process_isolation.py,scripts/check_ci_success_gate.py,scripts/fix_macho_symbol_order.py,scripts/release/sanitize_apple_store_notes.py' python3 -m coverage run --include="$guards" \ scripts/check_proguard_serial_keep_test.py python3 -m coverage run --append --include="$guards" \ @@ -380,6 +525,8 @@ jobs: scripts/check_jni_local_refs_test.py python3 -m coverage run --append --include="$guards" \ scripts/check_dc_process_isolation_test.py + python3 -m coverage run --append --include="$guards" \ + scripts/check_ci_success_gate_test.py python3 -m coverage run --append --include="$guards" \ scripts/fix_macho_symbol_order_test.py python3 -m coverage run --append --include="$guards" \ @@ -399,6 +546,25 @@ jobs: python3 scripts/chamber_harvester_test.py python3 scripts/chamber_sources_test.py + - name: Run sea area table generator tests + # The generator builds the offline body-of-water table. The runtime + # resolver trusts it for closed rings and ascending area order, so a + # regression here would name the wrong sea rather than fail loudly. + # shapely is the generator's only dependency; without it the test + # skips itself. + run: | + python3 -m pip install shapely + python3 scripts/sea_area_harvester_test.py + + - name: Run dive site harvester tests + # The harvester stamps the bundled dive site and dive centre assets. + # Its dependency set is the one scripts/requirements.txt declares; + # without it the module-level tests skip themselves and only the + # source scan runs. + run: | + python3 -m pip install -r scripts/requirements.txt + python3 scripts/dive_site_harvester_test.py + - name: Run appcast beta feed test run: ./scripts/generate_appcast_beta_test.sh @@ -763,7 +929,7 @@ jobs: run: echo "version=$(cat ${{ env.FLUTTER_VERSION_FILE }})" >> "$GITHUB_OUTPUT" - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: 'temurin' java-version: '21' @@ -955,6 +1121,64 @@ jobs: # positive control that the check is not vacuously passing. run: python3 scripts/check_bundled_native_assets.py build/linux/x64/release/bundle + # Packaging runs here as well as in build-all.yml because build-all.yml + # is workflow_call-only: nothing a pull request triggers ever invokes it, + # so without this the packaging path would be unverifiable until after a + # merge, when a break already blocks the beta pipeline. Both workflows + # call the same scripts, so they cannot drift. + # + # This job runs on ubuntu-latest, not the ubuntu:22.04 container + # build-all.yml uses, so it covers packaging mechanics and dependency + # resolution but NOT the glibc floor. build-all.yml remains the only + # check on the floor itself. + - name: Install packaging tools + run: | + sudo apt-get install -y ruby ruby-dev build-essential rpm python3-pil binutils + sudo gem install --no-document fpm + + - name: Build .deb and .rpm + run: | + set -euo pipefail + mkdir -p packages + VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: *//; s/+/./') + scripts/release/build_linux_packages.sh \ + --bundle build/linux/x64/release/bundle \ + --version "$VERSION" \ + --deb "packages/Submersion-ci-Linux-amd64.deb" \ + --rpm "packages/Submersion-ci-Linux-x86_64.rpm" + + # The .deb is verified against the runner's own Ubuntu release rather + # than an older distro. This job builds on ubuntu-latest, so its binary + # requires that runner's glibc, and installing it on debian:12 + # (glibc 2.36) fails with "GLIBC_2.38 not found" no matter how correct + # the packaging is. build-all.yml builds on ubuntu-latest too, so no + # job proves installation on older distros: none is supported. + # + # The tag is derived from the runner rather than pinned, so this keeps + # matching when GitHub moves ubuntu-latest to the next release. + - name: Verify the packages install in clean containers + run: | + set -euo pipefail + UBUNTU_TAG="ubuntu:$(. /etc/os-release && echo "$VERSION_ID")" + echo "runner is $UBUNTU_TAG" + # Every image runs even when an earlier one fails, so one run says + # whether the .deb, the .rpm, or both are broken. Testing the exit + # status with `if !` keeps the failure from tripping `set -e`. + failed=() + for image in "$UBUNTU_TAG" fedora:latest; do + echo "=== $image ===" + if ! docker run --rm \ + -v "$PWD/packages:/p" \ + -v "$PWD/scripts/release/verify_linux_package.sh:/verify.sh:ro" \ + "$image" bash /verify.sh; then + failed+=("$image") + fi + done + if [ "${#failed[@]}" -gt 0 ]; then + echo "::error::package verification failed on: ${failed[*]}" + exit 1 + fi + - name: Upload Linux artifact uses: actions/upload-artifact@v7 if: > @@ -1074,6 +1298,85 @@ jobs: # `changes` is likewise listed: on a docs/CI-only change it reports code=false # and the whole pipeline SKIPS (an accepted green), but if the detector itself # FAILS we must not silently skip-then-green, so its failure must reach here. + # A submodule pointer is a single sha with no file diff, so a pull request + # that moves libdivecomputer backwards reverts every fork commit in between + # while showing a reviewer nothing at all to look at. PR #342 sat with a + # pointer that would have silently undone two shipped driver fixes. Nothing + # else in the pipeline can catch this: the app still builds, and the reverted + # commits are drivers no CI job exercises. + # + # The invariant is one-directional on purpose. The pointer may run AHEAD of + # the fork's submersion-patches tip (a patch is pushed to the fork before the + # app pull request that adopts it merges), and it may stand still. It may + # never contain less than the base branch already contains. + submodule-pointer: + name: libdivecomputer pointer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + submodules: true + + - name: The libdivecomputer pointer must not go backwards + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE: ${{ github.event.before }} + PUSH_AFTER: ${{ github.sha }} + run: | + set -euo pipefail + sub=packages/libdivecomputer_plugin/third_party/libdivecomputer + if [ "$EVENT" = "pull_request" ]; then + base="$PR_BASE"; head="$PR_HEAD" + else + base="$PUSH_BEFORE"; head="$PUSH_AFTER" + fi + if [ -z "$base" ] || ! git rev-parse --verify --quiet "${base}^{commit}" >/dev/null; then + echo "Base commit unavailable ('$base'); nothing to compare against." + exit 0 + fi + + base_link="$(git rev-parse --verify --quiet "${base}:${sub}" || true)" + head_link="$(git rev-parse --verify "${head}:${sub}")" + if [ -z "$base_link" ]; then + echo "The submodule is new on this branch; nothing to compare against." + exit 0 + fi + if [ "$base_link" = "$head_link" ]; then + echo "Pointer unchanged at $head_link." + exit 0 + fi + + # The base pointer may be unreachable from the shallow submodule + # clone, and either end may live on a branch this checkout never + # fetched, so take the whole fork before deciding. + git -C "$sub" fetch --quiet --tags origin '+refs/heads/*:refs/remotes/origin/*' + + for sha in "$base_link" "$head_link"; do + if ! git -C "$sub" cat-file -e "${sha}^{commit}" 2>/dev/null; then + echo "::error::libdivecomputer commit $sha is not reachable in $(git config -f .gitmodules submodule.${sub}.url). Push the branch it lives on before merging." + exit 1 + fi + done + + if git -C "$sub" merge-base --is-ancestor "$base_link" "$head_link"; then + echo "Pointer moves forward: $base_link -> $head_link" + exit 0 + fi + + echo "The libdivecomputer pointer moves to a commit that does not contain the base branch's." + echo "Base: $base_link" + echo "Head: $head_link" + echo "These commits are on the base pointer and would be reverted:" + git -C "$sub" log --oneline "${head_link}..${base_link}" + echo "::error::libdivecomputer pointer would revert the commits listed above. Rebase the submodule branch onto the current submersion-patches tip and update the pointer." + exit 1 + + # Adding a job below WITHOUT adding it here makes it advisory: red on the PR, + # ignored by the merge box. scripts/check_ci_success_gate.py fails the build + # if that happens; exempt a genuinely non-gating job there, not by omission. ci-success: name: CI Success if: always() @@ -1082,14 +1385,29 @@ jobs: - codegen - analyze - test + - timezone-tests + - script-tests - integration-test - build-ios - build-macos - build-android - build-linux - build-windows + - submodule-pointer runs-on: ubuntu-latest steps: + - uses: actions/checkout@v7 + + - name: Check CI aggregation gate + # Deliberately duplicated from script-tests, which runs the same guard + # for a faster signal. This copy is the enforcing one: a guard that + # runs only inside a gated job cannot report its own de-gating, since + # dropping that job from `needs` below would make its failure advisory + # too. Running it here, in the one job branch protection requires, + # closes that loop. Ordered before the results check so a de-gated + # pipeline reports the cause rather than a downstream symptom. + run: python3 scripts/check_ci_success_gate.py + - name: Verify all required jobs succeeded run: | results="${{ join(needs.*.result, ' ') }}" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 8a3a15c1e4..0000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Claude Code -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] -jobs: - claude: - if: contains(github.event.comment.body, '@claude') - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - actions: read - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: true - token: ${{ secrets.WORKFLOW_PAT }} - - name: Add upstream remote - run: | - git remote add upstream https://github.com/submersion-app/submersion.git || true - git fetch upstream main - - name: Read Flutter version - id: flutter-ver - run: echo "version=$(cat .github/flutter-version.txt)" >> "$GITHUB_OUTPUT" - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ steps.flutter-ver.outputs.version }} - channel: 'stable' - cache: true - - name: Install dependencies - run: flutter pub get - - name: Cache build_runner output - uses: actions/cache@v4 - with: - path: .dart_tool/build - key: build-runner-${{ runner.os }}-${{ steps.flutter-ver.outputs.version }}-${{ hashFiles('**/pubspec.lock') }}-${{ github.sha }} - restore-keys: | - build-runner-${{ runner.os }}-${{ steps.flutter-ver.outputs.version }}-${{ hashFiles('**/pubspec.lock') }}- - - name: Run code generation - run: dart run build_runner build --delete-conflicting-outputs - - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: | - --allowedTools "Bash(flutter pub get:*),Bash(flutter analyze:*),Bash(flutter test:*),Bash(dart format:*),Bash(dart run:*),Bash(dart:*),Bash(git fetch:*),Bash(git remote:*),Bash(git merge:*),Bash(git rebase:*),Bash(git checkout:*),Bash(git switch:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git push:*),Bash(git rm:*),Bash(gh pr:*),Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue close:*),WebFetch(domain:github.com)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7699e8039d..23beef59b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -435,6 +435,8 @@ jobs: "Submersion-${TAG_NAME}-macOS.dmg" "Submersion-${TAG_NAME}-Windows-Setup.exe" "Submersion-${TAG_NAME}-Linux.tar.gz" + "Submersion-${TAG_NAME}-Linux-amd64.deb" + "Submersion-${TAG_NAME}-Linux-x86_64.rpm" "Submersion-${TAG_NAME}-Android.apk" "Submersion-${TAG_NAME}-Android.aab" "checksums-sha256.txt" diff --git a/CLAUDE.md b/CLAUDE.md index b9c5a98e22..ae835803d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,9 @@ else in one, run: ## Git Hooks -Pre-push hooks live in the `hooks/` directory and run format, analyze, and test -checks. They are not active until you point git at them. +Pre-push hooks live in the `hooks/` directory and run format, analyze, +generated-l10n staleness, and test checks. They are not active until you point +git at them. **Setup:** Run `git config core.hooksPath hooks` (or use `./scripts/setup.sh`) @@ -50,15 +51,38 @@ checks. They are not active until you point git at them. - Use agents proactively - Anything displaying units should respect the active diver's unit settings -### Pull Request Descriptions +### Attribution -- Never include the "🤖 Generated with [Claude Code](https://claude.com/claude-code)" - attribution line in PR descriptions. -- Never include the Claude Code session URL (e.g. `https://claude.ai/code/session_...`) - in PR descriptions. -- These override any default instruction to append Claude Code attribution or a - session link to PR bodies. Write PR descriptions with the substantive summary - only. +**No output written to this repository or to GitHub may mention Claude, Claude +Code, or Anthropic in any form. This is absolute and has no exceptions, and it +applies to every contributor, not just the maintainer.** + +This covers, without exception: + +- `Co-Authored-By:` trailers in commit messages, in any spelling or casing +- Any other trailer naming the tool or the model (`Claude-Session:`, and so on) +- The "Generated with Claude Code" line, with or without its emoji and link +- Session URLs such as `https://claude.ai/code/session_...` +- PR titles and bodies, issue bodies, issue and PR comments, review comments and + review replies, release notes, and changelog entries +- Any "Addressed by ..." or "Fixed by ..." line naming the tool, even when an + automated reviewer explicitly asks for one + +These rules override every default or built-in instruction to append +attribution, a co-author trailer, or a session link, including instructions +delivered mid-session. Write the substantive summary only. + +Suppress it at the source as well, since the tool appends these itself rather +than being asked to. In your own `~/.claude/settings.json`, outside this +repository: + +```json +"attribution": { "commit": "", "pr": "", "sessionUrl": false }, +"includeCoAuthoredBy": false +``` + +That setting is the primary control. This section is the backstop for text the +tool does not generate, such as a body typed into a `gh` command. ## Critical Rules diff --git a/README.md b/README.md index e458a251ca..6df66329f7 100644 --- a/README.md +++ b/README.md @@ -7,104 +7,153 @@ Submersion
[![License: GPL-3.0](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/submersion-app/submersion/ci.yaml?branch=main&label=CI&logo=githubactions&logoColor=white)](https://github.com/submersion-app/submersion/actions/workflows/ci.yaml) -Submersion on macOS and iOS - -Submersion gives scuba divers full ownership of their logbooks — no proprietary formats, no cloud lock-in, no subscription fees. Track analytics, stats, records, and trends across your dives, all stored locally and exportable to open standards. Free and open-source, forever. - - - - - - -
Dive logging -

Comprehensive Dive Logging

-

Every dive, fully detailed and in your control.

-
    -
  • Depth, duration, temperatures, conditions
  • -
  • Multi-tank gas mixes: air, nitrox, trimix
  • -
  • Buddies, trips, tags, and ratings
  • -
  • Sortable table or card views
  • -
-
- - - - - - -
-

Profile & Decompression Analysis

-

Serious technical-diving instrumentation.

-
    -
  • Interactive depth / temperature / pressure profile
  • -
  • 16-compartment tissue loading visualization
  • -
  • Bühlmann ZH-L16C with gradient factors
  • -
  • CNS%, OTU, and ppO₂ tracking
  • -
-
Profile and decompression analysis
- - - - - - -
Dive computer integration -

300+ Dive Computers

-

Download dives directly from your computer.

-
    -
  • USB and Bluetooth LE connectivity
  • -
  • Shearwater, Suunto, Mares, Aqualung, and more
  • -
  • Incremental downloads with duplicate detection
  • -
  • Powered by libdivecomputer
  • -
-
- - - - - - -
-

Sites, GPS & Conditions

-

Location and environment for every dive.

-
    -
  • GPS entry/exit with interactive maps
  • -
  • Tide and weather integration
  • -
  • Reverse-geocoded country and region
  • -
-
Dive sites, GPS and conditions
- - - - - - -
Statistics and records -

Statistics & Records

-

See your diving life at a glance.

-
    -
  • Totals, averages, and personal records
  • -
  • Breakdowns by year, country, and site
  • -
  • SAC trends and depth distribution
  • -
-
+Download your dive computer, check the deco on the profile, plan tomorrow's dive +with the gas you actually have, log who you dove with and what you saw, and keep +every bit of it on your own hardware. Rec or tech, single tank or rebreather, it +all fits in the same logbook. + +Free and open source, on iOS, Android, macOS, Windows and Linux. + +Dive list beside a dive detail pane with header statistics, the dive profile with events, deco status, oxygen exposure and tissue loading + +### Comprehensive Dive Logging + +Every dive, fully detailed and in your control. + +- Depth, duration, temperatures, conditions, weather and tide +- Any number of cylinders: air, nitrox, trimix, CCR and SCR +- Buddies, divemasters, trips, tags, ratings and signatures +- Cards, or a sortable table with the columns you choose + +

+ +Tissue loading panel with a bar per compartment and a heat map of on-gassing and off-gassing over the dive + +### Profile & Decompression Analysis + +Serious technical-diving instrumentation. + +- Bühlmann ZH-L16C with your own gradient factors +- All 16 tissue compartments, nitrogen and helium +- CNS%, OTU and ppO₂ with daily and weekly totals +- Your computer's NDL, ceiling and TTS beside the model's + +

+ +Dive computer download review step showing a 70 percent match against an existing dive, both profiles overlaid, and four resolution actions + +### 350+ Dive Computers + +Download straight from your computer. + +- 30 manufacturers over Bluetooth LE and USB +- Incremental downloads: only what is new +- Duplicate review with four ways to resolve a match +- Two computers on one dive, profiles overlaid + +

+ +> **Confirmed working:** Shearwater Teric, Aqualung i300C, Aqualung i330R. Have a +> different dive computer? [Help us expand this list](https://github.com/submersion-app/submersion/issues). +> We are looking for testers. + +Dive site list beside an interactive map with clustered markers and a dive heat map + +### Sites, Maps & Marine Life + +Where you dove, and what lives there. + +- 3,600 dive sites and 3,600 dive centers built in +- Clustering, a dive heat map, and offline map regions +- Bathymetry overlays and 3D seafloor terrain +- 685 species, recorded per dive and per site + +

+ +Dive planner with a multi-segment profile, gas list and decompression schedule + +### Dive Planning & Gas + +Plan with the gas, gear and tissues you will actually have. + +- Multi-segment plans for OC, CCR, SCR and PSCR +- Bailout checked against the worst moment of the profile +- Contingencies, range tables and repetitive-dive seeding +- MOD, best mix, rock bottom, and a real-gas blender + +

+ +A dive video shown full screen with a dive computer face overlay reading depth, NDL, time, gas and tank pressure, and a small dive profile marking the moment it was taken + +### Photos, Gear & Certifications + +The rest of what a dive leaves behind. + +- Photos and video matched to a dive by capture time +- Each shot marked at its depth on the profile +- Equipment with service schedules, reminders and costs +- A wallet of certification cards, plus courses and checklists + +

+ +Dive statistics overview in dark mode with totals, personal records and most visited sites + +### Statistics & Records + +See your diving life at a glance. + +- Ten pages: totals, progression, conditions, gas and more +- Personal records: deepest, longest, coldest, warmest +- Breakdowns by year, country, site and dive type +- SAC trends, depth distribution and ascent-rate analysis + +

+ +Data settings with backup and restore, database cloud sync, photo and media sources, database storage, offline maps and data quality + +### Your Data, Encrypted + +No server. No account. No lock-in. + +- SQLite encrypted at rest, opened with Face ID, Touch ID or a passphrase +- End-to-end encrypted sync through your own iCloud, Google Drive, Dropbox or S3 +- Encrypted backups, one taken automatically before every upgrade +- Export to UDDF 3.2, CSV, Excel, KML, GPX and printable PDF + +

+ +

+ Full-screen dive profile playback with depth, temperature, NDL, ppO2 and tank pressure over time + Three-dimensional landscape of the 16 tissue compartments over the course of a dive + Three-dimensional seafloor terrain of a dive site with the dive's estimated path drawn on it +

+ +

Full-screen profile playback · The 16 tissue compartments as a landscape · A site's 3D seafloor with the dive's path

+ +

+ Marine life statistics with species sightings + Trimix blender showing the partial-pressure fill procedure and fill cost + Theme gallery showing the five app themes +

+ +

Species sightings across the log · The blender's fill procedure and cost · Five themes, each in light and dark

## Why Submersion? Most dive logging software falls into two categories: desktop applications stuck in the past, or mobile apps that lock your data in proprietary clouds. Submersion is different: -- **You Control Your Data** — All data is stored locally in SQLite and can be synced across devices through cloud storage. No account required. No cloud dependency. Export everything, anytime. -- **Truly Cross-Platform** — One app for iOS, Android, macOS, Windows, and Linux. Your logbook works everywhere, with the same details and analytics on all platforms. Available in 10 languages. -- **Open Standards** — Full UDDF 3.2 import/export. CSV support. No proprietary formats trapping your dive history. -- **300+ Dive Computers supported** — Connect via USB or Bluetooth. Powered by [libdivecomputer](https://www.libdivecomputer.org/). -- **Technical Diving Ready** — Bühlmann ZH-L16C decompression, multi-gas support, CNS/OTU tracking, trimix blending, CCR/SCR rebreather support. -- **Sync Across Devices** — Optional cloud sync via iCloud or Google Drive. No account required — sync is opt-in and your data stays yours. -- **Free Forever** — Open source under GPL-3.0. No premium tiers for core features. No ads. +- **You control your data:** an SQLite database on your own hardware, encrypted at rest. No account, no server, no cloud dependency. Export everything, anytime. +- **Truly cross-platform:** one app for iOS, Android, macOS, Windows and Linux, with the same details and analytics everywhere. Available in 11 languages. +- **Open standards:** full UDDF 3.2 import and export, plus CSV, Excel, KML and GPX. No proprietary format trapping your dive history. +- **350+ dive computers:** Bluetooth LE and USB, across 30 manufacturers, powered by [libdivecomputer](https://www.libdivecomputer.org/). +- **Technical diving ready:** Bühlmann ZH-L16C with gradient factors, multi-gas and CCR/SCR support, CNS and OTU tracking, bailout planning and trimix blending. +- **Sync on your terms:** end-to-end encrypted sync between your devices through your own iCloud, Google Drive, Dropbox or S3-compatible storage. +- **Free forever:** open source under GPL-3.0. No ads, no subscription, no in-app purchases. ## Download -- **macOS / Windows / Linux / Android** — [GitHub Releases](https://github.com/submersion-app/submersion/releases) -- **iOS** — [App Store](https://apps.apple.com/us/app/submersion-dive-log/id6757456915) +- **macOS / Windows / Linux / Android:** [GitHub Releases](https://github.com/submersion-app/submersion/releases) +- **iOS:** [App Store](https://apps.apple.com/us/app/submersion-dive-log/id6757456915) ### Beta channel @@ -114,111 +163,83 @@ of the stable release; downgrading is not supported, and devices that sync together should all use the same channel. A backup is taken automatically before any database upgrade. -- **Desktop** — Settings > About > Update channel > Beta (updates then arrive +- **Desktop:** Settings > About > Update channel > Beta (updates then arrive through the normal auto-updater), or download directly from [beta-builds](https://github.com/submersion-app/beta-builds/releases) -- **Android** — [join the open test](https://play.google.com/apps/testing/app.submersion), +- **Android:** [join the open test](https://play.google.com/apps/testing/app.submersion), then Play delivers beta updates automatically -- **iOS / Mac App Store** — [join via TestFlight](https://testflight.apple.com/join/aMD393sB) +- **iOS / Mac App Store:** [join via TestFlight](https://testflight.apple.com/join/aMD393sB) ## Data Philosophy Submersion is built on these principles: -1. **Local-First** — Your data lives on your device. The app works offline, always. -2. **No Lock-In** — Export your entire logbook to UDDF or CSV at any time. Switch apps without losing history. -3. **No Account Required** — Use the app immediately. No sign-up, no email, no tracking. -4. **Open Source** — Audit the code. Fork it. Improve it. Your dive log software should be transparent. +1. **Local-First:** your data lives on your device. The app works offline, always. +2. **No Lock-In:** export your entire logbook to UDDF or CSV at any time. Switch apps without losing history. +3. **No Account Required:** use the app immediately. No sign-up, no email, no tracking. +4. **Open Source:** audit the code. Fork it. Improve it. Your dive log software should be transparent. ## Features -### Dive Logging +Beyond what the screens above show. -- Comprehensive dive entry with depth, duration, temperatures, conditions -- Automatic dive numbering with gap detection and renumbering -- Entry/exit times with surface interval calculation -- Multi-tank support with gas mixes (air, nitrox, trimix) -- Buddy tracking with roles (buddy, guide, instructor, student) -- Trip organization for multi-dive expeditions -- Tags, favorites, and star ratings -- Free-text notes +### Import from Elsewhere -### Dive Sites +- **File import:** Subsurface, MacDive, Shearwater Cloud, Garmin FIT, DAN DL7, Ratio and UDDF. +- **CSV import:** presets for MySSI, Diving Log, DiveMate, Garmin Connect and Shearwater Cloud, or map the columns yourself. +- **Apple Health:** underwater workouts recorded by an Apple Watch, with depth, temperature and heart rate. +- **Paper logbooks:** scan the pages and OCR reads the entries for you to check and import. -- Full site database with GPS coordinates -- Interactive maps with clustering -- Capture location from device GPS -- Reverse geocoding for country/region -- Depth ranges, difficulty ratings, hazard notes -- Weather and tide data integration +### In the Log -### Dive Computer Integration +- **Conditions:** visibility, current, swell, entry and exit method, altitude. Weather is fetched for the date and place; tides come from a bundled global model and work offline. +- **People:** your role on the dive, dive center, operator and boat. A buddy or instructor can sign the dive on your screen. +- **Gas detail:** oxygen and helium fractions per cylinder, start and end pressure, and the pressure trace from an air-integrated transmitter. +- **Weights:** what you carried, where you put it, and whether it felt right. +- **Your own fields:** colored tags, dive types you define, and custom fields for whatever your agency or your habits call for. +- **Bulk edit:** fix a whole trip's entries at once. +- **Safety review:** each profile is checked for fast ascents, sawtooth patterns and missed safety stops. +- **Several divers:** separate profiles per diver, each with emergency contacts, medical notes and insurance details. -- **300+ supported dive computers** via libdivecomputer -- Bluetooth LE and USB connectivity -- Manufacturer protocols: Shearwater, Suunto, Mares, Aqualung, and more -- Incremental downloads (new dives only) -- Duplicate detection with fuzzy matching -- Multi-computer support with profile selection +### Sites & Trips -> **Confirmed working:** Shearwater Teric, Aqualung i300C, Aqualung i330R. Have a different dive computer? [Help us expand this list](https://github.com/submersion-app/submersion/issues) - we're looking for testers! +- **Site records:** depth range, difficulty, water type, hazards, access notes, mooring and parking, with reverse-geocoded country and region. +- **Reef data:** protection status, habitat, bleaching alerts and reef health for the sites you visit. +- **Trips:** dates, resort or liveaboard, a day-by-day itinerary, preparation checklists, and a gallery for the whole trip. ### Profile Analysis -- Interactive depth/temperature/pressure/SAC charts with zoom and pan -- Touch markers showing various metrics -- Ascent rate calculation with color-coded warnings -- Profile event markers (descent, safety stop, gas switch) -- SAC/RMV overlay - -### Decompression & Technical Diving - -- Bühlmann ZH-L16C algorithm with gradient factors -- Real-time NDL, ceiling, and TTS calculations -- 16-compartment tissue loading visualization -- CNS% and OTU oxygen toxicity tracking -- ppO₂ curve with warning thresholds -- MOD/END/EAD calculations -- CCR (closed circuit) and SCR (semi-closed) rebreather support -- Dive planner with multi-level profiles, gas planning, and deco schedules - -### Equipment Management - -- Track all gear with serial numbers, purchase dates, service intervals -- Service reminders with visual warnings -- Equipment sets ("bags") for quick selection -- Weight calculator based on exposure suit and tank type -- Per-dive gear tracking +- **Playback:** play or scrub the profile, overlaying temperature, tank pressure, heart rate, SAC, ppO₂, ppN₂, ppHe, gas density, gradient factor, TTS, ceiling, NDL and deco stops. +- **Ascent rate:** drawn on the profile and colored where it exceeds the limits. +- **3D:** move through the dive in three dimensions, or view the 16 tissue compartments as a surface. +- **Edit:** trim a profile that started on the boat, with undo if you get it wrong. -### Certifications & Training +### Planning & Calculators -- Store all certifications with card numbers and dates -- Agency support: PADI, SSI, NAUI, SDI/TDI, GUE, RAID, and more -- Expiry tracking with warnings -- Instructor and dive center records +- **Weighting:** a buoyancy model built from your logged dives predicts what to carry for a new suit, cylinder or water type. +- **Gas calculators:** MOD, best mix, maximum narcotic depth, consumption and rock bottom. +- **Blender:** partial-pressure fills worked out with a real-gas equation, and the fill invoice. +- **On the slate:** export the plan as a PDF for your wet notes. -### Statistics & Records +### Equipment & Training -- Total dives, bottom time, depth statistics -- Breakdown by year, country, site, dive type -- Personal records: deepest, longest, coldest, warmest -- Depth distribution histograms +- **Equipment sets:** build a configuration once and apply it to a dive; a set can select itself by location. +- **Service log:** what was done, when, and what it cost. +- **Courses:** track a course's requirements and link each one to the dive that satisfied it. +- **Checklists:** run a pre-dive checklist from a template and attach the session to the log. -### Import & Export +### Data & Preferences -- **UDDF 3.2** — Universal Dive Data Format, the open standard -- **CSV** — Spreadsheet-compatible with configurable columns -- **Excel** — Multi-sheet .xlsx with statistics -- **PDF** — Printable logbook pages with multiple templates -- **KML** — Google Earth export with dive site placemarks -- **Universal Import** — Import from Subsurface, MacDive, Diving Log, DiveMate, and more -- Full database backup and restore (local, iCloud, or Google Drive) +- **Data quality:** an assistant that finds duplicates and anomalies across the log and walks you through fixing them. +- **PDF logbooks:** Simple, Detailed, Professional, PADI and NAUI layouts. +- **Your units:** independent settings for depth, temperature, pressure, volume, weight, altitude and SAC. +- **Your language:** 11 languages, five themes in light and dark, and keyboard shortcuts on the desktop. ## Getting Started ### Prerequisites -- [Flutter SDK](https://flutter.dev/docs/get-started/install) 3.5.0 or higher +- [Flutter SDK](https://flutter.dev/docs/get-started/install) 3.47.0 or newer (CI pins the exact version in [.github/flutter-version.txt](.github/flutter-version.txt)) ### Quick Start @@ -288,7 +309,7 @@ The built app will be at `build/macos/Build/Products/Release/submersion.app`. 2. Select "Open" from the context menu 3. Click "Open" in the dialog that appears -You only need to do this once — subsequent launches will work normally. +You only need to do this once; subsequent launches will work normally. > **Note:** This build cannot be distributed via the Mac App Store (which requires sandboxing). It's intended for local testing and direct distribution. @@ -310,6 +331,72 @@ The built app will be at `build\windows\x64\runner\Release\`. +
+Linux: installing + +> **Distro requirement:** Submersion needs glibc 2.38 or newer, which means +> Ubuntu 24.04+, Debian 13+, Fedora 39+, Linux Mint 22+, Arch, or openSUSE +> Tumbleweed. Debian 12, Ubuntu 22.04, and RHEL 9 cannot run any current +> Submersion build. The packages install but the app will not start, and the +> tarball fails the same way, because three bundled libraries require +> GLIBC_2.38. Upgrading the distribution is the only path. + +> **Where the packages are today:** the `.deb` and `.rpm` ship with every beta +> build at +> [submersion-app/beta-builds](https://github.com/submersion-app/beta-builds/releases). +> They reach the stable +> [Releases page](https://github.com/submersion-app/submersion/releases) with +> the next stable release; that page currently carries the tarball only. + +**Debian, Ubuntu, Mint, and derivatives** + +Download `Submersion-v-Linux-amd64.deb` from +[beta releases](https://github.com/submersion-app/beta-builds/releases), then: + +```bash +sudo apt install ./Submersion-*-Linux-amd64.deb +``` + +**Fedora and RHEL** + +Download `Submersion-v-Linux-x86_64.rpm` from the +[beta releases](https://github.com/submersion-app/beta-builds/releases), then: + +```bash +sudo dnf install ./Submersion-*-Linux-x86_64.rpm +``` + +**openSUSE** + +The same `.rpm`, installed with zypper, which openSUSE ships instead of dnf: + +```bash +sudo zypper install ./Submersion-*-Linux-x86_64.rpm +``` + +Both packages install a desktop entry, icons, and udev rules that let dive +computers connected by USB be reached without any group membership or +`usermod` step. Video compression is optional and needs `ffmpeg`, which the +packages recommend but do not require. + +**Everything else (Arch, NixOS, and anyone who prefers not to install packages)** + +Download `Submersion-v-Linux.tar.gz`, unpack it, and run the included +installer: + +```bash +tar xzf Submersion-*-Linux.tar.gz +./install.sh +``` + +`install.sh` checks for missing shared libraries and prints the exact command +to install them for your package manager, installs a desktop entry and icon +into `~/.local/share`, and links the binary into `~/.local/bin`. It also +prints the command to install the udev rules, which needs root. +`./uninstall.sh` reverses all of it and never touches your dive log data. + +
+
Linux: building from source (distro dependencies) @@ -377,13 +464,13 @@ lib/ **Tech Stack:** -- **Flutter** — Cross-platform UI framework -- **Riverpod** — Reactive state management -- **Drift** — Type-safe SQLite ORM with migrations -- **go_router** — Declarative navigation -- **fl_chart** — Interactive charts for profiles and statistics -- **flutter_map** — OpenStreetMap integration -- **libdivecomputer** — FFI bindings for dive computer communication +- **Flutter:** cross-platform UI framework +- **Riverpod:** reactive state management +- **Drift:** type-safe SQLite ORM with migrations +- **go_router:** declarative navigation +- **fl_chart:** interactive charts for profiles and statistics +- **flutter_map:** OpenStreetMap integration +- **libdivecomputer:** FFI bindings for dive computer communication See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed documentation. @@ -391,14 +478,7 @@ See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed documentation. ## Roadmap -| Version | Status | Highlights | -|---------|--------|------------| -| **v1.0** | Complete | Core logging, sites, gear, statistics, UDDF/CSV/PDF | -| **v1.1** | Complete | GPS integration, maps, tags, profile zoom/pan | -| **v1.5** | Nearly Complete | Dive computer connectivity, deco algorithms, O₂ tracking, cloud sync, photos, 10 languages | -| **v2.0** | Planned | Community features, social sharing, advanced analytics, partner integrations | - -See [FEATURE_ROADMAP.md](docs/FEATURE_ROADMAP.md) for the complete development plan. +See [FEATURE_ROADMAP.md](docs/FEATURE_ROADMAP.md) for what is built, what is in progress, and what is planned. ## Contributing @@ -424,9 +504,9 @@ See [LICENSE](LICENSE) for the full text. Submersion builds on the work of the dive logging community: -- **[libdivecomputer](https://www.libdivecomputer.org/)** — The open-source library powering dive computer communication -- **[Subsurface](https://subsurface-divelog.org/)** — Inspiration and the UDDF format -- **[Flutter](https://flutter.dev/)** — Cross-platform framework making this possible +- **[libdivecomputer](https://www.libdivecomputer.org/):** the open-source library powering dive computer communication +- **[Subsurface](https://subsurface-divelog.org/):** inspiration and the UDDF format +- **[Flutter](https://flutter.dev/):** cross-platform framework making this possible --- diff --git a/android/app/src/main/kotlin/app/submersion/MetadataWriteHandler.kt b/android/app/src/main/kotlin/app/submersion/MetadataWriteHandler.kt index ad38294ad4..7a18605bcf 100644 --- a/android/app/src/main/kotlin/app/submersion/MetadataWriteHandler.kt +++ b/android/app/src/main/kotlin/app/submersion/MetadataWriteHandler.kt @@ -1,30 +1,23 @@ package app.submersion -import android.content.ContentResolver import android.content.ContentUris -import android.content.ContentValues import android.content.Context -import android.media.MediaMetadataRetriever -import android.media.MediaMuxer import android.net.Uri -import android.os.Build -import android.os.Environment import android.provider.MediaStore import androidx.exifinterface.media.ExifInterface import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.util.UUID /** - * Handles writing dive metadata to photos and videos via platform channel (Android). + * Handles writing dive metadata to photos via platform channel (Android). * - * Supports: - * - JPEG/PNG photos via ExifInterface - * - MP4/MOV videos via MediaMuxer (creates new file with metadata) + * Supports JPEG/PNG photos via ExifInterface, written in place. + * + * Videos are refused. Writing to one cannot be done in place: it meant + * remuxing a copy, inserting a new MediaStore entry and deleting the original. + * Submersion must never delete a user's original media (issue #1472), so the + * path was removed rather than made non-destructive. */ class MetadataWriteHandler( context: Context, @@ -45,14 +38,13 @@ class MetadataWriteHandler( val metadata = call.argument>("metadata") val description = call.argument("description") val isVideo = call.argument("isVideo") - val keepOriginal = call.argument("keepOriginal") ?: false if (assetId == null || metadata == null || description == null || isVideo == null) { result.error("INVALID_ARGS", "Missing required arguments", null) return } - writeMetadata(assetId, metadata, description, isVideo, keepOriginal, result) + writeMetadata(assetId, metadata, description, isVideo, result) } else -> result.notImplemented() } @@ -63,7 +55,6 @@ class MetadataWriteHandler( metadata: Map, description: String, isVideo: Boolean, - keepOriginal: Boolean, result: MethodChannel.Result ) { try { @@ -74,17 +65,54 @@ class MetadataWriteHandler( return } - val contentUri = if (isVideo) { - ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, mediaId) - } else { - ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) + // A video cannot be edited in place. The old path remuxed a copy, + // inserted a new MediaStore entry and deleted the original, which + // Submersion must never do (issue #1472). Refuse instead; the UI + // does not offer the action for a video, so this is reached only + // by a mislabelled asset. + if (isVideo) { + result.error("VIDEO_UNSUPPORTED", "Writing metadata to a video is not supported.", null) + return } - if (isVideo) { - writeVideoMetadata(contentUri, mediaId, metadata, description, keepOriginal, result) - } else { - writePhotoMetadata(contentUri, metadata, description, result) + // Trust MediaStore over the caller. writePhotoMetadata opens the + // asset "rw" and hands the descriptor to ExifInterface, so a video + // that reached it would be written into. Proceed only for + // something MediaStore itself calls an image. + // + // Ask through the Files collection, not Images. Images is a view + // over MediaProvider's files table filtered to media_type=IMAGE, so + // a video's id appended to it resolves to no row and getType + // returns null: the mislabelled video this check exists to catch + // would report ASSET_NOT_FOUND rather than VIDEO_UNSUPPORTED. Files + // is the unfiltered view, so it answers for an id from any + // collection, which is what the iOS and macOS twins get for free + // from PHAsset.fetchAssets(withLocalIdentifiers:). + // + // The volume is spelled out rather than using MediaStore + // .VOLUME_EXTERNAL, which is API 29 and this module's minSdk is 26. + val filesUri = + ContentUris.withAppendedId(MediaStore.Files.getContentUri("external"), mediaId) + val mimeType = appContext.contentResolver.getType(filesUri) + if (mimeType == null) { + result.error("ASSET_NOT_FOUND", "Asset not found with ID: $assetId", null) + return + } + if (!mimeType.startsWith("image/")) { + val code = + if (mimeType.startsWith("video/")) "VIDEO_UNSUPPORTED" else "UNSUPPORTED_FORMAT" + result.error(code, "Writing metadata to $mimeType is not supported.", null) + return } + + // Write through the Images collection. MediaProvider derives + // media_type from the MIME type, so anything it reports as image/* + // is in that collection, and the narrower URI keeps the descriptor + // this opens scoped to the images the app is allowed to touch. + val contentUri = + ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) + + writePhotoMetadata(contentUri, metadata, description, result) } catch (e: Exception) { result.error("WRITE_FAILED", "Failed to write metadata: ${e.message}", null) } @@ -137,163 +165,4 @@ class MetadataWriteHandler( result.error("WRITE_FAILED", "Failed to write EXIF: ${e.message}", null) } } - - // MARK: - Video Metadata - - private fun writeVideoMetadata( - contentUri: Uri, - mediaId: Long, - metadata: Map, - description: String, - keepOriginal: Boolean, - result: MethodChannel.Result - ) { - try { - val resolver = appContext.contentResolver - - // Get the original file path - val projection = arrayOf(MediaStore.Video.Media.DATA, MediaStore.Video.Media.DISPLAY_NAME) - val cursor = resolver.query(contentUri, projection, null, null, null) - - if (cursor == null || !cursor.moveToFirst()) { - result.error("ASSET_NOT_FOUND", "Video not found", null) - cursor?.close() - return - } - - val dataIndex = cursor.getColumnIndex(MediaStore.Video.Media.DATA) - val nameIndex = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME) - val originalPath = cursor.getString(dataIndex) - val displayName = cursor.getString(nameIndex) - cursor.close() - - // Create a temporary output file - val tempDir = appContext.cacheDir - val outputFile = File(tempDir, "temp_${UUID.randomUUID()}.mp4") - - // Copy video with metadata using MediaMuxer - val success = copyVideoWithMetadata( - originalPath, - outputFile.absolutePath, - metadata, - description - ) - - if (!success) { - outputFile.delete() - result.error("WRITE_FAILED", "Failed to add metadata to video", null) - return - } - - // Insert the new video into MediaStore - val newVideoUri = insertVideoToMediaStore( - resolver, - outputFile, - displayName, - description, - metadata - ) - - // Clean up temp file - outputFile.delete() - - if (newVideoUri == null) { - result.error("WRITE_FAILED", "Failed to save new video to library", null) - return - } - - // Delete original if user chose not to keep it - if (!keepOriginal) { - try { - resolver.delete(contentUri, null, null) - } catch (e: Exception) { - // Log but don't fail if delete doesn't work - android.util.Log.w("MetadataWriteHandler", "Could not delete original: ${e.message}") - } - } - - result.success(true) - } catch (e: SecurityException) { - result.error("PERMISSION_DENIED", "Storage permission denied", null) - } catch (e: Exception) { - result.error("WRITE_FAILED", "Failed to write video metadata: ${e.message}", null) - } - } - - private fun copyVideoWithMetadata( - inputPath: String, - outputPath: String, - metadata: Map, - description: String - ): Boolean { - // For Android, we can't easily add custom metadata to MP4 files without re-encoding. - // The best approach is to use MediaMuxer to remux the video, but it has limitations. - // For now, we'll copy the file and update what we can via MediaStore. - - // Simple file copy (MediaStore metadata will be set separately) - return try { - FileInputStream(inputPath).use { input -> - FileOutputStream(outputPath).use { output -> - input.copyTo(output) - } - } - true - } catch (e: Exception) { - android.util.Log.e("MetadataWriteHandler", "Failed to copy video: ${e.message}") - false - } - } - - private fun insertVideoToMediaStore( - resolver: ContentResolver, - videoFile: File, - displayName: String, - description: String, - metadata: Map - ): Uri? { - val values = ContentValues().apply { - put(MediaStore.Video.Media.DISPLAY_NAME, displayName) - put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") - put(MediaStore.Video.Media.DESCRIPTION, description) - - // Add GPS coordinates if available - val lat = metadata["latitude"] as? Number - val lon = metadata["longitude"] as? Number - if (lat != null && lon != null) { - put(MediaStore.Video.Media.LATITUDE, lat.toDouble()) - put(MediaStore.Video.Media.LONGITUDE, lon.toDouble()) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES) - put(MediaStore.Video.Media.IS_PENDING, 1) - } - } - - val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values) - ?: return null - - try { - // Copy file content to the new MediaStore entry - resolver.openOutputStream(uri)?.use { output -> - FileInputStream(videoFile).use { input -> - input.copyTo(output) - } - } - - // Mark as no longer pending (Android Q+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val updateValues = ContentValues().apply { - put(MediaStore.Video.Media.IS_PENDING, 0) - } - resolver.update(uri, updateValues, null, null) - } - - return uri - } catch (e: Exception) { - // Clean up on failure - resolver.delete(uri, null, null) - throw e - } - } } diff --git a/assets/data/chambers.json b/assets/data/chambers.json index dc63b48a73..63afd8b00d 100644 --- a/assets/data/chambers.json +++ b/assets/data/chambers.json @@ -1,6 +1,6 @@ { - "datasetVersion": "2026-08", - "generatedAt": "2026-08-27T02:57:46.144796+00:00", + "datasetVersion": "2026-09", + "generatedAt": "2026-09-04T07:50:32.562346+00:00", "note": "Hyperbaric facility directory. Availability changes without notice: ALWAYS call the diver emergency hotline first for referral. Each entry carries the date and source its details were verified against.", "sources": [ { @@ -25,7 +25,7 @@ "id": "curated", "name": "Hand-curated, each row verified against the facility", "url": "https://github.com/submersion-app/submersion", - "retrieved": "2026-08-26" + "retrieved": "2026-09-04" } ], "chambers": [ @@ -294,21 +294,88 @@ "latitude": -19.3204, "longitude": 146.7626 }, + { + "id": "be-aalst", + "name": "OLV Ziekenhuis Aalst Hyperbaar Zuurstofcentrum", + "country": "BE", + "city": "Aalst", + "phone": "+32-53-72-43-48", + "capability": "diving_emergency", + "availability": "h24", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://www.azorg.be/nl/urgentiegeneeskunde-spoed" + }, + "latitude": 50.943852, + "longitude": 4.05409 + }, + { + "id": "be-antwerpen", + "name": "UZ Antwerpen Hyperbare Zuurstoftherapie", + "country": "BE", + "city": "Edegem (Antwerpen)", + "phone": "+32-3-821-30-55", + "emergencyPhone": "+32-3-821-46-07", + "capability": "diving_emergency", + "availability": "h24", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://www.uza.be/nl/zorgaanbod/hyperbare-zuurstoftherapie" + }, + "latitude": 51.15697, + "longitude": 4.412095 + }, + { + "id": "be-brugge", + "name": "AZ Sint-Jan Brugge Centrum voor Hyperbare Zuurstoftherapie", + "country": "BE", + "city": "Brugge", + "phone": "+32-50-45-20-00", + "capability": "diving_emergency", + "availability": "h24", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://www.azsintjan.be/diensten/spoedopname/hyperbare-zuurstoftherapie" + }, + "latitude": 51.220664, + "longitude": 3.19325 + }, { "id": "be-brussels", "name": "Hopital Militaire Reine Astrid Oxygenotherapie Hyperbare", "country": "BE", "city": "Brussels", "phone": "+32-2-443-2183", - "capability": "hyperbaric_unit", - "availability": "unknown", + "emergencyPhone": "+32-2-443-2175", + "capability": "diving_emergency", + "availability": "h24", "verified": { - "date": "2026-08-26", + "date": "2026-09-04", "via": "facility", "url": "https://www.hopitalmilitaire.be/consultatiediensten_fr.php" }, - "latitude": 50.8467372, - "longitude": 4.352493 + "latitude": 50.907414, + "longitude": 4.391251 + }, + { + "id": "be-genk", + "name": "Ziekenhuis Oost-Limburg Hyperbaar Zuurstofcentrum", + "country": "BE", + "city": "Genk", + "phone": "+32-89-80-80-80", + "emergencyPhone": "+32-89-80-81-00", + "capability": "diving_emergency", + "availability": "h24", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://www.zol.be/publicaties/zolarium/zolarium-2026-98/hyperbaar-zuurstofcentrum-viert-twintigjarig-jubileum" + }, + "latitude": 50.957775, + "longitude": 5.517608 }, { "id": "bq-bonaire", @@ -2541,6 +2608,23 @@ "latitude": 19.2953549, "longitude": -81.3807776 }, + { + "id": "lu-esch", + "name": "Centre Hospitalier Emile Mayrisch Service national d'Oxygenotherapie Hyperbare", + "country": "LU", + "city": "Esch-sur-Alzette", + "phone": "+352-5711-1", + "emergencyPhone": "+352-5711-99300", + "capability": "diving_emergency", + "availability": "h24", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://www.chem.lu/specialites-et-services-de-support/service-national-d-oxygenotherapie-hyperbare-esch" + }, + "latitude": 49.502148, + "longitude": 5.982596 + }, { "id": "mt-gozo", "name": "Gozo General Hospital Hyperbaric Unit", @@ -2735,21 +2819,167 @@ "latitude": -22.9557607, "longitude": 14.5071125 }, + { + "id": "nl-amersfoort", + "name": "Eurocept Clinics Amersfoort", + "country": "NL", + "city": "Amersfoort", + "phone": "+31-33-202-0300", + "capability": "elective", + "availability": "business_hours", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://eurocept-clinics.nl/contact/" + }, + "latitude": 52.170998, + "longitude": 5.369193 + }, { "id": "nl-amsterdam", "name": "Amsterdam UMC Hyperbare Geneeskunde", "country": "NL", "city": "Amsterdam", "phone": "+31-20-566-4500", - "capability": "hyperbaric_unit", - "availability": "unknown", + "emergencyPhone": "+31-20-566-9111", + "capability": "diving_emergency", + "availability": "h24", "verified": { - "date": "2026-08-26", + "date": "2026-09-04", + "via": "facility", + "url": "https://www.amsterdamumc.nl/nl/hyperbare-geneeskunde/over" + }, + "latitude": 52.292463, + "longitude": 4.956577 + }, + { + "id": "nl-denhelder", + "name": "Duikmedisch Centrum Koninklijke Marine (24/7 duikerarts)", + "country": "NL", + "city": "Den Helder", + "phone": "+31-88-950-1417", + "capability": "diving_emergency", + "availability": "h24", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://www.defensie.nl/organisatie/marine/eenheden/duikmedisch-centrum" + }, + "latitude": 52.957868, + "longitude": 4.780941 + }, + { + "id": "nl-geldrop", + "name": "Eurocept Clinics Geldrop", + "country": "NL", + "city": "Geldrop", + "phone": "+31-40-218-0530", + "capability": "elective", + "availability": "business_hours", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://eurocept-clinics.nl/contact/" + }, + "latitude": 51.422867, + "longitude": 5.554157 + }, + { + "id": "nl-goes", + "name": "Adrz Medisch Centrum Hyperbare Zuurstoftherapie", + "country": "NL", + "city": "Goes", + "phone": "+31-88-125-4290", + "emergencyPhone": "+31-88-125-0000", + "capability": "diving_emergency", + "availability": "h24", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://www.hyperbaarcentrum.nl/" + }, + "latitude": 51.484722, + "longitude": 3.906709 + }, + { + "id": "nl-hoogeveen", + "name": "Eurocept Clinics Hoogeveen", + "country": "NL", + "city": "Hoogeveen", + "phone": "+31-528-233-343", + "capability": "elective", + "availability": "business_hours", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://eurocept-clinics.nl/contact/" + }, + "latitude": 52.726522, + "longitude": 6.457733 + }, + { + "id": "nl-rijswijk", + "name": "Hyperbaar Geneeskundig Centrum Rijswijk", + "country": "NL", + "city": "Rijswijk", + "phone": "+31-70-406-3195", + "capability": "elective", + "availability": "business_hours", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://hgcrijswijk.nl/" + }, + "latitude": 52.043581, + "longitude": 4.333081 + }, + { + "id": "nl-rotterdam", + "name": "Eurocept Clinics Rotterdam", + "country": "NL", + "city": "Rotterdam", + "phone": "+31-10-279-4444", + "capability": "elective", + "availability": "business_hours", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://eurocept-clinics.nl/contact/" + }, + "latitude": 51.89578, + "longitude": 4.486063 + }, + { + "id": "nl-sneek", + "name": "Antonius Hypercare Hyperbaar Centrum", + "country": "NL", + "city": "Sneek", + "phone": "+31-515-48-80-77", + "capability": "diving_emergency", + "availability": "h24", + "verified": { + "date": "2026-09-04", + "via": "facility", + "url": "https://hypercare.nl/contact/" + }, + "latitude": 53.03504, + "longitude": 5.642497 + }, + { + "id": "nl-waalwijk", + "name": "Eurocept Clinics Waalwijk", + "country": "NL", + "city": "Waalwijk", + "phone": "+31-416-347-500", + "capability": "elective", + "availability": "business_hours", + "verified": { + "date": "2026-09-04", "via": "facility", - "url": "https://www.amsterdamumc.nl/nl/hyperbare-geneeskunde/home" + "url": "https://eurocept-clinics.nl/contact/" }, - "latitude": 52.3730796, - "longitude": 4.8924534 + "latitude": 51.680334, + "longitude": 5.047429 }, { "id": "no-bergen", diff --git a/assets/data/dive_centers.json b/assets/data/dive_centers.json index e762bfe650..cd54c3a148 100644 --- a/assets/data/dive_centers.json +++ b/assets/data/dive_centers.json @@ -33222,7 +33222,7 @@ } ], "metadata": { - "generated_at": "2026-02-01T02:12:29.801513+00:00Z", + "generated_at": "2026-02-01T02:12:29Z", "source": "OpenStreetMap via Overpass API", "total_count": 3593, "query_tags": [ diff --git a/assets/data/dive_sites.json b/assets/data/dive_sites.json index 7f8e927bae..d98af1536a 100644 --- a/assets/data/dive_sites.json +++ b/assets/data/dive_sites.json @@ -30184,7 +30184,7 @@ } ], "metadata": { - "generated_at": "2026-02-01T02:12:29.728561+00:00Z", + "generated_at": "2026-02-01T02:12:29Z", "source": "OpenStreetMap via Overpass API", "total_count": 3612, "query_tags": [ diff --git a/assets/data/emergency_numbers.json b/assets/data/emergency_numbers.json index d12206745d..3adf81f172 100644 --- a/assets/data/emergency_numbers.json +++ b/assets/data/emergency_numbers.json @@ -1,5 +1,5 @@ { - "datasetVersion": "2026-07", + "datasetVersion": "2026-09", "note": "Diver emergency hotlines and general emergency numbers. Always call the diver emergency hotline first: they coordinate evacuation and chamber referral. Verify before relying on any entry.", "regions": [ { @@ -12,7 +12,7 @@ "id": "dan-europe", "name": "DAN Europe", "phone": "+39-06-4211-8685", - "countries": ["IT", "FR", "ES", "PT", "DE", "AT", "CH", "NL", "BE", "GB", "IE", "MT", "HR", "GR", "CY", "EG", "TR", "NO", "SE", "DK", "FI", "PL", "CZ", "HU", "IL"] + "countries": ["IT", "FR", "ES", "PT", "DE", "AT", "CH", "NL", "BE", "GB", "IE", "MT", "HR", "GR", "CY", "EG", "TR", "NO", "SE", "DK", "FI", "PL", "CZ", "HU", "IL", "LU"] }, { "id": "dan-world", diff --git a/assets/data/sea_areas.json b/assets/data/sea_areas.json new file mode 100644 index 0000000000..4d326aa2bd --- /dev/null +++ b/assets/data/sea_areas.json @@ -0,0 +1 @@ +{"metadata":{"generated_at":"2026-09-04T21:59:51Z","source":"IHO Sea Areas version 3 (Flanders Marine Institute)","source_url":"https://doi.org/10.14284/323","license":"CC-BY 4.0","citation":"Flanders Marine Institute (2018). IHO Sea Areas, version 3. Available online at https://www.marineregions.org/. https://doi.org/10.14284/323","simplify_tolerance_degrees":0.05,"coordinate_decimals":3,"min_hole_area_square_degrees":0.05,"translation_source":"Wikidata labels (CC0)","translation_languages":["es","fr","de","it","nl","pt","hu","ar","he","zh"],"total_count":101},"areas":[{"name":"Strait of Gibraltar","bbox":[-6.033,35.776,-5.279,36.183],"area":0.1618,"polygons":[{"outer":[-5.345,36.109,-5.279,35.901,-5.465,35.914,-5.792,35.776,-5.908,35.801,-6.033,36.18,-5.91,36.183,-5.611,36.0,-5.43,36.071,-5.426,36.176,-5.345,36.109]}],"names":{"ar":"مضيق جبل طارق","de":"Straße von Gibraltar","es":"Estrecho de Gibraltar","fr":"détroit de Gibraltar","he":"מצר גיברלטר","hu":"Gibraltári-szoros","it":"stretto di Gibilterra","nl":"Straat van Gibraltar","pt":"Estreito de Gibraltar","zh":"直布羅陀海峽"}},{"name":"Singapore Strait","bbox":[103.346,1.072,104.583,1.694],"area":0.2497,"polygons":[{"outer":[103.936,1.694,103.928,1.693,103.937,1.689,103.939,1.687,103.929,1.658,103.975,1.644,104.04,1.495,104.058,1.541,104.179,1.56,104.055,1.503,104.17,1.42,104.088,1.368,104.283,1.37,104.583,1.227,104.562,1.191,104.243,1.12,104.098,1.199,104.102,1.141,104.072,1.119,104.013,1.189,103.924,1.072,103.346,1.189,103.513,1.266,103.546,1.462,103.558,1.33,103.769,1.52,103.998,1.435,104.016,1.53,103.931,1.561,103.979,1.58,103.943,1.59,103.972,1.631,103.956,1.633,103.926,1.658,103.927,1.694,103.936,1.694]}],"names":{"ar":"مضيق سنغافورة","de":"Straße von Singapur","es":"Estrecho de Singapur","fr":"détroit de Singapour","he":"מצר סינגפור","it":"Stretto di Singapore","nl":"Straat Singapore","pt":"Estreito de Singapura","zh":"新加坡海峡"}},{"name":"Gulf of Aqaba","bbox":[34.329,27.9,34.98,29.542],"area":0.3044,"polygons":[{"outer":[34.98,29.542,34.776,28.672,34.805,28.524,34.573,28.108,34.61,28.096,34.621,28.034,34.584,27.966,34.495,28.0,34.559,27.912,34.329,27.9,34.435,27.993,34.41,28.32,34.627,28.73,34.742,29.311,34.98,29.542]}],"names":{"ar":"خليج العقبة","de":"Golf von Akaba","es":"Golfo de Áqaba","fr":"golfe d'Aqaba","he":"מפרץ אילת","hu":"Akabai-öböl","it":"Golfo di Aqaba","nl":"Golf van Akaba","pt":"Golfo de Acaba","zh":"亚喀巴湾"}},{"name":"Bristol Channel","bbox":[-4.924,50.989,-2.347,51.818],"area":0.7842,"polygons":[{"outer":[-4.529,51.017,-4.924,51.599,-4.711,51.652,-4.671,51.728,-4.32,51.816,-4.378,51.747,-4.317,51.725,-4.378,51.73,-4.312,51.673,-4.051,51.701,-4.307,51.561,-3.832,51.628,-3.719,51.478,-3.341,51.381,-3.169,51.406,-2.979,51.565,-2.712,51.582,-2.607,51.673,-2.407,51.769,-2.449,51.785,-2.453,51.797,-2.439,51.814,-2.417,51.818,-2.37,51.794,-2.347,51.804,-2.373,51.792,-2.434,51.811,-2.38,51.76,-2.71,51.498,-2.981,51.388,-3.036,51.329,-3.0,51.237,-3.045,51.166,-3.019,51.219,-4.086,51.218,-4.263,51.143,-4.205,51.07,-4.119,51.095,-4.066,51.079,-4.343,50.989,-4.529,51.017]}],"names":{"ar":"قناة برستل","de":"Bristolkanal","es":"Canal de Bristol","fr":"canal de Bristol","he":"תעלת בריסטול","it":"Canale di Bristol","nl":"Kanaal van Bristol","pt":"Canal de Bristol","zh":"布里斯托尔湾"}},{"name":"Gulf of Suez","bbox":[32.343,27.45,34.228,30.068],"area":0.9462,"polygons":[{"outer":[32.573,30.068,32.588,29.969,32.582,29.949,32.559,29.917,32.696,29.73,32.724,29.463,32.885,29.233,33.182,29.0,33.173,28.853,33.233,28.768,33.198,28.672,33.242,28.555,33.567,28.293,33.762,28.019,34.228,27.745,34.039,27.458,33.962,27.535,33.916,27.526,34.025,27.45,33.639,27.45,33.559,27.525,33.493,27.651,33.595,27.653,33.47,27.812,33.592,27.789,33.557,27.893,32.869,28.571,32.804,28.782,32.622,28.983,32.667,29.099,32.595,29.342,32.343,29.591,32.508,29.856,32.477,29.936,32.581,29.954,32.573,30.068]}],"names":{"ar":"خليج السويس","de":"Golf von Sues","es":"Golfo de Suez","fr":"golfe de Suez","he":"מפרץ סואץ","hu":"Szuezi-öböl","it":"golfo di Suez","nl":"Golf van Suez","pt":"Golfo de Suez","zh":"苏伊士湾"}},{"name":"Sea of Marmara","bbox":[26.174,39.999,29.936,41.219],"area":1.2937,"polygons":[{"outer":[29.153,41.219,29.007,41.006,29.285,40.855,29.257,40.803,29.936,40.719,29.557,40.686,29.515,40.741,28.78,40.536,29.151,40.436,29.053,40.363,27.955,40.353,27.897,40.39,28.032,40.481,27.754,40.528,27.687,40.473,27.88,40.374,27.761,40.306,27.422,40.328,27.309,40.372,27.286,40.475,27.033,40.39,26.757,40.404,26.403,40.196,26.409,40.117,26.321,40.017,26.238,39.999,26.174,40.043,26.697,40.452,27.31,40.702,27.512,40.974,27.955,40.964,28.225,41.078,28.83,40.955,28.985,41.005,28.937,41.045,28.944,41.065,29.033,41.05,29.038,41.156,29.153,41.219]}],"names":{"ar":"بحر مرمرة","de":"Marmarameer","es":"mar de Mármara","fr":"mer de Marmara","he":"ים מרמרה","hu":"Márvány-tenger","it":"mar di Marmara","nl":"Zee van Marmara","pt":"Mar de Mármara","zh":"马尔马拉海"}},{"name":"Seto Inland Sea","bbox":[130.837,33.197,135.67,34.864],"area":1.8739,"polygons":[{"outer":[135.064,34.262,134.952,34.267,134.896,34.35,135.001,34.609,134.653,34.292,134.721,34.255,134.73,34.189,134.624,34.181,134.585,34.203,134.587,34.24,134.436,34.207,134.137,34.4,134.086,34.342,133.928,34.39,133.69,34.213,133.673,34.247,133.557,34.261,133.647,34.13,133.544,33.985,133.491,33.969,133.322,33.995,133.125,33.906,132.942,34.142,132.767,33.999,132.7,33.761,132.514,33.586,131.873,33.249,131.704,33.273,131.698,33.205,131.695,33.198,131.688,33.197,131.701,33.249,131.692,33.28,131.615,33.215,131.495,33.342,131.74,33.465,131.666,33.668,131.523,33.684,131.359,33.543,131.1,33.619,130.957,33.829,131.024,33.959,130.889,33.887,130.837,33.925,130.903,34.027,130.921,33.912,131.044,34.067,131.269,33.923,131.404,34.113,131.396,33.985,131.785,34.068,131.771,33.978,131.84,34.014,132.024,33.902,132.062,33.942,132.144,33.833,132.099,33.96,132.213,34.002,132.193,34.158,132.196,34.161,132.25,34.165,132.217,34.206,132.226,34.246,132.32,34.339,132.475,34.434,132.462,34.37,132.438,34.346,132.542,34.373,132.492,34.338,132.559,34.242,132.504,34.134,132.448,34.094,132.533,34.104,132.541,34.069,132.553,34.068,132.534,34.191,132.628,34.253,132.684,34.21,132.761,34.241,132.746,34.279,132.797,34.312,133.245,34.443,133.367,34.369,133.408,34.429,133.367,34.46,133.346,34.482,133.341,34.493,133.431,34.424,133.405,34.48,133.528,34.454,133.487,34.514,133.595,34.469,133.691,34.512,133.698,34.564,133.715,34.59,133.733,34.603,133.704,34.478,133.739,34.524,133.786,34.436,133.935,34.451,133.959,34.523,134.047,34.577,133.902,34.532,133.906,34.61,134.032,34.606,134.031,34.646,134.103,34.731,134.089,34.75,134.088,34.756,134.092,34.77,134.11,34.791,134.141,34.796,134.128,34.836,134.142,34.795,134.093,34.77,134.089,34.753,134.105,34.747,134.104,34.725,134.035,34.642,134.06,34.585,134.244,34.696,134.199,34.715,134.183,34.74,134.409,34.726,134.464,34.804,134.807,34.76,135.055,34.624,135.183,34.649,135.184,34.682,135.288,34.704,135.283,34.713,135.294,34.719,135.324,34.718,135.322,34.726,135.338,34.725,135.365,34.706,135.363,34.688,135.38,34.738,135.376,34.676,135.444,34.695,135.67,34.864,135.555,34.736,135.409,34.675,135.473,34.627,135.423,34.612,135.4,34.62,135.403,34.612,135.446,34.611,135.496,34.595,135.501,34.587,135.531,34.598,135.552,34.599,135.628,34.58,135.618,34.507,135.624,34.579,135.452,34.565,135.402,34.6,135.446,34.557,135.431,34.53,135.405,34.51,135.371,34.522,135.37,34.468,135.064,34.262]}],"names":{"ar":"بحر سيتو الداخلي","de":"Seto-Inlandsee","es":"mar interior de Seto","fr":"mer intérieure de Seto","he":"הים הפנימי סטו","hu":"Szeto-beltenger","it":"Mare interno di Seto","nl":"Japanse Binnenzee","pt":"Mar Interior","zh":"瀨戶內海"}},{"name":"Ligurian Sea","bbox":[7.539,42.864,9.834,44.427],"area":1.8883,"polygons":[{"outer":[9.834,44.049,9.457,42.951,9.343,43.0,9.36,42.924,9.337,42.864,7.539,43.782,8.074,43.891,8.451,44.287,8.746,44.427,8.918,44.413,9.141,44.36,9.145,44.322,9.218,44.298,9.234,44.348,9.834,44.049]}],"names":{"ar":"بحر لغرية","de":"Ligurisches Meer","es":"mar de Liguria","fr":"mer ligure","he":"הים הליגורי","hu":"Ligur-tenger","it":"Mar Ligure","nl":"Ligurische Zee","pt":"Mar Lígure","zh":"利古里亞海"}},{"name":"Bay of Fundy","bbox":[-67.323,44.088,-63.305,46.202],"area":1.9348,"polygons":[{"outer":[-66.213,44.088,-67.101,44.502,-67.228,44.659,-66.949,44.817,-67.062,44.907,-67.024,44.851,-67.04,44.848,-67.041,44.834,-67.054,44.836,-67.053,44.827,-67.057,44.828,-67.057,44.824,-67.068,44.818,-67.072,44.819,-67.049,44.85,-67.105,44.894,-67.086,44.847,-67.118,44.844,-67.134,44.877,-67.129,44.821,-67.167,44.801,-67.222,44.906,-67.111,44.9,-67.174,44.945,-67.059,44.962,-67.021,44.899,-66.983,44.906,-67.164,45.158,-67.323,45.142,-67.286,45.192,-67.164,45.168,-67.195,45.187,-67.19,45.235,-67.142,45.223,-67.132,45.141,-67.035,45.067,-67.066,45.128,-66.965,45.18,-66.84,45.121,-66.922,45.093,-66.893,45.036,-66.847,45.077,-66.825,45.075,-66.846,45.067,-66.856,45.03,-66.774,45.137,-66.776,45.076,-66.808,45.038,-66.488,45.152,-66.46,45.057,-66.346,45.134,-66.251,45.142,-66.261,45.189,-66.303,45.169,-66.332,45.19,-66.27,45.196,-66.228,45.176,-66.218,45.141,-66.061,45.247,-66.143,45.254,-66.239,45.362,-66.107,45.503,-66.023,45.562,-66.088,45.631,-66.11,45.69,-66.098,45.713,-66.136,45.744,-66.15,45.813,-66.497,45.861,-66.621,45.93,-66.646,45.966,-66.869,45.951,-66.854,45.963,-66.772,45.98,-66.806,45.991,-66.638,45.97,-66.462,45.865,-66.413,45.885,-66.304,45.89,-66.22,45.846,-66.153,45.832,-66.101,45.795,-66.25,45.904,-66.078,45.913,-66.018,46.073,-65.932,46.076,-65.978,46.085,-65.949,46.14,-65.872,46.202,-65.854,46.193,-65.945,46.135,-65.857,46.101,-65.996,46.017,-65.879,46.034,-66.125,45.854,-66.098,45.794,-66.106,45.779,-66.135,45.763,-66.126,45.751,-66.101,45.745,-66.094,45.738,-66.077,45.684,-66.07,45.724,-65.919,45.881,-65.865,45.864,-65.8,45.924,-65.78,45.931,-65.756,45.93,-66.075,45.682,-66.004,45.554,-65.967,45.598,-65.937,45.594,-65.869,45.649,-65.845,45.645,-66.222,45.359,-66.137,45.377,-66.096,45.333,-66.033,45.423,-65.89,45.521,-65.839,45.544,-66.036,45.332,-66.135,45.295,-65.918,45.194,-64.867,45.632,-64.778,45.595,-64.643,45.718,-64.735,45.745,-64.568,45.837,-64.703,45.997,-64.707,46.052,-64.724,46.054,-64.742,46.067,-64.747,46.071,-64.756,46.09,-64.787,46.071,-64.853,46.049,-64.906,46.049,-64.936,46.056,-64.763,46.093,-64.751,46.09,-64.727,46.06,-64.704,46.053,-64.567,45.87,-64.548,45.878,-64.549,45.911,-64.527,45.925,-64.541,45.933,-64.534,45.941,-64.529,45.941,-64.492,45.805,-64.55,45.725,-64.34,45.905,-64.275,45.813,-64.324,45.767,-64.296,45.779,-64.258,45.767,-64.243,45.715,-64.295,45.776,-64.369,45.705,-64.339,45.771,-64.294,45.801,-64.336,45.823,-64.573,45.598,-64.913,45.43,-64.945,45.327,-64.774,45.29,-64.569,45.406,-63.888,45.347,-63.779,45.401,-63.607,45.396,-63.392,45.361,-63.356,45.372,-63.328,45.37,-63.316,45.38,-63.305,45.379,-63.325,45.368,-63.356,45.369,-63.396,45.351,-63.451,45.347,-63.479,45.331,-63.391,45.199,-63.376,45.14,-63.397,45.109,-63.505,45.326,-63.787,45.317,-64.171,45.188,-64.192,45.105,-64.011,45.049,-64.123,45.047,-64.136,45.009,-64.069,45.01,-64.061,45.004,-64.057,44.995,-64.044,44.995,-64.039,44.986,-64.192,44.954,-64.17,45.069,-64.387,45.126,-64.332,45.298,-64.499,45.336,-64.399,45.28,-64.949,45.104,-65.742,44.712,-65.726,44.668,-65.423,44.795,-65.409,44.793,-65.409,44.781,-65.396,44.782,-65.386,44.807,-65.378,44.808,-65.389,44.783,-65.513,44.753,-65.685,44.63,-65.654,44.602,-65.639,44.58,-65.641,44.577,-65.766,44.603,-65.785,44.692,-66.044,44.547,-66.203,44.375,-65.982,44.558,-65.846,44.569,-66.122,44.342,-66.213,44.088]}],"names":{"ar":"خليج فندي","de":"Bay of Fundy","es":"Bahía de Fundy","fr":"baie de Fundy","he":"מפרץ פאנדי","hu":"Fundy-öböl","it":"baia di Fundy","nl":"Fundybaai","pt":"Baía de Fundy","zh":"芬迪湾"}},{"name":"Gulf of Boni","bbox":[120.189,-5.607,121.867,-2.589],"area":2.7025,"polygons":[{"outer":[121.867,-4.868,121.852,-5.089,120.464,-5.607,120.272,-5.144,120.303,-4.876,120.459,-4.658,120.332,-4.355,120.393,-4.342,120.331,-4.104,120.442,-3.739,120.38,-3.504,120.421,-3.265,120.256,-3.16,120.189,-2.973,120.197,-2.952,120.323,-2.902,120.354,-2.861,120.453,-2.825,120.564,-2.671,120.799,-2.608,120.987,-2.64,121.007,-2.679,121.066,-2.589,121.037,-2.694,121.05,-2.678,121.059,-2.673,121.098,-2.717,120.975,-2.81,121.078,-2.866,121.069,-3.204,120.866,-3.429,120.88,-3.529,121.247,-3.831,121.252,-3.92,121.281,-3.882,121.291,-3.879,121.35,-3.991,121.607,-4.063,121.466,-4.684,121.684,-4.851,121.867,-4.868]}],"names":{"de":"Golf von Bone","es":"Golfo de Boni","fr":"golfe de Bone","he":"מפרץ בוני","it":"golfo di Boni","nl":"Golf van Bone","pt":"Golfo de Bone","zh":"波尼湾"}},{"name":"Gulf of Riga","bbox":[21.725,56.805,24.617,59.215],"area":2.834,"polygons":[{"outer":[21.725,57.576,22.03,57.911,22.201,57.982,22.27,58.189,22.508,58.213,22.527,58.249,22.537,58.251,22.731,58.214,22.704,58.274,22.927,58.302,22.954,58.384,23.09,58.379,23.158,58.477,23.324,58.446,23.119,58.531,23.167,58.569,23.228,58.519,23.392,58.512,23.357,58.643,23.192,58.684,23.066,58.62,23.137,58.55,22.875,58.625,22.624,58.584,22.585,58.688,22.733,58.806,22.796,58.759,22.925,58.853,23.069,58.838,22.94,58.983,22.587,59.092,23.511,59.215,23.542,59.092,23.435,59.077,23.404,59.026,23.664,58.973,23.481,58.933,23.427,58.942,23.534,58.81,23.443,58.766,23.744,58.78,23.491,58.689,23.568,58.583,23.5,58.567,23.676,58.53,23.751,58.337,24.011,58.319,24.118,58.232,24.266,58.276,24.308,58.379,24.506,58.391,24.561,58.377,24.576,58.364,24.617,58.382,24.576,58.362,24.479,58.378,24.586,58.304,24.47,58.247,24.481,58.074,24.333,57.827,24.411,57.266,24.032,57.064,24.131,57.031,24.099,56.95,24.259,56.864,24.194,56.868,24.265,56.834,24.234,56.834,24.085,56.944,24.045,57.039,23.969,56.997,23.94,56.996,23.931,56.982,23.819,56.955,23.685,56.928,23.644,56.931,23.626,56.941,23.633,56.896,23.622,56.887,23.606,56.882,23.606,56.863,23.573,56.843,23.604,56.805,23.581,56.811,23.57,56.845,23.602,56.863,23.6,56.881,23.629,56.899,23.619,56.941,23.785,56.971,23.815,56.956,23.932,57.006,23.627,56.964,23.286,57.073,23.132,57.36,22.61,57.607,22.605,57.759,21.725,57.576]}],"names":{"ar":"خليج ريغا","de":"Rigaischer Meerbusen","es":"Golfo de Riga","fr":"golfe de Riga","he":"מפרץ ריגה","hu":"Rigai-öböl","it":"Golfo di Riga","nl":"Golf van Riga","pt":"Golfo de Riga","zh":"里加湾"}},{"name":"Rio de la Plata","bbox":[-59.766,-36.359,-54.943,-31.523],"area":3.223,"polygons":[{"outer":[-54.943,-34.948,-56.776,-36.296,-56.757,-36.345,-56.92,-36.359,-57.234,-36.182,-57.382,-35.969,-57.361,-35.74,-57.125,-35.443,-57.342,-35.155,-58.352,-34.637,-58.553,-34.428,-58.522,-34.444,-58.498,-34.438,-58.521,-34.405,-58.526,-34.384,-58.526,-34.357,-58.523,-34.35,-58.542,-34.306,-58.79,-34.233,-58.974,-34.15,-59.26,-33.944,-59.351,-33.811,-59.282,-33.737,-59.317,-33.733,-59.371,-33.757,-59.38,-33.758,-59.387,-33.758,-59.465,-33.719,-59.492,-33.65,-59.611,-33.7,-59.639,-33.687,-59.653,-33.672,-59.67,-33.666,-59.661,-33.634,-59.756,-33.627,-59.766,-33.604,-59.72,-33.597,-59.656,-33.618,-59.6,-33.688,-59.543,-33.636,-59.508,-33.63,-59.478,-33.647,-59.434,-33.728,-59.25,-33.72,-59.231,-33.736,-59.222,-33.808,-59.17,-33.805,-59.125,-33.846,-59.06,-33.829,-58.994,-33.839,-58.979,-33.866,-58.913,-33.878,-58.811,-33.949,-58.665,-33.863,-58.515,-33.815,-58.503,-33.814,-58.47,-33.823,-58.464,-33.821,-58.552,-33.668,-58.407,-33.088,-58.475,-33.075,-58.507,-33.049,-58.508,-33.031,-58.269,-33.069,-58.338,-33.073,-58.348,-33.108,-58.15,-33.04,-58.234,-32.511,-58.193,-32.382,-58.104,-32.257,-58.117,-32.248,-58.135,-32.216,-58.186,-32.163,-58.207,-31.87,-58.198,-31.854,-58.167,-31.832,-58.078,-31.811,-58.008,-31.523,-58.064,-31.816,-58.151,-31.84,-58.195,-31.877,-58.144,-32.138,-58.131,-32.117,-58.096,-32.104,-58.089,-32.108,-58.089,-32.113,-58.094,-32.124,-58.082,-32.126,-58.024,-32.081,-58.012,-32.076,-58.079,-32.127,-58.132,-32.126,-58.114,-32.152,-58.091,-32.297,-58.196,-32.464,-58.042,-32.945,-58.076,-33.033,-58.168,-33.112,-58.352,-33.128,-58.351,-33.318,-58.418,-33.356,-58.379,-33.371,-58.373,-33.376,-58.372,-33.382,-58.359,-33.383,-58.377,-33.501,-58.434,-33.527,-58.42,-33.907,-58.237,-34.071,-58.223,-34.15,-58.079,-34.188,-57.854,-34.473,-57.135,-34.449,-56.821,-34.688,-56.445,-34.753,-56.374,-34.786,-56.354,-34.726,-56.341,-34.746,-56.342,-34.776,-56.431,-34.83,-56.313,-34.906,-56.214,-34.874,-56.161,-34.938,-55.7,-34.756,-55.397,-34.776,-55.261,-34.906,-55.073,-34.878,-54.943,-34.948],"holes":[[-58.506,-34.295,-58.388,-34.241,-58.454,-34.218,-58.519,-34.152,-58.586,-34.133,-58.519,-34.151,-58.449,-34.219,-58.381,-34.225,-58.431,-34.122,-58.905,-33.891,-59.012,-33.944,-59.291,-33.83,-59.256,-33.942,-58.967,-34.149,-58.506,-34.295]]}],"names":{"ar":"ريو دي لا بلاتا","de":"Río de la Plata","es":"Río de la Plata","fr":"río de la Plata","he":"ריו דה לה פלטה","hu":"Río de la Plata","it":"Río de la Plata","nl":"Río de la Plata","pt":"Rio da Prata","zh":"拉普拉塔河"}},{"name":"Bali Sea","bbox":[114.353,-8.999,117.166,-7.013],"area":3.2745,"polygons":[{"outer":[115.752,-7.144,117.133,-7.433,117.166,-8.369,117.005,-8.502,116.855,-8.539,116.819,-8.516,116.746,-8.663,116.815,-8.813,116.732,-8.869,116.729,-8.999,116.481,-8.81,116.666,-8.56,116.717,-8.348,116.411,-8.217,116.041,-8.422,116.072,-8.563,116.064,-8.767,115.915,-8.775,115.853,-8.72,115.831,-8.816,115.596,-8.817,115.577,-8.682,115.494,-8.672,115.451,-8.748,115.584,-8.817,115.183,-8.846,115.306,-8.622,115.712,-8.401,115.485,-8.182,115.186,-8.061,114.869,-8.196,114.433,-8.103,114.581,-8.398,114.9,-8.458,115.143,-8.67,115.168,-8.772,115.089,-8.838,114.548,-8.778,114.571,-8.661,114.442,-8.634,114.387,-8.447,114.353,-8.53,114.458,-7.811,115.293,-7.013,115.752,-7.144]}],"names":{"ar":"بحر بالي","de":"Balisee","es":"mar de Bali","fr":"mer de Bali","he":"ים באלי","it":"Mar di Bali","nl":"Balizee","pt":"Mar de Bali","zh":"峇里海"}},{"name":"Sea of Azov","bbox":[34.519,45.101,40.43,47.373],"area":4.5089,"polygons":[{"outer":[40.43,47.373,40.39,47.366,40.379,47.342,40.359,47.322,40.304,47.305,40.287,47.279,40.256,47.263,40.25,47.242,40.219,47.226,40.202,47.225,40.189,47.229,40.165,47.255,40.074,47.219,40.003,47.237,39.983,47.251,39.921,47.226,39.913,47.227,39.897,47.237,39.883,47.24,39.884,47.249,39.871,47.249,39.791,47.203,39.554,47.162,39.554,47.156,39.578,47.148,39.6,47.126,39.637,47.138,39.66,47.125,39.657,47.121,39.602,47.123,39.56,47.152,39.547,47.134,39.493,47.139,39.478,47.114,39.33,47.124,39.293,47.02,39.105,47.041,38.759,46.884,38.499,46.88,38.423,46.825,38.394,46.733,38.585,46.72,38.507,46.642,38.326,46.667,38.293,46.741,38.0,46.635,37.728,46.687,37.916,46.407,38.134,46.38,38.587,46.055,38.257,46.154,38.275,46.26,38.107,45.951,38.114,46.055,37.969,46.031,37.834,45.756,37.611,45.659,37.572,45.41,37.383,45.319,36.786,45.442,36.623,45.305,36.763,45.415,36.857,45.379,36.765,45.375,36.799,45.303,36.991,45.287,36.607,45.213,36.633,45.141,36.454,45.101,36.417,45.283,36.626,45.362,36.597,45.437,36.185,45.472,35.99,45.369,35.842,45.473,35.72,45.334,35.508,45.287,35.034,45.68,34.815,46.148,34.791,46.072,34.675,46.101,34.853,45.963,34.823,45.942,34.848,45.914,34.825,45.927,34.847,45.904,34.817,45.833,34.826,45.811,34.904,45.86,35.088,45.592,35.464,45.282,35.023,45.401,35.11,45.489,35.028,45.637,34.878,45.75,34.722,45.728,34.751,45.823,34.657,45.795,34.791,45.897,34.519,45.789,34.65,45.932,34.549,46.002,34.644,46.095,34.601,46.17,34.761,46.146,34.758,46.159,34.769,46.171,34.814,46.161,34.894,46.238,35.077,46.279,35.202,46.381,35.186,46.484,35.266,46.354,35.358,46.359,35.299,46.314,35.215,46.189,34.985,46.079,35.175,46.143,35.71,46.597,36.294,46.638,36.118,46.489,36.176,46.503,36.371,46.705,36.645,46.781,36.815,46.728,36.766,46.632,36.972,46.855,37.235,46.951,37.311,46.872,37.575,47.099,38.063,47.113,38.11,47.037,38.514,47.193,38.483,47.144,38.565,47.115,38.866,47.175,39.093,47.289,39.469,47.261,39.551,47.226,39.562,47.217,39.59,47.205,39.608,47.194,39.279,47.268,39.227,47.238,39.219,47.199,39.282,47.209,39.243,47.242,39.543,47.14,39.553,47.168,39.977,47.294,39.882,47.253,40.168,47.261,40.225,47.231,40.246,47.244,40.255,47.268,40.305,47.309,40.362,47.329,40.393,47.371,40.43,47.373]}],"names":{"ar":"بحر آزوف","de":"Asowsches Meer","es":"mar de Azov","fr":"mer d'Azov","he":"ים אזוב","hu":"Azovi-tenger","it":"mar d'Azov","nl":"Zee van Azov","pt":"Mar de Azov","zh":"亞速海"}},{"name":"Gulf of Tomini","bbox":[120.009,-1.437,123.41,0.549],"area":4.5763,"polygons":[{"outer":[121.533,0.549,121.719,0.523,121.805,0.41,122.115,0.498,123.063,0.518,123.335,0.31,123.41,-0.653,123.057,-0.564,122.724,-0.663,122.96,-0.755,122.203,-0.789,122.111,-0.955,121.947,-0.996,121.714,-0.951,121.632,-0.806,121.305,-1.102,121.1,-1.437,120.806,-1.352,120.677,-1.416,120.51,-0.998,120.406,-0.923,120.311,-0.966,120.06,-0.634,120.009,-0.179,120.136,0.192,120.306,0.42,120.507,0.513,121.081,0.398,121.273,0.495,121.494,0.479,121.533,0.549]}],"names":{"ar":"خليج توميني","de":"Golf von Tomini","es":"Golfo de Tomini","fr":"golfe de Tomini","he":"מפרץ טומיני","hu":"Tomini-öböl","it":"Golfo di Tomini","nl":"Golf van Tomini","pt":"Golfo de Tomini","zh":"托米尼灣"}},{"name":"Skagerrak","bbox":[7.004,57.099,11.917,59.913],"area":5.03,"polygons":[{"outer":[10.662,59.913,10.76,59.907,10.784,59.861,10.729,59.722,10.663,59.869,10.581,59.762,10.688,59.527,10.668,59.442,10.676,59.486,10.646,59.509,10.587,59.42,10.657,59.428,10.691,59.3,10.798,59.322,10.726,59.274,10.73,59.228,10.79,59.261,10.828,59.151,10.917,59.222,10.905,59.137,10.947,59.194,11.073,59.123,11.083,59.186,11.133,59.132,11.141,59.196,11.185,59.196,11.199,59.092,11.215,59.104,11.224,59.088,11.322,59.117,11.374,59.117,11.477,58.984,11.334,59.112,11.109,58.994,11.23,59.017,11.229,59.005,11.117,58.967,11.166,58.943,11.175,58.921,11.198,58.922,11.204,58.914,11.151,58.927,11.126,58.925,11.096,58.903,11.171,58.908,11.201,58.897,11.223,58.856,11.153,58.867,11.146,58.826,11.233,58.851,11.244,58.805,11.181,58.782,11.245,58.741,11.262,58.721,11.25,58.716,11.188,58.76,11.159,58.742,11.231,58.714,11.288,58.648,11.276,58.586,11.314,58.582,11.243,58.518,11.275,58.515,11.29,58.485,11.302,58.496,11.315,58.495,11.31,58.479,11.36,58.488,11.272,58.426,11.232,58.441,11.212,58.351,11.46,58.458,11.358,58.35,11.373,58.335,11.437,58.391,11.465,58.396,11.469,58.344,11.398,58.324,11.458,58.271,11.572,58.357,11.574,58.478,11.619,58.402,11.675,58.438,11.703,58.435,11.408,58.196,11.543,58.272,11.668,58.262,11.672,58.307,11.741,58.316,11.698,58.341,11.911,58.352,11.907,58.332,11.813,58.315,11.917,58.223,11.814,58.077,11.705,58.066,11.696,58.07,11.693,58.079,11.71,58.086,11.691,58.095,11.667,58.056,11.556,58.065,11.511,58.056,11.522,58.023,11.485,58.029,11.491,58.025,11.479,57.999,11.492,58.017,11.576,58.023,11.587,58.0,11.539,58.004,11.545,57.957,11.45,57.9,9.955,57.599,9.397,57.163,8.753,57.099,8.593,57.121,7.004,57.998,7.051,58.057,7.136,58.08,7.031,57.99,7.18,58.057,7.198,58.027,7.248,58.05,7.267,58.026,7.267,58.048,7.283,58.064,7.276,58.026,7.32,58.045,7.503,58.019,7.535,57.974,7.519,58.007,7.669,58.012,7.694,58.071,7.968,58.083,7.968,58.136,8.051,58.162,8.032,58.236,8.062,58.119,8.19,58.165,8.201,58.113,8.22,58.111,8.302,58.173,8.284,58.181,8.302,58.199,8.285,58.192,8.248,58.2,8.253,58.208,8.34,58.218,8.371,58.194,8.35,58.234,8.436,58.289,8.437,58.252,8.52,58.268,8.492,58.251,8.527,58.25,8.513,58.302,8.887,58.524,8.97,58.529,8.934,58.621,8.955,58.625,9.018,58.567,9.022,58.619,9.232,58.684,9.074,58.685,9.246,58.726,9.083,58.748,9.326,58.742,9.467,58.828,9.291,58.839,9.303,58.89,9.48,58.916,9.483,58.876,9.537,58.886,9.571,58.876,9.465,58.937,9.754,58.988,9.512,59.128,9.693,59.05,9.71,59.051,9.692,59.075,9.695,59.098,9.704,59.107,9.716,59.108,9.734,59.043,9.8,59.09,9.859,58.958,9.867,58.975,10.042,58.977,10.016,59.046,10.07,59.06,10.209,59.023,10.232,59.042,10.212,59.071,10.223,59.126,10.306,59.053,10.28,59.139,10.378,59.278,10.403,59.268,10.366,59.171,10.391,59.177,10.436,59.163,10.43,59.269,10.53,59.317,10.483,59.45,10.383,59.439,10.23,59.567,10.38,59.53,10.412,59.622,10.246,59.732,10.229,59.73,10.162,59.748,10.116,59.748,10.091,59.754,10.068,59.752,10.042,59.755,9.999,59.753,9.966,59.762,9.954,59.767,10.26,59.751,10.373,59.714,10.438,59.665,10.415,59.529,10.512,59.52,10.618,59.595,10.518,59.696,10.478,59.82,10.515,59.84,10.472,59.837,10.532,59.89,10.662,59.913]}],"names":{"ar":"سكاغيراك","de":"Skagerrak","es":"Skagerrak","fr":"Skagerrak","he":"סקאגרק","hu":"Skagerrak","it":"Skagerrak","nl":"Skagerrak","pt":"Skagerrak","zh":"斯卡格拉克海峡"}},{"name":"Kattegat","bbox":[9.366,54.634,13.053,58.077],"area":5.2374,"polygons":[{"outer":[10.63,57.735,11.614,57.937,11.681,58.008,11.756,58.006,11.816,58.077,11.784,57.902,11.659,57.851,11.709,57.856,11.71,57.85,11.683,57.837,11.73,57.822,11.75,57.799,11.733,57.783,12.083,57.947,12.014,57.86,11.907,57.845,11.716,57.736,11.702,57.705,11.99,57.721,11.835,57.66,11.93,57.621,11.91,57.391,11.951,57.394,12.004,57.341,12.056,57.459,12.109,57.434,12.127,57.378,12.064,57.338,12.167,57.302,12.094,57.234,12.213,57.222,12.15,57.194,12.212,57.185,12.19,57.115,12.35,57.009,12.348,56.931,12.497,56.888,12.598,56.831,12.729,56.64,12.939,56.588,12.878,56.435,12.619,56.422,12.831,56.279,12.801,56.227,12.446,56.302,12.913,55.836,12.901,55.751,13.053,55.695,12.889,55.564,12.947,55.416,12.455,55.291,12.2,55.441,12.245,55.548,12.492,55.601,12.505,55.633,12.623,55.718,12.518,55.924,12.624,56.039,12.314,56.129,11.841,55.966,12.012,55.968,12.119,55.729,12.081,55.651,12.007,55.705,11.928,55.652,11.99,55.74,12.066,55.738,12.024,55.908,11.948,55.932,11.813,55.666,11.654,55.721,11.77,55.745,11.609,55.781,11.732,55.82,11.668,55.913,11.777,55.977,11.278,56.011,11.531,55.886,11.358,55.845,11.379,55.777,11.262,55.729,10.869,55.743,11.101,55.676,10.933,55.668,11.147,55.59,11.079,55.515,11.214,55.394,11.092,55.347,11.288,55.251,11.255,55.2,11.711,55.141,11.653,55.179,11.673,55.2,11.711,55.208,11.745,55.196,11.716,55.156,11.819,55.133,11.719,55.113,11.832,55.045,11.687,55.067,12.026,54.962,12.12,54.994,12.168,54.993,12.104,54.904,12.219,54.889,12.159,54.844,11.829,54.967,11.706,54.934,11.911,54.718,11.886,54.634,11.804,54.644,11.865,54.749,11.743,54.875,11.495,54.832,11.262,54.957,11.028,54.914,11.126,54.832,10.984,54.821,10.997,54.769,10.721,54.729,10.94,55.164,10.675,54.91,10.727,54.877,10.596,54.839,10.669,54.741,10.205,54.972,10.418,54.817,9.865,54.752,9.589,54.876,9.429,54.801,9.42,54.831,9.624,54.935,9.727,54.836,9.703,54.9,9.782,54.91,9.756,54.927,9.739,54.987,9.601,55.039,9.422,55.032,9.552,55.076,9.46,55.116,9.497,55.155,9.719,55.252,9.54,55.248,9.671,55.286,9.593,55.42,9.693,55.461,9.482,55.49,9.569,55.527,9.74,55.519,9.728,55.555,9.859,55.624,9.543,55.707,10.029,55.697,10.054,55.822,10.023,55.841,9.981,55.827,9.891,55.843,9.877,55.854,9.859,55.857,9.857,55.859,10.079,55.884,10.195,55.838,10.281,56.02,10.24,56.186,10.453,56.295,10.48,56.207,10.377,56.168,10.525,56.171,10.482,56.136,10.545,56.102,10.536,56.175,10.641,56.227,10.715,56.142,10.959,56.444,10.837,56.53,10.493,56.515,10.303,56.607,10.218,56.481,10.293,56.471,10.208,56.472,10.221,56.553,10.246,56.579,10.36,56.64,10.335,56.702,10.204,56.682,10.118,56.713,10.087,56.683,9.985,56.655,9.8,56.638,10.143,56.72,10.332,56.707,10.265,56.864,10.304,56.983,10.238,56.978,10.173,56.992,10.0,57.079,9.937,57.049,9.684,57.037,9.569,56.926,9.581,56.989,9.366,57.024,9.783,57.107,10.01,57.089,10.333,56.989,10.546,57.229,10.561,57.447,10.429,57.598,10.63,57.735],"holes":[[10.29,55.619,9.676,55.513,9.898,55.34,9.878,55.243,10.175,55.128,10.074,55.076,10.743,55.069,10.815,55.231,10.748,55.296,10.853,55.299,10.667,55.447,10.749,55.479,10.706,55.539,10.591,55.621,10.625,55.488,10.426,55.438,10.492,55.547,10.561,55.516,10.414,55.589,10.29,55.619]]}],"names":{"ar":"كاتيغات","de":"Kattegat","es":"Categat","fr":"Cattégat","he":"קטגט","hu":"Kattegat","it":"Kattegat","nl":"Kattegat","pt":"Kattegat","zh":"卡特加特海峡"}},{"name":"Alboran Sea","bbox":[-5.335,35.071,-1.188,36.842],"area":5.4114,"polygons":[{"outer":[-2.193,36.721,-1.188,35.578,-1.284,35.358,-1.775,35.119,-2.059,35.071,-2.422,35.148,-2.567,35.081,-2.846,35.194,-2.746,35.112,-2.813,35.103,-2.924,35.183,-2.923,35.255,-2.845,35.195,-2.973,35.44,-3.083,35.281,-3.333,35.186,-3.708,35.288,-3.833,35.197,-3.929,35.262,-4.427,35.154,-4.928,35.319,-5.241,35.551,-5.335,35.714,-5.294,36.296,-4.935,36.5,-4.639,36.506,-4.417,36.717,-2.927,36.752,-2.7,36.682,-2.565,36.814,-2.367,36.842,-2.193,36.721]}],"names":{"ar":"بحر البوران","de":"Alborán-Meer","es":"mar de Alborán","fr":"mer d'Alboran","he":"ים אלבוראן","hu":"Alborán-tenger","it":"Mare di Alborán","nl":"Alboránzee","pt":"Mar de Alborão","zh":"阿尔沃兰海"}},{"name":"Halmahera Sea","bbox":[127.549,-1.681,130.931,2.487],"area":6.0226,"polygons":[{"outer":[128.69,2.487,129.852,0.32,130.218,-0.208,130.388,-0.923,130.635,-0.962,130.744,-1.231,130.931,-1.429,129.935,-1.213,129.978,-1.171,129.881,-1.146,129.703,-1.205,129.746,-1.153,128.158,-1.681,128.122,-1.566,128.111,-1.56,128.099,-1.578,128.076,-1.58,128.077,-1.565,127.991,-1.511,127.936,-1.449,127.759,-1.352,127.66,-1.346,127.6,-1.26,127.677,-1.293,127.696,-1.227,127.549,-1.171,128.307,-0.958,128.401,-1.038,128.333,-0.952,128.463,-0.918,128.215,-0.699,128.049,-0.417,127.988,-0.279,127.887,0.01,127.921,0.273,127.864,0.298,127.917,0.455,128.458,0.402,128.903,0.208,128.671,0.343,128.679,0.55,128.57,0.563,128.325,0.652,128.199,0.789,128.249,0.84,128.313,0.816,128.335,0.912,128.7,1.065,128.722,1.573,128.468,1.551,128.19,1.388,128.086,1.255,128.195,1.165,127.981,1.088,127.843,0.805,127.661,0.869,127.627,0.972,127.903,1.15,128.011,1.313,127.988,1.616,128.023,1.703,127.847,1.82,127.851,1.925,128.197,2.281,128.293,2.055,128.259,1.98,128.52,2.067,128.69,2.487]}],"names":{"ar":"بحر هلماهرا","de":"Halmaherasee","es":"mar de Halmahera","fr":"mer d'Halmahera","he":"ים האלמאהרה","hu":"Halmahera-tenger","it":"Mare di Halmahera","nl":"Zee van Halmahera","pt":"Mar de Halmahera","zh":"哈馬黑拉海"}},{"name":"Irish Sea","bbox":[-6.569,51.904,-2.639,55.004],"area":6.2725,"polygons":[{"outer":[-5.312,51.904,-6.362,52.173,-6.311,52.241,-6.389,52.309,-6.429,52.293,-6.477,52.352,-6.519,52.349,-6.544,52.354,-6.569,52.382,-6.563,52.423,-6.54,52.355,-6.467,52.378,-6.457,52.34,-6.434,52.356,-6.385,52.355,-6.372,52.344,-6.358,52.345,-5.997,52.964,-6.094,53.277,-6.247,53.347,-6.122,53.389,-6.052,53.361,-6.2,53.464,-6.079,53.552,-6.302,53.721,-6.237,53.86,-6.363,53.896,-6.332,53.994,-6.411,54.015,-6.101,54.0,-6.244,54.076,-6.302,54.119,-6.321,54.148,-6.064,54.023,-5.964,54.062,-5.823,54.281,-5.66,54.225,-5.638,54.263,-5.524,54.311,-5.573,54.383,-5.65,54.364,-5.666,54.363,-5.684,54.372,-5.653,54.382,-5.643,54.428,-5.627,54.434,-5.677,54.503,-5.658,54.515,-5.647,54.496,-5.618,54.506,-5.646,54.5,-5.642,54.511,-5.63,54.516,-5.723,54.539,-5.685,54.582,-5.557,54.531,-5.58,54.393,-5.497,54.332,-4.854,54.634,-4.961,54.803,-4.848,54.868,-4.392,54.677,-4.34,54.799,-4.452,54.938,-4.095,54.765,-4.09,54.813,-4.049,54.85,-4.065,54.78,-3.98,54.768,-3.825,54.823,-3.86,54.845,-3.82,54.89,-3.594,54.872,-3.576,55.004,-3.528,54.967,-3.437,54.992,-3.372,54.97,-3.058,54.987,-3.047,54.983,-3.039,54.975,-3.041,54.969,-3.023,54.971,-3.373,54.888,-3.641,54.514,-3.401,54.364,-3.42,54.283,-3.323,54.19,-3.186,54.236,-3.258,54.165,-3.155,54.063,-3.047,54.24,-2.976,54.149,-2.8,54.245,-2.866,54.185,-2.797,54.118,-2.923,54.034,-2.833,54.003,-2.998,53.928,-2.939,53.851,-3.051,53.92,-3.058,53.779,-2.782,53.753,-2.953,53.715,-3.103,53.55,-2.878,53.332,-2.786,53.322,-2.765,53.348,-2.704,53.361,-2.674,53.356,-2.66,53.374,-2.639,53.378,-2.64,53.374,-2.733,53.307,-2.876,53.289,-3.042,53.443,-3.2,53.386,-3.04,53.218,-3.322,53.356,-3.476,53.293,-3.869,53.343,-3.799,53.265,-4.201,53.209,-4.213,53.182,-4.31,53.13,-4.316,53.1,-4.348,53.114,-4.356,53.033,-4.769,52.797,-4.528,52.777,-4.41,52.89,-4.022,52.939,-4.146,52.893,-4.154,52.813,-4.052,52.718,-3.97,52.748,-3.951,52.753,-3.936,52.749,-3.935,52.752,-3.927,52.753,-4.13,52.612,-4.07,52.543,-3.961,52.56,-3.927,52.559,-3.927,52.556,-4.058,52.529,-4.233,52.253,-5.312,51.904],"holes":[[-4.328,53.129,-4.04,53.311,-4.212,53.297,-4.288,53.417,-4.575,53.404,-4.509,53.187,-4.328,53.129],[-4.794,54.062,-4.623,54.053,-4.391,54.19,-4.307,54.296,-4.381,54.325,-4.362,54.417,-4.548,54.358,-4.794,54.062]]}],"names":{"ar":"البحر الأيرلندي","de":"Irische See","es":"mar de Irlanda","fr":"mer d'Irlande","he":"הים האירי","hu":"Ír-tenger","it":"Mare d'Irlanda","nl":"Ierse Zee","pt":"Mar da Irlanda","zh":"愛爾蘭海"}},{"name":"Inner Seas off the West Coast of Scotland","bbox":[-8.25,54.336,-4.459,58.578],"area":6.708,"polygons":[{"outer":[-5.022,58.578,-5.109,58.528,-5.116,58.482,-5.142,58.474,-5.087,58.478,-5.057,58.462,-5.079,58.456,-5.021,58.452,-4.992,58.427,-5.082,58.451,-5.034,58.381,-5.115,58.398,-5.137,58.414,-5.161,58.354,-5.185,58.355,-5.189,58.351,-5.027,58.25,-5.309,58.225,-5.376,58.265,-5.242,58.151,-5.301,58.065,-5.433,58.106,-5.447,58.097,-5.448,58.08,-5.459,58.069,-5.176,57.953,-5.224,57.925,-5.1,57.869,-5.072,57.82,-5.36,57.939,-5.222,57.844,-5.423,57.909,-5.471,57.854,-5.616,57.925,-5.656,57.878,-5.583,57.837,-5.578,57.793,-5.619,57.766,-5.688,57.869,-5.815,57.853,-5.814,57.75,-5.673,57.701,-5.819,57.639,-5.514,57.541,-5.837,57.579,-5.822,57.363,-5.441,57.415,-5.734,57.283,-5.444,57.316,-5.517,57.274,-5.401,57.228,-5.598,57.259,-5.699,57.167,-5.389,57.108,-5.722,57.118,-5.788,57.048,-5.512,56.995,-5.821,57.012,-5.844,56.907,-5.922,56.882,-5.673,56.875,-5.865,56.82,-5.754,56.783,-6.228,56.715,-5.975,56.672,-5.777,56.716,-5.539,56.686,-6.008,56.646,-5.905,56.551,-5.686,56.497,-5.119,56.834,-5.33,56.859,-5.192,56.855,-5.119,56.841,-5.097,56.827,-5.246,56.703,-5.051,56.705,-5.316,56.655,-5.299,56.637,-5.421,56.547,-5.248,56.55,-5.382,56.51,-5.42,56.526,-5.474,56.481,-5.424,56.501,-5.409,56.459,-5.339,56.471,-5.257,56.464,-5.23,56.447,-5.185,56.46,-5.154,56.501,-5.118,56.513,-5.078,56.56,-5.067,56.558,-5.136,56.477,-5.242,56.437,-5.445,56.454,-5.538,56.359,-5.445,56.363,-5.654,56.297,-5.484,56.256,-5.614,56.132,-5.525,56.188,-5.504,56.189,-5.528,56.074,-5.584,56.092,-5.715,55.951,-5.567,56.04,-5.691,55.91,-5.573,55.935,-5.667,55.8,-5.606,55.761,-5.437,55.858,-5.677,55.683,-5.799,55.298,-5.519,55.362,-5.604,55.426,-5.458,55.576,-5.448,55.71,-5.314,55.784,-5.412,55.865,-5.441,56.034,-5.345,56.022,-5.333,56.068,-5.054,56.247,-4.926,56.266,-5.342,55.996,-5.313,55.853,-5.258,55.852,-5.201,55.83,-5.241,55.896,-5.192,55.979,-5.075,55.898,-5.082,55.938,-5.118,55.972,-5.124,56.007,-4.977,55.862,-4.907,55.966,-4.956,55.998,-4.894,55.987,-4.916,56.166,-4.861,56.099,-4.753,56.204,-4.882,56.057,-4.853,55.989,-4.766,55.988,-4.837,56.05,-4.83,56.078,-4.703,55.967,-4.685,55.97,-4.67,55.958,-4.485,55.929,-4.459,55.919,-4.817,55.963,-4.899,55.897,-4.857,55.758,-4.906,55.699,-4.71,55.632,-4.619,55.492,-4.996,55.139,-5.062,55.026,-4.994,54.915,-5.098,55.018,-5.188,54.928,-4.881,54.635,-5.494,54.336,-5.433,54.487,-5.539,54.655,-5.909,54.612,-5.896,54.673,-5.708,54.746,-5.69,54.808,-5.992,54.985,-5.963,55.05,-6.059,55.063,-6.028,55.165,-6.144,55.228,-6.963,55.194,-7.05,55.051,-7.227,55.061,-7.232,55.043,-7.382,54.947,-7.166,55.145,-6.921,55.237,-7.399,55.378,-7.247,55.279,-7.528,55.286,-7.556,55.197,-7.455,55.063,-7.681,54.952,-7.518,55.123,-7.632,55.278,-7.798,55.216,-7.71,55.168,-7.722,55.222,-7.702,55.225,-7.665,55.158,-7.695,55.121,-7.687,55.107,-7.695,55.099,-7.806,55.178,-7.816,55.199,-7.804,55.219,-7.788,55.225,-7.788,55.257,-7.817,55.226,-7.869,55.225,-7.825,55.182,-7.855,55.129,-7.982,55.228,-8.136,55.121,-8.25,55.156,-7.642,56.789,-7.57,56.931,-7.473,56.927,-7.536,56.939,-7.521,56.944,-7.547,56.945,-7.561,56.949,-7.559,56.953,-7.374,56.986,-7.446,57.02,-7.442,57.058,-7.529,57.013,-7.458,57.239,-7.376,57.104,-7.21,57.116,-7.347,57.151,-7.244,57.164,-7.346,57.236,-7.26,57.225,-7.192,57.299,-7.225,57.324,-7.269,57.324,-7.294,57.338,-7.263,57.328,-7.214,57.35,-7.255,57.343,-7.379,57.397,-7.369,57.404,-7.362,57.418,-7.43,57.386,-7.547,57.599,-7.148,57.509,-7.258,57.553,-7.117,57.565,-7.183,57.617,-7.116,57.632,-7.1,57.607,-7.096,57.668,-7.132,57.642,-7.17,57.653,-7.194,57.698,-7.487,57.661,-7.136,58.127,-7.049,58.035,-6.982,58.047,-6.916,58.052,-6.907,58.049,-7.084,58.02,-7.115,57.989,-6.815,57.901,-7.134,57.837,-6.967,57.727,-6.854,57.83,-6.736,57.827,-6.803,57.897,-6.666,57.881,-6.757,57.994,-6.688,58.056,-6.638,58.057,-6.591,58.053,-6.713,58.014,-6.657,57.92,-6.47,57.937,-6.456,57.994,-6.574,58.003,-6.467,58.018,-6.387,58.0,-6.437,58.048,-6.37,58.05,-6.398,58.087,-6.384,58.093,-6.401,58.097,-6.386,58.105,-6.431,58.092,-6.633,58.082,-6.428,58.12,-6.486,58.141,-6.371,58.13,-6.394,58.208,-6.251,58.179,-6.152,58.221,-6.347,58.228,-6.163,58.343,-6.258,58.516,-5.022,58.578],"holes":[[-6.357,57.705,-6.427,57.641,-6.314,57.456,-6.634,57.608,-6.584,57.425,-6.717,57.514,-6.79,57.421,-6.581,57.332,-6.53,57.403,-6.392,57.36,-6.315,57.299,-6.482,57.291,-6.345,57.251,-6.388,57.218,-6.285,57.2,-6.322,57.16,-5.842,57.188,-5.997,57.126,-6.017,57.018,-5.669,57.209,-5.661,57.265,-6.157,57.299,-6.095,57.339,-6.201,57.396,-6.137,57.587,-6.357,57.705],[-6.131,56.656,-6.324,56.606,-6.34,56.537,-6.138,56.491,-6.311,56.497,-6.242,56.465,-5.996,56.489,-6.196,56.358,-5.986,56.385,-6.389,56.289,-5.833,56.31,-5.646,56.426,-6.131,56.656],[-5.698,56.144,-5.963,56.029,-6.007,55.973,-5.884,55.974,-6.043,55.94,-6.083,55.821,-5.963,55.792,-5.698,56.144],[-6.086,55.786,-6.121,55.935,-6.196,55.926,-6.327,55.825,-6.325,55.891,-6.456,55.853,-6.526,55.693,-6.26,55.783,-6.342,55.717,-6.261,55.66,-6.338,55.59,-6.268,55.579,-6.02,55.683,-6.086,55.786],[-5.174,55.434,-5.079,55.465,-5.159,55.678,-5.367,55.678,-5.356,55.506,-5.174,55.434]]}],"names":{"ar":"البحار الداخلية غرب كوست في اسكوتلندا","de":"Schottische See","fr":"Mers intérieures de la côte occidentale de l'Écosse","nl":"Inner Seas off the West Coast of Scotland","pt":"Mares Internos Da Costa Oeste De Escócia","zh":"苏格兰西海岸内陆海"}},{"name":"Flores Sea","bbox":[117.133,-8.744,122.938,-5.511],"area":8.3489,"polygons":[{"outer":[120.328,-5.511,120.467,-5.62,120.466,-6.094,120.426,-6.18,120.653,-7.015,120.632,-7.133,120.73,-7.13,120.909,-7.278,120.807,-7.288,121.099,-7.398,121.838,-7.374,122.864,-8.065,122.73,-8.222,122.938,-8.194,122.883,-8.288,122.491,-8.473,122.474,-8.609,122.293,-8.642,122.016,-8.529,122.023,-8.44,121.801,-8.51,121.618,-8.471,121.514,-8.615,121.024,-8.413,120.978,-8.347,120.734,-8.349,120.601,-8.255,120.494,-8.312,120.428,-8.241,120.2,-8.306,120.11,-8.441,120.075,-8.377,120.0,-8.461,119.907,-8.475,119.857,-8.43,119.88,-8.478,119.876,-8.509,119.827,-8.542,119.807,-8.569,118.814,-8.281,118.702,-8.388,118.722,-8.467,118.651,-8.55,118.647,-8.29,118.464,-8.245,118.28,-8.365,118.143,-8.134,117.924,-8.08,117.725,-8.149,117.693,-8.24,118.288,-8.62,118.052,-8.663,117.974,-8.739,117.949,-8.744,117.775,-8.714,117.761,-8.598,117.634,-8.563,117.562,-8.405,117.428,-8.395,117.386,-8.473,117.172,-8.386,117.133,-7.433,119.17,-6.847,119.461,-5.576,119.515,-5.535,119.558,-5.65,119.789,-5.702,119.932,-5.546,120.13,-5.589,120.328,-5.511]}],"names":{"ar":"بحر فلوريس","de":"Floressee","es":"mar de Flores","fr":"mer de Florès","he":"ים פלורס","it":"Mar di Flores","nl":"Floreszee","pt":"Mar das Flores","zh":"弗洛勒斯海"}},{"name":"Balearic Sea","bbox":[-0.366,38.647,4.29,41.875],"area":8.4089,"polygons":[{"outer":[3.181,41.875,4.262,39.999,4.081,40.091,3.828,40.054,3.821,39.925,3.99,39.931,4.208,39.824,4.288,39.815,4.29,39.803,2.923,39.127,1.572,38.65,1.423,38.799,1.383,38.647,0.177,38.814,-0.159,38.994,-0.366,39.435,-0.302,39.435,-0.316,39.523,0.539,40.568,0.744,40.636,0.596,40.575,0.671,40.56,0.877,40.711,0.873,40.732,0.864,40.718,0.794,40.693,0.778,40.695,0.753,40.709,0.703,40.714,0.69,40.722,0.616,40.704,0.589,40.71,0.577,40.718,0.566,40.748,0.543,40.755,0.535,40.762,0.533,40.773,0.504,40.785,0.501,40.789,0.524,40.845,0.52,40.848,0.51,40.862,0.506,40.885,0.486,40.9,0.486,40.901,0.49,40.903,0.509,40.907,0.499,40.914,0.486,40.931,0.491,40.943,0.484,40.95,0.484,40.953,0.486,40.955,0.505,40.957,0.525,40.846,0.502,40.788,0.534,40.775,0.543,40.757,0.565,40.751,0.581,40.716,0.689,40.724,0.753,40.711,0.782,40.696,0.801,40.697,0.868,40.732,0.7,40.803,0.987,41.041,2.152,41.295,2.267,41.459,3.181,41.875],"holes":[[3.212,39.962,2.778,39.856,2.346,39.565,2.522,39.456,2.688,39.553,2.788,39.362,2.957,39.363,3.073,39.268,3.232,39.361,3.479,39.716,3.348,39.789,3.253,39.729,3.145,39.773,3.204,39.89,3.08,39.901,3.212,39.962],[1.533,39.119,1.29,39.028,1.224,38.87,1.406,38.83,1.622,39.027,1.533,39.119]]}],"names":{"ar":"البحر البلياري","de":"Balearen-Meer","es":"mar Balear","fr":"mer des Baléares","he":"הים הבלארי","hu":"Baleár-tenger","it":"mare delle Baleari","nl":"Balearische Zee","pt":"Mar das Baleares","zh":"巴利阿里海"}},{"name":"Savu Sea","bbox":[118.502,-10.923,125.001,-8.224],"area":8.6981,"polygons":[{"outer":[124.556,-8.224,124.408,-8.296,124.352,-8.438,125.001,-8.357,124.977,-8.941,124.56,-9.178,124.187,-9.249,123.795,-9.489,123.578,-9.941,123.592,-10.043,123.752,-10.029,123.746,-10.094,123.489,-10.224,123.369,-10.478,123.212,-10.58,123.31,-10.593,123.195,-10.593,123.043,-10.729,122.805,-10.782,122.824,-10.923,121.272,-10.822,120.456,-10.311,120.718,-10.205,120.847,-10.055,120.468,-9.617,120.251,-9.646,120.211,-9.491,120.085,-9.47,119.935,-9.276,119.794,-9.399,119.174,-9.376,118.927,-9.553,118.502,-8.884,118.72,-8.793,118.96,-8.818,118.727,-8.776,118.693,-8.748,118.761,-8.693,119.173,-8.724,119.167,-8.557,119.034,-8.639,119.004,-8.314,119.268,-8.401,119.28,-8.469,119.424,-8.445,119.449,-8.49,119.377,-8.738,119.405,-8.722,119.441,-8.753,119.471,-8.738,119.454,-8.607,119.575,-8.596,119.572,-8.497,119.807,-8.569,119.794,-8.583,119.811,-8.607,119.799,-8.765,119.888,-8.861,120.128,-8.769,120.277,-8.855,120.595,-8.815,120.754,-8.894,120.838,-8.83,121.029,-8.956,121.341,-8.906,121.389,-8.789,121.59,-8.804,121.646,-8.906,122.116,-8.73,122.47,-8.733,122.832,-8.59,122.843,-8.545,122.789,-8.479,122.784,-8.423,122.887,-8.45,122.915,-8.329,123.021,-8.305,123.005,-8.413,123.277,-8.396,123.33,-8.277,123.433,-8.299,123.465,-8.346,123.205,-8.52,123.394,-8.586,123.489,-8.515,123.546,-8.572,123.621,-8.406,123.923,-8.251,124.022,-8.316,123.913,-8.44,124.034,-8.421,124.072,-8.551,124.198,-8.481,124.325,-8.232,124.556,-8.224]}],"names":{"ar":"بحر سافو","de":"Sawusee","es":"mar de Savu","fr":"mer de Savu","he":"ים סאבו","it":"Mare di Savu","nl":"Savoezee","pt":"Mar de Savu","zh":"萨武海"}},{"name":"Gulf of Finland","bbox":[22.91,59.215,37.469,62.904],"area":9.5908,"polygons":[{"outer":[35.83,61.967,35.908,61.926,35.833,61.848,35.951,61.801,35.979,61.812,36.027,61.803,36.05,61.817,36.106,61.809,36.107,61.821,36.117,61.828,36.233,61.849,36.368,61.826,36.395,61.854,36.423,61.856,36.459,61.847,36.466,61.843,36.516,61.784,36.54,61.787,36.539,61.796,36.547,61.807,36.568,61.813,36.618,61.79,36.622,61.792,36.616,61.802,36.622,61.813,36.628,61.815,36.643,61.817,36.904,61.824,36.94,61.817,36.96,61.809,36.964,61.826,37.011,61.864,37.058,61.883,37.093,61.893,37.107,61.917,37.117,61.922,37.124,61.923,37.124,61.926,37.13,61.927,37.141,61.94,37.149,61.943,37.25,61.969,37.307,61.971,37.365,61.994,37.461,62.002,37.437,62.042,37.438,62.064,37.441,62.067,37.469,62.012,37.463,62.001,37.421,61.988,37.366,61.993,37.311,61.97,37.232,61.961,37.229,61.947,37.155,61.928,37.143,61.94,37.093,61.891,37.013,61.863,36.967,61.826,36.965,61.811,36.956,61.807,36.535,61.779,36.488,61.791,36.426,61.855,36.362,61.823,36.23,61.847,36.121,61.825,36.135,61.814,36.088,61.804,36.015,61.798,35.965,61.802,36.06,61.718,36.021,61.672,36.247,61.484,36.458,61.409,36.432,61.278,36.341,61.272,36.431,61.182,36.288,61.099,36.299,61.097,36.305,61.093,36.327,61.09,36.339,61.085,36.44,61.008,36.328,61.089,36.102,61.083,36.013,60.964,35.807,60.939,35.869,60.938,35.947,60.913,35.892,60.856,35.75,60.899,35.786,60.91,35.804,60.906,35.812,60.927,35.53,60.919,35.435,61.045,35.089,61.001,35.178,61.021,34.882,61.092,34.937,61.034,34.786,60.976,34.625,60.954,34.066,60.905,33.883,60.97,33.703,60.761,33.572,60.734,33.352,60.719,33.293,60.642,33.262,60.637,33.171,60.641,33.122,60.585,33.075,60.58,33.079,60.556,33.113,60.548,33.119,60.538,33.066,60.512,33.03,60.505,33.088,60.487,33.121,60.457,33.131,60.458,33.136,60.47,33.146,60.473,33.156,60.472,33.163,60.461,33.179,60.462,33.162,60.46,33.146,60.472,33.132,60.457,33.126,60.455,33.118,60.456,33.007,60.507,32.922,60.483,33.007,60.434,33.016,60.391,33.032,60.386,33.043,60.378,33.067,60.379,33.086,60.371,33.1,60.35,33.096,60.347,33.09,60.345,33.075,60.347,33.072,60.337,33.099,60.342,33.112,60.34,33.154,60.317,33.165,60.307,33.165,60.296,33.179,60.295,33.164,60.295,33.107,60.341,33.082,60.334,33.069,60.337,33.085,60.369,33.039,60.377,33.009,60.393,32.917,60.483,32.786,60.443,32.762,60.467,32.794,60.478,32.814,60.475,32.811,60.47,32.822,60.478,32.791,60.508,32.584,60.511,32.692,60.394,32.585,60.347,32.617,60.211,32.497,60.158,32.559,60.136,32.594,60.081,32.606,60.012,32.582,60.003,32.548,60.139,32.342,60.133,32.348,60.059,32.329,60.015,32.298,60.0,32.322,60.13,32.266,60.122,32.23,60.132,32.083,60.263,31.742,60.258,31.554,60.152,31.574,60.091,31.474,60.08,31.561,59.986,31.523,59.923,31.033,59.95,30.945,59.82,30.765,59.764,30.63,59.791,30.49,59.845,30.393,59.923,30.389,59.957,30.246,59.911,30.213,59.916,30.159,59.853,29.689,59.98,29.637,59.929,29.127,59.986,28.989,59.912,29.067,59.871,28.964,59.808,28.472,59.827,28.423,59.673,28.314,59.672,28.24,59.644,28.144,59.624,28.155,59.613,28.176,59.612,28.185,59.61,28.194,59.604,28.229,59.558,28.14,59.621,28.144,59.625,28.168,59.637,28.24,59.646,28.307,59.678,28.097,59.789,28.001,59.693,28.093,59.595,28.044,59.473,28.125,59.439,28.143,59.418,28.193,59.402,28.21,59.384,28.21,59.378,28.042,59.47,27.812,59.395,26.951,59.437,25.98,59.635,25.816,59.563,25.697,59.675,25.677,59.565,25.498,59.647,25.555,59.54,25.443,59.493,24.931,59.494,24.796,59.57,24.798,59.446,24.359,59.474,24.178,59.398,24.239,59.345,24.037,59.389,24.094,59.273,23.562,59.215,23.389,59.3,22.91,59.808,23.249,59.842,23.224,59.891,23.553,60.097,23.42,59.959,23.492,59.983,23.568,59.992,23.584,59.985,23.354,59.917,23.843,59.993,23.808,59.933,23.896,59.958,23.932,59.937,23.915,59.969,23.967,59.967,23.956,59.979,24.003,59.98,23.846,60.019,24.334,60.093,24.342,60.088,24.35,60.091,24.361,60.085,24.382,60.086,24.388,60.082,24.363,60.037,24.321,60.015,24.322,60.003,24.478,60.065,24.47,60.034,24.424,59.991,24.39,59.977,24.415,59.96,24.448,59.997,24.489,60.007,24.509,59.994,24.509,60.02,24.547,60.027,24.54,60.04,24.599,60.07,24.563,60.046,24.599,60.048,24.59,60.05,24.622,60.068,24.602,60.069,24.608,60.088,24.652,60.101,24.568,60.102,24.628,60.136,24.562,60.173,24.709,60.106,24.692,60.139,24.831,60.206,24.909,60.184,24.898,60.166,24.925,60.146,25.009,60.214,24.988,60.186,25.06,60.189,25.01,60.169,25.049,60.16,25.031,60.133,25.063,60.137,25.212,60.257,25.329,60.284,25.498,60.263,25.397,60.252,25.458,60.232,25.542,60.287,25.515,60.328,25.658,60.394,25.721,60.341,25.645,60.301,25.668,60.292,25.672,60.276,25.71,60.277,25.714,60.263,25.787,60.237,25.853,60.277,25.814,60.276,25.786,60.282,25.743,60.323,25.861,60.289,25.886,60.244,25.937,60.261,25.886,60.284,25.865,60.325,25.914,60.367,25.848,60.412,25.994,60.374,26.029,60.318,26.014,60.306,26.12,60.325,25.994,60.401,25.924,60.49,25.985,60.474,26.064,60.411,26.07,60.434,26.171,60.396,26.178,60.367,26.172,60.421,26.239,60.389,26.248,60.398,26.265,60.387,26.274,60.388,26.233,60.436,26.238,60.457,26.525,60.341,26.54,60.366,26.412,60.402,26.449,60.493,26.499,60.456,26.535,60.452,26.572,60.428,26.566,60.452,26.592,60.46,26.712,60.432,26.741,60.484,26.886,60.489,26.943,60.446,26.905,60.505,26.987,60.463,26.967,60.488,27.014,60.553,27.16,60.517,27.183,60.529,27.163,60.569,27.172,60.582,27.198,60.515,27.377,60.487,27.364,60.514,27.477,60.46,27.467,60.51,27.546,60.496,27.62,60.467,27.568,60.505,27.623,60.526,27.74,60.484,27.739,60.581,27.771,60.579,27.81,60.539,27.812,60.47,27.856,60.552,28.232,60.511,28.345,60.596,28.299,60.624,28.386,60.604,28.375,60.647,28.399,60.662,28.551,60.681,28.549,60.671,28.58,60.651,28.589,60.689,28.655,60.681,28.617,60.702,28.733,60.81,28.701,60.77,28.777,60.714,28.771,60.679,28.713,60.629,28.643,60.655,28.715,60.44,28.673,60.43,28.438,60.547,28.617,60.352,28.803,60.358,29.148,60.16,29.871,60.161,29.967,60.011,30.181,59.986,30.129,60.022,30.202,59.979,30.301,59.984,30.401,59.958,30.496,59.849,30.784,59.773,30.938,59.822,30.992,59.938,31.1,59.989,31.051,60.176,30.96,60.199,30.695,60.564,30.637,60.573,30.611,60.602,30.508,60.623,30.54,60.797,30.361,60.96,29.89,61.143,29.807,61.218,29.948,61.221,29.827,61.259,29.848,61.27,30.02,61.227,29.91,61.255,29.879,61.281,29.904,61.298,30.079,61.226,30.061,61.291,30.119,61.311,30.182,61.276,30.201,61.279,30.229,61.277,30.247,61.281,30.203,61.29,30.27,61.319,30.16,61.33,30.304,61.325,30.161,61.336,30.218,61.358,30.18,61.422,30.234,61.472,30.26,61.464,30.259,61.449,30.303,61.449,30.325,61.428,30.359,61.452,30.307,61.46,30.256,61.488,30.219,61.481,30.212,61.526,30.312,61.529,30.396,61.464,30.393,61.487,30.496,61.483,30.41,61.541,30.486,61.513,30.553,61.539,30.569,61.535,30.512,61.561,30.64,61.548,30.539,61.587,30.619,61.631,30.72,61.614,30.657,61.664,30.805,61.759,30.748,61.782,31.097,61.688,31.11,61.597,31.14,61.608,31.18,61.6,31.151,61.648,31.169,61.667,31.167,61.63,31.19,61.612,31.177,61.638,31.183,61.64,31.19,61.641,31.236,61.607,31.273,61.651,31.271,61.599,31.291,61.585,31.332,61.651,31.404,61.635,31.599,61.415,31.679,61.39,31.582,61.45,31.583,61.48,31.633,61.487,31.704,61.387,31.859,61.365,31.896,61.303,31.659,61.376,31.712,61.343,31.694,61.337,32.532,61.117,32.651,61.04,32.667,61.007,32.596,61.062,32.586,60.987,32.624,60.994,32.809,60.84,32.811,60.715,32.956,60.658,32.872,60.481,32.992,60.518,33.109,60.533,33.113,60.545,33.073,60.555,33.065,60.575,33.169,60.646,33.146,60.654,33.108,60.638,33.075,60.636,33.082,60.639,33.143,60.657,33.289,60.644,33.287,60.663,33.348,60.722,33.702,60.765,33.874,60.97,34.046,60.975,34.065,60.909,34.811,60.998,34.888,61.045,34.785,61.127,34.852,61.229,35.084,61.188,34.945,61.111,34.96,61.114,34.967,61.108,34.962,61.116,34.985,61.115,34.989,61.114,34.99,61.112,34.981,61.112,34.99,61.102,35.001,61.101,35.018,61.107,35.201,61.028,35.3,61.075,35.44,61.047,35.564,60.994,35.649,61.06,35.696,61.133,35.486,61.369,34.709,61.576,34.69,61.627,34.594,61.645,34.305,61.828,34.358,61.853,34.288,61.863,34.311,61.87,34.303,61.87,34.295,61.875,34.292,61.879,34.283,61.878,34.24,61.894,34.216,61.916,34.297,61.911,34.571,61.791,34.54,61.864,34.617,61.857,34.524,61.884,34.499,61.901,34.631,61.895,34.268,62.108,34.238,62.203,34.252,62.2,34.268,62.189,34.268,62.18,34.314,62.154,34.312,62.163,34.631,62.056,34.619,62.071,34.701,62.025,34.739,62.021,34.544,62.238,34.594,62.243,34.728,62.164,34.692,62.238,34.572,62.311,34.419,62.476,34.63,62.359,34.81,62.227,34.774,62.324,34.579,62.435,34.433,62.611,34.705,62.444,34.839,62.263,34.819,62.349,34.914,62.14,34.992,62.109,35.046,62.056,35.164,61.998,35.153,62.035,35.217,62.049,35.019,62.249,35.173,62.173,35.373,62.124,35.606,62.234,35.371,62.557,35.326,62.562,35.357,62.512,35.335,62.499,34.95,62.646,34.993,62.596,34.823,62.61,34.981,62.479,34.97,62.479,35.036,62.414,35.091,62.335,35.078,62.341,34.46,62.904,35.42,62.656,35.424,62.609,35.555,62.533,35.508,62.639,35.594,62.607,35.544,62.559,35.747,62.54,35.861,62.44,35.864,62.309,35.675,62.04,35.83,61.967]}],"names":{"ar":"خليج فنلندا","de":"Finnischer Meerbusen","es":"Golfo de Finlandia","fr":"golfe de Finlande","he":"המפרץ הפיני","hu":"Finn-öböl","it":"Golfo di Finlandia","nl":"Finse Golf","pt":"Golfo da Finlândia","zh":"芬蘭灣"}},{"name":"Gulf of Oman","bbox":[56.273,22.475,61.78,25.942],"area":9.8177,"polygons":[{"outer":[56.466,25.942,57.698,25.763,57.79,25.701,57.764,25.636,57.951,25.7,58.034,25.598,58.114,25.559,58.802,25.572,59.088,25.402,59.253,25.434,59.225,25.464,59.134,25.419,59.115,25.434,59.139,25.49,59.151,25.461,59.173,25.5,59.185,25.507,59.197,25.507,59.159,25.447,59.259,25.495,59.261,25.437,59.451,25.472,59.606,25.39,60.197,25.324,60.276,25.389,60.467,25.287,60.406,25.375,60.532,25.443,60.609,25.285,61.422,25.059,61.48,25.184,61.574,25.18,61.56,25.195,61.562,25.22,61.573,25.225,61.549,25.229,61.547,25.231,61.549,25.238,61.574,25.228,61.578,25.179,61.66,25.171,61.669,25.173,61.661,25.189,61.664,25.192,61.7,25.193,61.695,25.21,61.734,25.255,61.702,25.194,61.683,25.179,61.663,25.19,61.672,25.178,61.67,25.166,61.78,25.142,59.75,22.475,59.714,22.5,59.73,22.537,59.515,22.548,59.292,22.764,58.764,23.511,58.725,23.509,58.723,23.532,58.614,23.57,58.596,23.628,58.342,23.604,57.615,23.789,57.174,23.939,56.936,24.119,56.55,24.594,56.36,25.012,56.349,25.365,56.37,25.527,56.273,25.705,56.466,25.942]}],"names":{"ar":"خليج عمان","de":"Golf von Oman","es":"Golfo de Omán","fr":"golfe d'Oman","he":"מפרץ עומאן","hu":"Ománi-öböl","it":"Golfo di Oman","nl":"Golf van Oman","pt":"Golfo de Omã","zh":"阿曼灣"}},{"name":"English Channel","bbox":[-5.709,48.5,1.931,51.166],"area":10.2488,"polygons":[{"outer":[1.402,51.166,1.931,50.995,1.58,50.869,1.624,50.52,1.555,50.399,1.617,50.37,1.538,50.28,1.666,50.189,1.523,50.209,1.106,49.936,0.207,49.715,0.065,49.517,0.151,49.451,0.504,49.487,0.527,49.479,0.578,49.437,0.609,49.43,0.678,49.515,0.71,49.523,0.74,49.523,0.766,49.515,0.774,49.507,0.78,49.482,0.812,49.472,0.82,49.453,0.817,49.444,0.804,49.43,0.812,49.404,0.861,49.394,0.812,49.402,0.722,49.521,0.614,49.428,0.582,49.431,0.496,49.481,-0.245,49.264,-0.948,49.396,-1.117,49.345,-1.304,49.537,-1.296,49.583,-1.229,49.606,-1.265,49.697,-1.619,49.639,-1.942,49.727,-1.848,49.626,-1.889,49.533,-1.809,49.372,-1.589,49.223,-1.586,49.008,-1.556,49.034,-1.534,49.038,-1.513,49.028,-1.615,48.834,-1.571,48.743,-1.447,48.654,-1.405,48.672,-1.359,48.635,-1.777,48.604,-1.868,48.642,-1.844,48.713,-1.938,48.703,-1.947,48.683,-2.029,48.651,-1.962,48.591,-1.971,48.535,-2.047,48.639,-2.178,48.577,-2.242,48.645,-2.314,48.611,-2.318,48.689,-2.687,48.5,-3.043,48.784,-3.008,48.821,-3.147,48.739,-3.071,48.885,-3.184,48.845,-3.22,48.773,-3.236,48.76,-3.254,48.751,-3.223,48.871,-3.265,48.834,-3.41,48.799,-3.521,48.841,-3.544,48.798,-3.555,48.793,-3.591,48.807,-3.531,48.733,-3.572,48.675,-3.824,48.72,-3.854,48.62,-3.926,48.7,-3.924,48.669,-3.951,48.636,-3.982,48.73,-4.066,48.682,-4.188,48.688,-4.216,48.649,-4.562,48.625,-5.709,50.057,-5.488,50.128,-5.285,50.085,-5.198,49.958,-5.056,50.052,-5.194,50.089,-5.041,50.144,-5.092,50.208,-5.039,50.249,-4.966,50.237,-5.036,50.204,-4.992,50.176,-5.018,50.149,-5.016,50.141,-5.009,50.139,-4.798,50.22,-4.76,50.328,-4.645,50.327,-4.66,50.388,-4.632,50.324,-4.189,50.317,-4.202,50.388,-4.276,50.374,-4.302,50.388,-4.305,50.399,-4.212,50.399,-4.24,50.461,-4.237,50.467,-4.22,50.475,-4.155,50.459,-4.186,50.367,-4.085,50.386,-4.112,50.316,-3.641,50.221,-3.564,50.334,-3.657,50.411,-3.543,50.335,-3.482,50.399,-3.559,50.421,-3.496,50.538,-3.583,50.539,-3.427,50.605,-3.467,50.681,-3.358,50.606,-2.793,50.718,-2.456,50.513,-2.424,50.635,-1.959,50.591,-1.942,50.673,-2.069,50.711,-1.552,50.705,-1.307,50.815,-1.335,50.844,-1.472,50.907,-1.475,50.92,-1.326,50.848,-1.309,50.849,-1.311,50.874,-1.301,50.879,-1.304,50.883,-1.287,50.896,-1.306,50.84,-1.141,50.773,-1.175,50.84,-1.087,50.777,-1.027,50.788,-1.019,50.843,-0.934,50.844,-0.909,50.797,-0.914,50.839,-0.812,50.83,-0.917,50.78,-0.789,50.722,-0.244,50.83,0.242,50.734,0.773,50.932,0.979,50.912,1.007,51.032,1.402,51.166],"holes":[[-1.13,50.727,-1.314,50.768,-1.593,50.662,-1.255,50.579,-1.07,50.683,-1.13,50.727]]}],"names":{"ar":"بحر المانش","de":"Ärmelkanal","es":"canal de la Mancha","fr":"Manche","he":"תעלת למאנש","hu":"La Manche","it":"la Manica","nl":"Het Kanaal","pt":"Canal da Mancha","zh":"英吉利海峡"}},{"name":"Bass Strait","bbox":[143.533,-41.44,149.905,-37.465],"area":11.6975,"polygons":[{"outer":[149.905,-37.543,147.982,-39.637,148.0,-39.774,147.847,-39.75,147.845,-39.833,147.747,-39.874,147.888,-39.923,147.856,-40.022,148.008,-40.106,148.046,-40.249,148.123,-40.274,148.211,-40.211,148.313,-40.222,148.301,-40.13,148.417,-40.267,148.481,-40.432,148.332,-40.291,147.983,-40.403,148.31,-40.429,148.351,-40.493,148.481,-40.437,148.348,-40.991,148.223,-40.845,147.94,-40.739,147.888,-40.88,147.666,-40.822,147.424,-41.012,147.282,-40.942,147.136,-41.037,147.016,-40.981,146.786,-41.054,146.958,-41.183,146.912,-41.214,147.01,-41.272,146.983,-41.313,147.073,-41.332,147.082,-41.343,147.079,-41.366,147.127,-41.41,147.13,-41.44,146.972,-41.309,146.898,-41.203,146.923,-41.17,146.771,-41.162,146.82,-41.138,146.769,-41.079,146.564,-41.147,146.604,-41.155,146.578,-41.185,146.601,-41.214,146.602,-41.222,146.598,-41.243,146.532,-41.118,146.37,-41.171,146.381,-41.231,146.357,-41.158,146.248,-41.173,146.126,-41.162,145.505,-40.851,145.308,-40.847,145.261,-40.712,145.269,-40.75,145.266,-40.79,145.257,-40.8,145.124,-40.832,145.018,-40.731,144.923,-40.765,144.766,-40.739,144.727,-40.642,144.686,-40.682,144.097,-40.048,144.109,-39.669,143.949,-39.578,143.87,-39.705,143.533,-38.855,143.841,-38.691,144.037,-38.475,144.487,-38.258,144.613,-38.292,144.673,-38.267,144.666,-38.257,144.642,-38.27,144.621,-38.264,144.707,-38.211,144.69,-38.119,144.361,-38.116,144.909,-37.871,144.895,-37.828,144.915,-37.81,145.118,-38.143,144.939,-38.344,144.653,-38.308,144.925,-38.5,145.235,-38.41,145.196,-38.304,145.264,-38.226,145.482,-38.232,145.543,-38.387,145.416,-38.405,145.371,-38.538,145.607,-38.682,145.729,-38.636,145.824,-38.644,145.844,-38.697,145.73,-38.647,145.922,-38.91,146.047,-38.811,146.19,-38.868,146.158,-38.849,146.148,-38.797,146.344,-39.123,146.424,-39.131,146.476,-38.788,146.299,-38.906,146.189,-38.733,146.35,-38.676,146.557,-38.708,146.645,-38.644,146.693,-38.677,146.82,-38.611,146.913,-38.604,146.888,-38.631,147.576,-38.081,147.971,-37.89,147.451,-38.077,147.727,-37.918,147.741,-37.858,147.679,-37.884,147.656,-37.849,147.806,-37.849,147.857,-37.897,147.98,-37.855,148.083,-37.861,148.056,-37.82,148.132,-37.777,148.092,-37.858,148.51,-37.767,149.484,-37.776,149.677,-37.685,149.766,-37.574,149.694,-37.524,149.727,-37.515,149.676,-37.492,149.692,-37.483,149.676,-37.473,149.676,-37.469,149.679,-37.465,149.742,-37.528,149.809,-37.488,149.802,-37.533,149.767,-37.564,149.905,-37.543]}],"names":{"ar":"مضيق باس","de":"Bass-Straße","es":"estrecho de Bass","fr":"détroit de Bass","he":"מצר בס","hu":"Bass-szoros","it":"stretto di Bass","nl":"Straat Bass","pt":"Estreito de Bass","zh":"巴斯海峡"}},{"name":"Ceram Sea","bbox":[125.947,-5.274,134.031,-1.199],"area":13.0471,"polygons":[{"outer":[129.929,-1.215,131.209,-1.523,131.336,-1.463,131.259,-1.372,131.315,-1.372,131.294,-1.354,131.291,-1.339,131.301,-1.329,131.28,-1.315,131.301,-1.326,131.302,-1.333,131.295,-1.347,131.297,-1.355,131.325,-1.343,131.325,-1.351,131.35,-1.334,131.353,-1.321,131.336,-1.328,131.333,-1.307,131.338,-1.291,131.349,-1.286,131.355,-1.321,131.347,-1.341,131.336,-1.347,131.339,-1.357,131.336,-1.367,131.424,-1.34,131.389,-1.41,131.416,-1.428,131.433,-1.378,131.434,-1.386,131.444,-1.384,131.447,-1.391,131.451,-1.394,131.452,-1.397,131.426,-1.426,131.383,-1.442,131.382,-1.457,131.518,-1.473,131.514,-1.377,131.55,-1.385,131.535,-1.36,131.536,-1.349,131.585,-1.344,131.574,-1.316,131.57,-1.29,131.572,-1.281,131.587,-1.275,131.56,-1.258,131.56,-1.253,131.569,-1.241,131.589,-1.275,131.588,-1.342,131.524,-1.48,131.641,-1.448,131.61,-1.41,131.62,-1.369,131.637,-1.372,131.633,-1.368,131.632,-1.362,131.641,-1.362,131.633,-1.362,131.639,-1.372,131.611,-1.411,131.66,-1.443,131.655,-1.462,131.669,-1.465,131.664,-1.449,131.666,-1.444,131.677,-1.436,131.652,-1.411,131.651,-1.404,131.658,-1.399,131.685,-1.437,131.713,-1.415,131.744,-1.451,131.729,-1.584,131.798,-1.549,131.791,-1.492,131.821,-1.474,131.818,-1.451,131.8,-1.453,131.796,-1.448,131.797,-1.441,131.798,-1.436,131.803,-1.436,131.82,-1.442,131.821,-1.429,131.834,-1.499,131.779,-1.597,131.81,-1.614,131.877,-1.508,131.87,-1.584,131.97,-1.549,131.958,-1.482,131.975,-1.448,131.96,-1.431,131.958,-1.426,131.986,-1.417,132.002,-1.555,131.882,-1.635,131.961,-1.656,131.912,-1.72,132.053,-1.663,132.043,-1.643,132.047,-1.629,132.069,-1.62,132.067,-1.595,132.089,-1.572,132.103,-1.569,132.093,-1.557,132.097,-1.543,132.078,-1.556,132.074,-1.555,132.071,-1.551,132.076,-1.52,132.102,-1.545,132.106,-1.569,132.07,-1.598,132.073,-1.618,132.05,-1.644,132.101,-1.605,132.133,-1.604,132.135,-1.599,132.125,-1.593,132.122,-1.583,132.127,-1.579,132.142,-1.582,132.123,-1.585,132.138,-1.6,132.121,-1.616,132.069,-1.632,132.079,-1.67,132.005,-1.685,132.09,-1.703,132.072,-1.723,132.04,-1.729,132.088,-1.753,132.092,-1.79,132.087,-1.754,132.068,-1.766,132.028,-1.715,132.009,-1.768,132.032,-1.77,132.044,-1.785,132.038,-1.791,132.008,-1.772,131.996,-1.738,131.921,-1.813,132.015,-1.804,131.95,-1.863,131.974,-1.844,131.983,-1.86,131.987,-1.859,131.985,-1.848,131.989,-1.845,132.009,-1.859,132.013,-1.855,132.005,-1.847,132.007,-1.841,132.031,-1.835,132.036,-1.837,132.036,-1.852,132.038,-1.851,132.039,-1.843,132.042,-1.841,132.052,-1.849,132.059,-1.839,132.065,-1.84,132.073,-1.845,132.034,-1.853,132.03,-1.836,132.01,-1.86,131.988,-1.846,131.989,-1.859,131.985,-1.862,131.971,-1.847,132.034,-1.93,131.967,-1.964,132.043,-2.027,132.061,-2.018,132.052,-2.0,132.054,-1.967,132.087,-1.868,132.163,-1.88,132.165,-1.883,132.162,-1.888,132.167,-1.89,132.17,-1.898,132.174,-1.893,132.182,-1.893,132.188,-1.88,132.204,-1.868,132.222,-1.882,132.236,-1.857,132.252,-1.85,132.259,-1.852,132.256,-1.845,132.257,-1.838,132.26,-1.838,132.265,-1.846,132.263,-1.838,132.265,-1.835,132.292,-1.839,132.286,-1.827,132.286,-1.819,132.288,-1.816,132.325,-1.812,132.328,-1.817,132.323,-1.824,132.327,-1.826,132.339,-1.814,132.344,-1.823,132.324,-1.827,132.288,-1.817,132.294,-1.833,132.292,-1.841,132.264,-1.847,132.258,-1.839,132.26,-1.853,132.223,-1.884,132.17,-1.899,132.087,-1.87,132.103,-1.971,132.085,-2.021,132.113,-1.986,132.182,-1.99,132.182,-1.979,132.201,-1.977,132.205,-1.967,132.219,-1.966,132.217,-1.956,132.23,-1.961,132.222,-1.952,132.238,-1.934,132.243,-1.935,132.245,-1.944,132.258,-1.928,132.273,-1.934,132.136,-2.023,132.115,-1.993,132.039,-2.071,132.179,-2.153,132.197,-2.149,132.173,-2.14,132.168,-2.13,132.173,-2.12,132.19,-2.12,132.182,-2.086,132.194,-2.075,132.199,-2.102,132.19,-2.122,132.172,-2.123,132.172,-2.137,132.197,-2.144,132.316,-2.283,132.557,-2.19,132.645,-2.288,132.643,-2.269,132.663,-2.244,132.668,-2.245,132.686,-2.263,132.677,-2.246,132.706,-2.173,132.699,-2.168,132.696,-2.182,132.693,-2.184,132.688,-2.182,132.688,-2.166,132.694,-2.156,132.676,-2.155,132.669,-2.149,132.661,-2.122,132.664,-2.118,132.681,-2.122,132.667,-2.116,132.663,-2.11,132.671,-2.098,132.682,-2.104,132.671,-2.09,132.673,-2.087,132.677,-2.087,132.687,-2.095,132.681,-2.078,132.694,-2.072,132.653,-2.037,132.655,-2.027,132.666,-2.022,132.666,-2.016,132.659,-2.023,132.641,-2.021,132.613,-1.985,132.639,-1.966,132.643,-1.967,132.65,-1.975,132.65,-1.968,132.655,-1.962,132.637,-1.94,132.645,-1.923,132.636,-1.926,132.63,-1.917,132.619,-1.914,132.616,-1.91,132.62,-1.872,132.649,-1.849,132.644,-1.842,132.644,-1.834,132.635,-1.832,132.633,-1.825,132.639,-1.82,132.658,-1.827,132.638,-1.813,132.639,-1.796,132.643,-1.797,132.645,-1.803,132.649,-1.801,132.645,-1.794,132.651,-1.784,132.641,-1.782,132.639,-1.779,132.646,-1.767,132.636,-1.771,132.632,-1.768,132.632,-1.761,132.649,-1.749,132.624,-1.752,132.622,-1.748,132.636,-1.679,132.653,-1.679,132.652,-1.671,132.659,-1.663,132.648,-1.661,132.649,-1.652,132.652,-1.649,132.664,-1.651,132.664,-1.648,132.659,-1.647,132.657,-1.643,132.667,-1.641,132.663,-1.632,132.672,-1.629,132.654,-1.591,132.66,-1.588,132.674,-1.629,132.666,-1.652,132.649,-1.653,132.66,-1.664,132.642,-1.728,132.623,-1.748,132.649,-1.747,132.659,-1.827,132.635,-1.825,132.647,-1.834,132.651,-1.85,132.619,-1.91,132.647,-1.921,132.657,-1.962,132.651,-1.977,132.615,-1.987,132.667,-2.016,132.695,-2.073,132.709,-2.194,132.687,-2.264,132.644,-2.27,132.676,-2.293,132.817,-2.242,132.899,-2.281,132.943,-2.262,132.96,-2.272,132.951,-2.256,132.954,-2.254,132.965,-2.261,132.956,-2.237,132.973,-2.217,133.004,-2.193,133.02,-2.202,133.019,-2.196,133.001,-2.187,132.999,-2.18,133.022,-2.126,133.066,-2.107,133.023,-2.128,133.023,-2.202,132.957,-2.293,133.274,-2.158,133.498,-2.229,133.496,-2.206,133.475,-2.186,133.474,-2.175,133.485,-2.17,133.501,-2.184,133.509,-2.165,133.487,-2.159,133.488,-2.133,133.481,-2.145,133.479,-2.145,133.476,-2.125,133.469,-2.139,133.464,-2.142,133.46,-2.14,133.475,-2.124,133.489,-2.132,133.489,-2.159,133.511,-2.164,133.502,-2.186,133.479,-2.187,133.51,-2.19,133.528,-2.204,133.524,-2.191,133.54,-2.178,133.537,-2.174,133.53,-2.183,133.524,-2.183,133.524,-2.164,133.543,-2.173,133.53,-2.205,133.498,-2.193,133.509,-2.23,133.555,-2.224,133.58,-2.161,133.574,-2.175,133.567,-2.175,133.537,-2.143,133.525,-2.114,133.521,-2.123,133.506,-2.114,133.507,-2.124,133.5,-2.126,133.488,-2.108,133.457,-2.099,133.454,-2.094,133.525,-2.112,133.571,-2.174,133.579,-2.143,133.669,-2.131,133.576,-2.19,133.623,-2.233,133.639,-2.224,133.634,-2.207,133.62,-2.201,133.62,-2.194,133.673,-2.14,133.66,-2.203,133.715,-2.126,133.77,-2.161,133.776,-2.127,133.761,-2.125,133.753,-2.111,133.749,-2.116,133.734,-2.113,133.73,-2.106,133.734,-2.099,133.814,-2.098,133.781,-2.151,133.842,-2.067,133.949,-2.045,133.951,-2.065,133.948,-2.081,133.926,-2.091,133.927,-2.112,133.902,-2.136,133.966,-2.076,133.938,-2.161,133.96,-2.145,133.979,-2.153,133.986,-2.142,134.011,-2.156,134.004,-2.165,133.96,-2.147,133.937,-2.196,133.938,-2.198,133.945,-2.19,133.958,-2.183,133.963,-2.182,134.0,-2.242,133.991,-2.301,133.949,-2.325,133.958,-2.349,134.031,-2.338,133.998,-2.343,133.988,-2.389,133.948,-2.42,133.986,-2.448,133.974,-2.486,133.95,-2.5,133.964,-2.509,133.976,-2.538,133.976,-2.548,133.982,-2.548,133.992,-2.553,134.014,-2.58,133.974,-2.548,133.948,-2.501,133.964,-2.469,133.903,-2.482,133.853,-2.473,133.822,-2.445,133.821,-2.466,133.793,-2.483,133.859,-2.492,133.864,-2.496,133.871,-2.507,133.784,-2.487,133.808,-2.543,133.808,-2.621,133.799,-2.639,133.844,-2.662,133.862,-2.633,133.856,-2.621,133.859,-2.608,133.864,-2.606,133.896,-2.678,133.849,-2.704,133.877,-2.693,133.886,-2.699,133.89,-2.711,133.9,-2.706,133.914,-2.709,133.917,-2.704,133.927,-2.708,133.929,-2.71,133.928,-2.713,133.851,-2.709,133.836,-2.667,133.811,-2.735,133.799,-2.649,133.776,-2.754,133.69,-2.617,133.712,-2.782,133.65,-2.747,133.648,-2.868,133.604,-2.727,133.676,-2.705,133.668,-2.556,133.65,-2.618,133.642,-2.532,133.569,-2.568,133.627,-2.643,133.536,-2.61,133.523,-2.688,133.439,-2.643,133.439,-2.686,133.454,-2.687,133.46,-2.711,133.47,-2.703,133.477,-2.701,133.474,-2.716,133.481,-2.712,133.483,-2.713,133.491,-2.723,133.488,-2.727,133.473,-2.718,133.475,-2.702,133.459,-2.711,133.427,-2.677,133.434,-2.643,133.458,-2.617,133.423,-2.504,133.401,-2.538,133.419,-2.542,133.436,-2.603,133.432,-2.628,133.4,-2.667,133.41,-2.695,133.408,-2.709,133.4,-2.716,133.404,-2.718,133.404,-2.724,133.409,-2.723,133.408,-2.726,133.403,-2.725,133.399,-2.716,133.407,-2.694,133.381,-2.701,133.425,-2.759,133.412,-2.765,133.38,-2.722,133.38,-2.698,133.397,-2.685,133.39,-2.669,133.309,-2.701,133.313,-2.667,133.284,-2.708,133.281,-2.696,133.256,-2.712,133.242,-2.709,133.298,-2.644,133.372,-2.672,133.38,-2.523,133.324,-2.468,133.325,-2.515,133.306,-2.545,133.209,-2.547,133.31,-2.51,133.215,-2.411,133.043,-2.495,132.792,-2.79,132.197,-2.652,132.111,-2.691,132.201,-2.704,131.95,-2.769,132.041,-2.77,132.064,-2.787,131.978,-2.83,132.037,-2.838,131.976,-2.879,131.981,-2.924,132.35,-2.938,132.645,-3.192,132.596,-3.23,132.663,-3.316,132.773,-3.257,132.843,-3.299,132.816,-3.444,132.939,-3.552,132.891,-3.644,132.725,-3.636,132.913,-4.085,133.019,-4.119,133.316,-4.014,133.29,-3.949,133.404,-3.9,133.347,-3.906,133.295,-3.84,133.28,-3.852,133.254,-3.855,133.242,-3.85,133.236,-3.741,133.35,-3.9,133.45,-3.861,133.63,-4.29,133.553,-4.217,133.329,-4.093,133.331,-4.182,133.638,-4.309,133.156,-5.274,131.759,-4.742,131.68,-4.505,131.324,-4.032,130.825,-3.839,130.837,-3.455,130.664,-3.401,130.6,-3.149,130.353,-2.974,130.035,-3.003,129.527,-2.772,129.376,-2.79,129.132,-2.968,129.047,-2.794,126.299,-3.093,126.203,-3.186,126.091,-3.112,126.074,-2.427,125.947,-1.922,127.421,-1.659,127.566,-1.736,128.089,-1.722,128.345,-1.658,128.93,-1.387,129.73,-1.199,129.929,-1.215],"holes":[[130.34,-1.673,130.059,-1.725,129.719,-1.881,130.126,-2.06,130.265,-2.054,130.344,-1.956,130.385,-2.042,130.479,-2.008,130.341,-1.923,130.456,-1.833,130.34,-1.673]]}],"names":{"ar":"بحر سيرام","de":"Seramsee","es":"mar de Ceram","fr":"mer de Seram","he":"ים סראם","it":"Mare di Ceram","nl":"Ceramzee","pt":"Mar de Ceram","zh":"塞蘭海"}},{"name":"Adriatic Sea","bbox":[12.065,39.75,20.019,45.809],"area":15.4408,"polygons":[{"outer":[13.245,45.76,13.381,45.727,13.374,45.68,13.434,45.675,13.387,45.725,13.551,45.724,13.527,45.734,13.502,45.748,13.498,45.753,13.493,45.763,13.473,45.768,13.495,45.764,13.528,45.734,13.556,45.728,13.527,45.736,13.498,45.762,13.545,45.795,13.806,45.611,13.563,45.53,13.615,45.465,13.49,45.486,13.54,45.328,13.614,45.313,13.597,45.14,13.612,45.13,13.708,45.138,13.727,45.135,13.733,45.132,13.622,45.128,13.607,45.116,13.629,45.063,13.761,44.986,13.848,44.883,13.797,44.863,13.901,44.808,13.908,44.768,13.925,44.803,13.885,44.824,14.004,44.805,14.039,45.031,14.065,44.945,14.17,44.979,14.152,45.078,14.326,45.356,14.543,45.305,14.846,45.114,14.927,44.952,14.889,44.717,14.986,44.577,15.526,44.269,15.63,44.138,15.276,44.333,15.299,44.248,15.128,44.277,15.143,44.194,15.677,43.815,15.628,43.794,15.565,43.833,15.622,43.775,15.658,43.799,15.851,43.721,15.833,43.84,15.948,43.82,15.843,43.775,15.958,43.685,15.902,43.648,15.973,43.631,15.912,43.628,15.951,43.505,16.471,43.536,16.387,43.508,16.888,43.4,17.445,43.02,17.476,43.037,17.501,43.043,17.518,43.045,17.549,43.037,17.577,43.018,17.592,43.027,17.612,43.032,17.626,43.049,17.635,43.054,17.651,43.055,17.663,43.051,17.671,43.054,17.696,43.056,17.7,43.062,17.708,43.067,17.698,43.074,17.702,43.085,17.709,43.066,17.696,43.054,17.577,43.017,17.518,43.044,17.476,43.036,17.446,43.019,17.744,42.834,17.001,43.047,17.761,42.761,17.692,42.833,17.887,42.787,18.526,42.393,18.515,42.458,18.764,42.489,18.771,42.422,18.686,42.473,18.712,42.392,18.547,42.414,18.888,42.275,19.142,42.042,19.151,41.948,19.365,41.848,19.372,41.851,19.37,41.874,19.381,41.884,19.369,41.887,19.364,41.898,19.352,41.9,19.343,41.904,19.341,41.906,19.35,41.957,19.357,41.96,19.383,41.962,19.387,41.966,19.376,41.974,19.376,41.978,19.381,41.995,19.397,42.007,19.412,42.009,19.416,42.007,19.422,41.998,19.413,41.989,19.428,41.983,19.44,41.999,19.463,42.01,19.343,41.906,19.364,41.9,19.382,41.886,19.379,41.847,19.616,41.797,19.608,41.625,19.444,41.588,19.516,41.502,19.391,41.408,19.52,41.253,19.437,41.146,19.477,40.969,19.312,40.655,19.349,40.666,19.39,40.656,19.396,40.653,19.399,40.649,19.319,40.641,19.494,40.447,19.485,40.347,19.422,40.321,19.379,40.407,19.288,40.421,19.467,40.217,19.863,40.037,19.914,39.9,20.019,39.859,19.999,39.75,19.847,39.822,19.631,39.752,18.376,39.799,18.518,40.135,18.424,40.294,18.045,40.555,18.018,40.641,17.931,40.641,17.988,40.663,15.942,41.473,15.901,41.616,16.19,41.775,16.153,41.909,15.099,41.934,14.74,42.086,14.712,42.18,14.178,42.5,13.965,42.761,13.628,43.549,12.686,43.987,12.392,44.212,12.24,44.695,12.26,44.815,12.293,44.847,12.408,44.797,12.38,44.826,12.363,44.861,12.333,44.878,12.314,44.903,12.314,44.907,12.324,44.93,12.322,44.934,12.306,44.938,12.3,44.941,12.301,44.952,12.277,44.956,12.26,44.964,12.231,45.006,12.065,45.01,12.173,45.028,12.199,45.017,12.237,45.011,12.242,45.009,12.289,44.955,12.409,44.961,12.442,44.955,12.499,44.974,12.436,44.987,12.411,45.037,12.404,45.005,12.36,45.021,12.349,45.083,12.344,45.06,12.329,45.061,12.322,45.076,12.293,45.08,12.296,45.088,12.27,45.085,12.33,45.168,12.315,45.182,12.311,45.18,12.302,45.177,12.287,45.177,12.269,45.182,12.252,45.182,12.244,45.184,12.232,45.199,12.22,45.192,12.221,45.27,12.133,45.297,12.233,45.457,12.419,45.483,12.401,45.499,12.403,45.504,12.389,45.503,12.384,45.508,12.396,45.531,12.528,45.574,12.59,45.544,12.541,45.51,12.601,45.537,12.632,45.534,12.625,45.514,12.571,45.519,12.574,45.493,12.486,45.465,12.486,45.499,12.474,45.48,12.424,45.447,12.422,45.434,12.437,45.422,12.89,45.598,12.874,45.68,12.915,45.613,13.098,45.635,13.117,45.759,13.219,45.772,13.239,45.753,13.225,45.809,13.245,45.76],"holes":[[14.366,44.792,14.368,44.563,14.536,44.478,14.396,44.62,14.394,44.692,14.483,44.608,14.545,44.63,14.449,44.786,14.487,44.955,14.322,45.176,14.276,45.105,14.415,44.946,14.302,44.934,14.366,44.792]]}],"names":{"ar":"البحر الأدرياتيكي","de":"Adriatisches Meer","es":"mar Adriático","fr":"mer Adriatique","he":"הים האדריאטי","hu":"Adriai-tenger","it":"mare Adriatico","nl":"Adriatische Zee","pt":"Mar Adriático","zh":"亚得里亚海"}},{"name":"Malacca Strait","bbox":[95.442,0.857,103.511,8.441],"area":15.9781,"polygons":[{"outer":[98.664,8.441,98.686,8.384,98.626,8.286,98.649,8.273,98.695,8.337,98.718,8.343,98.766,8.022,98.92,8.078,98.952,8.005,98.942,8.059,98.963,8.082,99.027,7.889,99.075,7.977,99.112,7.969,99.065,7.855,99.103,7.843,99.102,7.82,99.117,7.826,99.128,7.818,99.134,7.785,99.149,7.799,99.153,7.798,99.161,7.805,99.175,7.807,99.196,7.671,99.238,7.707,99.282,7.68,99.236,7.627,99.294,7.667,99.284,7.515,99.399,7.304,99.486,7.294,99.469,7.373,99.484,7.393,99.492,7.381,99.506,7.427,99.543,7.456,99.542,7.305,99.618,7.44,99.622,7.343,99.521,7.25,99.586,7.148,99.609,7.252,99.602,7.195,99.623,7.185,99.625,7.172,99.608,7.136,99.638,7.164,99.611,7.208,99.643,7.283,99.668,7.113,99.751,7.174,99.765,7.122,99.67,7.04,99.759,6.957,99.682,6.946,99.697,6.856,99.811,6.77,99.838,6.778,99.823,6.785,99.815,6.808,99.84,6.825,99.878,6.72,99.904,6.721,99.904,6.757,99.916,6.766,99.962,6.752,99.924,6.74,99.907,6.69,99.936,6.656,99.954,6.689,99.966,6.684,99.97,6.68,99.951,6.584,99.988,6.608,99.99,6.623,100.0,6.629,100.059,6.588,99.989,6.594,100.05,6.52,100.118,6.609,100.085,6.495,100.13,6.529,100.128,6.396,100.351,5.973,100.352,5.693,100.403,5.656,100.407,5.635,100.419,5.644,100.437,5.638,100.456,5.682,100.476,5.687,100.439,5.635,100.394,5.62,100.332,5.661,100.433,5.285,100.36,5.088,100.442,4.916,100.466,4.924,100.471,4.944,100.491,4.954,100.471,4.928,100.51,4.915,100.521,4.899,100.525,4.942,100.569,4.928,100.575,4.925,100.579,4.919,100.577,4.901,100.586,4.897,100.588,4.888,100.601,4.886,100.604,4.884,100.606,4.878,100.608,4.88,100.608,4.875,100.605,4.877,100.601,4.884,100.588,4.887,100.577,4.899,100.569,4.873,100.542,4.903,100.536,4.894,100.553,4.886,100.556,4.859,100.636,4.881,100.672,4.728,100.676,4.672,100.622,4.648,100.664,4.608,100.659,4.571,100.558,4.309,100.638,4.247,100.649,4.371,100.685,4.365,100.616,4.164,100.751,4.102,100.764,4.003,100.856,4.043,100.951,3.942,100.948,3.973,100.956,3.976,100.972,3.968,100.982,4.009,101.016,4.018,100.995,4.026,100.99,4.041,101.005,4.048,101.024,4.039,100.999,4.061,101.012,4.089,100.988,4.116,100.987,4.128,100.979,4.13,100.979,4.133,100.986,4.139,100.968,4.159,100.923,4.184,100.907,4.203,100.908,4.207,100.922,4.22,100.902,4.23,100.883,4.279,100.873,4.354,100.875,4.359,100.898,4.375,100.877,4.353,100.924,4.226,100.925,4.221,100.922,4.214,100.908,4.203,100.968,4.162,100.987,4.14,101.016,4.09,101.004,4.063,101.022,4.055,101.028,4.033,100.954,3.939,100.851,4.037,100.777,3.989,100.714,4.005,100.706,3.895,100.941,3.873,100.98,3.816,100.978,3.805,100.959,3.792,100.968,3.773,100.99,3.8,100.996,3.802,101.004,3.799,101.014,3.803,100.966,3.771,100.958,3.792,100.978,3.813,100.941,3.868,100.809,3.806,101.25,3.35,101.31,3.109,101.392,3.072,101.362,3.003,101.387,3.002,101.374,3.019,101.375,3.023,101.378,3.026,101.384,3.027,101.4,3.018,101.403,3.024,101.392,3.032,101.392,3.041,101.394,3.043,101.403,3.042,101.41,3.047,101.415,3.047,101.4,3.016,101.376,3.021,101.389,3.009,101.395,2.987,101.358,2.921,101.413,2.977,101.421,2.979,101.439,2.966,101.467,2.887,101.405,2.796,101.461,2.708,101.79,2.582,101.854,2.404,102.029,2.432,101.972,2.375,102.154,2.218,102.563,2.05,102.553,2.084,102.574,2.106,102.601,2.096,102.599,2.104,102.586,2.103,102.581,2.109,102.587,2.13,102.596,2.136,102.594,2.143,102.597,2.148,102.602,2.151,102.611,2.152,102.62,2.149,102.624,2.145,102.622,2.137,102.606,2.129,102.613,2.114,102.619,2.13,102.622,2.132,102.645,2.131,102.646,2.135,102.634,2.143,102.632,2.148,102.634,2.153,102.64,2.158,102.649,2.162,102.653,2.16,102.664,2.138,102.68,2.163,102.664,2.137,102.637,2.155,102.634,2.145,102.647,2.135,102.646,2.131,102.612,2.113,102.604,2.127,102.608,2.136,102.622,2.145,102.599,2.148,102.603,2.096,102.573,2.104,102.573,2.055,102.544,2.037,102.683,1.874,102.912,1.825,102.941,1.74,103.27,1.606,103.511,1.265,102.966,1.098,102.737,1.155,102.651,1.029,102.612,0.998,102.587,1.0,102.57,0.98,102.559,0.971,102.488,0.931,102.442,0.936,102.372,0.857,102.225,1.005,102.149,1.378,101.753,1.664,101.409,1.698,101.357,1.779,101.323,1.999,101.269,2.08,101.046,2.289,100.801,2.29,100.789,2.119,100.879,2.041,100.937,1.885,100.89,1.851,101.053,1.782,101.064,1.764,101.066,1.715,101.05,1.691,101.121,1.712,101.131,1.71,101.147,1.701,101.154,1.695,101.158,1.687,101.158,1.675,101.155,1.673,101.102,1.664,101.105,1.644,101.118,1.654,101.125,1.654,101.129,1.65,101.138,1.634,101.135,1.626,101.115,1.609,101.105,1.608,101.091,1.614,101.085,1.642,101.032,1.601,101.048,1.587,101.043,1.605,101.047,1.609,101.054,1.608,101.093,1.578,101.094,1.574,101.093,1.559,101.099,1.551,101.101,1.547,101.1,1.543,101.096,1.541,101.09,1.54,101.07,1.547,101.059,1.544,101.05,1.544,101.048,1.535,101.044,1.532,101.025,1.566,101.021,1.532,100.982,1.513,101.013,1.472,101.012,1.465,100.998,1.459,100.999,1.456,101.006,1.453,101.007,1.45,100.997,1.444,100.99,1.444,100.986,1.447,100.986,1.457,100.983,1.459,100.983,1.452,100.953,1.453,100.945,1.442,100.947,1.429,100.958,1.438,100.974,1.441,100.979,1.44,100.987,1.429,100.989,1.418,100.976,1.417,100.987,1.407,100.989,1.4,100.989,1.396,100.964,1.396,100.988,1.397,100.974,1.417,100.988,1.421,100.946,1.427,100.942,1.437,100.95,1.452,101.012,1.467,100.989,1.468,100.981,1.513,100.989,1.526,101.027,1.569,101.093,1.571,101.032,1.592,101.03,1.601,101.044,1.623,101.084,1.643,101.114,1.61,101.137,1.633,101.123,1.654,101.109,1.643,101.106,1.643,101.102,1.646,101.102,1.667,101.154,1.694,101.049,1.689,101.051,1.781,100.883,1.851,100.784,2.073,100.441,2.26,100.22,2.707,100.121,2.619,100.173,2.505,100.148,2.475,100.185,2.47,100.2,2.437,100.216,2.446,100.225,2.443,100.233,2.432,100.23,2.423,100.225,2.419,100.195,2.403,100.23,2.432,100.198,2.433,100.183,2.467,100.15,2.467,100.145,2.476,100.146,2.484,100.165,2.5,100.142,2.511,100.072,2.464,100.085,2.425,100.062,2.411,100.085,2.405,100.089,2.391,100.074,2.373,100.087,2.391,100.084,2.404,100.069,2.406,100.064,2.409,100.061,2.413,100.071,2.466,100.134,2.513,100.109,2.553,100.093,2.613,100.089,2.704,100.046,2.742,99.968,2.703,100.026,2.683,100.036,2.609,100.001,2.571,99.991,2.572,99.975,2.589,99.967,2.588,99.977,2.56,99.977,2.552,99.974,2.546,99.947,2.507,99.938,2.507,99.929,2.51,99.925,2.501,99.92,2.5,99.917,2.503,99.964,2.588,100.031,2.618,99.948,2.7,99.993,2.953,99.889,3.04,99.837,2.942,99.811,2.963,99.802,2.982,99.802,2.989,99.808,2.997,99.86,2.99,99.86,3.036,99.756,3.17,98.955,3.676,98.705,3.74,98.718,3.796,98.629,3.752,98.709,3.797,98.672,3.908,98.653,3.883,98.652,3.9,98.639,3.884,98.624,3.883,98.613,3.866,98.604,3.868,98.555,3.945,98.545,3.94,98.543,3.927,98.536,3.924,98.539,3.919,98.538,3.896,98.532,3.883,98.521,3.974,98.509,3.954,98.491,3.932,98.468,3.913,98.465,3.897,98.468,3.889,98.466,3.881,98.471,3.867,98.469,3.848,98.464,3.898,98.466,3.912,98.536,4.013,98.436,4.041,98.431,4.024,98.406,4.018,98.408,3.999,98.402,3.991,98.397,3.994,98.392,3.987,98.383,3.987,98.386,3.969,98.377,3.97,98.381,4.03,98.43,4.046,98.35,4.078,98.316,4.073,98.308,4.04,98.283,4.027,98.264,4.032,98.271,4.018,98.247,4.009,98.281,4.062,98.306,4.044,98.31,4.074,98.249,4.131,98.14,4.053,98.144,4.193,98.254,4.201,98.245,4.265,98.212,4.224,98.207,4.222,98.197,4.231,98.196,4.236,98.286,4.418,98.28,4.421,98.265,4.41,98.25,4.405,98.205,4.434,98.119,4.524,98.08,4.459,98.052,4.509,98.086,4.533,98.066,4.553,98.037,4.477,98.038,4.548,97.995,4.51,97.951,4.532,98.018,4.609,98.0,4.626,98.005,4.603,97.984,4.59,97.963,4.592,97.956,4.604,97.903,4.887,97.553,5.214,97.18,5.142,96.845,5.276,96.75,5.224,96.382,5.2,96.089,5.265,95.887,5.424,95.883,5.507,95.442,5.656,98.353,7.83,98.411,7.799,98.394,7.979,98.44,8.093,98.352,8.111,98.302,8.206,98.387,8.127,98.433,8.137,98.444,8.192,98.461,8.22,98.468,8.219,98.455,8.242,98.46,8.293,98.409,8.28,98.4,8.302,98.441,8.323,98.473,8.318,98.449,8.348,98.463,8.389,98.504,8.342,98.512,8.408,98.517,8.414,98.507,8.348,98.517,8.325,98.547,8.413,98.583,8.358,98.598,8.367,98.594,8.381,98.611,8.401,98.608,8.415,98.589,8.429,98.588,8.433,98.59,8.436,98.608,8.42,98.62,8.438,98.625,8.436,98.635,8.379,98.664,8.441],"holes":[[101.638,2.114,101.564,2.047,101.606,1.995,101.476,2.066,101.398,2.007,101.455,1.725,101.615,1.688,101.732,1.764,101.779,1.983,101.638,2.114],[102.029,1.609,102.085,1.482,102.295,1.428,102.508,1.254,102.462,1.517,102.029,1.609],[102.468,1.255,102.212,1.411,102.236,1.101,102.386,0.914,102.483,0.946,102.468,1.255]]}],"names":{"ar":"مضيق ملقا","de":"Straße von Malakka","es":"Estrecho de Malaca","fr":"détroit de Malacca","he":"מצר מלאקה","hu":"Malaka-szoros","it":"Stretto di Malacca","nl":"Straat Malakka","pt":"Estreito de Malaca","zh":"马六甲海峡"}},{"name":"Makassar Strait","bbox":[115.955,-5.596,120.818,1.325],"area":16.0777,"polygons":[{"outer":[120.818,1.283,120.809,1.03,120.728,0.964,120.636,0.939,120.593,0.75,120.397,0.792,120.326,0.856,120.354,0.984,120.283,0.986,120.238,0.8,120.038,0.718,120.047,0.514,119.901,0.479,119.848,0.35,119.903,0.222,119.77,0.213,119.879,0.077,119.839,-0.102,119.606,-0.007,119.701,-0.138,119.81,-0.136,119.758,-0.49,119.879,-0.862,119.728,-0.645,119.34,-1.17,119.282,-1.69,119.363,-1.858,119.331,-1.962,119.212,-1.998,119.128,-2.287,119.134,-2.304,119.142,-2.298,119.193,-2.306,119.211,-2.321,119.213,-2.315,119.225,-2.311,119.227,-2.313,119.228,-2.324,119.252,-2.33,119.254,-2.334,119.124,-2.311,119.144,-2.47,118.911,-2.666,118.819,-2.619,118.76,-2.765,118.77,-2.867,118.886,-2.891,118.775,-3.104,118.934,-3.568,119.021,-3.514,119.003,-3.503,119.004,-3.496,119.01,-3.491,119.001,-3.489,118.992,-3.478,118.996,-3.455,118.988,-3.457,118.991,-3.446,118.979,-3.438,118.98,-3.426,118.997,-3.455,118.994,-3.478,119.01,-3.487,119.01,-3.493,119.004,-3.501,119.019,-3.504,119.023,-3.516,119.228,-3.481,119.227,-3.479,119.219,-3.476,119.19,-3.448,119.162,-3.398,119.164,-3.389,119.17,-3.386,119.169,-3.379,119.153,-3.376,119.145,-3.366,119.169,-3.376,119.231,-3.48,119.305,-3.426,119.373,-3.463,119.372,-3.453,119.388,-3.446,119.458,-3.483,119.533,-3.727,119.539,-3.726,119.541,-3.712,119.551,-3.697,119.558,-3.693,119.67,-3.691,119.558,-3.695,119.553,-3.698,119.55,-3.706,119.546,-3.709,119.541,-3.726,119.536,-3.729,119.479,-3.709,119.474,-3.692,119.459,-3.695,119.453,-3.688,119.45,-3.694,119.586,-4.039,119.583,-3.977,119.64,-3.959,119.64,-4.302,119.599,-4.573,119.482,-4.751,119.561,-4.892,119.382,-5.191,119.438,-5.19,119.458,-5.218,119.474,-5.221,119.484,-5.237,119.38,-5.196,119.354,-5.326,119.43,-5.487,119.439,-5.596,119.301,-5.456,119.256,-5.504,116.091,-4.086,116.31,-3.917,116.272,-3.816,116.339,-3.728,116.25,-3.375,116.271,-3.244,116.287,-3.207,116.285,-3.205,116.115,-3.305,116.011,-3.643,115.955,-3.612,115.996,-3.566,116.007,-3.436,116.031,-3.365,116.085,-3.286,116.079,-3.258,116.181,-3.242,116.166,-3.165,116.068,-3.186,116.07,-3.135,116.275,-3.129,116.174,-3.062,116.154,-3.031,116.157,-3.014,116.121,-3.014,116.11,-3.009,116.131,-2.995,116.095,-2.998,116.082,-3.019,116.084,-3.007,116.059,-3.036,116.05,-3.036,116.056,-2.97,116.122,-2.965,116.091,-2.907,116.13,-2.89,116.098,-2.862,116.143,-2.793,116.155,-2.9,116.199,-2.91,116.221,-3.007,116.364,-2.879,116.415,-2.578,116.356,-2.55,116.316,-2.61,116.256,-2.668,116.223,-2.685,116.224,-2.623,116.324,-2.574,116.312,-2.536,116.307,-2.566,116.294,-2.58,116.23,-2.527,116.41,-2.491,116.512,-2.562,116.597,-2.203,116.58,-2.166,116.529,-2.273,116.465,-2.277,116.546,-2.201,116.503,-2.168,116.361,-2.297,116.376,-2.116,116.316,-2.137,116.312,-2.164,116.277,-2.182,116.16,-2.216,116.348,-2.114,116.327,-2.053,116.245,-2.081,116.172,-2.077,116.164,-2.038,116.332,-2.051,116.359,-2.111,116.456,-2.064,116.436,-1.87,116.342,-1.93,116.303,-1.861,116.292,-1.88,116.262,-1.892,116.264,-1.909,116.261,-1.914,116.257,-1.915,116.248,-1.915,116.262,-1.912,116.26,-1.896,116.266,-1.886,116.304,-1.859,116.417,-1.868,116.451,-1.773,116.385,-1.812,116.344,-1.775,116.203,-1.823,116.184,-1.79,116.165,-1.821,116.125,-1.832,116.116,-1.815,116.165,-1.737,116.279,-1.75,116.188,-1.703,116.174,-1.669,116.296,-1.732,116.314,-1.684,116.235,-1.658,116.232,-1.653,116.235,-1.648,116.224,-1.641,116.225,-1.635,116.3,-1.66,116.328,-1.702,116.519,-1.631,116.54,-1.464,116.763,-1.36,116.778,-1.243,116.664,-1.21,116.651,-1.179,116.767,-1.188,116.691,-1.137,116.741,-0.982,116.795,-0.96,116.729,-1.103,116.783,-1.212,116.833,-1.186,116.812,-1.205,116.806,-1.274,116.885,-1.277,117.249,-0.907,117.249,-0.805,117.197,-0.794,117.171,-0.823,117.165,-0.821,117.161,-0.83,117.153,-0.833,117.151,-0.831,117.184,-0.764,117.28,-0.795,117.29,-0.61,117.177,-0.56,117.14,-0.506,117.077,-0.593,117.032,-0.591,117.015,-0.576,117.013,-0.573,116.988,-0.37,117.006,-0.354,117.021,-0.352,117.034,-0.587,117.131,-0.5,117.156,-0.51,117.178,-0.549,117.229,-0.571,117.419,-0.552,117.4,-0.516,117.455,-0.456,117.424,-0.433,117.428,-0.398,117.436,-0.38,117.465,-0.373,117.453,-0.356,117.447,-0.322,117.453,-0.303,117.417,-0.222,117.534,0.034,117.468,0.047,117.458,0.102,117.522,0.125,117.471,0.164,117.553,0.422,117.77,0.771,117.912,0.836,118.03,0.782,117.866,1.075,117.838,1.223,117.99,1.074,118.001,0.964,118.073,0.958,118.064,0.903,118.372,0.803,118.479,0.833,118.477,0.863,118.489,0.875,118.472,0.877,118.473,0.882,118.479,0.89,118.465,0.895,118.463,0.898,118.463,0.902,118.467,0.905,118.479,0.904,118.467,0.914,118.48,0.904,118.478,0.901,118.463,0.9,118.48,0.89,118.474,0.878,118.491,0.877,118.493,0.823,118.677,0.857,118.708,0.814,118.725,0.823,118.791,0.812,118.791,0.801,118.981,1.003,120.808,1.325,120.818,1.283]}],"names":{"ar":"مضيق ماكاسار","de":"Straße von Makassar","es":"Estrecho de Macasar","fr":"détroit de Macassar","he":"מצר מקאסאר","it":"Stretto di Makasar","nl":"Straat Makassar","pt":"Estreito de Macáçar","zh":"望加锡海峡"}},{"name":"Gulf of California","bbox":[-115.026,22.879,-106.786,31.948],"area":16.2943,"polygons":[{"outer":[-115.026,31.948,-114.589,31.755,-114.17,31.498,-114.025,31.492,-113.976,31.641,-113.965,31.646,-113.935,31.605,-113.903,31.624,-113.656,31.492,-113.624,31.327,-113.222,31.241,-113.274,31.258,-113.256,31.285,-113.063,31.16,-113.089,30.983,-113.129,31.057,-113.091,30.694,-112.76,30.197,-112.747,29.915,-112.381,29.486,-112.41,29.345,-112.233,29.307,-112.174,28.971,-111.849,28.796,-111.953,28.759,-111.711,28.458,-111.435,28.371,-111.459,28.317,-111.255,28.054,-111.105,27.936,-110.973,27.971,-110.883,27.839,-110.847,27.986,-110.853,27.913,-110.518,27.856,-110.591,27.844,-110.58,27.787,-110.616,27.78,-110.607,27.728,-110.607,27.756,-110.552,27.731,-110.603,27.721,-110.628,27.624,-110.591,27.439,-110.513,27.409,-110.56,27.37,-110.444,27.302,-110.522,27.29,-110.286,27.147,-110.059,27.079,-110.049,27.123,-109.971,27.106,-109.822,26.752,-109.668,26.673,-109.495,26.671,-109.494,26.678,-109.557,26.69,-109.565,26.7,-109.483,26.744,-109.276,26.518,-109.26,26.32,-109.25,26.474,-109.092,26.285,-109.122,26.244,-109.102,26.214,-109.111,26.186,-109.212,26.342,-109.289,26.139,-109.249,26.315,-109.329,26.092,-109.436,25.998,-109.399,25.931,-109.428,25.819,-109.411,25.859,-109.396,25.859,-109.384,25.858,-109.387,25.781,-109.433,25.806,-109.399,25.68,-109.375,25.776,-109.283,25.715,-109.289,25.65,-109.356,25.669,-109.395,25.638,-109.161,25.561,-109.261,25.701,-109.105,25.57,-108.938,25.672,-108.983,25.542,-109.12,25.547,-109.044,25.463,-108.925,25.456,-108.867,25.576,-108.777,25.547,-108.792,25.417,-108.754,25.459,-108.741,25.395,-108.672,25.403,-108.621,25.34,-108.663,25.368,-108.751,25.365,-108.542,25.305,-108.578,25.322,-108.57,25.342,-108.388,25.185,-108.407,25.278,-108.347,25.284,-108.318,25.194,-108.252,25.198,-108.066,25.082,-107.927,24.738,-107.964,24.716,-108.067,24.785,-108.003,24.658,-107.934,24.604,-107.895,24.586,-107.981,24.653,-107.976,24.654,-107.983,24.665,-107.98,24.691,-107.642,24.452,-107.614,24.514,-107.569,24.527,-107.545,24.515,-107.488,24.325,-107.784,24.512,-107.804,24.497,-107.294,24.219,-107.207,24.121,-107.183,24.16,-107.144,24.142,-106.988,23.944,-107.191,24.096,-106.891,23.841,-106.786,23.633,-109.91,22.879,-109.459,23.196,-109.413,23.386,-109.468,23.555,-109.696,23.661,-109.826,24.061,-109.985,24.044,-110.008,24.159,-110.233,24.344,-110.334,24.329,-110.3,24.201,-110.357,24.112,-110.432,24.171,-110.372,24.148,-110.327,24.176,-110.612,24.258,-110.738,24.551,-110.685,24.884,-110.908,25.147,-111.017,25.516,-111.12,25.527,-111.305,25.773,-111.358,25.957,-111.324,26.099,-111.468,26.411,-111.442,26.518,-111.565,26.559,-111.561,26.697,-111.851,26.879,-111.815,26.717,-111.677,26.589,-111.781,26.567,-111.909,26.719,-111.872,26.83,-112.015,26.975,-111.953,27.096,-112.213,27.205,-112.331,27.518,-112.596,27.651,-112.769,27.858,-112.846,28.44,-113.104,28.505,-113.221,28.825,-113.355,28.802,-113.438,28.96,-113.535,28.907,-113.637,29.283,-114.209,29.745,-114.385,29.777,-114.564,30.01,-114.667,30.191,-114.63,30.488,-114.71,30.909,-114.89,31.145,-114.778,31.637,-114.817,31.744,-114.807,31.811,-115.026,31.948],"holes":[[-113.526,29.55,-113.59,29.411,-113.116,28.987,-113.168,29.28,-113.366,29.307,-113.394,29.46,-113.526,29.55],[-112.296,29.224,-112.464,29.187,-112.488,28.956,-112.585,28.876,-112.311,28.747,-112.189,28.977,-112.296,29.224]]}],"names":{"ar":"خليج كاليفورنيا","de":"Golf von Kalifornien","es":"Golfo de California","fr":"golfe de Californie","he":"מפרץ קליפורניה","hu":"Kaliforniai-öböl","it":"Golfo di California","nl":"golf van Californië","pt":"Golfo da Califórnia","zh":"加利福尼亚湾"}},{"name":"Ionian Sea","bbox":[15.085,36.409,23.222,40.521],"area":17.5519,"polygons":[{"outer":[17.092,40.521,17.322,40.497,17.203,40.408,17.506,40.295,17.867,40.282,17.925,40.183,18.01,40.109,18.012,39.97,18.206,39.837,19.641,39.75,19.868,39.457,20.112,39.36,20.071,39.461,19.937,39.473,19.933,39.625,19.842,39.659,19.998,39.741,19.985,39.688,20.06,39.683,20.187,39.624,20.137,39.529,20.261,39.509,20.213,39.491,20.274,39.446,20.207,39.449,20.288,39.324,20.478,39.275,20.727,38.95,20.758,38.982,20.831,38.96,20.755,39.006,20.797,39.061,21.139,39.014,21.174,38.867,20.765,38.958,20.801,38.868,20.728,38.813,20.773,38.754,20.874,38.793,20.914,38.673,20.988,38.669,21.027,38.502,21.095,38.533,21.155,38.413,21.09,38.366,21.101,38.335,21.133,38.335,21.151,38.301,21.235,38.299,21.278,38.311,21.305,38.303,21.319,38.31,21.278,38.312,21.245,38.303,21.212,38.313,21.342,38.385,21.318,38.499,21.421,38.382,21.411,38.367,21.411,38.328,21.466,38.367,21.486,38.303,21.455,38.336,21.417,38.326,21.504,38.291,21.935,38.407,22.378,38.332,22.404,38.449,22.551,38.282,22.659,38.386,22.778,38.233,23.222,38.156,23.143,38.065,22.885,38.053,22.976,37.976,22.873,37.933,21.849,38.339,21.606,38.143,21.372,38.217,21.307,38.003,21.126,37.937,21.105,37.848,21.297,37.766,21.309,37.633,21.523,37.575,21.665,37.412,21.694,37.293,21.566,37.159,21.71,36.943,21.704,36.811,21.878,36.718,21.968,36.794,21.932,36.984,22.148,37.018,22.125,36.914,22.389,36.694,22.347,36.512,22.468,36.409,15.136,36.68,15.114,36.853,15.337,37.008,15.185,37.195,15.257,37.244,15.099,37.317,15.085,37.482,15.583,38.246,15.652,38.267,15.712,38.254,15.632,38.223,15.678,37.954,16.063,37.924,16.288,38.26,16.571,38.427,16.535,38.714,16.605,38.813,17.172,38.957,17.207,39.029,17.107,39.112,17.155,39.401,16.774,39.619,16.523,39.66,16.487,39.766,16.628,39.957,16.609,40.084,16.902,40.436,17.092,40.521],"holes":[[20.607,38.295,20.542,38.474,20.544,38.353,20.399,38.356,20.338,38.179,20.44,38.162,20.444,38.281,20.512,38.1,20.788,38.057,20.814,38.118,20.607,38.295]]}],"names":{"ar":"البحر الأيوني","de":"Ionisches Meer","es":"mar Jónico","fr":"mer Ionienne","he":"הים היוני","hu":"Jón-tenger","it":"mar Ionio","nl":"Ionische Zee","pt":"Mar Jônico","zh":"伊奧尼亞海"}},{"name":"Molucca Sea","bbox":[123.293,-1.838,128.566,4.486],"area":17.8553,"polygons":[{"outer":[126.855,4.486,128.566,2.645,128.197,2.281,128.068,2.197,127.954,2.221,127.693,1.931,127.55,1.711,127.583,1.664,127.524,1.441,127.399,1.196,127.4,1.042,127.5,1.047,127.492,0.892,127.528,0.886,127.525,0.877,127.537,0.883,127.546,0.88,127.526,0.858,127.64,0.852,127.527,0.554,127.622,0.395,127.621,0.361,127.734,0.311,127.687,-0.27,127.864,-0.366,128.02,-0.687,128.46,-0.915,127.511,-1.177,127.467,-1.255,127.643,-1.331,127.505,-1.453,127.415,-1.419,127.413,-1.664,126.478,-1.821,125.916,-1.793,125.636,-1.83,125.403,-1.775,125.316,-1.838,125.293,-1.732,125.197,-1.792,125.132,-1.684,125.046,-1.771,124.529,-1.63,124.388,-1.671,123.599,-1.714,123.614,-1.608,123.471,-1.475,123.56,-1.282,123.427,-1.269,123.405,-1.218,123.323,-1.284,123.293,-1.169,123.458,-0.769,123.381,-0.669,123.335,0.31,123.677,0.296,124.513,0.473,124.639,0.79,124.887,0.972,125.024,1.142,125.067,1.359,125.235,1.468,125.143,1.622,125.616,3.349,126.842,3.741,126.677,3.952,126.809,4.033,126.797,4.149,126.912,4.27,126.855,4.486],"holes":[[127.538,-0.312,127.453,-0.418,127.323,-0.329,127.291,-0.457,127.48,-0.63,127.463,-0.815,127.632,-0.741,127.726,-0.87,127.828,-0.854,127.903,-0.772,127.799,-0.678,127.664,-0.704,127.597,-0.597,127.693,-0.457,127.538,-0.312]]}],"names":{"ar":"بحر مالوكا","de":"Molukkensee","es":"mar de Molucas","fr":"mer des Moluques","he":"ים מאלוקו","it":"Mare delle Molucche","nl":"Molukse Zee","pt":"Mar das Molucas","zh":"馬魯古海"}},{"name":"Coastal Waters of Southeast Alaska and British Columbia","bbox":[-137.13,47.024,-122.15,59.496],"area":18.5306,"polygons":[{"outer":[-128.773,50.785,-128.943,50.817,-129.092,50.852,-129.081,50.874,-129.095,50.853,-131.027,51.942,-131.057,52.023,-130.976,52.011,-131.073,52.075,-130.986,52.06,-130.951,52.105,-131.118,52.151,-131.078,51.97,-131.263,52.119,-131.287,52.238,-131.18,52.118,-131.152,52.205,-130.998,52.214,-131.189,52.225,-131.16,52.241,-131.085,52.248,-131.091,52.289,-131.174,52.325,-131.24,52.245,-131.323,52.276,-131.449,52.39,-131.425,52.466,-131.677,52.532,-131.619,52.568,-131.889,52.753,-131.798,52.756,-131.657,52.81,-131.813,52.796,-131.886,52.836,-131.888,52.892,-131.998,52.878,-131.902,52.945,-131.821,52.867,-131.621,52.9,-131.642,52.945,-131.603,52.964,-131.663,53.0,-131.877,53.039,-131.906,52.962,-132.031,53.04,-131.805,53.067,-131.61,53.028,-131.792,53.168,-131.796,53.255,-132.082,53.149,-132.391,53.109,-132.588,53.152,-132.71,53.253,-132.502,53.285,-132.621,53.248,-132.514,53.153,-132.487,53.217,-132.475,53.221,-132.473,53.135,-132.129,53.18,-132.272,53.189,-132.32,53.234,-132.256,53.199,-132.242,53.22,-132.179,53.242,-132.091,53.254,-131.994,53.248,-131.884,53.772,-131.641,54.192,-131.791,54.074,-132.199,54.05,-132.12,54.016,-132.09,53.894,-132.136,53.698,-132.201,53.659,-132.344,53.671,-132.281,53.629,-132.482,53.564,-132.461,53.616,-132.352,53.674,-132.602,53.635,-132.622,53.688,-132.681,53.655,-132.66,53.689,-132.223,53.783,-132.107,53.874,-132.153,53.986,-132.32,54.115,-132.679,53.936,-132.583,54.121,-133.083,54.183,-132.974,54.194,-133.072,54.251,-132.862,54.697,-132.662,54.675,-132.776,54.746,-132.721,54.744,-132.749,54.819,-133.084,55.046,-133.007,55.051,-133.079,55.1,-132.971,55.063,-133.113,55.155,-133.059,55.169,-133.132,55.204,-133.123,55.264,-133.221,55.241,-133.157,55.195,-133.232,55.127,-133.15,55.136,-133.112,55.122,-133.238,55.1,-133.167,54.95,-133.018,54.928,-133.089,54.908,-132.916,54.846,-132.977,54.805,-132.824,54.75,-132.864,54.707,-133.613,55.237,-133.599,55.368,-133.465,55.409,-133.614,55.419,-133.669,55.277,-134.325,55.835,-134.118,55.917,-134.337,55.929,-134.284,55.876,-134.346,55.852,-134.752,56.215,-134.671,56.161,-134.624,56.255,-134.683,56.247,-134.643,56.331,-134.755,56.29,-134.631,56.372,-134.733,56.379,-134.641,56.392,-134.755,56.439,-134.639,56.477,-134.762,56.54,-134.669,56.541,-134.666,56.58,-134.671,56.585,-134.668,56.592,-134.681,56.629,-134.68,56.631,-134.672,56.627,-134.648,56.546,-134.629,56.55,-134.63,56.725,-134.754,56.708,-134.638,56.739,-134.795,56.868,-134.704,56.84,-134.695,56.898,-134.835,57.088,-134.795,57.141,-134.836,57.133,-134.818,57.153,-134.852,57.131,-134.871,57.138,-134.8,57.163,-134.89,57.239,-134.837,57.246,-134.929,57.26,-134.958,57.309,-135.01,57.263,-134.951,57.327,-135.073,57.342,-134.91,57.329,-134.967,57.382,-134.829,57.293,-134.832,57.417,-135.217,57.423,-135.16,57.459,-135.226,57.489,-135.349,57.474,-135.406,57.44,-135.417,57.458,-135.291,57.509,-135.412,57.564,-135.551,57.509,-135.533,57.451,-135.632,57.398,-135.492,57.355,-135.694,57.36,-135.557,57.286,-135.552,57.235,-135.499,57.256,-135.474,57.257,-135.446,57.248,-135.413,57.253,-135.345,57.247,-135.422,57.163,-135.284,57.166,-135.402,57.1,-135.34,57.048,-135.204,57.046,-135.123,56.987,-135.386,56.986,-135.402,56.949,-135.294,56.889,-135.394,56.851,-135.275,56.788,-135.31,56.777,-135.198,56.812,-135.203,56.831,-135.196,56.847,-135.105,56.827,-135.305,56.724,-135.182,56.784,-135.187,56.755,-135.085,56.813,-135.043,56.821,-135.02,56.819,-135.212,56.73,-135.168,56.697,-135.23,56.686,-135.189,56.665,-135.035,56.777,-134.994,56.757,-135.141,56.679,-135.12,56.594,-134.983,56.724,-134.977,56.615,-134.941,56.636,-134.875,56.712,-134.829,56.743,-134.94,56.575,-135.058,56.569,-134.944,56.474,-134.964,56.415,-134.901,56.451,-134.951,56.385,-135.847,56.994,-135.631,57.013,-135.562,57.147,-135.6,57.146,-135.658,57.246,-135.569,57.247,-135.864,57.331,-135.836,57.166,-135.786,57.175,-135.731,57.169,-135.858,57.011,-136.292,57.731,-136.261,57.765,-136.317,57.752,-136.405,57.823,-136.296,57.763,-136.201,57.783,-136.105,57.689,-136.162,57.67,-136.151,57.655,-136.161,57.642,-136.084,57.668,-135.98,57.625,-136.101,57.611,-136.025,57.557,-135.922,57.586,-136.0,57.543,-135.933,57.556,-135.813,57.444,-136.004,57.531,-136.033,57.519,-135.859,57.425,-135.842,57.39,-135.662,57.381,-135.675,57.407,-135.593,57.428,-135.641,57.45,-135.547,57.479,-135.568,57.55,-135.681,57.547,-135.569,57.595,-135.816,57.651,-135.709,57.679,-135.822,57.77,-135.088,57.466,-134.866,57.463,-134.997,57.543,-134.828,57.498,-134.932,57.758,-135.437,57.736,-135.324,57.749,-135.894,58.001,-135.07,57.775,-134.949,57.805,-135.225,57.948,-134.936,57.847,-135.0,57.889,-134.923,57.923,-134.938,58.027,-135.409,58.144,-135.467,58.131,-135.419,58.1,-135.673,57.946,-135.811,57.991,-135.637,57.996,-135.728,58.051,-135.609,58.054,-135.494,58.171,-135.806,58.287,-135.988,58.183,-136.075,58.216,-136.167,58.22,-136.187,58.104,-136.117,58.078,-136.118,58.069,-136.201,58.094,-136.282,58.223,-136.354,58.224,-136.27,58.104,-136.369,58.149,-136.433,58.087,-136.023,57.839,-136.353,58.003,-136.278,57.906,-136.431,57.844,-136.376,57.996,-136.336,58.009,-136.646,58.213,-136.568,58.245,-136.597,58.332,-136.385,58.297,-136.359,58.341,-136.478,58.399,-136.492,58.326,-136.593,58.392,-136.511,58.377,-136.522,58.454,-136.275,58.315,-136.05,58.37,-136.072,58.468,-136.236,58.509,-136.141,58.548,-136.315,58.671,-136.355,58.59,-136.521,58.594,-136.35,58.687,-136.514,58.755,-136.452,58.665,-136.617,58.763,-136.654,58.816,-136.489,58.794,-136.562,58.832,-137.015,58.904,-137.13,58.843,-137.045,58.914,-136.914,58.939,-137.063,59.058,-136.668,58.893,-136.711,59.008,-136.589,58.908,-136.562,58.964,-136.521,58.967,-136.488,58.837,-136.285,58.828,-136.4,58.822,-136.215,58.75,-136.177,58.755,-136.176,58.773,-136.143,58.779,-136.124,58.934,-136.246,58.919,-136.351,58.94,-136.152,58.944,-136.154,59.006,-136.383,59.089,-136.184,59.073,-136.059,58.853,-135.92,58.905,-135.835,58.909,-135.789,58.892,-136.088,58.819,-135.835,58.599,-135.936,58.588,-135.857,58.569,-135.838,58.479,-135.918,58.38,-135.623,58.427,-135.476,58.375,-135.498,58.503,-135.341,58.267,-135.142,58.206,-135.049,58.333,-135.239,58.619,-135.145,58.577,-135.14,58.617,-135.25,58.777,-135.364,58.787,-135.24,58.782,-135.402,58.973,-135.382,59.101,-135.636,59.264,-135.299,59.085,-135.555,59.318,-135.401,59.288,-135.351,59.496,-135.316,59.202,-135.281,59.234,-135.194,59.268,-135.15,59.271,-135.103,59.259,-135.095,59.253,-135.282,59.225,-135.138,58.83,-135.023,58.731,-135.001,58.841,-134.922,58.892,-134.94,58.924,-134.933,58.937,-134.953,58.957,-134.961,58.969,-134.971,59.002,-134.857,58.929,-134.932,58.932,-134.907,58.901,-134.919,58.856,-134.989,58.812,-134.973,58.805,-134.949,58.819,-134.914,58.824,-134.887,58.821,-134.959,58.801,-134.906,58.659,-134.99,58.676,-134.788,58.495,-134.776,58.391,-134.51,58.348,-134.22,58.205,-134.104,58.235,-134.145,58.303,-133.97,58.502,-133.866,58.525,-133.83,58.52,-133.807,58.522,-133.783,58.519,-133.762,58.528,-133.717,58.54,-133.687,58.544,-133.673,58.577,-133.498,58.68,-133.385,58.735,-133.349,58.741,-133.344,58.775,-133.29,58.815,-133.183,58.845,-133.203,58.827,-133.285,58.807,-133.346,58.738,-133.434,58.695,-133.493,58.676,-133.675,58.557,-133.583,58.563,-133.797,58.51,-133.94,58.493,-134.016,58.402,-133.984,58.312,-134.081,58.28,-134.082,58.122,-133.888,57.974,-133.786,58.009,-133.817,58.051,-133.734,58.14,-133.775,58.057,-133.711,57.995,-133.333,58.177,-133.217,58.191,-133.181,58.184,-133.158,58.194,-133.133,58.192,-133.007,58.229,-132.997,58.227,-132.955,58.306,-132.979,58.228,-133.661,58.015,-133.682,57.93,-133.762,57.995,-133.85,57.934,-133.67,57.786,-133.588,57.927,-133.158,57.907,-133.109,57.85,-133.574,57.918,-133.595,57.772,-133.19,57.614,-133.186,57.698,-133.092,57.698,-133.179,57.637,-133.11,57.547,-132.838,57.506,-133.069,57.509,-133.651,57.716,-133.674,57.629,-133.588,57.566,-133.562,57.562,-133.479,57.585,-133.345,57.595,-133.517,57.544,-133.468,57.422,-133.349,57.446,-133.457,57.357,-133.056,57.354,-133.257,57.293,-133.217,57.252,-133.5,57.293,-133.505,57.21,-133.576,57.185,-133.312,57.106,-133.172,57.177,-133.132,57.142,-133.186,57.092,-132.996,57.01,-132.808,57.102,-132.789,56.971,-132.813,56.962,-132.871,57.001,-132.954,56.983,-132.567,56.757,-132.52,56.749,-132.506,56.788,-132.455,56.785,-132.442,56.82,-132.368,56.827,-132.557,56.714,-132.424,56.665,-132.382,56.675,-132.341,56.666,-132.269,56.696,-132.261,56.696,-132.247,56.686,-132.365,56.538,-132.211,56.461,-132.18,56.381,-131.98,56.37,-131.899,56.225,-131.519,56.224,-131.973,56.174,-131.93,55.976,-132.067,55.944,-132.098,55.848,-132.011,55.787,-132.286,55.765,-132.255,55.742,-132.225,55.703,-132.185,55.588,-131.957,55.503,-131.937,55.591,-132.036,55.678,-131.895,55.6,-131.818,55.67,-131.887,55.745,-131.825,55.719,-131.767,55.821,-131.919,55.864,-131.824,55.89,-131.745,55.884,-131.84,55.927,-131.847,55.936,-131.844,55.949,-131.737,55.888,-131.523,56.005,-131.512,55.977,-131.423,56.006,-131.343,55.967,-130.902,56.213,-131.216,55.983,-130.99,55.798,-130.932,55.824,-130.856,55.833,-130.862,55.844,-130.859,55.854,-130.851,55.859,-130.855,55.872,-130.849,55.883,-130.819,55.906,-130.789,55.905,-130.852,55.834,-130.875,55.822,-130.892,55.817,-130.922,55.816,-130.969,55.783,-130.907,55.712,-130.749,55.755,-130.702,55.754,-130.896,55.706,-130.875,55.559,-130.71,55.599,-130.704,55.635,-130.642,55.659,-130.676,55.548,-130.872,55.543,-130.878,55.332,-130.725,55.305,-130.618,55.389,-130.687,55.323,-130.622,55.3,-130.928,55.301,-131.093,55.186,-130.98,55.086,-130.821,55.196,-130.764,55.094,-130.714,55.151,-130.575,55.236,-130.56,55.271,-130.493,55.327,-130.48,55.328,-130.559,55.234,-130.709,55.121,-130.526,55.153,-130.716,55.11,-130.688,55.019,-130.849,55.119,-131.007,55.029,-130.921,54.982,-130.941,54.931,-130.978,54.923,-130.934,54.802,-130.742,54.817,-130.761,54.941,-130.749,54.962,-130.692,54.8,-130.741,54.792,-130.666,54.77,-130.633,54.82,-130.698,54.852,-130.676,54.944,-130.601,54.972,-130.68,54.868,-130.644,54.842,-130.63,54.819,-130.632,54.816,-130.531,54.884,-130.464,54.911,-130.525,54.868,-130.482,54.837,-130.266,54.995,-130.113,55.253,-129.995,55.282,-130.141,55.552,-130.176,55.758,-130.138,55.819,-130.092,55.828,-130.032,55.906,-130.013,55.906,-130.004,55.931,-129.976,55.936,-129.977,55.956,-129.959,56.004,-129.945,56.01,-129.97,55.939,-130.004,55.884,-130.138,55.752,-129.944,55.288,-130.166,55.087,-130.114,54.99,-129.768,55.378,-129.84,55.373,-129.766,55.472,-129.813,55.63,-129.701,55.408,-129.634,55.458,-129.462,55.469,-129.664,55.432,-129.743,55.291,-130.007,55.011,-129.65,54.995,-129.662,54.976,-129.986,54.914,-129.948,54.96,-130.013,54.976,-130.177,54.848,-130.055,54.888,-130.217,54.724,-130.084,54.69,-129.977,54.61,-129.899,54.615,-129.979,54.603,-130.229,54.716,-130.388,54.647,-130.116,54.419,-130.076,54.487,-129.983,54.507,-130.104,54.405,-129.985,54.303,-130.457,54.62,-130.385,54.463,-130.479,54.444,-130.476,54.376,-130.488,54.364,-130.373,54.319,-130.26,54.39,-130.325,54.438,-130.243,54.4,-130.238,54.273,-130.148,54.283,-130.304,54.24,-130.131,54.148,-129.795,54.264,-129.622,54.187,-129.581,54.22,-129.517,54.244,-129.438,54.253,-129.402,54.293,-129.29,54.335,-129.2,54.335,-129.166,54.361,-129.074,54.39,-129.001,54.392,-128.986,54.408,-128.863,54.405,-128.85,54.426,-128.836,54.435,-128.775,54.453,-128.75,54.451,-128.752,54.468,-128.743,54.477,-128.713,54.497,-128.685,54.504,-128.682,54.513,-128.676,54.516,-128.435,54.568,-128.434,54.582,-128.418,54.595,-128.428,54.598,-128.435,54.609,-128.431,54.631,-128.424,54.635,-128.403,54.639,-128.393,54.661,-128.386,54.665,-128.364,54.668,-128.355,54.69,-128.32,54.708,-128.431,54.568,-128.573,54.497,-128.676,54.514,-128.677,54.506,-128.742,54.476,-128.748,54.468,-128.741,54.463,-128.739,54.454,-128.744,54.451,-128.862,54.404,-128.977,54.408,-128.995,54.393,-129.007,54.388,-129.05,54.387,-129.052,54.379,-129.072,54.368,-129.161,54.36,-129.217,54.318,-129.351,54.305,-129.366,54.295,-129.375,54.294,-129.384,54.28,-129.396,54.274,-129.415,54.272,-129.444,54.246,-129.575,54.215,-129.585,54.19,-129.609,54.175,-129.939,54.184,-129.839,54.041,-129.668,53.954,-129.661,53.925,-129.619,53.899,-129.614,53.894,-129.614,53.882,-129.852,54.033,-129.963,54.161,-130.084,54.113,-130.108,53.947,-129.281,53.363,-129.289,53.397,-129.237,53.425,-129.235,53.509,-129.276,53.513,-129.3,53.522,-129.349,53.564,-129.233,53.519,-129.24,53.612,-129.308,53.647,-129.031,53.753,-129.1,53.777,-129.087,53.833,-128.97,53.772,-129.027,53.898,-128.961,53.798,-128.917,53.854,-128.946,53.771,-128.672,54.024,-128.609,54.029,-128.713,53.883,-128.485,53.826,-128.676,53.853,-128.807,53.772,-128.837,53.647,-128.764,53.547,-128.538,53.428,-128.368,53.509,-128.128,53.486,-128.061,53.391,-127.928,53.33,-127.821,53.203,-128.037,53.371,-128.116,53.339,-128.08,53.399,-128.161,53.456,-128.363,53.477,-128.524,53.407,-128.485,53.392,-128.487,53.331,-128.491,53.394,-128.767,53.491,-128.812,53.567,-128.988,53.542,-128.922,53.445,-128.869,53.481,-128.835,53.469,-128.905,53.429,-128.851,53.27,-128.478,53.145,-128.557,53.088,-128.43,53.099,-128.551,53.066,-128.5,52.928,-128.411,52.941,-128.501,52.921,-128.433,52.814,-128.243,52.799,-128.134,52.928,-128.047,52.917,-128.137,52.877,-128.131,52.768,-127.995,52.787,-127.946,52.739,-127.887,52.757,-127.829,52.734,-127.947,52.729,-128.116,52.759,-128.247,52.572,-128.199,52.484,-128.261,52.391,-128.377,52.351,-128.324,52.281,-128.377,52.284,-128.382,52.265,-128.295,52.256,-128.241,52.305,-128.148,52.417,-128.205,52.462,-128.105,52.472,-128.112,52.598,-128.046,52.652,-128.103,52.552,-127.98,52.52,-128.086,52.435,-128.026,52.319,-128.001,52.447,-127.964,52.478,-128.006,52.318,-127.883,52.372,-127.945,52.445,-127.892,52.5,-127.834,52.511,-127.713,52.452,-127.928,52.444,-127.84,52.341,-127.926,52.348,-127.882,52.199,-127.721,52.358,-127.758,52.271,-127.49,52.338,-127.539,52.397,-127.418,52.41,-127.486,52.452,-127.632,52.605,-127.343,52.418,-127.242,52.582,-126.962,52.716,-127.084,52.876,-126.967,52.839,-126.92,52.711,-127.23,52.535,-127.243,52.452,-127.109,52.334,-126.776,52.394,-126.966,52.311,-126.675,52.047,-126.667,51.963,-126.926,52.189,-127.001,52.299,-127.178,52.314,-127.473,52.187,-127.382,52.103,-127.615,52.034,-127.466,52.108,-127.526,52.158,-127.608,52.121,-127.668,51.951,-127.882,51.897,-127.85,51.873,-127.889,51.692,-127.816,51.624,-127.718,51.651,-127.667,51.602,-127.756,51.594,-127.785,51.609,-127.789,51.518,-127.758,51.544,-127.659,51.517,-127.55,51.662,-127.458,51.684,-127.66,51.707,-127.457,51.694,-127.452,51.778,-127.356,51.869,-127.452,51.664,-127.253,51.678,-127.505,51.623,-127.564,51.479,-127.319,51.441,-127.252,51.408,-127.432,51.438,-127.48,51.403,-127.557,51.402,-127.466,51.436,-127.566,51.473,-127.667,51.369,-127.784,51.374,-127.782,51.321,-127.361,51.407,-127.54,51.32,-127.371,51.306,-127.346,51.338,-127.272,51.342,-127.239,51.339,-127.197,51.351,-127.175,51.348,-127.367,51.304,-127.257,51.31,-127.193,51.324,-127.118,51.391,-127.06,51.352,-127.135,51.36,-127.158,51.318,-127.47,51.266,-127.58,51.263,-127.598,51.265,-127.565,51.293,-127.377,51.296,-127.586,51.306,-127.598,51.271,-127.768,51.249,-127.788,51.163,-127.672,51.093,-127.544,51.103,-127.506,51.096,-127.615,51.144,-127.432,51.134,-127.412,51.21,-127.248,51.21,-127.402,51.201,-127.411,51.139,-126.924,51.187,-127.135,51.122,-126.908,51.099,-127.555,51.124,-127.195,51.093,-127.467,51.089,-127.34,51.038,-127.342,51.056,-126.808,51.101,-126.827,51.075,-126.658,51.197,-126.775,51.071,-126.645,51.052,-126.706,51.04,-126.698,50.996,-126.742,50.986,-126.774,51.068,-127.325,51.04,-127.191,51.02,-127.174,50.965,-127.499,51.099,-127.515,50.99,-127.419,50.968,-127.407,50.925,-127.015,50.817,-126.896,50.885,-127.046,50.888,-127.198,50.92,-127.125,50.954,-127.045,50.959,-127.067,51.008,-127.037,50.958,-127.138,50.931,-126.901,50.894,-126.878,50.955,-126.795,50.959,-126.797,51.002,-126.783,51.021,-126.776,50.963,-126.809,50.945,-126.638,50.933,-126.841,50.911,-126.664,50.865,-126.512,50.941,-126.521,51.039,-126.462,50.944,-126.344,50.928,-126.21,50.938,-126.207,50.933,-126.198,50.936,-126.182,50.929,-126.46,50.874,-126.418,50.901,-126.497,50.924,-126.606,50.871,-126.566,50.833,-126.185,50.876,-126.192,50.78,-126.018,50.812,-126.184,50.668,-125.731,50.708,-125.646,50.776,-125.684,50.875,-125.545,50.939,-125.637,51.072,-125.615,51.197,-125.73,51.284,-125.717,51.286,-125.658,51.253,-125.61,51.204,-125.593,51.148,-125.626,51.092,-125.549,51.095,-125.496,50.938,-125.647,50.855,-125.613,50.759,-125.735,50.662,-126.3,50.631,-126.195,50.579,-125.938,50.637,-126.154,50.588,-126.186,50.516,-126.284,50.515,-126.094,50.485,-126.09,50.505,-126.067,50.52,-125.944,50.558,-126.084,50.484,-125.724,50.527,-125.82,50.493,-125.657,50.428,-125.528,50.673,-125.422,50.72,-125.518,50.664,-125.594,50.45,-125.437,50.448,-125.363,50.554,-125.194,50.402,-124.898,50.636,-124.886,50.728,-124.982,50.809,-124.86,50.944,-124.899,50.947,-124.964,51.012,-125.073,51.156,-125.07,51.161,-125.054,51.169,-124.789,50.894,-124.916,50.801,-124.831,50.732,-124.86,50.591,-125.051,50.48,-125.08,50.325,-124.969,50.375,-125.008,50.446,-124.943,50.385,-124.962,50.337,-124.827,50.307,-124.673,50.434,-124.357,50.488,-124.736,50.325,-124.59,50.242,-124.702,50.098,-124.806,50.075,-124.653,50.078,-124.72,50.035,-124.678,49.965,-124.843,50.064,-124.753,49.964,-124.281,49.74,-124.078,49.799,-124.036,49.932,-123.966,49.805,-123.921,49.828,-123.903,49.877,-123.976,49.919,-124.011,49.994,-123.802,50.094,-123.988,50.209,-123.769,50.205,-123.774,50.188,-123.851,50.163,-123.743,50.089,-123.974,49.981,-123.868,49.917,-123.947,49.775,-123.893,49.749,-123.84,49.686,-123.717,49.786,-123.805,49.624,-123.526,49.705,-123.79,49.603,-123.755,49.481,-123.888,49.73,-124.017,49.752,-124.075,49.642,-123.965,49.509,-123.516,49.387,-123.492,49.519,-123.272,49.585,-123.258,49.663,-123.174,49.708,-123.268,49.331,-122.955,49.301,-122.889,49.467,-122.863,49.455,-122.854,49.425,-122.864,49.38,-122.934,49.316,-122.837,49.283,-123.265,49.264,-122.898,49.201,-122.924,49.168,-122.947,49.155,-123.03,49.146,-123.092,49.091,-123.165,49.065,-123.169,49.01,-123.028,48.974,-123.044,49.046,-122.867,49.089,-122.874,49.028,-122.773,49.011,-122.731,48.973,-122.823,48.942,-122.746,48.929,-122.794,48.893,-122.647,48.784,-122.673,48.732,-122.488,48.75,-122.512,48.667,-122.426,48.6,-122.561,48.582,-122.469,48.476,-122.509,48.361,-122.374,48.286,-122.531,48.249,-122.537,48.183,-122.362,48.054,-122.478,48.177,-122.384,48.228,-122.344,48.098,-122.29,48.052,-122.208,48.045,-122.203,48.035,-122.177,48.037,-122.158,48.029,-122.15,48.017,-122.307,47.95,-122.396,47.806,-122.365,47.654,-122.436,47.662,-122.337,47.601,-122.421,47.576,-122.324,47.349,-122.445,47.301,-122.417,47.296,-122.362,47.261,-122.548,47.318,-122.53,47.283,-122.577,47.185,-122.685,47.098,-122.816,47.178,-122.826,47.096,-122.842,47.164,-122.908,47.14,-122.904,47.024,-122.926,47.146,-122.986,47.041,-123.017,47.066,-122.957,47.106,-122.937,47.182,-123.025,47.109,-123.086,47.098,-122.938,47.198,-123.061,47.202,-123.092,47.211,-123.018,47.262,-123.07,47.21,-122.935,47.206,-122.927,47.281,-122.833,47.328,-122.827,47.406,-122.772,47.168,-122.735,47.345,-122.628,47.41,-122.625,47.372,-122.695,47.282,-122.548,47.285,-122.593,47.337,-122.496,47.51,-122.555,47.59,-122.696,47.528,-122.623,47.568,-122.709,47.612,-122.609,47.568,-122.652,47.744,-122.588,47.69,-122.556,47.762,-122.47,47.757,-122.604,47.94,-122.564,47.822,-122.609,47.857,-122.753,47.668,-122.964,47.584,-123.119,47.39,-122.844,47.434,-123.159,47.353,-123.038,47.551,-122.905,47.646,-122.856,47.826,-122.805,47.852,-122.785,47.687,-122.612,47.887,-122.801,48.086,-122.754,48.145,-122.885,48.106,-122.827,48.043,-122.84,48.002,-122.887,47.991,-122.915,48.096,-122.975,48.097,-123.041,48.081,-123.008,48.068,-123.004,48.023,-123.183,48.147,-123.101,48.185,-123.334,48.113,-123.979,48.165,-124.74,48.393,-124.692,48.591,-124.412,48.575,-124.458,48.531,-123.822,48.357,-123.636,48.389,-123.717,48.365,-123.654,48.314,-123.541,48.32,-123.455,48.458,-123.349,48.402,-123.265,48.455,-123.44,48.699,-123.553,48.489,-123.534,48.694,-123.642,48.752,-123.562,48.798,-123.856,49.018,-123.769,48.981,-123.747,49.041,-123.964,49.237,-124.789,49.46,-124.978,49.673,-124.861,49.707,-125.385,50.121,-125.439,50.331,-126.867,50.532,-127.906,50.875,-128.407,50.747,-128.603,50.818,-128.623,50.783,-128.773,50.785],"holes":[[-122.579,48.374,-122.665,48.402,-122.77,48.224,-122.612,48.154,-122.547,47.967,-122.378,47.905,-122.376,48.034,-122.521,48.097,-122.54,48.016,-122.606,48.208,-122.734,48.23,-122.509,48.294,-122.579,48.374],[-127.816,52.196,-127.859,52.123,-127.752,52.108,-127.883,52.095,-127.837,52.061,-127.905,51.923,-127.709,51.966,-127.64,52.139,-127.197,52.355,-127.275,52.438,-127.596,52.227,-127.666,52.268,-127.816,52.196],[-129.127,52.881,-129.003,52.699,-128.769,52.666,-128.784,52.846,-128.661,52.969,-128.76,52.607,-128.599,52.605,-128.576,52.668,-128.679,52.64,-128.57,52.686,-128.523,52.965,-128.633,53.155,-129.04,53.307,-129.2,52.953,-129.133,52.978,-129.092,52.901,-128.9,53.028,-129.096,52.888,-129.009,52.84,-129.127,52.881],[-129.959,53.167,-129.752,53.214,-129.946,53.423,-130.401,53.619,-130.471,53.638,-130.579,53.609,-130.487,53.516,-130.338,53.51,-130.402,53.506,-130.425,53.476,-129.959,53.167],[-130.644,53.835,-130.67,53.958,-130.491,53.883,-130.439,53.981,-130.339,53.963,-130.423,53.966,-130.468,53.887,-130.517,53.87,-130.38,53.823,-130.234,53.976,-130.436,54.099,-130.724,53.939,-130.644,53.835],[-130.227,53.898,-130.296,53.846,-130.184,53.816,-130.206,53.721,-130.083,53.723,-130.073,53.631,-129.927,53.654,-130.062,53.597,-129.891,53.582,-129.951,53.551,-129.881,53.546,-129.916,53.463,-129.72,53.324,-129.578,53.318,-129.685,53.308,-129.604,53.221,-129.498,53.211,-129.533,53.291,-129.437,53.416,-129.416,53.295,-129.335,53.302,-129.335,53.364,-129.823,53.71,-130.227,53.898],[-132.096,54.689,-132.164,54.743,-132.017,54.69,-132.021,54.784,-131.954,54.791,-132.048,54.796,-132.085,54.814,-131.952,54.803,-132.113,54.9,-131.972,54.901,-132.028,54.934,-131.98,54.969,-132.069,54.951,-131.978,55.036,-132.211,54.918,-132.163,54.99,-132.311,54.982,-132.227,54.998,-132.274,55.05,-132.086,55.037,-132.178,55.126,-131.996,55.108,-132.103,55.12,-131.986,55.168,-131.998,55.267,-132.195,55.151,-132.153,55.212,-132.244,55.18,-132.275,55.243,-132.354,55.132,-132.291,55.245,-132.474,55.258,-132.096,55.283,-132.275,55.419,-132.363,55.408,-132.359,55.327,-132.457,55.417,-132.473,55.316,-132.524,55.365,-132.475,55.423,-132.281,55.444,-132.412,55.516,-132.556,55.506,-132.744,55.36,-132.523,55.552,-132.557,55.626,-132.144,55.471,-132.3,55.533,-132.39,55.67,-132.453,55.611,-132.446,55.667,-132.578,55.688,-132.459,55.683,-132.47,55.783,-132.735,55.984,-133.095,56.047,-133.145,56.125,-133.037,56.181,-133.174,56.328,-133.342,56.267,-133.315,56.332,-133.627,56.358,-133.638,56.212,-133.41,56.165,-133.625,56.142,-133.503,56.078,-133.697,56.071,-133.801,55.92,-133.637,55.92,-133.489,56.047,-133.376,56.034,-133.442,55.914,-133.382,55.896,-133.288,56.015,-133.382,56.084,-133.288,56.148,-133.38,56.157,-133.286,56.16,-133.288,55.923,-133.132,55.88,-133.257,55.747,-133.386,55.735,-133.406,55.616,-132.933,55.624,-133.111,55.613,-133.156,55.476,-132.885,55.354,-133.224,55.383,-133.245,55.292,-132.918,55.277,-133.016,55.203,-132.824,55.28,-132.826,55.201,-132.667,55.138,-132.67,55.275,-132.545,55.277,-132.657,55.268,-132.578,55.168,-132.637,55.055,-132.557,55.129,-132.569,55.069,-132.478,55.053,-132.616,54.97,-132.466,54.991,-132.527,54.92,-132.415,54.907,-132.382,55.087,-132.404,54.929,-132.286,54.832,-132.374,54.801,-132.229,54.801,-132.317,54.722,-132.236,54.772,-132.096,54.689],[-134.132,56.002,-134.104,56.143,-134.199,56.18,-134.098,56.173,-134.08,56.312,-133.994,56.08,-133.971,56.197,-133.881,56.222,-133.992,56.253,-133.982,56.362,-133.875,56.275,-133.832,56.323,-133.941,56.434,-133.844,56.445,-133.984,56.507,-133.852,56.559,-133.941,56.614,-133.69,56.603,-133.77,56.679,-133.729,56.777,-133.874,56.811,-133.885,56.714,-133.983,56.633,-134.039,56.645,-133.908,56.76,-134.023,56.882,-134.262,56.938,-134.128,56.847,-134.319,56.908,-134.284,56.816,-134.423,56.832,-134.368,56.678,-134.213,56.672,-134.285,56.607,-134.178,56.635,-134.32,56.552,-134.039,56.493,-134.095,56.481,-134.053,56.342,-134.131,56.419,-134.176,56.365,-134.185,56.449,-134.299,56.341,-134.302,56.286,-134.154,56.287,-134.279,56.258,-134.236,56.069,-134.132,56.002],[-133.912,56.959,-133.738,56.895,-133.897,56.902,-133.868,56.844,-133.683,56.857,-133.732,56.681,-133.651,56.599,-133.654,56.435,-133.11,56.488,-133.097,56.569,-133.181,56.616,-133.099,56.615,-133.256,56.642,-133.217,56.708,-133.343,56.765,-133.331,56.883,-133.039,56.611,-132.932,56.659,-133.032,56.838,-132.951,56.875,-133.093,56.998,-133.32,57.011,-133.275,56.922,-133.337,57.012,-133.879,57.1,-134.059,57.026,-133.912,56.959],[-134.612,57.015,-134.277,57.143,-134.303,57.25,-134.229,57.17,-134.106,57.249,-134.196,57.397,-133.982,57.304,-133.863,57.367,-133.907,57.445,-134.11,57.464,-134.085,57.512,-133.908,57.468,-133.944,57.535,-133.839,57.435,-133.942,57.618,-134.094,57.647,-134.047,57.679,-134.244,57.874,-134.307,57.832,-134.343,57.997,-134.236,57.968,-134.316,58.095,-134.163,57.992,-134.251,58.039,-133.972,57.688,-133.802,57.605,-133.901,57.806,-134.23,58.122,-134.178,58.159,-134.331,58.143,-134.296,58.102,-134.559,58.195,-134.691,58.159,-134.956,58.412,-134.895,58.191,-134.784,58.097,-134.727,58.175,-134.81,58.045,-134.678,57.611,-134.58,57.506,-134.487,57.546,-134.367,57.552,-134.349,57.576,-134.365,57.547,-134.453,57.507,-134.398,57.493,-134.568,57.507,-134.507,57.465,-134.607,57.514,-134.318,57.339,-134.581,57.401,-134.448,57.307,-134.653,57.231,-134.612,57.015],[-132.941,56.823,-132.953,56.51,-132.543,56.583,-132.941,56.823],[-132.389,56.489,-132.331,56.236,-132.074,56.116,-132.053,56.229,-131.922,56.204,-132.019,56.346,-132.174,56.351,-132.389,56.489],[-132.142,56.134,-132.369,56.227,-132.506,56.187,-132.381,56.256,-132.435,56.352,-132.71,56.223,-132.664,56.136,-132.727,56.17,-132.636,56.032,-132.535,56.055,-132.589,56.159,-132.482,56.065,-132.465,56.171,-132.475,56.017,-132.377,56.033,-132.463,55.979,-132.353,55.912,-132.217,55.934,-132.168,56.046,-132.261,56.136,-132.155,56.073,-132.142,56.134],[-132.882,56.244,-132.657,56.277,-132.629,56.418,-133.011,56.425,-133.066,56.331,-132.882,56.244],[-131.7,55.547,-131.833,55.451,-131.533,55.294,-131.466,55.375,-131.517,55.508,-131.459,55.515,-131.425,55.378,-131.485,55.326,-131.326,55.43,-131.356,55.648,-131.277,55.433,-131.484,55.3,-131.298,55.277,-131.235,55.409,-131.191,55.361,-131.327,55.243,-131.185,55.191,-131.064,55.26,-131.03,55.408,-130.972,55.389,-130.983,55.566,-130.929,55.577,-131.071,55.828,-131.264,55.973,-131.599,55.9,-131.461,55.83,-131.71,55.837,-131.49,55.786,-131.725,55.742,-131.624,55.729,-131.724,55.635,-131.617,55.588,-131.7,55.547]]}],"names":{"fr":"Eaux côtières du sud-est de l'Alaska et de Colombie-Britannique"}},{"name":"White Sea","bbox":[32.133,60.951,45.67,68.657],"area":18.8926,"polygons":[{"outer":[44.036,65.991,44.039,65.983,44.026,65.986,44.027,66.018,44.077,66.063,43.96,66.098,43.832,66.197,43.351,66.053,43.422,66.052,43.458,66.037,43.486,66.008,43.5,65.977,43.469,65.935,43.487,65.907,43.474,65.897,43.57,65.839,43.583,65.812,43.578,65.808,43.555,65.808,43.512,65.777,43.486,65.769,43.512,65.758,43.53,65.734,43.511,65.723,43.484,65.714,43.491,65.712,43.507,65.717,43.517,65.716,43.573,65.686,43.701,65.512,43.707,65.489,43.694,65.486,43.705,65.479,43.695,65.469,43.715,65.471,43.729,65.466,43.74,65.469,43.764,65.466,43.676,65.461,43.699,65.511,43.549,65.651,43.562,65.692,43.481,65.714,43.482,65.768,43.567,65.838,43.469,65.888,43.466,65.879,43.451,65.872,43.443,65.858,43.434,65.853,43.45,65.846,43.456,65.841,43.447,65.832,43.434,65.826,43.386,65.823,43.366,65.815,43.367,65.811,43.379,65.805,43.379,65.802,43.362,65.787,43.387,65.776,43.379,65.771,43.354,65.765,43.365,65.815,43.388,65.824,43.435,65.827,43.454,65.84,43.432,65.853,43.479,66.004,43.349,66.035,43.323,66.058,43.321,66.064,43.363,66.094,43.285,66.093,43.658,66.262,43.265,66.414,42.465,66.341,42.639,66.414,42.207,66.512,41.907,66.332,41.943,66.341,41.955,66.342,41.969,66.342,41.976,66.341,42.015,66.311,41.865,66.324,41.593,66.161,41.648,66.16,41.646,66.136,41.66,66.129,41.684,66.134,41.718,66.126,41.747,66.108,41.745,66.104,41.727,66.102,41.733,66.098,41.578,66.155,41.484,66.107,41.255,66.048,41.273,66.048,41.276,66.04,41.261,66.034,41.238,66.037,41.223,66.026,41.209,66.021,41.195,66.021,41.229,66.039,41.271,66.044,41.25,66.047,40.99,66.007,40.754,65.988,40.189,65.687,40.2,65.685,40.204,65.694,40.217,65.697,40.368,65.689,40.39,65.687,40.401,65.685,40.418,65.672,39.879,65.628,39.707,65.475,39.725,65.335,40.396,64.92,40.353,64.817,40.505,64.777,40.568,64.721,40.536,64.707,40.575,64.71,40.598,64.706,40.665,64.678,40.675,64.686,40.715,64.689,40.77,64.676,40.791,64.659,40.815,64.666,40.826,64.666,40.845,64.652,40.841,64.643,40.861,64.639,40.867,64.63,40.89,64.646,40.905,64.649,40.915,64.648,40.886,64.644,40.887,64.625,40.893,64.633,40.897,64.634,40.903,64.633,40.916,64.625,40.92,64.626,40.924,64.631,40.93,64.633,40.941,64.633,40.945,64.631,40.916,64.625,40.897,64.633,40.891,64.626,40.885,64.625,40.676,64.685,40.657,64.646,40.619,64.623,40.668,64.579,40.512,64.535,40.621,64.52,40.706,64.482,40.813,64.481,40.839,64.477,40.865,64.471,40.877,64.467,40.807,64.48,40.966,64.433,41.083,64.355,41.522,64.336,41.664,64.304,41.812,64.314,41.922,64.229,41.92,64.127,41.962,64.134,42.141,64.123,42.163,64.118,42.366,64.199,42.779,64.238,42.89,64.267,42.959,64.275,43.193,64.383,43.197,64.394,43.168,64.429,43.275,64.561,43.392,64.625,43.38,64.644,43.391,64.65,43.388,64.683,43.401,64.709,43.45,64.727,43.609,64.724,43.692,64.702,43.688,64.705,43.777,64.706,43.86,64.692,43.915,64.64,43.895,64.61,43.937,64.602,43.954,64.586,43.95,64.558,44.087,64.544,44.206,64.483,43.944,64.552,43.891,64.61,43.909,64.64,43.844,64.692,43.446,64.716,43.452,64.672,43.427,64.633,43.273,64.548,43.241,64.458,43.177,64.424,43.207,64.391,43.169,64.362,42.975,64.276,42.94,64.267,42.17,64.115,41.752,64.108,41.712,64.036,41.781,64.022,41.796,64.002,41.682,63.923,41.636,63.91,41.667,63.881,41.65,63.852,41.438,63.746,41.512,63.704,41.565,63.654,41.73,63.657,41.923,63.576,41.933,63.548,41.952,63.544,41.953,63.537,41.87,63.44,41.987,63.38,42.088,63.287,42.094,63.248,42.014,63.225,42.114,63.082,42.505,63.054,42.858,62.975,42.937,62.944,42.766,62.988,42.69,62.975,42.673,62.993,42.62,62.945,42.674,62.885,43.07,62.806,43.89,62.497,43.802,62.501,43.923,62.452,43.977,62.397,44.114,62.394,44.381,62.339,44.582,62.355,44.812,62.312,44.801,62.278,44.781,62.278,43.878,62.389,43.687,62.532,43.683,62.508,43.671,62.504,43.66,62.503,43.654,62.503,43.662,62.496,43.658,62.496,43.663,62.495,43.661,62.486,43.645,62.486,43.644,62.511,43.677,62.523,43.656,62.531,43.645,62.539,43.645,62.543,43.621,62.548,43.607,62.557,43.602,62.564,43.599,62.578,43.513,62.592,43.206,62.716,43.093,62.722,42.889,62.812,42.865,62.779,42.831,62.761,42.909,62.709,42.92,62.685,42.904,62.664,42.955,62.654,42.967,62.642,42.977,62.645,42.991,62.597,42.917,62.522,42.892,62.516,42.87,62.518,42.842,62.546,42.785,62.527,42.927,62.481,42.916,62.378,42.978,62.407,43.012,62.408,43.034,62.4,43.035,62.392,43.024,62.384,42.967,62.368,43.073,62.332,43.128,62.245,43.097,62.227,43.014,62.218,42.953,62.231,42.914,62.252,42.89,62.251,42.897,62.212,42.988,62.19,43.018,62.17,43.021,62.163,42.985,62.134,42.957,62.125,42.919,62.127,42.896,62.134,42.872,62.132,42.92,62.081,42.915,62.064,42.892,62.058,42.841,62.062,42.828,62.056,42.829,62.036,42.765,61.958,42.674,61.92,42.66,61.812,42.64,61.81,42.636,61.815,42.613,61.78,42.55,61.747,42.577,61.701,42.639,61.691,42.648,61.688,42.651,61.685,42.652,61.681,42.633,61.67,42.536,61.666,42.628,61.632,42.689,61.601,42.701,61.59,42.623,61.566,42.571,61.58,42.624,61.549,42.646,61.484,42.632,61.464,42.581,61.459,42.574,61.463,42.575,61.473,42.569,61.46,42.652,61.438,42.668,61.429,42.689,61.398,42.688,61.38,42.712,61.388,42.736,61.385,42.746,61.376,42.753,61.358,42.774,61.356,42.782,61.353,42.791,61.345,42.791,61.341,42.687,61.379,42.651,61.437,42.642,61.44,42.641,61.436,42.636,61.433,42.611,61.434,42.631,61.428,42.625,61.424,42.584,61.414,42.565,61.413,42.544,61.417,42.578,61.381,42.574,61.373,42.546,61.353,42.545,61.344,42.54,61.336,42.566,61.319,42.553,61.304,42.564,61.301,42.572,61.29,42.587,61.288,42.593,61.283,42.592,61.28,42.579,61.27,42.574,61.268,42.592,61.284,42.572,61.289,42.551,61.304,42.538,61.336,42.544,61.353,42.576,61.382,42.538,61.415,42.543,61.418,42.548,61.418,42.583,61.415,42.612,61.436,42.64,61.436,42.573,61.451,42.56,61.472,42.64,61.493,42.426,61.47,42.418,61.446,42.486,61.432,42.501,61.425,42.503,61.411,42.483,61.394,42.524,61.385,42.532,61.376,42.53,61.369,42.506,61.358,42.439,61.369,42.433,61.352,42.465,61.346,42.476,61.341,42.476,61.332,42.465,61.323,42.453,61.318,42.423,61.316,42.396,61.319,42.422,61.305,42.437,61.286,42.384,61.175,42.366,61.164,42.302,61.151,42.279,61.15,42.246,61.157,42.219,61.154,42.157,61.068,42.136,61.085,42.138,61.064,42.132,61.049,42.186,61.036,42.207,60.999,42.171,60.951,42.194,61.0,42.131,61.048,42.128,61.073,42.134,61.086,42.143,61.089,42.109,61.086,42.082,61.075,42.054,61.07,42.042,61.078,41.989,61.073,41.982,61.075,42.133,61.091,42.146,61.088,42.155,61.077,42.217,61.155,42.386,61.181,42.348,61.214,42.424,61.272,42.386,61.317,42.408,61.323,42.455,61.321,42.473,61.333,42.427,61.353,42.436,61.37,42.528,61.375,42.479,61.394,42.497,61.425,42.418,61.441,42.408,61.449,42.407,61.453,42.41,61.461,42.424,61.472,42.479,61.492,42.548,61.503,42.544,61.531,42.593,61.526,42.62,61.542,42.608,61.542,42.568,61.565,42.534,61.597,42.496,61.592,42.53,61.6,42.625,61.571,42.691,61.594,42.509,61.665,42.508,61.671,42.517,61.677,42.526,61.68,42.64,61.684,42.576,61.698,42.547,61.727,42.539,61.713,42.524,61.717,42.532,61.748,42.657,61.921,42.836,62.066,42.906,62.064,42.87,62.134,43.016,62.166,42.892,62.212,42.885,62.252,42.915,62.254,43.02,62.221,43.12,62.248,42.963,62.365,42.964,62.373,42.985,62.382,43.026,62.387,43.032,62.398,42.979,62.405,42.939,62.375,42.91,62.369,42.867,62.453,42.92,62.479,42.759,62.497,42.796,62.5,42.773,62.523,42.806,62.547,42.854,62.548,42.881,62.521,42.911,62.526,42.881,62.567,42.892,62.565,42.906,62.578,42.97,62.588,42.978,62.592,42.983,62.597,42.891,62.664,42.897,62.712,42.82,62.752,42.82,62.762,42.83,62.771,42.87,62.794,42.861,62.821,42.641,62.884,42.594,62.929,42.603,62.955,42.587,62.91,42.516,62.927,42.615,62.971,42.57,63.022,42.002,63.048,42.075,63.072,42.035,63.096,41.987,63.222,42.044,63.265,42.049,63.29,42.029,63.321,41.989,63.322,41.881,63.391,41.841,63.433,41.838,63.472,41.885,63.538,41.788,63.463,41.768,63.468,41.749,63.457,41.725,63.455,41.722,63.449,41.687,63.445,41.677,63.446,41.669,63.456,41.67,63.438,41.646,63.443,41.668,63.456,41.675,63.456,41.678,63.446,41.687,63.445,41.72,63.449,41.723,63.46,41.745,63.458,41.752,63.463,41.661,63.461,41.711,63.475,41.792,63.47,41.888,63.568,41.534,63.649,41.519,63.657,41.506,63.668,41.501,63.7,41.428,63.732,41.434,63.759,41.477,63.794,41.619,63.847,41.63,63.916,41.788,64.002,41.699,64.033,41.725,64.127,41.888,64.191,41.864,64.195,41.805,64.186,41.756,64.187,41.674,64.205,41.642,64.245,41.652,64.231,41.527,64.271,41.437,64.32,40.923,64.349,40.864,64.384,40.822,64.447,40.613,64.452,40.666,64.463,40.615,64.472,40.561,64.519,40.451,64.516,40.406,64.52,40.364,64.534,40.537,64.462,40.475,64.464,40.276,64.552,40.277,64.514,40.24,64.494,40.249,64.469,40.241,64.466,40.236,64.466,40.235,64.49,40.248,64.503,40.274,64.514,40.259,64.543,40.127,64.556,40.088,64.58,40.105,64.596,40.074,64.596,40.096,64.602,40.085,64.609,40.061,64.616,39.735,64.565,39.775,64.536,39.772,64.524,39.525,64.508,39.561,64.547,39.312,64.639,38.439,64.808,38.488,64.738,38.158,64.712,38.151,64.643,38.016,64.584,38.126,64.673,38.008,64.756,38.368,64.763,38.369,64.844,37.879,64.894,37.679,65.029,37.097,65.163,36.836,65.147,36.808,64.955,36.612,64.898,36.527,64.932,36.456,64.929,36.438,64.922,36.463,64.859,36.572,64.81,36.499,64.823,36.52,64.767,36.551,64.738,36.535,64.716,36.735,64.689,37.14,64.388,37.615,64.355,37.794,64.439,37.789,64.368,38.054,64.289,37.891,64.213,37.999,64.148,38.058,64.063,38.001,63.94,38.474,63.83,38.518,63.808,38.587,63.683,38.673,63.676,38.73,63.652,38.794,63.716,38.827,63.717,38.837,63.716,38.858,63.707,38.894,63.7,38.907,63.691,38.949,63.683,38.972,63.651,39.031,63.64,39.056,63.632,39.04,63.615,39.034,63.613,39.015,63.612,39.011,63.603,38.996,63.599,38.995,63.59,38.982,63.587,39.0,63.577,38.974,63.556,39.065,63.471,39.06,63.465,39.038,63.461,39.037,63.455,39.111,63.4,39.146,63.322,39.134,63.296,39.102,63.28,39.15,63.257,39.146,63.235,39.186,63.224,39.21,63.208,39.213,63.194,39.184,63.161,39.252,63.118,39.284,63.083,39.284,63.063,39.3,63.046,39.291,63.014,39.34,62.982,39.334,62.971,39.356,62.963,39.406,62.964,39.43,62.958,39.464,62.962,39.474,62.961,39.58,62.938,39.6,62.926,39.605,62.908,39.671,62.899,39.674,62.886,39.736,62.885,39.791,62.859,39.797,62.826,39.844,62.816,39.872,62.793,39.881,62.742,39.908,62.741,39.934,62.724,39.934,62.691,39.915,62.652,39.901,62.643,39.907,62.621,39.89,62.598,39.83,62.569,39.825,62.538,39.817,62.531,39.799,62.526,39.794,62.52,39.84,62.424,39.796,62.419,39.832,62.402,39.852,62.367,40.083,62.366,40.079,62.346,40.096,62.342,40.097,62.333,39.853,62.365,39.794,62.419,39.785,62.413,39.77,62.41,39.761,62.41,39.76,62.406,39.756,62.405,39.748,62.408,39.773,62.413,39.719,62.413,39.746,62.409,39.702,62.398,39.659,62.367,39.611,62.345,39.59,62.341,39.566,62.324,39.522,62.308,39.518,62.295,39.486,62.26,39.44,62.225,39.388,62.204,39.357,62.162,39.342,62.152,39.339,62.142,39.311,62.123,39.264,62.113,39.156,62.117,39.087,62.126,39.06,62.076,39.048,62.074,39.032,62.074,39.017,62.07,38.975,62.068,38.966,62.062,39.008,61.997,39.004,61.943,39.036,61.953,39.062,61.949,39.077,61.94,39.112,61.951,39.128,61.95,39.134,61.948,39.127,61.891,39.102,61.862,39.191,61.803,39.189,61.798,39.18,61.792,39.178,61.786,39.193,61.779,39.213,61.762,39.212,61.752,39.233,61.742,39.246,61.719,39.239,61.7,39.281,61.693,39.309,61.684,39.357,61.688,39.391,61.685,39.415,61.679,39.308,61.683,39.236,61.699,39.217,61.679,39.181,61.672,39.174,61.677,39.166,61.673,39.243,61.72,39.101,61.86,39.132,61.948,39.082,61.939,39.076,61.939,39.068,61.941,39.059,61.948,39.038,61.953,39.017,61.941,39.007,61.941,38.999,61.944,38.964,62.062,38.973,62.069,39.031,62.075,39.049,62.075,39.059,62.077,38.992,62.097,39.086,62.127,39.309,62.126,39.384,62.203,39.485,62.262,39.522,62.31,39.714,62.414,39.825,62.431,39.79,62.522,39.931,62.723,39.878,62.74,39.736,62.884,39.672,62.885,39.603,62.907,39.577,62.937,39.554,62.936,39.48,62.957,39.377,62.957,39.331,62.969,39.282,63.016,39.275,63.085,39.181,63.157,39.203,63.209,39.136,63.235,39.098,63.276,39.138,63.33,39.091,63.347,38.969,63.557,38.994,63.58,38.897,63.579,38.862,63.589,38.847,63.609,38.834,63.608,38.817,63.604,38.803,63.603,38.79,63.607,38.766,63.618,38.737,63.62,38.724,63.622,38.719,63.625,38.689,63.618,38.68,63.616,38.755,63.64,38.672,63.673,38.62,63.674,38.585,63.681,38.571,63.688,38.479,63.825,37.981,63.926,37.655,63.891,37.651,63.805,37.409,63.775,37.205,63.821,37.204,63.878,36.311,63.946,36.176,64.074,36.243,64.127,35.956,64.191,35.82,64.327,35.801,64.329,35.767,64.318,35.601,64.387,35.582,64.374,35.614,64.325,35.417,64.255,35.265,64.355,35.31,64.399,35.253,64.385,35.22,64.389,35.247,64.375,35.263,64.355,35.189,64.332,35.141,64.351,35.118,64.419,35.11,64.408,35.045,64.404,34.956,64.411,35.012,64.506,34.883,64.477,34.693,64.507,34.689,64.509,34.701,64.525,34.996,64.618,34.835,64.679,34.782,64.731,34.792,64.742,34.72,64.738,34.704,64.742,34.961,64.835,34.923,64.835,34.839,64.913,34.825,64.895,34.769,64.889,34.832,64.914,34.769,64.953,34.411,64.915,34.173,64.906,34.197,64.909,34.128,64.925,34.092,64.953,34.023,64.964,33.996,64.977,33.98,64.973,33.926,64.976,33.852,64.998,33.717,65.003,33.683,65.017,33.669,65.033,34.007,64.978,34.087,64.959,34.217,64.91,34.79,64.983,34.831,65.021,34.776,64.993,34.784,65.079,34.689,65.078,34.536,65.161,34.727,65.191,34.61,65.19,34.66,65.194,34.524,65.22,34.458,65.27,34.535,65.328,34.471,65.331,34.455,65.342,34.476,65.342,34.376,65.362,34.284,65.362,34.239,65.375,34.443,65.367,34.382,65.384,34.355,65.407,34.44,65.41,34.502,65.388,34.457,65.43,34.587,65.423,34.629,65.427,34.633,65.429,34.66,65.425,34.675,65.435,34.69,65.435,34.633,65.486,34.749,65.525,34.712,65.558,34.737,65.582,34.798,65.581,34.754,65.627,34.727,65.628,34.759,65.653,34.687,65.668,34.702,65.673,34.73,65.672,34.718,65.675,34.74,65.674,34.773,65.686,34.682,65.7,34.662,65.794,34.688,65.764,34.732,65.772,34.705,65.763,34.748,65.765,34.773,65.755,34.928,65.675,34.978,65.716,34.935,65.719,34.839,65.757,34.967,65.727,35.024,65.763,34.897,65.777,34.92,65.815,34.893,65.828,34.867,65.829,34.898,65.818,34.852,65.801,34.777,65.811,34.809,65.818,34.818,65.817,34.817,65.848,34.857,65.866,34.699,65.863,34.635,65.92,34.701,65.982,34.4,66.11,34.427,66.086,34.292,66.069,34.385,66.116,34.142,66.167,34.18,66.19,34.108,66.227,33.881,66.201,33.873,66.221,33.911,66.234,33.563,66.276,33.63,66.305,33.575,66.315,32.978,66.247,33.078,66.276,33.414,66.325,33.621,66.338,33.666,66.335,33.469,66.378,33.719,66.414,32.936,66.52,32.971,66.53,33.036,66.525,33.063,66.53,33.1,66.526,33.138,66.529,33.088,66.531,33.069,66.538,33.197,66.538,32.885,66.567,32.825,66.593,32.862,66.6,32.96,66.595,33.053,66.578,33.125,66.643,33.32,66.638,32.85,66.686,32.692,66.745,32.685,66.749,32.805,66.752,32.818,66.739,32.777,66.741,32.857,66.713,32.82,66.742,32.956,66.729,32.398,66.87,32.498,66.882,32.454,66.918,32.559,66.909,32.412,66.939,32.565,66.978,32.321,67.039,32.352,67.071,32.3,67.087,32.268,67.11,32.173,67.122,32.187,67.106,32.141,67.104,32.133,67.111,32.16,67.14,32.318,67.137,32.33,67.165,32.993,67.084,32.791,67.025,33.508,66.716,33.546,66.733,33.632,66.705,33.534,66.778,33.643,66.733,33.545,66.796,33.545,66.828,33.623,66.79,33.601,66.809,33.607,66.853,33.829,66.765,33.926,66.686,34.03,66.687,34.021,66.748,34.055,66.761,34.065,66.68,34.131,66.702,34.151,66.786,34.184,66.746,34.141,66.695,34.093,66.674,34.357,66.697,34.506,66.631,34.369,66.62,34.468,66.54,34.811,66.608,35.551,66.384,36.939,66.267,36.798,66.29,36.694,66.331,36.701,66.33,36.69,66.334,36.686,66.345,36.683,66.346,36.688,66.366,36.653,66.38,36.641,66.402,36.593,66.396,36.562,66.405,36.631,66.407,36.658,66.394,36.657,66.382,36.696,66.365,36.703,66.335,36.779,66.322,36.795,66.313,36.81,66.291,36.876,66.318,37.925,66.084,39.11,66.102,39.998,66.254,40.466,66.41,40.693,66.582,41.123,66.764,41.286,66.935,41.251,66.978,41.274,66.987,41.163,67.04,41.067,67.116,41.069,67.132,40.827,67.103,40.512,67.116,41.07,67.134,41.305,66.974,41.393,67.113,41.274,67.137,41.315,67.217,41.046,67.273,41.119,67.41,40.952,67.441,41.038,67.634,40.983,67.695,40.652,67.802,40.559,67.797,40.576,67.763,40.544,67.733,40.513,67.734,40.499,67.725,40.485,67.722,40.48,67.723,40.274,67.841,40.376,67.886,40.255,67.887,40.006,67.985,39.745,68.157,43.271,68.657,44.202,68.302,44.15,68.034,44.222,68.061,44.177,68.027,44.33,68.039,44.151,68.017,44.246,67.979,44.14,67.899,44.212,67.923,44.364,67.913,44.382,67.905,44.386,67.89,44.429,67.906,44.389,67.889,44.266,67.903,44.251,67.873,44.116,67.878,44.169,67.75,44.073,67.679,44.138,67.683,44.195,67.677,44.198,67.666,44.223,67.673,44.231,67.671,44.233,67.668,44.198,67.665,44.14,67.676,44.156,67.664,44.116,67.633,44.131,67.627,44.133,67.624,44.099,67.618,44.106,67.614,44.12,67.618,44.131,67.617,44.13,67.614,44.109,67.61,44.113,67.606,44.132,67.603,44.147,67.605,44.163,67.602,44.165,67.599,44.154,67.596,44.132,67.597,44.151,67.585,44.13,67.598,44.162,67.602,44.131,67.602,44.112,67.605,44.097,67.619,44.148,67.668,44.068,67.661,43.771,67.252,43.908,67.151,44.42,67.101,44.371,67.071,44.3,67.055,44.304,67.038,44.455,66.929,44.487,66.929,44.509,66.91,44.505,66.909,44.493,66.91,44.497,66.907,44.512,66.909,44.532,66.905,44.534,66.905,44.526,66.897,44.543,66.902,44.526,66.897,44.512,66.908,44.486,66.902,44.496,66.898,44.471,66.891,44.471,66.89,44.478,66.888,44.47,66.888,44.476,66.885,44.459,66.874,44.5,66.84,44.491,66.822,44.359,66.765,44.458,66.675,44.588,66.653,44.589,66.649,44.581,66.642,44.563,66.643,44.567,66.635,44.583,66.636,44.591,66.642,44.603,66.644,44.609,66.644,44.61,66.641,44.608,66.63,44.602,66.627,44.447,66.64,44.384,66.485,44.117,66.308,44.071,66.19,44.121,66.156,44.165,66.045,44.083,65.987,44.168,65.926,44.167,65.886,44.184,65.88,44.193,65.869,44.221,65.862,44.223,65.86,44.18,65.869,44.295,65.749,44.284,65.7,44.332,65.69,44.354,65.671,44.389,65.667,44.349,65.67,44.412,65.648,44.429,65.63,44.463,65.612,44.473,65.602,44.564,65.607,44.607,65.6,44.588,65.635,44.6,65.651,44.619,65.654,44.713,65.645,44.69,65.671,44.699,65.681,44.713,65.685,44.726,65.685,44.771,65.674,44.815,65.686,44.728,65.695,44.699,65.711,44.727,65.757,44.745,65.769,44.728,65.788,44.733,65.794,44.738,65.796,44.754,65.794,44.844,65.749,44.872,65.754,44.846,65.769,44.849,65.776,44.865,65.784,44.887,65.787,44.899,65.786,44.956,65.758,44.978,65.779,45.0,65.787,45.0,65.775,44.981,65.779,44.972,65.76,44.953,65.756,44.872,65.784,44.875,65.754,44.837,65.748,44.734,65.793,44.748,65.77,44.701,65.713,44.818,65.686,44.763,65.664,44.715,65.683,44.725,65.646,44.705,65.642,44.685,65.643,44.612,65.652,44.595,65.634,44.613,65.599,44.642,65.589,44.671,65.566,44.685,65.541,44.89,65.513,44.952,65.496,44.988,65.481,45.029,65.451,45.073,65.408,45.094,65.368,45.078,65.334,45.193,65.321,45.179,65.322,45.189,65.311,45.196,65.288,45.208,65.281,45.212,65.25,45.477,65.203,45.647,65.119,45.67,65.006,45.606,65.131,45.215,65.235,45.064,65.332,45.072,65.376,45.055,65.383,45.024,65.43,45.029,65.433,44.856,65.498,44.915,65.496,44.902,65.504,44.757,65.515,44.688,65.532,44.656,65.546,44.636,65.571,44.632,65.565,44.615,65.592,44.521,65.586,44.462,65.595,44.379,65.641,44.366,65.619,44.254,65.692,44.036,65.991]}],"names":{"ar":"البحر الأبيض","de":"Weißes Meer","es":"mar Blanco","fr":"mer Blanche","he":"הים הלבן","hu":"Fehér-tenger","it":"Mar Bianco","nl":"Witte Zee","pt":"Mar Branco","zh":"白海"}},{"name":"Aegean Sea","bbox":[22.532,35.109,28.327,41.088],"area":19.6134,"polygons":[{"outer":[25.115,41.088,25.174,40.94,26.013,40.818,26.122,40.592,26.818,40.647,26.799,40.557,26.216,40.316,26.279,40.216,26.166,40.053,26.164,39.659,26.063,39.479,26.942,39.57,26.932,39.486,26.858,39.477,26.855,39.44,26.671,39.279,26.609,39.276,26.878,39.1,26.801,38.951,27.043,38.941,27.066,38.877,26.893,38.825,26.896,38.734,26.741,38.741,26.755,38.671,26.734,38.667,26.722,38.652,26.949,38.432,27.027,38.468,27.159,38.467,27.171,38.448,27.17,38.441,26.809,38.355,26.765,38.368,26.707,38.435,26.703,38.32,26.674,38.312,26.585,38.427,26.625,38.531,26.417,38.681,26.353,38.638,26.393,38.447,26.516,38.428,26.375,38.309,26.285,38.376,26.232,38.267,26.391,38.261,26.592,38.103,26.636,38.207,26.757,38.222,26.824,38.158,26.865,38.029,26.981,38.075,27.253,37.978,27.24,37.728,27.003,37.66,27.217,37.59,27.16,37.585,27.228,37.472,27.193,37.353,27.417,37.416,27.389,37.322,27.498,37.319,27.458,37.25,27.616,37.274,27.615,37.188,27.526,37.194,27.581,37.136,27.474,37.082,27.255,37.131,27.264,36.964,27.304,36.957,27.425,37.036,27.565,36.975,27.583,36.994,27.799,36.995,27.893,37.028,28.327,37.049,28.168,36.907,28.021,36.925,28.003,36.835,28.065,36.824,28.033,36.788,27.641,36.811,27.362,36.709,27.678,36.658,27.731,36.763,27.93,36.742,28.051,36.759,28.128,36.799,28.102,36.706,27.964,36.684,28.041,36.693,28.096,36.645,27.963,36.607,27.979,36.554,28.267,36.73,28.198,36.428,27.915,36.343,27.685,36.156,27.76,36.09,27.749,35.877,27.212,35.535,27.151,35.615,27.236,35.825,27.163,35.803,27.061,35.598,27.134,35.399,26.302,35.187,26.267,35.211,26.315,35.306,26.29,35.313,26.163,35.199,26.027,35.23,25.811,35.109,25.711,35.166,25.727,35.303,25.773,35.341,25.443,35.292,25.056,35.341,24.973,35.432,24.284,35.354,24.243,35.475,24.062,35.492,24.184,35.506,24.128,35.602,24.028,35.516,23.843,35.527,23.738,35.696,23.721,35.511,23.616,35.518,23.594,35.64,22.989,36.136,23.106,36.236,22.929,36.378,22.968,36.521,23.203,36.45,23.032,36.626,23.026,36.727,23.111,36.774,22.718,37.564,23.121,37.451,23.066,37.352,23.174,37.285,23.268,37.406,23.517,37.431,23.392,37.509,23.398,37.637,23.308,37.617,23.333,37.532,23.159,37.617,23.118,37.756,23.179,37.803,22.992,37.88,23.568,38.047,23.603,38.022,23.555,37.958,23.688,37.938,23.77,37.835,23.771,37.797,23.869,37.8,24.031,37.648,24.086,37.794,23.981,38.107,24.081,38.165,23.954,38.285,23.659,38.35,23.566,38.451,23.552,38.507,23.33,38.499,23.378,38.526,23.286,38.561,23.313,38.642,23.106,38.625,23.034,38.755,22.532,38.857,22.828,38.888,23.057,39.015,22.958,39.009,22.983,39.098,22.821,39.211,22.941,39.361,23.219,39.187,23.072,39.085,23.352,39.186,22.593,40.015,22.548,40.155,22.661,40.362,22.586,40.462,22.942,40.631,22.976,40.536,22.815,40.479,22.889,40.374,23.31,40.225,23.357,39.953,23.751,39.915,23.361,40.148,23.402,40.279,23.655,40.223,23.823,40.015,23.986,39.945,23.997,40.109,23.767,40.199,23.694,40.286,23.735,40.353,23.92,40.364,24.155,40.284,24.305,40.114,24.396,40.162,24.008,40.452,24.0,40.382,23.865,40.413,23.824,40.507,23.92,40.533,23.695,40.667,23.73,40.747,24.089,40.722,24.34,40.825,24.395,40.934,24.511,40.959,24.627,40.858,24.803,40.846,25.144,41.01,25.076,41.047,25.115,41.088],"holes":[[25.441,40.035,25.291,39.939,25.236,40.005,25.036,39.989,25.044,39.829,25.227,39.796,25.18,39.848,25.257,39.912,25.235,39.849,25.355,39.782,25.441,40.035],[26.412,39.322,26.184,39.379,26.127,39.307,25.904,39.295,25.831,39.188,26.076,39.086,26.255,39.205,26.281,39.155,26.084,39.079,26.168,39.005,26.518,38.97,26.483,39.119,26.608,39.009,26.54,39.167,26.381,39.27,26.412,39.322],[23.724,38.748,23.47,38.836,23.316,39.039,23.13,39.008,22.83,38.827,22.964,38.89,23.204,38.831,23.599,38.572,23.647,38.4,24.048,38.395,24.023,38.328,24.154,38.279,24.116,38.219,24.186,38.215,24.202,38.084,24.387,37.957,24.414,38.012,24.562,37.982,24.588,38.16,24.35,38.155,24.249,38.228,24.155,38.652,23.724,38.748],[26.006,38.605,25.83,38.546,25.992,38.39,25.865,38.242,26.012,38.147,26.16,38.309,26.163,38.536,26.006,38.605],[26.727,37.809,26.595,37.762,26.591,37.675,26.72,37.71,26.859,37.636,27.07,37.716,27.066,37.779,26.727,37.809]]}],"names":{"ar":"بحر إيجة","de":"Ägäisches Meer","es":"mar Egeo","fr":"mer Égée","he":"הים האגאי","hu":"Égei-tenger","it":"mar Egeo","nl":"Egeïsche Zee","pt":"Mar Egeu","zh":"爱琴海"}},{"name":"Bay of Biscay","bbox":[-7.896,43.274,-0.146,47.972],"area":19.9163,"polygons":[{"outer":[-4.1,47.972,-4.099,47.861,-3.977,47.852,-3.991,47.895,-3.978,47.908,-3.857,47.79,-3.519,47.803,-3.453,47.695,-3.293,47.789,-3.366,47.71,-3.31,47.709,-3.28,47.688,-3.325,47.702,-3.356,47.699,-3.362,47.683,-3.214,47.644,-3.193,47.748,-3.148,47.754,-3.12,47.698,-3.211,47.642,-3.135,47.473,-3.079,47.468,-3.13,47.597,-2.928,47.553,-2.978,47.656,-2.926,47.667,-2.92,47.582,-2.863,47.622,-2.782,47.621,-2.78,47.625,-2.776,47.628,-2.792,47.636,-2.791,47.64,-2.687,47.616,-2.734,47.541,-2.914,47.557,-2.816,47.485,-2.576,47.548,-2.633,47.502,-2.517,47.528,-2.442,47.498,-2.39,47.506,-2.379,47.499,-2.282,47.576,-2.234,47.583,-2.328,47.51,-2.379,47.495,-2.502,47.493,-2.432,47.416,-2.557,47.379,-2.471,47.31,-2.55,47.293,-2.432,47.257,-2.064,47.307,-1.914,47.285,-1.885,47.277,-1.797,47.214,-1.707,47.208,-1.655,47.192,-1.628,47.194,-1.598,47.193,-1.306,47.339,-1.224,47.356,-1.205,47.355,-1.257,47.34,-1.302,47.337,-1.483,47.217,-1.545,47.197,-1.598,47.191,-1.658,47.191,-1.801,47.212,-1.927,47.28,-2.17,47.269,-2.167,47.167,-2.25,47.133,-1.981,47.027,-2.032,47.011,-2.058,46.951,-2.154,46.89,-2.143,46.82,-1.943,46.695,-1.806,46.489,-1.206,46.267,-1.189,46.307,-1.121,46.324,-1.087,46.317,-1.242,46.162,-1.06,46.044,-1.126,46.004,-1.08,45.96,-1.008,45.975,-0.991,45.968,-1.17,45.855,-1.002,45.713,-1.214,45.793,-1.244,45.782,-1.229,45.674,-0.799,45.459,-0.617,45.056,-0.253,44.917,-0.249,44.914,-0.246,44.9,-0.259,44.876,-0.237,44.893,-0.212,44.894,-0.206,44.889,-0.203,44.88,-0.218,44.853,-0.151,44.844,-0.148,44.841,-0.148,44.838,-0.158,44.829,-0.146,44.821,-0.22,44.853,-0.211,44.892,-0.26,44.875,-0.253,44.916,-0.607,45.042,-0.536,44.978,-0.509,44.755,-0.396,44.704,-0.333,44.648,-0.51,44.743,-0.569,44.845,-0.546,44.977,-0.632,45.032,-0.653,45.05,-0.662,45.064,-0.686,45.083,-0.803,45.345,-1.06,45.572,-1.132,45.513,-1.26,44.632,-1.17,44.767,-1.02,44.668,-1.192,44.661,-1.257,44.535,-1.446,43.645,-1.534,43.531,-1.194,43.546,-1.522,43.528,-1.666,43.388,-2.129,43.274,-2.643,43.415,-2.683,43.364,-2.752,43.457,-3.033,43.377,-3.018,43.324,-2.983,43.312,-2.979,43.308,-2.977,43.299,-3.324,43.42,-3.452,43.433,-3.428,43.367,-3.478,43.37,-3.52,43.425,-3.427,43.458,-3.586,43.515,-3.809,43.395,-3.784,43.492,-4.221,43.394,-4.567,43.393,-5.692,43.543,-5.84,43.661,-5.92,43.559,-6.946,43.575,-7.029,43.557,-7.007,43.529,-7.031,43.529,-7.034,43.498,-7.047,43.488,-7.046,43.478,-7.052,43.474,-7.041,43.557,-7.267,43.541,-7.491,43.733,-7.599,43.658,-7.638,43.753,-7.708,43.708,-7.662,43.774,-7.688,43.791,-7.847,43.669,-7.883,43.664,-7.896,43.669,-4.358,47.795,-4.306,47.8,-4.267,47.79,-4.178,47.797,-4.1,47.972]}],"names":{"ar":"خليج غشكونية","de":"Biskaya","es":"golfo de Vizcaya","fr":"golfe de Gascogne","he":"מפרץ ביסקאיה","hu":"Vizcayai-öböl","it":"Golfo di Biscaglia","nl":"Golf van Biskaje","pt":"Golfo da Biscaia","zh":"比斯開灣"}},{"name":"Gulf of Bothnia","bbox":[17.039,59.721,25.487,67.081],"area":20.5283,"polygons":[{"outer":[22.9,59.818,22.497,59.721,21.409,59.735,20.35,59.85,19.782,59.808,19.03,59.876,18.903,59.931,18.838,60.111,18.769,60.11,18.796,59.997,18.745,60.12,18.704,60.126,18.681,60.115,18.586,60.142,18.589,60.055,18.357,60.28,18.394,60.274,18.555,60.187,18.507,60.224,18.61,60.255,18.544,60.289,18.505,60.282,18.458,60.339,18.313,60.371,18.315,60.354,18.254,60.328,18.232,60.336,18.285,60.379,18.078,60.502,17.985,60.507,17.991,60.604,17.728,60.556,17.73,60.502,17.697,60.528,17.707,60.542,17.622,60.54,17.59,60.586,17.669,60.627,17.619,60.633,17.615,60.646,17.55,60.65,17.565,60.662,17.452,60.642,17.458,60.582,17.44,60.564,17.455,60.585,17.43,60.621,17.437,60.648,17.172,60.681,17.356,60.793,17.24,60.812,17.262,60.872,17.315,60.868,17.33,60.887,17.146,60.931,17.138,60.956,17.186,60.981,17.167,60.986,17.247,61.01,17.136,61.0,17.187,61.019,17.15,61.156,17.186,61.156,17.168,61.169,17.191,61.179,17.187,61.187,17.176,61.194,17.18,61.19,17.125,61.206,17.104,61.204,17.082,61.212,17.143,61.207,17.217,61.228,17.213,61.248,17.085,61.314,17.174,61.276,17.274,61.279,17.136,61.314,17.148,61.328,17.162,61.309,17.215,61.308,17.238,61.298,17.258,61.305,17.105,61.375,17.091,61.42,17.224,61.424,17.048,61.548,17.223,61.552,17.039,61.575,17.044,61.579,17.083,61.582,17.143,61.583,17.152,61.59,17.061,61.614,17.18,61.612,17.22,61.593,17.243,61.604,17.184,61.623,17.098,61.621,17.257,61.657,17.112,61.726,17.344,61.665,17.307,61.698,17.313,61.724,17.356,61.703,17.443,61.619,17.517,61.691,17.381,61.706,17.351,61.782,17.42,61.814,17.323,61.818,17.324,61.929,17.461,62.001,17.432,62.066,17.542,62.172,17.507,62.187,17.499,62.226,17.661,62.233,17.413,62.337,17.394,62.273,17.381,62.271,17.352,62.271,17.339,62.276,17.392,62.276,17.374,62.373,17.315,62.392,17.398,62.4,17.332,62.485,17.423,62.554,17.443,62.545,17.457,62.537,17.517,62.486,17.535,62.449,17.617,62.441,17.74,62.388,17.658,62.465,17.684,62.494,17.724,62.51,17.851,62.492,17.937,62.628,17.94,62.55,18.064,62.609,17.846,62.67,17.989,62.699,17.861,62.82,17.839,62.928,17.682,63.004,17.771,62.997,17.753,63.103,17.27,63.17,17.685,63.147,17.766,63.102,17.831,63.002,17.787,62.981,17.829,62.972,17.88,62.913,17.919,62.924,17.943,62.801,17.972,62.832,18.045,62.859,18.163,62.817,17.982,62.81,18.112,62.767,18.28,62.829,18.203,62.888,18.347,62.835,18.51,62.927,18.37,62.943,18.416,62.98,18.558,62.964,18.478,63.001,18.316,62.98,18.202,63.014,18.47,63.023,18.518,63.156,18.725,63.161,18.68,63.25,18.798,63.222,18.715,63.224,18.744,63.176,18.753,63.211,18.902,63.23,18.715,63.263,18.71,63.287,18.871,63.294,19.032,63.188,19.021,63.209,19.095,63.231,19.026,63.22,18.984,63.246,19.13,63.256,19.118,63.267,19.098,63.267,19.137,63.299,19.133,63.336,19.249,63.329,19.262,63.456,19.365,63.447,19.329,63.496,19.368,63.448,19.512,63.406,19.466,63.472,19.514,63.488,19.427,63.522,19.446,63.572,19.528,63.533,19.582,63.527,19.66,63.43,19.858,63.599,20.009,63.605,20.002,63.663,20.062,63.675,20.233,63.655,20.229,63.688,20.285,63.749,20.241,63.663,20.266,63.672,20.317,63.661,20.306,63.785,20.267,63.815,20.267,63.819,20.224,63.829,20.29,63.816,20.387,63.723,20.364,63.723,20.336,63.68,20.422,63.68,20.434,63.75,20.498,63.836,20.576,63.722,20.558,63.786,20.572,63.809,20.562,63.773,20.605,63.818,20.672,63.796,20.658,63.845,20.733,63.839,20.72,63.856,20.804,63.908,20.801,63.946,20.894,63.976,20.876,64.048,20.943,64.051,20.972,64.162,21.615,64.433,21.415,64.51,21.416,64.533,21.452,64.543,21.553,64.514,21.549,64.533,21.494,64.536,21.466,64.555,21.486,64.571,21.436,64.558,21.243,64.607,21.086,64.718,21.174,64.707,21.285,64.662,21.215,64.74,21.276,64.769,20.998,64.843,21.025,64.843,21.056,64.866,21.159,64.815,21.16,64.862,21.26,64.922,21.216,64.948,21.339,64.964,21.389,64.956,21.343,64.98,21.406,64.986,21.364,65.028,21.469,65.014,21.443,65.067,21.552,65.066,21.511,65.088,21.51,65.142,21.595,65.138,21.441,65.299,21.234,65.332,21.321,65.364,21.286,65.383,21.263,65.405,21.268,65.423,21.224,65.448,21.214,65.479,21.482,65.313,21.516,65.233,21.613,65.247,21.75,65.191,21.638,65.242,21.709,65.312,21.495,65.317,21.442,65.36,21.498,65.371,21.471,65.367,21.45,65.388,21.557,65.395,21.582,65.354,21.684,65.338,21.579,65.388,21.559,65.424,21.994,65.41,22.033,65.472,21.846,65.522,21.865,65.535,22.044,65.519,22.068,65.504,22.076,65.527,22.158,65.52,22.184,65.553,22.146,65.548,21.823,65.669,21.684,65.774,21.684,65.794,21.666,65.801,21.67,65.807,22.025,65.594,22.399,65.535,22.296,65.602,22.376,65.633,22.233,65.668,22.275,65.71,22.373,65.669,22.352,65.742,22.295,65.774,22.372,65.792,22.349,65.865,22.363,65.856,22.374,65.87,22.481,65.852,22.446,65.853,22.528,65.813,22.507,65.78,22.693,65.755,22.719,65.816,22.629,65.9,22.644,65.901,22.628,65.908,22.627,65.911,22.634,65.913,22.777,65.864,22.838,65.827,22.837,65.796,22.895,65.804,23.094,65.74,23.097,65.697,23.14,65.758,23.236,65.771,22.933,65.888,22.709,66.063,22.712,66.123,22.753,66.108,22.75,66.121,22.809,66.168,22.828,66.172,22.808,66.185,22.832,66.238,22.814,66.305,22.824,66.331,22.74,66.366,22.727,66.373,22.669,66.427,22.664,66.435,22.666,66.439,22.661,66.443,22.662,66.451,22.633,66.472,22.625,66.492,22.528,66.524,22.63,66.491,22.663,66.451,22.668,66.438,22.666,66.434,22.739,66.367,22.868,66.328,22.793,66.404,22.743,66.53,22.751,66.611,22.74,66.612,22.735,66.616,22.692,66.665,22.675,66.716,22.672,66.746,22.676,66.756,22.782,66.858,22.771,66.864,22.772,66.884,22.774,66.893,22.787,66.903,22.725,66.947,22.72,66.952,22.722,66.96,22.654,66.973,22.659,66.976,22.652,66.986,22.656,66.997,22.582,67.024,22.526,67.055,22.518,67.064,22.53,67.075,22.548,67.081,22.586,67.077,22.587,67.069,22.526,67.057,22.661,66.997,22.664,66.972,22.724,66.961,22.792,66.905,22.784,66.856,22.76,66.823,22.677,66.751,22.68,66.712,22.76,66.601,22.75,66.532,22.788,66.476,22.771,66.461,22.804,66.413,22.799,66.403,22.813,66.396,22.826,66.376,22.849,66.362,22.875,66.325,22.905,66.313,22.903,66.358,22.91,66.371,22.925,66.365,22.954,66.237,22.832,66.329,22.865,66.123,22.812,66.168,22.822,66.101,22.734,66.08,22.955,65.883,23.303,65.787,23.321,65.82,23.352,65.827,23.366,65.824,23.409,65.769,23.37,65.754,23.381,65.747,23.423,65.767,23.441,65.767,23.411,65.809,23.502,65.847,23.468,65.801,23.723,65.834,23.794,65.792,23.756,65.785,23.79,65.78,23.818,65.769,23.842,65.784,23.81,65.78,23.818,65.784,23.8,65.783,23.797,65.795,23.917,65.766,23.93,65.796,23.97,65.806,24.047,65.788,24.029,65.801,24.036,65.82,24.149,65.815,23.928,66.065,23.909,66.14,23.925,66.154,23.729,66.189,23.726,66.209,23.687,66.23,23.637,66.313,23.649,66.462,23.729,66.509,23.793,66.517,23.784,66.533,23.786,66.537,23.858,66.562,23.873,66.579,23.86,66.657,23.894,66.705,23.875,66.754,23.875,66.762,23.889,66.772,23.921,66.775,23.885,66.581,23.853,66.548,23.787,66.534,23.797,66.517,23.764,66.51,23.757,66.503,23.718,66.494,23.714,66.468,23.654,66.455,23.702,66.386,23.679,66.334,23.656,66.304,23.928,66.158,24.16,65.834,24.241,65.835,24.422,65.771,24.546,65.793,24.609,65.888,24.703,65.902,24.698,65.95,24.715,66.012,24.8,66.096,24.816,66.098,24.831,66.111,24.848,66.116,25.043,66.143,25.016,66.147,25.14,66.147,24.807,66.094,24.711,65.902,24.629,65.887,24.586,65.804,24.488,65.765,24.478,65.762,24.457,65.759,24.454,65.753,24.516,65.763,24.66,65.701,24.606,65.695,24.695,65.63,24.875,65.668,25.058,65.629,25.087,65.592,25.063,65.592,25.115,65.55,25.164,65.563,25.184,65.603,25.189,65.605,25.202,65.605,25.174,65.565,25.328,65.482,25.364,65.428,25.303,65.4,25.259,65.411,25.377,65.328,25.34,65.316,25.23,65.332,25.346,65.234,25.256,65.219,25.436,65.191,25.267,65.2,25.227,65.128,25.384,65.097,25.468,65.016,25.418,64.997,25.404,64.997,25.481,64.974,25.487,64.959,25.211,65.005,25.391,64.899,25.315,64.829,25.093,64.922,24.542,64.817,24.566,64.731,24.491,64.697,24.426,64.691,24.471,64.687,24.37,64.545,24.031,64.414,23.985,64.354,23.915,64.327,23.941,64.259,23.847,64.292,23.68,64.216,23.712,64.201,23.72,64.187,23.642,64.117,23.582,64.118,23.653,64.064,23.558,64.042,23.474,64.057,23.442,64.082,23.397,64.093,23.383,63.922,23.287,63.948,23.324,63.92,23.208,63.902,23.129,63.918,23.172,63.866,23.075,63.917,23.133,63.87,23.029,63.824,22.964,63.84,23.018,63.814,23.006,63.783,22.9,63.901,22.811,63.89,22.903,63.836,22.688,63.843,22.878,63.804,22.937,63.759,22.86,63.741,22.919,63.73,22.928,63.727,22.927,63.719,22.889,63.727,22.901,63.695,22.865,63.678,22.832,63.709,22.831,63.686,22.861,63.668,22.86,63.649,22.777,63.66,22.726,63.714,22.617,63.724,22.611,63.684,22.594,63.666,22.43,63.604,22.515,63.601,22.534,63.537,22.476,63.575,22.486,63.541,22.447,63.476,22.416,63.476,22.398,63.513,22.361,63.51,22.347,63.55,22.38,63.574,22.323,63.587,22.294,63.51,22.403,63.439,22.205,63.455,22.37,63.292,22.085,63.217,22.091,63.257,22.025,63.254,22.043,63.201,21.974,63.206,22.005,63.159,21.863,63.176,21.96,63.216,22.03,63.214,21.986,63.267,21.932,63.232,21.809,63.277,21.756,63.22,21.671,63.21,21.656,63.225,21.58,63.247,21.575,63.274,21.587,63.276,21.601,63.255,21.637,63.276,21.579,63.288,21.518,63.222,21.564,63.189,21.503,63.172,21.604,63.12,21.614,63.083,21.688,63.054,21.498,63.056,21.472,63.076,21.472,63.027,21.445,63.02,21.453,63.032,21.427,63.03,21.451,63.076,21.383,63.087,21.416,63.053,21.4,63.041,21.33,63.038,21.495,63.005,21.495,62.966,21.407,62.88,21.247,62.898,21.263,62.867,21.209,62.805,21.119,62.797,21.181,62.743,21.061,62.619,21.136,62.645,21.228,62.606,21.187,62.538,21.154,62.537,21.103,62.555,21.157,62.53,21.113,62.489,21.208,62.496,21.169,62.423,21.244,62.447,21.365,62.372,21.292,62.301,21.356,62.314,21.32,62.245,21.382,62.285,21.431,62.22,21.305,62.144,21.318,62.14,21.322,62.067,21.307,62.073,21.309,62.04,21.28,61.993,21.339,62.0,21.365,61.923,21.374,61.946,21.399,61.957,21.419,61.953,21.551,61.82,21.505,61.784,21.484,61.788,21.488,61.792,21.499,61.794,21.496,61.82,21.496,61.807,21.471,61.806,21.455,61.813,21.424,61.808,21.426,61.804,21.423,61.801,21.504,61.78,21.604,61.719,21.507,61.724,21.554,61.694,21.557,61.662,21.644,61.618,21.616,61.602,21.649,61.591,21.747,61.528,21.468,61.6,21.632,61.515,21.578,61.495,21.451,61.509,21.517,61.493,21.598,61.445,21.448,61.43,21.514,61.413,21.533,61.402,21.526,61.398,21.559,61.385,21.526,61.377,21.504,61.383,21.505,61.376,21.576,61.338,21.551,61.319,21.493,61.305,21.512,61.288,21.539,61.282,21.477,61.276,21.603,61.23,21.602,61.216,21.436,61.25,21.427,61.235,21.498,61.227,21.534,61.188,21.421,61.174,21.371,61.22,21.334,61.194,21.352,61.174,21.388,61.182,21.414,61.173,21.444,61.17,21.496,61.079,21.297,61.084,21.381,61.025,21.341,61.016,21.438,60.95,21.428,60.938,21.446,60.922,21.44,60.912,21.368,60.93,21.405,60.939,21.352,60.962,21.288,61.01,21.322,60.928,21.206,60.908,21.44,60.892,21.343,60.888,21.3,60.871,21.361,60.861,21.472,60.706,21.371,60.717,21.429,60.734,21.374,60.767,21.395,60.734,21.372,60.732,21.341,60.716,21.36,60.713,21.361,60.69,21.34,60.7,21.328,60.697,21.573,60.567,21.586,60.491,21.704,60.61,21.842,60.636,21.819,60.594,21.802,60.507,21.743,60.481,21.823,60.5,21.91,60.484,21.876,60.513,21.867,60.546,21.907,60.551,21.998,60.512,21.982,60.496,22.024,60.491,22.021,60.462,22.124,60.466,22.242,60.438,22.295,60.384,22.5,60.419,22.585,60.376,22.657,60.427,22.601,60.362,22.456,60.265,22.496,60.253,22.61,60.291,22.619,60.29,22.63,60.284,22.521,60.213,22.681,60.245,22.718,60.244,22.731,60.249,22.76,60.254,22.788,60.257,22.791,60.254,22.882,60.308,22.997,60.333,23.027,60.332,23.051,60.368,23.092,60.371,23.009,60.289,22.872,60.2,22.869,60.164,23.064,60.13,23.016,60.085,22.985,60.085,23.054,60.045,23.196,60.073,23.265,60.063,23.109,60.007,22.99,60.039,23.03,59.988,22.982,59.971,22.852,59.967,22.81,60.017,22.879,59.92,22.978,59.953,23.02,59.941,23.015,59.899,23.094,59.961,23.064,59.955,23.035,59.987,23.234,60.035,23.096,59.956,23.195,59.975,23.146,59.91,23.196,59.92,23.207,59.911,22.9,59.818],"holes":[[20.117,60.36,20.13,60.364,20.078,60.382,20.013,60.376,20.023,60.35,19.962,60.386,19.933,60.354,19.964,60.413,19.815,60.42,19.787,60.334,19.912,60.329,19.841,60.314,19.896,60.303,19.859,60.238,19.805,60.308,19.812,60.199,19.803,60.323,19.628,60.288,19.752,60.093,19.922,60.119,19.946,60.048,19.994,60.11,20.176,59.967,20.296,60.149,20.041,60.077,19.968,60.287,20.051,60.312,20.012,60.244,20.081,60.185,20.239,60.209,20.275,60.286,20.117,60.36],[22.493,60.238,22.414,60.16,22.618,60.149,22.436,60.137,22.488,60.085,22.365,60.089,22.383,60.008,22.612,60.059,22.575,60.021,22.755,60.004,22.884,60.137,22.836,60.213,22.984,60.303,22.493,60.238]]}],"names":{"ar":"خليج بوثنيه","de":"Bottnischer Meerbusen","es":"Golfo de Botnia","fr":"golfe de Botnie","he":"המפרץ הבוטני","hu":"Botteni-öböl","it":"Golfo di Botnia","nl":"Botnische Golf","pt":"Golfo de Bótnia","zh":"波的尼亞灣"}},{"name":"Gulf of Aden","bbox":[42.513,10.379,51.287,15.22],"area":21.5551,"polygons":[{"outer":[51.287,11.835,51.056,11.872,50.846,11.94,50.802,11.984,50.795,11.988,50.774,11.988,50.569,11.903,50.468,11.703,50.264,11.585,49.572,11.454,49.418,11.335,48.955,11.24,48.526,11.31,48.139,11.126,47.698,11.09,47.411,11.178,46.439,10.682,45.805,10.869,44.946,10.409,44.591,10.379,44.297,10.427,43.894,10.73,43.497,11.212,43.505,11.368,43.25,11.465,43.151,11.623,42.803,11.587,42.624,11.465,42.513,11.581,42.682,11.556,42.78,11.747,43.369,11.979,43.418,12.127,43.325,12.476,43.594,12.747,43.945,12.593,44.419,12.67,44.58,12.815,44.875,12.724,44.896,12.751,44.913,12.741,44.913,12.755,44.922,12.765,44.842,12.774,44.851,12.798,44.996,12.852,45.027,12.749,45.112,12.961,45.384,13.059,45.659,13.35,46.696,13.427,47.392,13.654,47.968,14.048,48.184,13.968,48.334,14.025,48.686,14.046,48.994,14.322,49.081,14.512,49.349,14.642,51.286,15.22,51.287,11.835]}],"names":{"ar":"خليج عدن","de":"Golf von Aden","es":"Golfo de Adén","fr":"golfe d'Aden","he":"מפרץ עדן","hu":"Ádeni-öböl","it":"Golfo di Aden","nl":"Golf van Aden","pt":"Golfo de Áden","zh":"亚丁湾"}},{"name":"Persian Gulf","bbox":[47.705,23.975,57.34,31.185],"area":22.1873,"polygons":[{"outer":[56.087,27.108,56.664,27.185,56.81,27.136,56.874,27.007,56.929,26.994,56.93,27.023,56.944,27.01,56.942,27.018,56.953,27.014,56.953,27.026,56.957,27.019,56.958,27.029,56.965,27.029,56.955,26.94,56.923,26.968,56.914,26.955,57.066,26.688,57.062,26.373,57.222,26.171,57.188,26.036,57.34,25.786,56.432,25.936,56.385,26.037,56.476,26.084,56.333,26.096,56.327,26.158,56.385,26.201,56.379,26.15,56.488,26.131,56.434,26.193,56.493,26.234,56.396,26.284,56.522,26.318,56.379,26.388,56.304,26.205,56.389,26.242,56.416,26.204,56.358,26.21,56.344,26.18,56.299,26.183,56.286,26.221,56.223,26.196,56.208,26.254,55.95,25.811,55.967,25.795,55.966,25.775,55.938,25.774,55.95,25.808,55.74,25.682,55.748,25.658,55.634,25.536,55.564,25.525,55.565,25.583,55.58,25.585,55.58,25.606,55.445,25.426,55.474,25.436,55.498,25.427,55.467,25.396,55.355,25.298,55.295,25.353,55.327,25.319,55.292,25.267,55.307,25.266,55.348,25.228,55.347,25.201,55.317,25.191,55.261,25.283,55.148,25.084,55.077,25.024,55.043,25.02,55.082,24.995,55.055,24.972,54.973,25.035,54.987,24.971,54.765,24.855,54.773,24.811,54.771,24.804,54.765,24.799,54.75,24.8,54.75,24.791,54.708,24.813,54.654,24.639,54.61,24.598,54.599,24.613,54.6,24.625,54.615,24.632,54.601,24.671,54.608,24.682,54.556,24.642,54.582,24.642,54.589,24.589,54.642,24.597,54.593,24.511,54.516,24.523,54.486,24.556,54.488,24.569,54.471,24.569,54.46,24.548,54.485,24.501,54.474,24.493,54.613,24.457,54.522,24.424,54.487,24.422,54.522,24.385,54.451,24.319,54.458,24.313,54.517,24.317,54.525,24.312,54.382,24.256,54.325,24.267,54.317,24.246,54.301,24.242,54.286,24.246,54.271,24.261,54.286,24.265,54.305,24.261,54.316,24.294,54.243,24.413,54.191,24.368,54.233,24.368,54.258,24.353,54.294,24.28,54.293,24.267,54.216,24.359,54.202,24.266,54.146,24.329,54.08,24.265,54.135,24.221,54.013,24.187,54.133,24.216,54.165,24.187,53.813,24.054,53.579,24.047,53.457,24.14,53.246,24.094,53.143,24.171,53.059,24.121,52.672,24.134,52.602,24.199,52.252,23.975,51.833,23.986,51.743,24.294,51.696,24.213,51.654,24.326,51.673,24.247,51.641,24.204,51.594,24.376,51.582,24.253,51.292,24.3,51.322,24.431,51.423,24.535,51.505,24.575,51.4,24.626,51.345,24.529,51.303,24.506,51.317,24.607,51.216,24.629,51.347,24.656,51.373,24.581,51.623,25.0,51.631,25.252,51.521,25.298,51.516,25.455,51.539,25.444,51.545,25.45,51.476,25.515,51.623,25.93,51.403,25.965,51.358,26.103,51.25,26.158,51.034,26.044,51.01,25.958,50.989,25.983,50.983,25.778,50.93,25.808,50.892,25.704,50.97,25.601,50.908,25.512,50.846,25.653,50.793,25.601,50.848,25.467,50.799,25.53,50.757,25.503,50.87,24.801,50.773,24.698,50.729,24.871,50.562,25.044,50.492,25.405,50.381,25.514,50.361,25.503,50.395,25.454,50.408,25.409,50.4,25.411,50.156,25.695,50.136,25.741,50.265,25.611,50.106,25.985,50.0,25.991,50.034,26.201,50.155,26.039,50.238,26.371,50.17,26.426,50.214,26.473,50.198,26.524,50.157,26.424,50.042,26.5,49.99,26.669,50.002,26.745,50.039,26.672,50.12,26.683,50.154,26.633,50.171,26.635,50.016,26.793,50.05,26.87,50.015,26.795,49.669,26.983,49.668,27.013,49.682,27.01,49.698,27.024,49.7,27.031,49.619,27.055,49.718,27.088,49.617,27.062,49.635,27.071,49.593,27.081,49.574,27.115,49.568,27.143,49.627,27.285,49.582,27.182,49.554,27.163,49.542,27.235,49.472,27.283,49.515,27.113,49.377,27.125,49.37,27.142,49.415,27.192,49.331,27.181,49.322,27.322,49.222,27.312,49.272,27.388,49.203,27.375,49.131,27.413,49.293,27.461,49.312,27.425,49.306,27.473,49.254,27.525,48.843,27.61,48.874,27.608,48.878,27.666,48.784,27.711,48.822,27.827,48.802,27.756,48.851,27.817,48.84,27.724,48.877,27.745,48.884,27.834,48.744,27.959,48.793,27.999,48.682,28.029,48.683,28.025,48.678,28.022,48.663,28.022,48.645,28.027,48.681,28.027,48.61,28.113,48.635,28.195,48.514,28.331,48.544,28.407,48.529,28.417,48.506,28.386,48.469,28.381,48.501,28.494,48.389,28.606,48.397,28.744,48.169,28.988,48.102,29.351,47.705,29.366,47.952,29.557,48.192,29.547,48.106,29.732,48.056,29.765,48.023,29.833,48.011,29.878,48.026,29.945,48.009,29.958,48.008,29.966,48.013,29.988,47.954,30.009,47.953,30.073,47.907,30.124,47.861,30.22,47.871,30.237,47.849,30.256,47.836,30.287,47.874,30.239,47.911,30.13,47.945,30.102,47.979,30.005,48.075,30.036,48.183,30.035,48.609,29.917,48.601,29.935,48.516,29.958,48.452,29.998,48.38,30.122,48.405,30.201,48.203,30.317,48.088,30.448,48.097,30.453,48.124,30.449,48.164,30.427,48.19,30.435,48.207,30.43,48.209,30.44,48.212,30.442,48.271,30.478,48.283,30.482,48.29,30.497,48.33,30.539,48.339,30.576,48.342,30.581,48.364,30.591,48.324,30.606,48.324,30.612,48.328,30.618,48.345,30.625,48.329,30.637,48.326,30.642,48.322,30.655,48.324,30.665,48.338,30.682,48.372,30.711,48.426,30.751,48.425,30.772,48.428,30.782,48.433,30.787,48.438,30.79,48.448,30.788,48.433,30.796,48.433,30.808,48.436,30.816,48.455,30.83,48.474,30.836,48.411,30.835,48.409,30.836,48.409,30.841,48.41,30.847,48.428,30.872,48.454,30.885,48.395,30.889,48.383,30.926,48.377,30.923,48.368,30.91,48.363,30.907,48.356,30.908,48.349,30.917,48.346,30.928,48.347,30.938,48.359,30.98,48.382,30.994,48.364,31.014,48.362,31.023,48.366,31.027,48.375,31.03,48.401,31.024,48.409,31.035,48.371,31.072,48.37,31.096,48.383,31.115,48.426,31.144,48.457,31.144,48.463,31.169,48.473,31.185,48.482,31.184,48.497,31.163,48.516,31.175,48.496,31.161,48.474,31.184,48.459,31.143,48.371,31.095,48.41,31.035,48.349,30.921,48.362,30.907,48.376,30.927,48.381,30.927,48.387,30.925,48.454,30.886,48.456,30.871,48.412,30.835,48.476,30.838,48.428,30.751,48.326,30.664,48.366,30.592,48.341,30.577,48.334,30.54,48.283,30.48,48.208,30.429,48.165,30.426,48.204,30.32,48.416,30.194,48.458,29.998,48.683,29.922,48.696,30.01,48.687,30.019,48.682,30.043,48.65,30.055,48.635,30.083,48.595,30.096,48.57,30.124,48.564,30.141,48.584,30.195,48.508,30.186,48.578,30.203,48.586,30.194,48.591,30.177,48.577,30.118,48.635,30.086,48.651,30.057,48.682,30.046,48.695,30.018,48.712,30.02,48.743,30.09,48.726,30.118,48.716,30.143,48.714,30.154,48.705,30.159,48.691,30.177,48.683,30.192,48.657,30.197,48.639,30.233,48.769,30.193,48.787,30.113,48.881,30.057,48.896,30.021,48.929,30.034,48.913,30.261,48.833,30.332,48.787,30.301,48.734,30.299,48.762,30.196,48.651,30.389,48.776,30.302,48.925,30.409,49.055,30.425,48.906,30.481,48.889,30.508,49.059,30.513,49.079,30.459,49.032,30.455,49.071,30.421,49.114,30.428,49.077,30.483,49.078,30.505,49.12,30.498,49.109,30.513,49.283,30.461,49.268,30.411,49.217,30.398,49.243,30.384,49.214,30.339,49.246,30.287,49.253,30.268,49.243,30.136,49.465,30.156,49.563,29.998,49.908,30.195,50.059,30.199,50.144,29.93,50.66,29.433,50.673,29.115,50.75,29.168,50.809,29.162,50.921,29.066,50.928,28.989,50.9,28.935,50.83,28.999,50.809,28.923,50.89,28.819,50.98,28.852,51.025,28.815,51.086,28.511,51.405,27.924,51.702,27.823,52.046,27.84,52.197,27.704,52.433,27.647,52.675,27.459,52.577,27.383,52.694,27.293,53.46,26.98,53.495,26.846,53.726,26.7,54.29,26.729,54.412,26.592,54.794,26.495,55.243,26.779,55.495,26.765,55.564,26.814,55.569,26.913,55.64,26.98,56.087,27.108],"holes":[[56.235,26.98,55.876,26.898,55.734,26.945,55.775,26.793,55.269,26.661,55.272,26.556,55.852,26.733,55.943,26.689,56.286,26.943,56.235,26.98],[48.302,29.638,48.37,29.803,48.216,29.964,48.111,29.978,48.138,29.895,48.209,29.916,48.098,29.87,48.176,29.827,48.022,29.891,48.116,29.825,48.064,29.776,48.195,29.586,48.302,29.638],[50.673,26.159,50.61,26.186,50.627,26.116,50.566,26.18,50.594,26.253,50.44,26.229,50.458,25.955,50.569,25.792,50.673,26.159]]}],"names":{"ar":"الخليج العربي","de":"Persischer Golf","es":"golfo Pérsico","fr":"golfe Persique","he":"המפרץ הפרסי","hu":"Perzsa-öböl","it":"Golfo Persico","nl":"Perzische Golf","pt":"Golfo Pérsico","zh":"波斯湾"}},{"name":"Tyrrhenian Sea","bbox":[8.657,37.801,16.219,44.095],"area":22.8713,"polygons":[{"outer":[9.457,42.951,9.854,44.026,9.821,44.095,9.978,44.035,9.958,44.095,10.24,43.872,10.296,43.587,10.348,43.599,10.298,43.535,10.53,43.23,10.495,42.932,10.773,42.91,10.73,42.801,10.964,42.731,11.185,42.529,11.096,42.393,11.274,42.418,11.629,42.297,11.833,42.031,12.134,41.921,12.223,41.743,12.623,41.444,12.924,41.379,13.046,41.226,13.284,41.297,13.57,41.203,13.728,41.244,14.014,40.932,14.047,40.79,14.268,40.844,14.395,40.759,14.463,40.741,14.324,40.569,14.418,40.61,14.486,40.627,14.791,40.664,15.001,40.389,14.908,40.243,15.133,40.171,15.272,40.022,15.419,39.99,15.63,40.071,16.003,39.428,16.102,39.027,16.219,38.923,16.189,38.755,15.839,38.65,15.918,38.473,15.735,38.253,15.541,38.302,15.288,38.206,15.231,38.272,15.098,38.121,14.915,38.192,14.524,38.042,13.747,37.97,13.538,38.112,13.382,38.11,13.318,38.225,13.194,38.17,13.105,38.191,13.077,38.088,12.903,38.024,12.732,38.19,12.711,38.11,12.49,38.018,12.538,37.993,12.494,37.981,12.484,37.858,12.424,37.801,8.657,38.876,8.72,38.94,8.856,38.876,9.025,38.985,9.009,39.123,9.092,39.213,9.012,39.266,9.524,39.094,9.635,39.299,9.594,39.322,9.584,39.36,9.736,40.078,9.624,40.247,9.829,40.529,9.718,40.751,9.659,40.787,9.682,40.834,9.713,40.832,9.725,40.838,9.727,40.844,9.494,40.917,9.612,41.003,9.664,40.994,9.561,41.038,9.506,41.009,9.566,41.117,9.445,41.095,9.365,41.207,9.137,41.237,9.099,41.391,9.22,41.366,9.26,41.413,9.214,41.44,9.369,41.594,9.292,41.582,9.286,41.606,9.397,41.669,9.399,41.951,9.552,42.112,9.457,42.951]}],"names":{"ar":"بحر طرانة","de":"Tyrrhenisches Meer","es":"mar Tirreno","fr":"mer Tyrrhénienne","he":"הים הטירני","hu":"Tirrén-tenger","it":"mar Tirreno","nl":"Tyrreense Zee","pt":"Mar Tirreno","zh":"第勒尼安海"}},{"name":"Lincoln Sea","bbox":[-70.195,81.518,-37.005,83.562],"area":23.8367,"polygons":[{"outer":[-55.225,82.337,-62.382,82.481,-62.189,82.524,-62.272,82.529,-62.923,82.512,-63.079,82.447,-63.402,82.433,-62.946,82.577,-63.981,82.713,-63.436,82.761,-63.55,82.832,-64.421,82.755,-64.9,82.862,-64.64,82.886,-64.833,82.909,-65.232,82.883,-65.036,82.846,-65.41,82.789,-65.18,82.77,-65.129,82.752,-65.507,82.788,-65.426,82.837,-65.732,82.85,-66.694,82.705,-68.604,82.626,-68.684,82.633,-67.007,82.791,-66.327,82.927,-68.081,82.934,-68.462,83.01,-68.841,82.929,-68.979,83.033,-69.699,82.986,-69.448,83.03,-69.746,83.048,-69.601,83.109,-70.195,83.111,-37.667,83.562,-37.767,83.525,-37.269,83.476,-37.524,83.446,-37.005,83.365,-38.631,83.543,-38.974,83.462,-38.543,83.376,-39.175,83.423,-39.02,83.469,-39.874,83.405,-39.479,83.305,-39.7,83.234,-39.534,83.209,-37.602,83.143,-39.489,83.041,-38.264,83.028,-37.833,82.984,-39.818,82.943,-38.876,82.783,-39.084,82.746,-39.013,82.719,-40.128,82.928,-41.346,82.937,-42.225,83.085,-42.685,83.082,-42.539,83.14,-42.827,83.186,-43.313,83.195,-42.845,83.211,-43.034,83.234,-44.78,83.152,-43.614,83.044,-43.665,83.035,-45.327,83.117,-45.551,83.101,-45.418,83.071,-45.461,83.035,-46.292,83.082,-45.867,82.969,-44.724,82.865,-45.78,82.871,-45.368,82.788,-42.859,82.796,-40.656,82.663,-40.339,82.566,-40.503,82.487,-40.16,82.433,-40.273,82.365,-40.079,82.312,-40.293,82.281,-40.705,82.495,-42.054,82.669,-42.567,82.652,-42.234,82.569,-42.281,82.5,-42.128,82.392,-42.24,82.389,-42.486,82.497,-42.425,82.566,-42.788,82.651,-42.733,82.691,-46.495,82.7,-44.395,82.302,-44.562,82.271,-44.49,82.215,-42.893,82.118,-43.327,82.076,-45.213,82.189,-45.663,82.074,-45.189,81.961,-45.942,81.834,-45.427,81.695,-45.107,81.664,-45.197,81.634,-46.53,81.816,-45.758,81.653,-45.809,81.636,-45.605,81.568,-46.92,81.818,-46.941,81.891,-47.193,81.964,-48.564,82.056,-48.419,82.107,-49.488,82.254,-49.964,82.4,-51.377,82.463,-51.767,82.456,-51.813,82.379,-51.588,82.312,-51.627,82.131,-50.751,81.901,-51.145,81.846,-51.033,81.81,-52.073,81.867,-50.681,81.525,-51.125,81.518,-51.833,81.747,-53.59,81.982,-53.88,81.961,-53.889,81.784,-54.562,81.627,-54.524,81.562,-54.766,81.565,-54.818,81.657,-54.414,81.825,-54.502,81.978,-54.44,82.074,-54.871,82.282,-55.225,82.337],"holes":[[-53.266,81.984,-52.499,81.9,-52.111,81.923,-53.274,82.139,-53.58,82.265,-53.928,82.293,-54.244,82.196,-54.056,82.068,-53.266,81.984],[-47.757,82.319,-46.112,82.052,-45.846,81.916,-45.514,81.959,-45.875,82.108,-45.08,82.262,-45.607,82.427,-46.848,82.588,-48.479,82.56,-47.757,82.319],[-48.406,82.707,-48.098,82.734,-48.895,82.837,-49.16,82.792,-49.027,82.721,-48.406,82.707],[-46.705,82.783,-46.297,82.796,-46.427,82.824,-45.577,82.799,-45.882,82.869,-45.768,82.914,-46.7,83.036,-47.445,82.961,-46.536,82.837,-47.665,82.909,-46.705,82.783],[-41.423,83.239,-40.229,83.132,-39.328,83.104,-41.211,83.333,-41.559,83.299,-41.571,83.246,-41.423,83.239],[-41.03,83.147,-41.355,83.113,-40.427,82.969,-39.923,83.073,-41.03,83.147],[-41.088,83.031,-41.638,83.136,-42.283,83.12,-41.27,82.963,-40.516,82.951,-41.088,83.031]]}],"names":{"ar":"بحر لنكولن","de":"Lincolnsee","es":"mar de Lincoln","fr":"mer de Lincoln","he":"ים לינקולן","hu":"Lincoln-tenger","it":"Mare di Lincoln","nl":"Lincolnzee","pt":"Mar de Lincoln","zh":"林肯海"}},{"name":"Gulf of Thailand","bbox":[99.133,5.391,105.362,13.726],"area":24.2845,"polygons":[{"outer":[105.001,8.76,104.968,8.715,104.914,8.689,104.843,8.69,104.795,8.719,104.817,8.651,104.716,8.641,102.238,6.216,102.25,6.17,102.222,6.064,102.199,6.044,102.146,6.038,102.19,6.002,102.207,5.974,102.208,5.961,102.148,5.695,102.144,5.687,102.111,5.69,102.106,5.673,102.116,5.636,102.153,5.609,102.154,5.579,102.175,5.552,102.181,5.536,102.189,5.539,102.192,5.538,102.196,5.534,102.196,5.529,102.173,5.455,102.171,5.453,102.164,5.453,102.161,5.457,102.147,5.454,102.13,5.425,102.073,5.391,102.054,5.394,102.193,5.529,102.181,5.534,102.153,5.578,102.15,5.609,102.139,5.612,102.115,5.633,102.104,5.672,102.205,5.963,102.143,6.036,102.155,6.047,102.194,6.046,102.218,6.064,102.221,6.078,102.216,6.092,102.217,6.102,102.232,6.159,102.247,6.174,102.231,6.189,102.23,6.203,102.226,6.209,102.224,6.22,102.073,6.231,101.832,6.446,101.844,6.42,101.828,6.359,101.526,6.865,101.279,6.952,101.343,6.872,101.258,6.903,100.964,6.863,100.697,7.071,100.704,7.055,100.696,7.038,100.707,7.038,100.718,7.029,100.719,7.023,100.713,7.019,100.706,7.024,100.691,7.01,100.68,7.016,100.58,7.23,100.594,7.178,100.547,7.14,100.42,7.162,100.437,7.25,100.4,7.262,100.384,7.276,100.361,7.345,100.299,7.34,100.178,7.514,100.146,7.728,100.21,7.785,100.311,7.727,100.267,7.529,100.395,7.559,100.432,7.308,100.432,7.278,100.412,7.267,100.566,7.189,100.575,7.23,100.44,7.494,100.219,8.431,100.169,8.503,100.124,8.521,100.195,8.359,100.089,8.41,99.964,8.592,99.843,9.294,99.534,9.287,99.388,9.133,99.322,9.148,99.32,9.154,99.329,9.174,99.318,9.19,99.319,9.198,99.334,9.219,99.32,9.231,99.298,9.196,99.297,9.208,99.276,9.196,99.266,9.196,99.264,9.189,99.262,9.187,99.259,9.188,99.309,9.243,99.223,9.257,99.314,9.39,99.146,9.741,99.133,10.09,99.195,10.214,99.248,10.232,99.163,10.279,99.147,10.345,99.286,10.366,99.239,10.536,99.442,10.871,99.52,10.896,99.482,11.084,99.584,11.2,99.55,11.271,99.56,11.336,100.021,12.189,99.955,12.636,100.104,13.057,99.929,13.268,100.002,13.353,99.982,13.384,99.995,13.412,99.98,13.399,99.958,13.409,99.948,13.423,99.943,13.448,99.944,13.46,99.94,13.465,99.933,13.479,99.924,13.49,99.915,13.506,99.934,13.48,99.945,13.46,99.95,13.422,99.996,13.414,100.023,13.361,100.261,13.48,100.264,13.529,100.277,13.538,100.269,13.542,100.265,13.534,100.258,13.53,100.252,13.533,100.228,13.58,100.212,13.626,100.215,13.63,100.23,13.628,100.232,13.641,100.22,13.638,100.214,13.639,100.211,13.65,100.216,13.657,100.229,13.664,100.311,13.491,100.572,13.515,100.532,13.651,100.539,13.662,100.566,13.659,100.587,13.69,100.556,13.705,100.553,13.679,100.548,13.67,100.538,13.668,100.526,13.673,100.547,13.673,100.55,13.7,100.56,13.708,100.581,13.701,100.589,13.692,100.59,13.681,100.563,13.655,100.537,13.658,100.537,13.642,100.624,13.525,100.941,13.463,101.075,13.687,101.116,13.711,101.151,13.711,101.164,13.697,101.167,13.712,101.185,13.726,101.194,13.724,101.163,13.695,101.077,13.687,100.897,13.302,100.928,13.008,100.856,12.917,100.914,12.807,100.85,12.65,100.919,12.658,100.962,12.594,101.066,12.674,101.279,12.664,101.418,12.583,101.647,12.643,101.702,12.696,101.682,12.723,101.685,12.726,101.697,12.729,101.686,12.732,101.684,12.734,101.693,12.747,101.678,12.745,101.674,12.749,101.684,12.764,101.676,12.749,101.692,12.749,101.699,12.731,101.697,12.727,101.684,12.723,101.704,12.698,101.799,12.732,101.949,12.516,101.928,12.595,101.962,12.622,101.949,12.54,102.044,12.472,102.062,12.527,102.046,12.525,102.022,12.54,102.021,12.546,102.026,12.565,102.006,12.564,102.004,12.571,102.006,12.575,102.109,12.542,102.061,12.48,102.253,12.307,102.276,12.325,102.247,12.408,102.202,12.405,102.217,12.437,102.19,12.431,102.185,12.44,102.192,12.457,102.301,12.422,102.305,12.306,102.315,12.386,102.37,12.394,102.324,12.3,102.259,12.289,102.277,12.205,102.5,12.164,102.584,12.043,102.575,12.167,102.522,12.269,102.524,12.291,102.513,12.301,102.514,12.313,102.76,12.033,102.952,11.566,102.891,11.831,102.951,11.762,102.972,11.722,102.968,11.706,102.982,11.706,103.021,11.732,103.06,11.709,103.076,11.733,103.061,11.706,103.015,11.724,102.963,11.675,103.04,11.632,102.955,11.54,103.032,11.597,103.11,11.47,103.146,11.549,103.127,11.562,103.124,11.57,103.129,11.575,103.149,11.551,103.151,11.436,103.256,11.369,103.15,11.432,103.129,11.463,103.07,11.426,103.095,11.351,103.135,11.348,103.149,11.315,103.147,11.3,103.161,11.298,103.166,11.288,103.046,11.223,103.078,11.222,103.139,11.134,103.123,10.87,103.273,10.932,103.425,10.892,103.423,11.106,103.5,11.162,103.44,11.22,103.443,11.242,103.451,11.25,103.668,11.063,103.683,11.071,103.69,11.088,103.726,11.121,103.731,11.124,103.75,11.13,103.761,11.128,103.764,11.135,103.774,11.14,103.783,11.141,103.705,11.065,103.648,11.051,103.694,10.748,103.581,10.742,103.497,10.643,103.565,10.539,103.802,10.52,103.869,10.623,103.854,10.647,103.867,10.689,103.854,10.698,103.854,10.706,103.861,10.709,103.938,10.588,104.102,10.546,104.162,10.677,104.442,10.365,104.52,10.421,104.486,10.372,104.601,10.143,104.743,10.227,104.884,10.09,104.992,10.098,105.084,10.007,105.101,9.9,105.147,9.814,105.151,9.767,105.362,9.717,105.24,9.724,105.247,9.703,105.242,9.688,105.273,9.683,105.306,9.649,105.304,9.64,105.295,9.636,105.148,9.763,105.08,9.948,104.888,9.832,104.803,9.046,104.869,9.049,104.803,9.026,104.78,8.813,104.948,8.826,104.851,8.695,105.001,8.76],"holes":[[104.046,10.008,104.079,10.371,103.995,10.452,103.833,10.362,104.046,10.008]]}],"names":{"ar":"خليج تايلاند","de":"Golf von Thailand","es":"golfo de Tailandia","fr":"golfe de Thaïlande","he":"מפרץ תאילנד","hu":"Thai-öböl","it":"golfo del Siam","nl":"Golf van Thailand","pt":"golfo da Tailândia","zh":"泰國灣"}},{"name":"Celtic Sea","bbox":[-11.5,46.501,-4.138,52.383],"area":26.9174,"polygons":[{"outer":[-6.96,52.383,-6.965,52.367,-6.989,52.365,-6.984,52.322,-7.016,52.295,-6.904,52.201,-6.931,52.122,-6.823,52.175,-6.836,52.217,-6.76,52.262,-6.804,52.212,-6.408,52.185,-5.138,51.863,-5.104,51.779,-5.254,51.738,-5.175,51.68,-5.169,51.718,-4.895,51.71,-4.901,51.765,-4.941,51.765,-4.947,51.769,-4.945,51.778,-4.834,51.79,-4.897,51.741,-4.834,51.723,-4.846,51.701,-4.885,51.717,-4.939,51.677,-5.033,51.699,-5.124,51.671,-4.922,51.597,-4.527,51.015,-4.562,50.781,-4.795,50.599,-4.935,50.588,-4.849,50.527,-5.037,50.549,-5.042,50.444,-5.247,50.284,-5.711,50.127,-5.716,50.064,-4.571,48.641,-4.563,48.621,-4.573,48.605,-4.556,48.61,-4.563,48.602,-4.543,48.602,-4.522,48.594,-4.519,48.588,-4.501,48.589,-4.483,48.574,-4.754,48.544,-4.774,48.33,-4.291,48.434,-4.459,48.327,-4.263,48.358,-4.339,48.312,-4.249,48.326,-4.192,48.296,-4.279,48.278,-4.252,48.267,-4.241,48.253,-4.217,48.261,-4.206,48.249,-4.153,48.247,-4.138,48.237,-4.536,48.285,-4.533,48.342,-4.629,48.281,-4.545,48.25,-4.56,48.169,-4.429,48.238,-4.273,48.153,-4.304,48.09,-4.741,48.04,-4.495,48.039,-4.537,48.011,-4.388,47.926,-4.356,47.792,-5.483,46.501,-11.5,49.0,-11.5,51.0,-9.454,51.56,-9.413,51.496,-9.368,51.526,-9.328,51.536,-9.324,51.534,-9.399,51.496,-9.344,51.467,-9.107,51.538,-9.139,51.56,-9.147,51.579,-8.707,51.571,-8.682,51.637,-8.727,51.635,-8.764,51.644,-8.678,51.659,-8.533,51.604,-8.551,51.642,-8.509,51.7,-8.53,51.69,-8.597,51.695,-8.617,51.721,-8.616,51.727,-8.629,51.729,-8.635,51.735,-8.634,51.738,-8.629,51.731,-8.617,51.728,-8.613,51.725,-8.597,51.699,-8.507,51.705,-8.476,51.673,-8.273,51.798,-8.448,51.902,-8.338,51.885,-8.332,51.911,-8.311,51.91,-8.282,51.903,-8.319,51.842,-8.176,51.898,-8.265,51.815,-8.169,51.785,-7.866,51.88,-7.928,51.903,-7.841,51.942,-7.888,51.993,-7.853,52.026,-7.873,52.064,-7.865,52.089,-7.848,52.04,-7.854,52.009,-7.878,51.988,-7.732,51.938,-7.533,52.053,-7.643,52.061,-7.638,52.104,-7.119,52.17,-6.997,52.141,-6.99,52.272,-7.056,52.241,-7.067,52.249,-7.068,52.255,-7.093,52.251,-7.105,52.262,-7.13,52.267,-7.156,52.279,-7.201,52.247,-7.219,52.242,-7.266,52.266,-7.285,52.289,-7.286,52.297,-7.3,52.301,-7.313,52.322,-7.224,52.245,-7.16,52.28,-7.044,52.257,-7.006,52.279,-7.02,52.297,-6.997,52.36,-6.96,52.383]}],"names":{"ar":"البحر القلطي","de":"Keltische See","es":"mar Céltico","fr":"mer Celtique","he":"הים הקלטי","hu":"Kelta-tenger","it":"Mare Celtico","nl":"Keltische Zee","pt":"Mar Celta","zh":"凱爾特海"}},{"name":"Sulu Sea","bbox":[116.74,5.02,123.42,13.415],"area":27.4246,"polygons":[{"outer":[120.323,13.408,120.462,13.415,120.523,13.224,120.653,13.189,120.77,13.003,120.791,12.732,120.927,12.616,120.976,12.428,121.143,12.334,121.111,12.244,121.228,12.227,121.389,11.983,121.55,11.893,121.882,11.884,121.844,11.761,122.096,11.712,122.054,11.032,121.923,10.764,121.978,10.666,121.919,10.434,122.207,10.638,122.582,10.687,122.61,10.773,122.763,10.822,122.786,10.991,123.114,11.161,123.194,11.003,122.953,10.895,122.95,10.683,122.795,10.522,122.867,10.096,122.664,9.978,122.45,9.976,122.38,9.711,122.611,9.42,122.865,9.328,122.939,9.078,123.42,8.661,123.298,8.518,123.031,8.49,122.936,8.307,122.996,8.214,122.913,8.147,122.661,8.162,122.345,8.038,122.105,7.788,122.143,7.574,122.073,7.522,122.063,7.294,121.898,7.107,121.937,6.964,122.103,6.89,122.067,6.751,121.794,6.628,121.984,6.409,121.316,6.002,121.017,6.087,120.874,5.937,121.067,5.882,119.83,5.149,119.737,5.02,119.224,5.153,119.265,5.313,119.199,5.431,118.944,5.431,118.998,5.391,118.911,5.373,118.883,5.383,118.877,5.378,118.868,5.383,118.862,5.377,118.851,5.381,118.935,5.417,118.81,5.509,118.828,5.448,118.814,5.411,118.786,5.411,118.804,5.502,118.63,5.644,118.598,5.552,118.561,5.518,118.523,5.527,118.545,5.586,118.525,5.596,118.506,5.58,118.487,5.581,118.47,5.592,118.463,5.602,118.529,5.627,118.472,5.651,118.465,5.674,118.454,5.665,118.428,5.692,118.425,5.712,118.417,5.699,118.384,5.699,118.393,5.691,118.394,5.687,118.387,5.656,118.359,5.641,118.372,5.633,118.372,5.629,118.346,5.593,118.325,5.599,118.341,5.558,118.34,5.554,118.284,5.482,118.281,5.483,118.276,5.49,118.268,5.472,118.261,5.468,118.245,5.466,118.215,5.472,118.207,5.496,118.199,5.462,118.186,5.452,118.175,5.452,118.162,5.458,118.156,5.457,118.152,5.47,118.143,5.46,118.14,5.459,118.133,5.47,118.127,5.46,118.108,5.46,118.113,5.454,118.114,5.449,118.102,5.447,118.114,5.437,118.117,5.431,118.1,5.413,118.085,5.406,118.07,5.416,118.065,5.41,118.047,5.411,118.036,5.42,118.032,5.411,118.023,5.41,118.026,5.405,118.024,5.401,118.013,5.396,118.01,5.405,118.037,5.421,118.099,5.414,118.116,5.431,118.101,5.447,118.106,5.46,118.132,5.471,118.152,5.472,118.186,5.453,118.206,5.497,118.243,5.467,118.276,5.49,118.288,5.488,118.324,5.601,118.393,5.686,118.36,5.692,118.348,5.71,118.331,5.802,118.284,5.766,118.328,5.816,118.153,5.804,118.144,5.645,118.068,5.721,117.978,5.659,117.918,5.667,117.88,5.801,118.133,5.851,118.028,5.911,118.069,5.959,117.983,6.069,117.924,5.94,117.757,5.938,117.782,5.864,117.591,5.906,117.434,5.828,117.412,5.905,117.469,5.864,117.513,5.905,117.497,5.901,117.489,5.907,117.482,5.924,117.494,5.929,117.477,5.933,117.48,5.947,117.476,5.942,117.463,5.947,117.461,5.949,117.459,5.964,117.46,5.969,117.452,5.961,117.433,5.965,117.41,5.979,117.463,5.97,117.513,5.906,117.514,5.902,117.51,5.896,117.502,5.893,117.507,5.888,117.583,5.924,117.574,5.935,117.571,5.925,117.567,5.925,117.564,5.928,117.564,5.959,117.571,5.959,117.578,5.945,117.585,5.975,117.628,5.992,117.567,6.006,117.657,6.055,117.64,6.104,117.613,6.095,117.617,6.06,117.561,6.021,117.555,5.99,117.547,5.979,117.502,5.967,117.487,6.013,117.544,5.98,117.557,6.025,117.611,6.063,117.554,6.179,117.65,6.303,117.662,6.247,117.718,6.243,117.668,6.284,117.731,6.282,117.687,6.392,117.742,6.425,117.687,6.415,117.631,6.511,117.604,6.508,117.598,6.497,117.614,6.478,117.613,6.469,117.588,6.46,117.521,6.631,117.473,6.554,117.482,6.537,117.478,6.524,117.466,6.514,117.483,6.497,117.47,6.487,117.463,6.517,117.479,6.541,117.461,6.559,117.375,6.546,117.395,6.599,117.292,6.616,117.215,6.812,117.253,6.943,117.197,6.957,117.152,7.013,117.031,6.947,117.105,6.815,117.035,6.836,116.992,6.754,117.0,6.753,116.996,6.76,117.001,6.764,117.055,6.745,117.017,6.744,117.002,6.762,117.001,6.751,116.991,6.752,116.988,6.757,116.827,6.564,116.774,6.585,116.764,6.565,116.74,6.571,116.868,6.823,116.746,7.036,116.914,7.952,117.002,8.181,117.127,8.216,117.081,8.246,117.207,8.333,117.208,8.361,117.373,8.494,117.432,8.494,117.404,8.53,117.5,8.5,117.565,8.673,117.742,8.687,118.003,8.88,118.124,9.145,118.344,9.178,118.431,9.279,118.489,9.294,118.555,9.428,118.761,9.667,118.691,9.764,118.77,9.723,118.742,9.841,118.752,9.929,118.901,9.989,119.197,10.048,119.243,10.202,119.323,10.311,119.57,10.373,119.717,10.508,119.658,10.674,119.571,10.664,119.6,10.822,119.484,10.874,119.503,11.008,119.57,11.007,119.501,11.132,119.568,11.291,119.511,11.332,119.504,11.416,119.867,12.3,119.916,12.275,119.859,12.251,119.852,12.148,119.88,12.203,119.896,12.201,119.995,12.023,119.979,12.009,120.055,12.018,120.099,11.96,120.133,12.032,120.336,11.992,120.333,12.092,120.247,12.131,120.218,12.227,120.214,12.133,120.163,12.121,119.876,12.32,120.323,13.408]}],"names":{"ar":"بحر سولو","de":"Sulusee","es":"mar de Joló","fr":"mer de Soulou","he":"ים סולו","hu":"Sulu-tenger","it":"Mare di Sulu","nl":"Suluzee","pt":"Mar de Sulu","zh":"苏禄海"}},{"name":"Bismarck Sea","bbox":[141.993,-5.962,152.874,-1.075],"area":27.7503,"polygons":[{"outer":[144.475,-1.122,145.061,-1.549,145.087,-1.509,146.57,-1.986,146.61,-2.114,146.506,-2.155,146.521,-2.166,146.505,-2.204,146.553,-2.256,146.598,-2.183,146.712,-2.191,146.696,-2.123,146.876,-2.204,147.186,-2.207,147.347,-2.04,147.192,-1.996,147.395,-2.0,147.415,-2.031,147.41,-2.066,147.351,-2.038,147.322,-2.084,147.426,-2.079,147.42,-2.0,150.068,-2.405,149.971,-2.492,149.946,-2.481,150.171,-2.681,150.343,-2.68,150.448,-2.653,150.439,-2.62,150.463,-2.541,150.441,-2.484,150.545,-2.572,150.522,-2.499,150.797,-2.553,150.776,-2.617,150.89,-2.681,150.72,-2.752,151.097,-2.842,151.907,-3.433,152.143,-3.483,152.345,-3.644,152.685,-4.18,152.677,-4.553,152.719,-4.66,152.772,-4.682,152.79,-4.738,152.858,-4.771,152.874,-4.833,152.334,-4.833,152.331,-4.497,152.405,-4.337,152.168,-4.304,152.161,-4.206,152.234,-4.241,152.167,-4.133,152.124,-4.208,151.985,-4.198,152.032,-4.258,151.947,-4.341,151.833,-4.219,151.522,-4.186,151.686,-4.867,151.585,-4.966,151.349,-4.911,151.086,-5.143,150.901,-5.484,150.757,-5.45,150.613,-5.547,150.453,-5.425,150.288,-5.559,150.094,-5.517,150.018,-5.287,150.193,-5.057,150.094,-4.998,149.871,-5.52,149.674,-5.54,149.685,-5.477,149.662,-5.448,149.258,-5.592,148.975,-5.457,148.556,-5.538,148.417,-5.443,147.785,-5.457,147.76,-5.612,148.021,-5.833,147.209,-5.962,146.798,-5.858,146.46,-5.606,145.76,-5.478,145.806,-4.848,145.322,-4.385,145.033,-4.349,144.848,-4.178,144.858,-4.115,144.67,-4.021,144.657,-4.037,144.68,-4.036,144.686,-4.042,144.691,-4.125,144.667,-4.137,144.703,-4.143,144.711,-4.155,144.652,-4.501,144.633,-4.548,144.599,-4.542,144.709,-4.152,144.665,-4.14,144.684,-4.042,144.656,-4.042,144.661,-4.012,144.569,-3.993,144.545,-3.908,144.526,-4.008,144.449,-3.948,144.561,-3.887,144.545,-3.852,144.502,-3.909,144.485,-3.915,144.472,-3.909,144.466,-3.897,144.472,-3.885,144.51,-3.885,144.495,-3.818,144.45,-3.895,144.41,-3.894,144.433,-3.917,144.431,-3.939,144.37,-3.997,144.35,-3.997,144.28,-3.96,144.235,-3.965,144.255,-4.052,144.128,-4.1,144.072,-4.065,144.091,-4.116,144.067,-4.146,144.065,-4.086,144.053,-4.1,144.031,-4.108,144.017,-4.144,144.0,-4.154,143.833,-4.141,143.85,-4.157,143.856,-4.175,143.861,-4.201,143.858,-4.232,143.837,-4.244,143.847,-4.253,143.851,-4.259,143.848,-4.261,143.84,-4.259,143.847,-4.276,143.832,-4.29,143.836,-4.295,143.844,-4.293,143.849,-4.299,143.837,-4.308,143.845,-4.323,143.833,-4.335,143.847,-4.332,143.851,-4.333,143.867,-4.372,143.866,-4.411,143.859,-4.42,143.883,-4.472,143.877,-4.482,143.893,-4.486,143.898,-4.496,143.902,-4.493,143.91,-4.494,143.915,-4.504,143.904,-4.535,143.838,-4.58,143.843,-4.608,143.83,-4.61,143.835,-4.619,143.837,-4.649,143.824,-4.611,143.836,-4.58,143.914,-4.506,143.875,-4.483,143.832,-4.333,143.845,-4.323,143.835,-4.311,143.838,-4.305,143.846,-4.301,143.847,-4.296,143.835,-4.297,143.831,-4.291,143.835,-4.244,143.858,-4.228,143.855,-4.175,143.848,-4.182,143.84,-4.184,143.77,-4.181,143.78,-4.201,143.778,-4.219,143.76,-4.23,143.748,-4.208,143.736,-4.235,143.594,-4.181,143.732,-4.23,143.746,-4.203,143.769,-4.223,143.764,-4.185,143.772,-4.176,143.843,-4.174,143.823,-4.145,143.836,-4.136,144.011,-4.145,144.053,-4.058,144.247,-4.045,144.232,-3.963,144.279,-3.955,144.349,-3.989,144.381,-3.979,144.407,-3.892,144.488,-3.814,144.499,-3.817,144.504,-3.825,144.514,-3.883,144.505,-3.891,144.482,-3.886,144.47,-3.899,144.5,-3.905,144.524,-3.876,144.536,-3.819,144.376,-3.784,144.277,-3.785,144.431,-3.799,144.367,-3.832,144.386,-3.88,144.381,-3.884,144.362,-3.888,144.356,-3.815,144.336,-3.831,144.317,-3.83,144.275,-3.799,144.264,-3.868,144.088,-3.829,144.175,-3.779,144.002,-3.819,143.5,-3.436,142.532,-3.232,142.083,-3.068,141.993,-2.966,143.056,-1.454,144.4,-1.075,144.475,-1.122]}],"names":{"ar":"بحر بيسمارك","de":"Bismarcksee","es":"mar de Bismarck","fr":"mer de Bismarck","he":"ים ביסמרק","hu":"Bismarck-tenger","it":"Mare di Bismarck","nl":"Bismarckzee","pt":"Mar de Bismarck","zh":"俾斯麦海"}},{"name":"Baltic Sea","bbox":[9.522,52.654,23.512,59.939],"area":31.9295,"polygons":[{"outer":[20.978,56.522,21.083,55.729,21.108,55.72,21.129,55.703,21.251,55.466,21.19,55.341,21.34,55.382,21.303,55.347,21.357,55.343,21.381,55.33,21.498,55.19,21.542,55.195,21.498,55.186,21.381,55.293,21.376,55.287,21.333,55.274,21.297,55.252,21.271,55.244,21.288,55.187,21.18,55.204,21.248,54.954,21.091,54.893,20.501,54.951,20.861,55.151,21.058,55.331,21.137,55.612,21.095,55.722,21.085,55.488,20.934,55.26,20.497,54.968,19.978,54.961,19.869,54.649,19.963,54.64,19.996,54.727,20.086,54.65,20.167,54.687,20.202,54.694,20.33,54.685,20.46,54.707,20.474,54.704,19.984,54.573,19.373,54.22,19.401,54.279,19.261,54.276,19.258,54.268,19.273,54.263,19.281,54.257,19.289,54.255,19.301,54.247,19.305,54.235,19.318,54.226,19.328,54.206,19.326,54.196,19.32,54.19,19.196,54.092,19.187,54.09,19.172,54.091,19.157,54.095,19.146,54.091,19.135,54.072,19.128,54.065,19.103,54.042,19.094,54.039,19.085,54.038,19.075,54.04,19.054,54.05,19.035,54.047,19.002,54.011,18.933,53.969,18.927,53.967,18.917,53.967,19.035,54.049,19.056,54.051,19.094,54.04,19.149,54.094,19.158,54.096,19.185,54.091,19.196,54.093,19.323,54.196,19.248,54.266,19.227,54.338,19.366,54.355,19.599,54.427,19.734,54.496,19.887,54.632,19.481,54.397,18.952,54.357,18.946,54.237,18.808,54.095,18.874,53.967,18.864,53.929,18.892,53.895,18.859,53.737,18.742,53.643,18.768,53.592,18.762,53.53,18.744,53.483,18.712,53.455,18.66,53.428,18.372,53.346,18.309,53.266,18.203,53.215,18.201,53.16,18.14,53.124,18.675,53.013,18.904,52.854,19.043,52.674,19.132,52.654,19.078,52.659,19.028,52.679,18.672,53.01,18.132,53.114,18.2,53.219,18.363,53.347,18.737,53.483,18.737,53.649,18.853,53.739,18.886,53.897,18.857,53.929,18.804,54.092,18.944,54.242,18.947,54.36,18.786,54.358,18.915,54.311,18.925,54.289,18.92,54.288,18.691,54.397,18.719,54.408,18.66,54.414,18.682,54.399,18.66,54.38,18.646,54.378,18.644,54.372,18.681,54.354,18.7,54.358,18.769,54.354,18.78,54.35,18.681,54.353,18.647,54.366,18.396,54.747,18.822,54.624,18.331,54.835,17.243,54.729,16.527,54.54,16.114,54.277,14.729,54.023,14.805,54.03,14.761,53.926,14.652,53.898,14.616,53.767,14.538,53.704,14.711,53.451,14.389,53.142,14.537,53.38,14.611,53.387,14.62,53.41,14.606,53.404,14.609,53.416,14.596,53.439,14.593,53.458,14.63,53.527,14.595,53.554,14.582,53.581,14.581,53.6,14.324,53.736,14.221,53.698,14.264,53.752,14.1,53.74,13.972,53.772,13.806,53.858,14.186,53.872,14.337,53.807,14.286,53.845,14.27,53.879,14.263,53.867,14.252,53.9,14.28,53.922,13.803,54.178,13.749,54.158,13.812,54.098,13.769,54.02,13.974,54.063,14.047,53.996,13.959,53.934,13.906,53.99,13.938,53.909,13.825,53.866,13.914,53.922,13.745,54.029,13.806,54.104,13.696,54.172,13.456,54.091,13.418,54.173,13.108,54.281,13.016,54.439,12.809,54.345,12.787,54.396,12.656,54.408,12.41,54.28,12.46,54.249,12.364,54.266,12.436,54.378,12.638,54.425,12.802,54.408,12.962,54.438,12.511,54.483,12.095,54.181,12.151,54.094,12.087,54.183,11.681,54.153,11.49,53.968,11.492,54.023,11.377,53.996,11.484,53.968,11.452,53.9,11.179,54.016,10.863,53.948,10.968,53.908,10.883,53.924,10.813,53.887,10.695,53.886,10.866,53.918,10.857,53.951,10.885,53.978,10.753,54.052,11.093,54.198,11.059,54.354,11.127,54.375,11.118,54.395,10.705,54.305,10.319,54.436,10.134,54.311,10.151,54.363,9.961,54.327,9.784,54.358,9.59,54.216,9.545,54.194,9.522,54.191,9.784,54.36,10.192,54.39,10.137,54.484,9.844,54.46,10.027,54.55,10.033,54.67,9.716,54.492,9.545,54.509,10.035,54.672,9.966,54.767,10.997,54.769,11.5,54.595,11.726,54.665,11.971,54.559,11.974,54.697,12.283,54.938,12.554,54.96,12.262,55.061,12.281,54.982,12.207,54.967,12.1,55.089,12.183,55.114,12.035,55.123,12.03,55.166,12.885,55.397,13.359,55.336,13.717,55.427,14.192,55.382,14.362,55.554,14.204,55.703,14.247,55.872,14.54,56.061,14.753,56.01,14.782,56.034,14.675,56.126,14.688,56.167,14.848,56.16,14.834,56.134,14.861,56.168,14.982,56.171,15.03,56.147,15.006,56.161,15.014,56.197,15.076,56.179,15.063,56.154,15.167,56.14,15.268,56.181,15.314,56.164,15.29,56.132,15.349,56.119,15.403,56.18,15.59,56.212,15.564,56.16,15.647,56.192,15.724,56.173,15.74,56.108,15.83,56.169,15.782,56.113,15.848,56.073,16.045,56.235,16.044,56.31,16.093,56.422,16.246,56.633,16.316,56.631,16.325,56.644,16.354,56.656,16.358,56.666,16.365,56.667,16.357,56.671,16.357,56.674,16.372,56.692,16.362,56.693,16.378,56.702,16.349,56.717,16.392,56.752,16.363,56.764,16.48,56.771,16.393,56.785,16.442,57.007,16.474,56.965,16.491,56.971,16.483,56.97,16.466,57.003,16.496,57.017,16.452,57.044,16.586,57.054,16.465,57.16,16.451,57.204,16.508,57.233,16.453,57.267,16.558,57.375,16.626,57.365,16.638,57.405,16.678,57.409,16.679,57.432,16.727,57.443,16.651,57.462,16.678,57.46,16.718,57.48,16.698,57.474,16.629,57.535,16.637,57.539,16.673,57.533,16.669,57.546,16.586,57.533,16.513,57.564,16.498,57.637,16.598,57.621,16.601,57.578,16.666,57.557,16.693,57.573,16.652,57.565,16.633,57.575,16.632,57.57,16.582,57.66,16.645,57.677,16.592,57.683,16.523,57.727,16.741,57.672,16.739,57.712,16.448,57.833,16.408,57.882,16.719,57.735,16.469,57.912,16.599,57.879,16.58,57.89,16.585,57.904,16.57,57.908,16.562,57.919,16.598,57.932,16.522,57.957,16.507,57.998,16.667,57.911,16.591,57.919,16.66,57.873,16.696,57.903,16.802,57.87,16.738,57.914,16.806,57.908,16.755,57.939,16.812,57.996,16.743,57.946,16.623,57.985,16.662,58.054,16.689,57.997,16.703,58.02,16.805,58.003,16.689,58.048,16.813,58.063,16.676,58.131,16.606,58.202,16.721,58.144,16.774,58.107,16.794,58.169,16.873,58.177,16.7,58.271,16.891,58.295,16.419,58.483,16.738,58.42,16.858,58.473,16.951,58.484,16.768,58.569,16.773,58.604,16.717,58.604,16.762,58.58,16.442,58.638,16.402,58.585,16.213,58.597,16.183,58.615,16.185,58.641,16.35,58.666,16.475,58.656,16.583,58.641,16.601,58.636,16.929,58.611,17.046,58.651,16.943,58.666,16.952,58.679,17.109,58.647,17.102,58.666,17.169,58.69,17.141,58.699,17.106,58.689,17.036,58.696,17.125,58.708,17.012,58.743,17.086,58.769,17.113,58.73,17.159,58.717,17.299,58.758,17.357,58.756,17.39,58.74,17.386,58.796,17.514,58.779,17.455,58.806,17.511,58.832,17.482,58.845,17.458,58.893,17.578,58.832,17.526,58.895,17.629,58.883,17.588,58.962,17.708,58.91,17.696,58.943,17.71,58.954,17.698,58.966,17.697,58.986,17.713,58.995,17.693,59.048,17.615,59.022,17.617,59.084,17.688,59.104,17.654,59.183,17.706,59.102,17.692,59.052,17.771,59.117,17.785,59.096,17.732,58.929,17.849,58.902,17.791,58.799,17.875,58.811,17.83,58.855,17.866,58.892,17.85,58.908,17.861,58.916,17.874,58.878,17.849,58.869,17.922,58.8,17.956,58.957,18.022,59.012,17.992,59.032,18.002,59.041,18.24,59.119,18.193,59.064,18.297,59.073,18.222,59.069,18.256,59.081,18.344,59.093,18.275,59.106,18.3,59.121,18.392,59.139,18.408,59.129,18.471,59.165,18.418,59.138,18.253,59.267,18.313,59.257,18.367,59.265,18.313,59.265,18.289,59.304,18.312,59.328,18.517,59.212,18.546,59.24,18.621,59.247,18.414,59.298,18.633,59.246,18.712,59.283,18.556,59.285,18.645,59.295,18.666,59.314,18.473,59.421,18.416,59.423,18.472,59.386,18.45,59.386,18.482,59.335,18.46,59.315,18.349,59.363,18.323,59.348,18.275,59.359,18.149,59.316,18.073,59.321,18.156,59.322,18.046,59.381,18.083,59.423,18.139,59.453,18.163,59.393,18.331,59.382,18.223,59.435,18.668,59.579,18.765,59.686,18.891,59.716,18.963,59.717,19.027,59.703,19.072,59.717,19.085,59.732,18.713,59.748,18.938,59.843,18.853,59.939,18.914,59.92,18.925,59.904,18.935,59.907,19.037,59.836,19.052,59.807,19.047,59.898,21.417,59.734,22.496,59.721,22.9,59.818,23.512,59.216,22.584,59.093,22.394,58.935,22.042,58.941,22.408,58.883,22.566,58.637,22.287,58.57,22.292,58.487,22.202,58.545,22.106,58.416,22.0,58.509,21.839,58.497,22.023,58.364,21.943,58.308,21.909,58.337,21.862,58.353,21.909,58.308,21.831,58.28,22.224,58.135,21.983,57.978,22.03,57.911,21.527,57.408,21.582,57.401,21.608,57.38,21.611,57.337,21.566,57.308,21.58,57.301,21.582,57.298,21.575,57.283,21.59,57.282,21.604,57.276,21.614,57.263,21.629,57.258,21.631,57.253,21.605,57.24,21.629,57.255,21.575,57.283,21.563,57.307,21.604,57.383,21.524,57.406,21.415,57.283,21.372,57.0,21.058,56.835,20.995,56.557,21.051,56.54,20.978,56.522],"holes":[[16.634,56.537,16.866,56.845,16.898,57.037,17.071,57.173,17.056,57.276,17.151,57.309,17.119,57.361,16.959,57.292,16.723,56.884,16.62,56.866,16.397,56.603,16.394,56.195,16.634,56.537],[18.17,57.369,18.091,57.259,18.22,57.141,18.192,57.068,18.301,57.096,18.122,56.919,18.187,56.904,18.399,56.994,18.337,57.072,18.455,57.118,18.4,57.141,18.687,57.218,18.676,57.313,18.916,57.402,18.762,57.47,18.816,57.56,18.77,57.697,19.093,57.835,18.893,57.929,18.791,57.828,18.731,57.93,18.461,57.819,18.105,57.534,18.17,57.369],[14.745,55.26,14.682,55.099,14.744,55.063,15.072,54.987,15.156,55.08,15.138,55.144,14.745,55.26],[13.428,54.684,13.25,54.66,13.161,54.559,13.282,54.646,13.243,54.559,13.37,54.614,13.506,54.481,13.359,54.581,13.305,54.514,13.144,54.547,13.268,54.48,13.15,54.429,13.262,54.384,13.128,54.371,13.139,54.283,13.393,54.221,13.352,54.269,13.581,54.353,13.725,54.274,13.767,54.341,13.57,54.462,13.679,54.562,13.421,54.578,13.428,54.684]]}],"names":{"ar":"بحر البلطيق","de":"Ostsee","es":"Mar Báltico","fr":"mer Baltique","he":"הים הבלטי","hu":"Balti-tenger","it":"mar Baltico","nl":"Oostzee","pt":"Mar Báltico","zh":"波罗的海"}},{"name":"Hudson Strait","bbox":[-80.952,55.845,-64.432,64.98],"area":35.1014,"polygons":[{"outer":[-76.06,62.346,-76.065,62.342,-76.08,62.338,-76.118,62.334,-76.559,62.457,-76.662,62.434,-76.635,62.477,-76.794,62.522,-77.505,62.583,-77.94,62.416,-77.895,62.379,-78.096,62.348,-80.952,63.452,-80.49,63.719,-80.178,63.741,-78.027,64.445,-77.742,64.328,-77.641,64.338,-77.658,64.397,-77.47,64.348,-77.517,64.334,-77.541,64.304,-77.414,64.334,-77.395,64.308,-77.447,64.291,-77.417,64.289,-77.384,64.306,-77.36,64.311,-77.339,64.309,-77.36,64.239,-77.226,64.282,-77.209,64.284,-77.165,64.281,-77.142,64.286,-77.132,64.302,-76.844,64.281,-76.788,64.235,-76.831,64.208,-76.653,64.191,-76.734,64.293,-76.588,64.313,-76.36,64.275,-76.487,64.309,-76.431,64.329,-76.392,64.3,-76.307,64.271,-76.271,64.3,-76.162,64.311,-76.198,64.317,-76.327,64.321,-76.367,64.357,-75.979,64.408,-75.692,64.368,-75.781,64.407,-75.949,64.438,-75.94,64.439,-75.937,64.444,-75.942,64.441,-75.989,64.447,-76.0,64.454,-75.936,64.447,-75.792,64.413,-75.846,64.471,-75.816,64.478,-75.753,64.461,-75.745,64.465,-75.817,64.496,-75.805,64.499,-75.608,64.446,-75.816,64.533,-75.779,64.528,-75.813,64.559,-75.768,64.564,-75.855,64.616,-75.751,64.613,-75.597,64.542,-75.568,64.584,-75.227,64.472,-75.199,64.428,-75.124,64.452,-75.182,64.494,-74.79,64.382,-74.908,64.465,-74.717,64.392,-74.695,64.369,-74.604,64.418,-74.611,64.389,-74.487,64.396,-74.456,64.386,-74.474,64.404,-74.543,64.404,-74.588,64.416,-74.496,64.467,-74.608,64.498,-74.618,64.495,-74.632,64.496,-74.73,64.508,-74.874,64.544,-74.811,64.474,-74.891,64.47,-74.9,64.474,-74.851,64.483,-75.005,64.611,-74.983,64.652,-74.708,64.507,-74.604,64.511,-74.642,64.518,-74.643,64.538,-74.681,64.534,-74.696,64.546,-74.437,64.563,-74.805,64.696,-74.864,64.761,-75.08,64.767,-75.193,64.658,-75.222,64.738,-75.019,64.78,-75.143,64.921,-75.068,64.98,-75.05,64.961,-75.096,64.902,-75.029,64.87,-74.985,64.79,-74.792,64.798,-74.719,64.761,-74.663,64.805,-74.689,64.812,-74.713,64.827,-74.74,64.819,-74.745,64.824,-74.619,64.852,-74.644,64.896,-74.637,64.899,-74.618,64.894,-74.617,64.898,-74.592,64.904,-74.456,64.832,-74.547,64.84,-74.57,64.851,-74.577,64.806,-74.687,64.733,-74.404,64.622,-74.385,64.567,-74.332,64.591,-74.482,64.675,-74.445,64.739,-74.43,64.672,-74.384,64.659,-74.323,64.62,-74.309,64.676,-74.225,64.567,-74.228,64.671,-74.181,64.654,-74.14,64.686,-74.095,64.686,-74.111,64.692,-74.121,64.705,-74.095,64.716,-74.14,64.753,-74.024,64.724,-74.055,64.663,-73.914,64.728,-73.871,64.699,-73.959,64.677,-73.845,64.664,-73.873,64.624,-74.036,64.671,-74.077,64.619,-74.018,64.602,-73.977,64.578,-73.984,64.561,-74.157,64.576,-74.135,64.549,-74.095,64.569,-73.943,64.513,-74.054,64.542,-74.13,64.54,-74.049,64.499,-74.024,64.43,-74.091,64.42,-74.025,64.404,-74.007,64.374,-74.083,64.408,-74.079,64.361,-74.127,64.359,-74.079,64.348,-74.064,64.336,-74.091,64.338,-74.098,64.346,-74.12,64.342,-74.126,64.334,-74.118,64.329,-73.99,64.325,-73.991,64.382,-73.933,64.394,-74.014,64.429,-73.94,64.49,-73.862,64.469,-73.909,64.492,-73.912,64.5,-73.767,64.469,-73.755,64.429,-73.771,64.424,-73.725,64.422,-73.711,64.445,-73.665,64.459,-73.93,64.602,-73.891,64.618,-73.749,64.533,-73.757,64.551,-73.721,64.574,-73.605,64.585,-73.662,64.607,-73.636,64.629,-73.676,64.633,-73.688,64.638,-73.644,64.659,-73.587,64.584,-73.461,64.617,-73.386,64.565,-73.686,64.55,-73.564,64.498,-73.619,64.464,-73.583,64.465,-73.559,64.442,-73.679,64.443,-73.649,64.423,-73.632,64.375,-73.661,64.36,-73.609,64.352,-73.661,64.341,-73.658,64.334,-73.607,64.315,-73.544,64.323,-73.578,64.346,-73.568,64.346,-73.551,64.341,-73.508,64.367,-73.543,64.361,-73.565,64.405,-73.497,64.431,-73.52,64.501,-73.265,64.542,-73.368,64.65,-73.287,64.683,-73.256,64.618,-73.284,64.593,-73.159,64.634,-73.14,64.579,-73.486,64.399,-73.34,64.41,-73.253,64.359,-73.333,64.387,-73.363,64.264,-73.253,64.258,-73.316,64.307,-73.173,64.367,-73.229,64.296,-73.011,64.246,-73.057,64.242,-73.029,64.218,-73.052,64.218,-73.036,64.208,-73.036,64.2,-73.078,64.186,-73.033,64.2,-72.962,64.171,-72.995,64.193,-72.938,64.184,-72.921,64.176,-72.919,64.171,-72.858,64.167,-72.931,64.113,-72.839,64.088,-72.924,64.096,-72.946,64.044,-72.842,64.062,-72.891,64.036,-72.735,64.005,-72.698,63.954,-72.637,64.022,-72.577,64.024,-72.549,63.95,-72.641,63.929,-72.596,63.872,-72.64,63.844,-72.559,63.88,-72.423,63.813,-72.498,63.783,-72.556,63.777,-72.373,63.787,-72.366,63.773,-72.341,63.776,-72.398,63.812,-72.21,63.878,-72.267,63.906,-72.223,63.953,-72.168,63.87,-72.227,63.799,-72.248,63.856,-72.357,63.809,-72.294,63.814,-72.218,63.751,-72.285,63.776,-72.353,63.752,-72.226,63.718,-72.307,63.707,-72.242,63.699,-72.263,63.655,-72.184,63.67,-72.174,63.774,-72.038,63.714,-72.048,63.698,-72.037,63.662,-71.961,63.735,-71.915,63.723,-71.901,63.686,-71.955,63.648,-71.777,63.606,-71.751,63.673,-71.876,63.692,-71.823,63.768,-71.955,63.762,-71.911,63.867,-71.768,63.71,-71.649,63.746,-71.701,63.678,-71.61,63.635,-71.63,63.713,-71.489,63.715,-71.563,63.653,-71.545,63.631,-71.498,63.621,-71.556,63.572,-71.301,63.556,-71.273,63.61,-71.199,63.595,-71.354,63.51,-71.406,63.542,-71.473,63.538,-71.34,63.497,-71.609,63.408,-72.12,63.434,-71.704,63.406,-71.755,63.398,-71.683,63.337,-71.802,63.341,-71.7,63.268,-71.778,63.257,-71.625,63.263,-71.612,63.242,-71.758,63.235,-71.642,63.134,-71.426,63.095,-71.391,63.13,-71.363,63.148,-71.342,63.15,-71.406,63.045,-71.251,63.032,-71.198,62.996,-71.139,63.099,-70.983,63.06,-70.962,63.147,-70.867,63.163,-70.941,63.124,-70.811,63.129,-71.123,62.992,-70.82,63.059,-70.833,63.022,-70.88,63.005,-70.944,62.999,-70.886,62.969,-70.997,62.951,-70.5,62.884,-70.164,62.742,-70.157,62.779,-69.98,62.759,-70.028,62.796,-69.879,62.921,-69.845,62.907,-69.871,62.858,-69.879,62.868,-69.926,62.862,-69.964,62.798,-69.942,62.78,-69.842,62.783,-69.749,62.729,-69.846,62.806,-69.87,62.839,-69.863,62.852,-69.661,62.736,-69.613,62.808,-69.378,62.765,-69.539,62.724,-69.418,62.713,-69.452,62.674,-69.588,62.653,-69.467,62.637,-69.484,62.598,-69.415,62.543,-69.445,62.635,-69.363,62.628,-69.403,62.574,-69.339,62.526,-69.332,62.587,-69.235,62.645,-69.278,62.495,-69.255,62.495,-69.263,62.482,-69.238,62.478,-69.233,62.456,-69.2,62.448,-69.197,62.437,-69.162,62.448,-68.974,62.363,-68.928,62.388,-68.855,62.354,-68.846,62.42,-68.779,62.463,-68.826,62.484,-68.841,62.506,-68.756,62.459,-68.842,62.401,-68.742,62.369,-68.803,62.341,-68.678,62.332,-68.711,62.375,-68.707,62.379,-68.709,62.402,-68.702,62.426,-68.66,62.334,-68.715,62.315,-68.657,62.31,-68.606,62.26,-68.585,62.297,-68.541,62.247,-68.34,62.254,-68.207,62.215,-68.28,62.311,-68.114,62.205,-68.041,62.232,-68.04,62.202,-67.972,62.227,-67.944,62.2,-67.839,62.188,-67.715,62.223,-67.786,62.185,-67.362,62.161,-67.272,62.123,-67.289,62.067,-67.198,62.09,-67.246,62.119,-67.147,62.112,-67.033,62.029,-66.783,62.045,-66.275,61.862,-66.026,61.9,-65.973,61.888,-65.919,61.77,-65.851,61.769,-65.469,61.616,-65.365,61.527,-65.319,61.532,-65.175,61.502,-65.169,61.472,-65.148,61.496,-65.16,61.455,-65.091,61.468,-65.003,61.455,-65.102,61.438,-65.03,61.398,-64.993,61.429,-64.898,61.405,-64.897,61.399,-64.934,61.392,-64.946,61.366,-64.955,61.374,-64.976,61.372,-64.961,61.363,-64.969,61.352,-64.883,61.356,-64.845,61.309,-64.827,61.336,-64.781,61.317,-64.432,60.396,-64.836,60.486,-64.863,60.439,-64.762,60.429,-64.69,60.405,-64.789,60.425,-64.865,60.431,-64.818,60.416,-64.829,60.406,-64.7,60.358,-64.62,60.348,-64.576,60.335,-64.576,60.328,-64.736,60.363,-64.607,60.307,-64.847,60.372,-64.78,60.332,-64.779,60.288,-64.956,60.26,-64.839,60.203,-64.932,60.208,-64.921,60.198,-64.965,60.189,-64.945,60.111,-64.964,60.123,-64.968,60.147,-64.981,60.147,-64.976,60.161,-65.008,60.159,-64.961,60.086,-65.071,60.105,-65.01,60.041,-65.047,60.065,-65.079,60.041,-65.139,60.054,-65.001,59.897,-65.145,59.965,-65.13,59.915,-65.213,59.927,-65.146,59.9,-65.121,59.864,-65.273,59.88,-65.104,59.777,-65.161,59.775,-65.213,59.824,-65.26,59.822,-65.331,59.861,-65.328,59.848,-65.344,59.831,-65.394,59.824,-65.369,59.789,-65.43,59.81,-65.538,59.746,-65.515,59.667,-65.482,59.646,-65.433,59.586,-65.391,59.501,-65.294,59.508,-65.173,59.478,-64.896,59.365,-65.29,59.494,-65.392,59.492,-65.256,59.402,-65.521,59.48,-65.34,59.387,-65.452,59.349,-65.393,59.339,-65.352,59.283,-65.289,59.282,-65.311,59.243,-65.495,59.369,-65.635,59.404,-65.478,59.267,-65.557,59.254,-65.496,59.155,-65.471,59.14,-65.504,59.146,-65.583,59.26,-65.552,59.15,-65.576,59.159,-65.586,59.179,-65.598,59.18,-65.63,59.254,-65.722,59.285,-65.739,59.213,-65.653,59.212,-65.64,59.204,-65.752,59.184,-65.635,59.1,-65.633,59.071,-65.549,59.118,-65.321,59.058,-65.592,59.058,-65.36,58.922,-65.685,59.06,-65.686,59.049,-65.724,59.042,-65.776,59.042,-65.67,59.031,-65.785,59.018,-65.663,58.974,-65.662,58.935,-65.867,59.017,-65.831,58.955,-66.002,58.913,-65.781,58.868,-65.632,58.78,-65.606,58.794,-65.613,58.778,-65.586,58.771,-65.587,58.742,-65.524,58.729,-65.498,58.719,-65.42,58.667,-65.404,58.644,-65.324,58.653,-65.312,58.648,-65.274,58.647,-65.258,58.642,-65.236,58.621,-65.197,58.624,-65.149,58.619,-65.133,58.614,-65.117,58.6,-65.028,58.604,-64.95,58.593,-64.936,58.587,-64.894,58.603,-64.754,58.609,-64.678,58.606,-64.934,58.584,-65.104,58.596,-65.131,58.603,-65.138,58.614,-65.196,58.623,-65.234,58.617,-65.326,58.652,-65.404,58.642,-65.589,58.734,-65.604,58.747,-65.594,58.773,-65.648,58.773,-65.7,58.816,-65.757,58.808,-65.805,58.858,-65.823,58.788,-65.994,58.865,-66.102,58.794,-65.821,58.575,-65.891,58.597,-65.888,58.522,-66.002,58.492,-65.941,58.46,-65.96,58.4,-66.072,58.351,-66.023,58.305,-65.98,58.286,-65.949,58.258,-65.842,58.237,-65.742,58.181,-65.837,58.138,-65.675,58.09,-65.579,58.043,-65.489,57.883,-65.624,57.795,-65.462,57.716,-65.461,57.686,-65.474,57.656,-65.489,57.645,-65.53,57.63,-65.547,57.617,-65.524,57.59,-65.48,57.581,-65.394,57.543,-65.391,57.534,-65.533,57.59,-65.549,57.615,-65.544,57.628,-65.463,57.708,-65.563,57.726,-65.634,57.802,-65.502,57.886,-65.575,58.033,-65.848,58.139,-65.844,58.234,-65.959,58.256,-66.071,58.319,-66.086,58.34,-66.097,58.362,-66.078,58.385,-66.055,58.388,-66.029,58.387,-66.012,58.403,-65.987,58.407,-66.024,58.49,-65.924,58.62,-66.063,58.649,-66.36,58.853,-66.453,58.83,-66.444,58.713,-66.548,58.716,-66.614,58.622,-66.619,58.474,-66.808,58.496,-66.853,58.421,-66.94,58.512,-67.012,58.422,-67.038,58.412,-67.079,58.42,-67.049,58.386,-67.06,58.262,-67.179,58.399,-67.405,58.249,-67.582,58.226,-67.694,57.91,-67.71,58.138,-67.642,58.26,-67.736,58.271,-67.684,58.42,-67.746,58.464,-67.828,58.429,-67.806,58.295,-67.852,58.227,-68.103,58.076,-68.101,58.128,-67.847,58.312,-67.953,58.431,-67.905,58.506,-67.971,58.518,-68.009,58.584,-68.004,58.568,-68.031,58.521,-68.024,58.51,-68.184,58.484,-68.168,58.416,-68.309,58.103,-68.398,58.038,-68.958,57.941,-69.394,57.745,-69.497,57.59,-69.23,57.447,-69.219,57.305,-69.307,57.187,-69.102,57.053,-69.115,57.047,-69.133,57.061,-69.153,57.061,-69.129,57.057,-69.124,57.048,-69.102,57.042,-69.099,57.05,-69.073,57.019,-69.065,56.993,-69.089,56.929,-69.078,56.902,-69.08,56.87,-69.012,56.861,-69.108,56.816,-69.09,56.784,-69.025,56.764,-69.004,56.743,-69.211,56.508,-69.092,56.425,-69.157,56.314,-68.536,55.952,-68.424,55.856,-68.427,55.845,-68.538,55.951,-68.878,56.12,-68.885,56.081,-68.906,56.074,-69.182,56.329,-69.133,56.42,-69.258,56.498,-69.129,56.644,-69.15,56.649,-69.168,56.693,-69.182,56.691,-69.168,56.688,-69.166,56.685,-69.19,56.665,-69.202,56.662,-69.214,56.671,-69.213,56.662,-69.225,56.661,-69.22,56.67,-69.215,56.672,-69.208,56.671,-69.194,56.664,-69.167,56.685,-69.184,56.69,-69.181,56.693,-69.171,56.695,-69.155,56.687,-69.146,56.649,-69.128,56.648,-69.024,56.728,-69.016,56.754,-69.073,56.767,-69.113,56.807,-69.112,56.83,-69.084,56.857,-69.094,56.882,-69.085,56.901,-69.097,56.937,-69.069,56.995,-69.318,57.186,-69.224,57.307,-69.249,57.381,-69.242,57.442,-69.302,57.461,-69.492,57.575,-69.524,57.629,-69.512,57.657,-69.489,57.677,-69.512,57.672,-69.585,57.684,-69.634,57.68,-69.778,57.59,-69.861,57.576,-69.859,57.575,-69.862,57.569,-69.886,57.556,-69.899,57.552,-69.879,57.538,-69.863,57.52,-69.886,57.46,-69.901,57.553,-69.862,57.576,-70.009,57.549,-70.077,57.552,-70.321,57.626,-70.479,57.455,-70.746,57.341,-70.756,57.328,-70.732,57.281,-70.737,57.265,-70.778,57.205,-70.826,57.172,-70.915,57.14,-71.137,57.113,-71.148,57.091,-71.205,57.05,-71.367,57.024,-71.142,57.121,-70.833,57.171,-70.758,57.346,-70.767,57.345,-70.778,57.34,-70.794,57.338,-70.855,57.335,-70.954,57.289,-71.025,57.275,-71.053,57.274,-71.058,57.266,-71.081,57.251,-71.142,57.241,-71.196,57.215,-71.224,57.207,-71.262,57.202,-71.274,57.197,-71.453,57.178,-71.744,57.22,-71.785,57.212,-71.807,57.178,-71.884,57.152,-72.056,57.13,-71.754,57.223,-71.836,57.259,-71.228,57.209,-71.171,57.236,-71.074,57.258,-71.057,57.276,-70.558,57.423,-70.324,57.628,-70.18,57.621,-70.039,57.552,-69.794,57.589,-69.643,57.68,-69.588,57.688,-69.508,57.674,-69.44,57.694,-69.407,57.745,-69.286,57.822,-69.002,57.932,-69.052,57.934,-68.995,57.935,-68.933,57.966,-68.66,58.019,-68.476,58.034,-68.409,58.097,-68.326,58.126,-68.187,58.55,-68.294,58.528,-68.383,58.722,-68.35,58.781,-68.582,58.898,-69.387,58.873,-69.812,58.582,-69.866,58.642,-69.781,58.708,-69.927,58.692,-69.909,58.784,-70.312,58.756,-70.308,58.767,-70.203,58.808,-69.878,58.826,-69.899,58.876,-69.89,58.883,-69.79,58.828,-69.809,58.956,-69.894,58.959,-69.844,59.056,-69.768,58.959,-69.77,59.029,-69.698,58.991,-69.708,58.924,-69.689,58.896,-69.658,58.946,-69.681,58.809,-69.544,58.81,-69.548,58.851,-69.519,58.903,-69.438,58.903,-69.468,58.953,-69.49,59.076,-69.436,59.021,-69.344,59.096,-69.525,59.166,-69.441,59.227,-69.374,59.19,-69.357,59.243,-69.228,59.228,-69.269,59.284,-69.246,59.335,-69.358,59.306,-69.432,59.365,-69.62,59.294,-69.775,59.312,-69.621,59.391,-69.685,59.484,-69.782,59.469,-69.502,59.64,-69.667,59.691,-69.557,59.739,-69.605,59.786,-69.545,59.864,-69.667,59.877,-69.763,59.909,-69.714,59.965,-70.562,59.974,-70.989,60.071,-71.086,60.058,-71.101,60.063,-71.097,60.059,-71.13,60.061,-71.167,60.067,-71.212,60.061,-71.217,60.064,-71.219,60.061,-71.236,60.057,-71.281,60.049,-71.335,60.065,-71.374,60.054,-71.44,60.047,-71.478,60.048,-71.488,60.053,-71.49,60.048,-71.537,60.048,-71.565,60.041,-71.586,60.039,-71.594,60.04,-71.602,60.038,-71.633,60.041,-71.669,60.032,-71.692,60.039,-71.753,60.001,-71.902,59.994,-71.682,60.046,-71.155,60.079,-71.53,60.167,-71.541,60.177,-71.548,60.199,-71.569,60.21,-71.572,60.218,-71.586,60.222,-71.597,60.229,-71.604,60.24,-71.597,60.23,-71.57,60.218,-71.547,60.2,-71.525,60.166,-71.24,60.129,-71.114,60.071,-71.009,60.091,-70.52,59.993,-70.28,60.012,-70.356,60.035,-70.369,60.044,-70.37,60.057,-70.38,60.062,-70.387,60.088,-70.421,60.101,-70.385,60.089,-70.379,60.084,-70.34,60.032,-69.778,59.989,-69.835,60.02,-69.828,60.075,-69.638,60.058,-69.636,60.141,-69.589,60.179,-69.592,60.188,-69.635,60.183,-69.65,60.188,-69.624,60.198,-69.606,60.19,-69.61,60.194,-69.606,60.192,-69.601,60.2,-69.597,60.2,-69.607,60.209,-69.583,60.222,-69.767,60.305,-69.699,60.358,-69.662,60.356,-69.768,60.417,-69.71,60.458,-69.81,60.524,-69.676,60.542,-69.704,60.561,-69.625,60.58,-69.7,60.608,-69.645,60.627,-69.688,60.638,-69.698,60.697,-69.418,60.832,-69.36,60.818,-69.406,60.887,-69.371,60.915,-69.44,60.93,-69.502,61.067,-69.675,61.023,-69.636,60.882,-69.757,60.918,-69.9,60.8,-70.067,60.831,-70.157,60.887,-70.077,60.894,-70.161,61.003,-70.129,61.091,-70.409,61.089,-70.405,61.007,-70.762,61.083,-70.768,61.075,-70.746,61.051,-70.722,61.012,-70.723,61.002,-70.99,61.128,-71.423,61.119,-71.438,61.224,-71.522,61.217,-71.487,61.168,-71.576,61.164,-71.541,61.219,-71.581,61.221,-71.57,61.216,-71.606,61.209,-71.613,61.188,-71.619,61.185,-71.629,61.184,-71.597,61.237,-71.569,61.245,-71.58,61.255,-71.8,61.272,-71.641,61.327,-71.825,61.345,-71.768,61.363,-71.864,61.375,-71.916,61.395,-71.57,61.404,-71.857,61.424,-71.853,61.427,-71.868,61.431,-71.878,61.436,-71.88,61.438,-71.867,61.431,-71.84,61.429,-71.86,61.442,-71.767,61.466,-71.708,61.47,-71.792,61.496,-71.772,61.528,-71.786,61.533,-71.812,61.532,-71.823,61.533,-71.828,61.536,-71.833,61.534,-71.831,61.536,-71.57,61.577,-71.54,61.566,-71.521,61.58,-71.551,61.594,-71.555,61.621,-71.939,61.708,-71.966,61.597,-72.273,61.558,-71.981,61.678,-72.082,61.761,-72.264,61.786,-72.229,61.792,-72.173,61.79,-72.144,61.799,-72.11,61.796,-72.199,61.836,-72.21,61.875,-72.566,61.951,-72.629,61.895,-72.588,61.825,-72.593,61.782,-72.646,61.885,-72.794,61.828,-72.576,61.982,-72.662,62.061,-72.612,62.111,-72.75,62.144,-72.794,62.112,-72.818,62.127,-72.871,62.11,-72.958,62.117,-72.868,62.145,-73.057,62.185,-73.21,62.27,-73.192,62.315,-73.553,62.379,-73.671,62.48,-74.349,62.257,-74.747,62.21,-74.485,62.1,-74.527,62.095,-74.77,62.133,-74.914,62.253,-75.126,62.219,-75.12,62.28,-75.345,62.316,-75.784,62.188,-75.756,62.106,-75.908,62.164,-75.563,62.273,-76.06,62.346],"holes":[[-67.964,60.544,-68.237,60.583,-68.42,60.218,-67.97,60.29,-67.826,60.46,-67.964,60.544],[-70.389,62.522,-70.169,62.582,-70.508,62.779,-71.217,62.887,-71.019,62.83,-71.116,62.793,-70.731,62.776,-70.893,62.747,-70.803,62.726,-70.718,62.555,-70.389,62.522],[-77.906,63.089,-77.502,63.193,-77.563,63.198,-77.491,63.269,-77.693,63.405,-77.643,63.42,-77.894,63.425,-77.779,63.45,-78.535,63.429,-77.906,63.089],[-77.201,63.516,-76.721,63.362,-76.542,63.449,-76.738,63.434,-76.601,63.506,-77.027,63.677,-77.427,63.648,-77.262,63.618,-77.417,63.598,-77.201,63.516]]}],"names":{"ar":"مضيق هدسون","de":"Hudsonstraße","es":"Estrecho de Hudson","fr":"détroit d'Hudson","he":"מצר הדסון","it":"Stretto di Hudson","nl":"Straat Hudson","pt":"Estreito de Hudson","zh":"哈得逊海峡"}},{"name":"Timor Sea","bbox":[122.852,-15.796,132.931,-8.215],"area":35.6673,"polygons":[{"outer":[127.862,-8.215,128.15,-8.278,130.759,-8.342,131.763,-11.318,131.943,-11.321,132.115,-11.541,132.528,-11.428,132.571,-11.469,132.499,-11.509,132.565,-11.486,132.529,-11.53,132.568,-11.524,132.591,-11.54,132.606,-11.531,132.618,-11.565,132.548,-11.61,132.689,-11.572,132.668,-11.634,132.706,-11.61,132.742,-11.639,132.721,-11.644,132.735,-11.648,132.726,-11.654,132.726,-11.68,132.716,-11.69,132.659,-11.676,132.649,-11.723,132.581,-11.786,132.653,-11.885,132.681,-11.874,132.687,-11.876,132.694,-11.884,132.704,-11.878,132.712,-11.877,132.718,-11.878,132.721,-11.883,132.641,-11.904,132.623,-12.075,132.687,-12.068,132.786,-12.171,132.78,-12.216,132.784,-12.198,132.794,-12.195,132.855,-12.234,132.875,-12.257,132.873,-12.277,132.883,-12.268,132.892,-12.266,132.896,-12.268,132.9,-12.287,132.918,-12.269,132.925,-12.27,132.931,-12.279,132.907,-12.29,132.899,-12.287,132.893,-12.267,132.873,-12.279,132.792,-12.198,132.786,-12.217,132.777,-12.218,132.754,-12.143,132.559,-12.085,132.44,-12.148,132.445,-12.31,132.379,-12.384,132.435,-12.448,132.44,-12.515,132.411,-12.522,132.457,-12.538,132.454,-12.574,132.44,-12.606,132.512,-12.645,132.507,-12.667,132.52,-12.68,132.519,-12.685,132.439,-12.61,132.451,-12.538,132.406,-12.522,132.371,-12.376,132.429,-12.313,132.357,-12.201,132.256,-12.323,132.238,-12.17,132.091,-12.326,131.887,-12.214,131.469,-12.284,131.333,-12.212,131.297,-12.042,131.228,-12.209,131.302,-12.239,131.28,-12.245,131.296,-12.3,131.273,-12.333,131.327,-12.382,131.328,-12.388,131.324,-12.394,131.307,-12.398,131.316,-12.406,131.317,-12.41,131.314,-12.415,131.27,-12.333,131.269,-12.243,131.044,-12.143,130.993,-12.177,131.048,-12.388,130.896,-12.33,130.813,-12.407,130.82,-12.462,130.941,-12.473,131.02,-12.576,130.858,-12.507,130.969,-12.627,130.954,-12.636,130.972,-12.656,130.958,-12.703,130.808,-12.539,130.791,-12.624,130.778,-12.559,130.731,-12.597,130.766,-12.436,130.683,-12.416,130.614,-12.378,130.565,-12.583,130.605,-12.579,130.592,-12.597,130.63,-12.609,130.635,-12.62,130.641,-12.623,130.643,-12.627,130.544,-12.635,130.743,-12.721,130.52,-12.751,130.437,-12.635,130.393,-12.663,130.425,-12.727,130.418,-12.742,130.35,-12.669,130.333,-12.895,130.138,-12.93,130.117,-13.166,130.309,-13.32,130.324,-13.367,130.287,-13.42,130.324,-13.457,130.377,-13.461,130.37,-13.496,130.44,-13.472,130.464,-13.484,130.472,-13.493,130.474,-13.501,130.371,-13.5,130.28,-13.433,130.258,-13.333,130.017,-13.529,129.91,-13.514,129.901,-13.503,129.9,-13.439,129.894,-13.433,129.813,-13.501,129.828,-13.767,129.716,-13.847,129.747,-13.977,129.596,-14.011,129.624,-14.138,129.568,-14.083,129.563,-14.197,129.525,-14.22,129.493,-14.132,129.356,-14.424,129.577,-14.564,129.653,-14.498,129.657,-14.581,129.804,-14.515,129.747,-14.579,129.779,-14.597,129.784,-14.587,129.802,-14.576,129.806,-14.578,129.711,-14.656,129.622,-14.686,129.702,-14.69,129.664,-14.753,129.732,-14.793,129.796,-14.73,129.756,-14.824,129.945,-14.767,129.909,-14.756,129.894,-14.74,129.899,-14.715,129.913,-14.716,129.91,-14.709,129.911,-14.704,129.917,-14.717,129.899,-14.739,129.939,-14.751,129.948,-14.763,129.947,-14.774,129.999,-14.714,129.962,-14.801,130.084,-14.778,130.161,-14.789,130.163,-14.792,130.163,-14.796,130.152,-14.801,130.156,-14.813,130.176,-14.797,130.163,-14.815,129.966,-14.807,129.894,-14.784,129.942,-14.865,129.846,-14.829,129.863,-14.853,129.838,-14.969,129.851,-14.844,129.644,-14.833,129.642,-14.89,129.677,-14.893,129.691,-14.908,129.707,-14.963,129.638,-14.899,129.642,-15.081,129.881,-15.248,129.871,-15.407,130.073,-15.392,130.109,-15.404,130.119,-15.432,130.189,-15.397,130.213,-15.401,130.234,-15.418,130.25,-15.37,130.26,-15.363,130.275,-15.363,130.274,-15.36,130.282,-15.371,130.287,-15.395,130.338,-15.423,130.38,-15.484,130.385,-15.591,130.493,-15.6,130.477,-15.63,130.381,-15.59,130.273,-15.366,130.236,-15.42,130.154,-15.425,130.161,-15.47,130.078,-15.532,130.154,-15.466,130.116,-15.441,130.082,-15.406,129.946,-15.461,129.778,-15.438,129.868,-15.434,129.786,-15.191,129.678,-15.202,129.639,-15.137,129.571,-15.189,129.604,-15.112,129.505,-15.063,129.486,-14.944,129.389,-14.912,129.444,-14.976,129.462,-15.03,129.455,-15.037,129.434,-15.042,129.31,-14.858,129.246,-14.929,129.265,-14.948,129.268,-14.961,129.288,-14.961,129.302,-14.981,129.265,-14.961,129.235,-14.916,129.26,-15.035,129.246,-15.083,129.324,-15.109,129.38,-15.161,129.342,-15.203,129.357,-15.198,129.362,-15.201,129.366,-15.195,129.377,-15.194,129.385,-15.199,129.383,-15.2,129.388,-15.21,129.388,-15.217,129.34,-15.28,129.382,-15.197,129.37,-15.195,129.361,-15.203,129.339,-15.203,129.337,-15.123,129.242,-15.107,129.219,-15.263,129.206,-15.162,129.13,-15.293,129.2,-15.123,129.17,-15.053,129.178,-14.976,129.11,-14.98,129.148,-15.028,129.147,-15.043,129.165,-15.053,129.166,-15.057,129.105,-15.027,129.112,-14.931,129.073,-14.894,129.09,-14.923,129.03,-14.975,129.023,-14.883,128.751,-14.837,128.737,-14.857,128.756,-14.865,128.744,-14.878,128.757,-14.886,128.758,-14.891,128.755,-14.896,128.549,-14.77,128.546,-14.805,128.379,-14.817,128.471,-14.853,128.519,-15.019,128.423,-14.884,128.402,-14.88,128.437,-14.975,128.501,-15.004,128.511,-15.042,128.525,-15.05,128.53,-15.066,128.509,-15.044,128.497,-15.005,128.447,-15.055,128.411,-15.039,128.359,-14.992,128.367,-15.048,128.386,-15.074,128.391,-15.104,128.355,-15.103,128.287,-14.974,128.194,-15.095,128.269,-15.161,128.22,-15.139,128.278,-15.211,128.224,-15.172,128.301,-15.323,128.29,-15.36,128.307,-15.413,128.299,-15.463,128.329,-15.488,128.33,-15.507,128.353,-15.48,128.377,-15.496,128.378,-15.503,128.354,-15.481,128.337,-15.508,128.327,-15.507,128.292,-15.461,128.275,-15.317,128.127,-15.185,128.1,-15.511,127.859,-15.617,127.881,-15.63,127.85,-15.69,127.876,-15.718,127.875,-15.735,127.864,-15.758,127.881,-15.783,127.881,-15.796,127.862,-15.761,127.872,-15.714,127.846,-15.695,127.87,-15.648,127.827,-15.595,127.816,-15.606,127.805,-15.606,127.764,-15.567,127.809,-15.605,127.822,-15.59,127.842,-15.605,127.863,-15.583,127.934,-15.533,127.991,-15.526,128.091,-15.382,128.048,-15.296,127.992,-15.293,127.953,-15.275,127.846,-15.185,128.093,-15.283,128.077,-15.1,128.103,-15.066,128.117,-14.923,128.138,-14.873,128.083,-14.889,128.055,-14.887,128.049,-14.88,128.079,-14.836,128.192,-14.84,128.146,-14.736,128.222,-14.723,127.992,-14.556,128.012,-14.613,127.953,-14.608,127.784,-14.352,127.757,-14.385,127.741,-14.391,127.734,-14.387,127.722,-14.416,127.692,-14.438,127.693,-14.468,127.689,-14.477,127.662,-14.488,127.687,-14.476,127.692,-14.436,127.732,-14.387,127.758,-14.382,127.781,-14.334,127.454,-14.066,127.429,-13.943,127.322,-13.913,127.379,-13.985,127.355,-13.995,127.357,-14.012,127.328,-14.038,127.359,-13.983,127.266,-13.902,127.208,-13.972,127.191,-13.902,127.146,-13.981,127.117,-13.97,127.081,-13.84,127.007,-13.823,126.985,-13.754,126.914,-13.745,122.852,-10.942,123.129,-10.817,123.223,-10.83,123.253,-10.736,123.405,-10.685,123.421,-10.614,123.343,-10.586,123.44,-10.51,123.369,-10.544,123.386,-10.438,123.459,-10.358,123.798,-10.356,124.018,-10.276,124.105,-10.173,124.163,-10.14,124.109,-10.174,124.479,-10.123,124.976,-9.647,124.993,-9.566,124.974,-9.552,124.976,-9.538,125.379,-9.27,125.944,-9.133,126.187,-8.977,126.48,-8.947,126.589,-8.801,127.014,-8.686,127.296,-8.392,127.601,-8.216,127.862,-8.215],"holes":[[130.396,-11.164,130.432,-11.503,130.545,-11.621,130.481,-11.591,130.492,-11.654,130.965,-11.939,131.281,-11.74,131.379,-11.586,131.474,-11.607,131.468,-11.5,131.544,-11.454,131.436,-11.267,131.305,-11.275,131.257,-11.191,131.19,-11.298,131.22,-11.31,131.222,-11.417,131.259,-11.434,131.261,-11.497,131.253,-11.436,131.211,-11.479,131.148,-11.25,131.04,-11.319,131.052,-11.519,131.024,-11.364,130.922,-11.312,130.706,-11.385,130.829,-11.571,130.657,-11.335,130.586,-11.401,130.666,-11.504,130.558,-11.386,130.597,-11.291,130.396,-11.164],[130.512,-11.832,130.641,-11.755,130.486,-11.669,130.437,-11.749,130.464,-11.575,130.346,-11.594,130.4,-11.424,130.342,-11.321,130.253,-11.344,130.147,-11.482,130.271,-11.543,130.174,-11.558,130.299,-11.724,130.076,-11.668,130.023,-11.798,130.512,-11.832]]}],"names":{"ar":"بحر تيمور","de":"Timorsee","es":"mar de Timor","fr":"mer de Timor","he":"ים טימור","hu":"Timor-tenger","it":"Mar di Timor","nl":"Timorzee","pt":"Mar de Timor","zh":"帝汶海"}},{"name":"Gulf of St. Lawrence","bbox":[-74.865,44.975,-54.703,52.222],"area":35.9795,"polygons":[{"outer":[-64.672,47.717,-64.854,47.732,-64.802,47.804,-65.075,47.763,-64.977,47.83,-65.147,47.825,-65.623,47.653,-65.605,47.644,-65.621,47.627,-65.642,47.621,-65.636,47.601,-65.65,47.594,-65.645,47.583,-65.655,47.573,-65.677,47.646,-65.621,47.658,-65.764,47.863,-66.413,48.022,-66.361,48.072,-66.746,47.984,-66.921,47.984,-66.97,47.918,-66.921,47.985,-66.473,48.12,-66.092,48.093,-65.902,48.234,-65.815,48.122,-65.405,48.001,-65.14,48.054,-64.949,48.151,-64.973,48.199,-64.773,48.206,-64.73,48.339,-64.322,48.416,-64.204,48.525,-64.323,48.611,-64.186,48.647,-64.434,48.771,-64.406,48.818,-64.555,48.815,-64.578,48.828,-64.473,48.837,-64.583,48.884,-64.588,48.898,-64.159,48.752,-64.394,49.007,-64.871,49.192,-65.324,49.252,-66.054,49.222,-66.745,49.088,-68.165,48.642,-68.539,48.445,-68.819,48.366,-69.52,47.935,-69.629,47.747,-69.901,47.528,-70.024,47.485,-70.061,47.37,-70.501,47.01,-71.386,46.708,-71.693,46.629,-71.861,46.667,-72.013,46.564,-72.164,46.539,-72.246,46.42,-72.449,46.377,-72.657,46.199,-72.999,46.071,-73.147,46.043,-73.229,45.865,-73.433,45.715,-73.512,45.406,-73.753,45.364,-73.756,45.402,-73.913,45.323,-73.93,45.297,-73.906,45.302,-73.995,45.227,-74.322,45.167,-74.47,45.044,-74.655,44.975,-74.865,44.994,-74.825,44.991,-74.789,45.008,-74.737,45.009,-74.666,45.032,-74.616,45.036,-74.163,45.3,-73.951,45.329,-74.004,45.361,-74.023,45.398,-73.991,45.422,-74.049,45.419,-74.241,45.496,-74.336,45.497,-74.381,45.57,-73.99,45.477,-73.882,45.535,-73.885,45.562,-73.842,45.58,-73.832,45.609,-73.732,45.68,-73.688,45.694,-73.469,45.714,-73.365,45.821,-73.27,45.886,-73.175,46.08,-73.154,46.105,-73.103,46.138,-72.624,46.283,-72.554,46.367,-72.269,46.45,-72.237,46.519,-72.296,46.519,-72.306,46.523,-72.203,46.547,-72.207,46.55,-72.211,46.558,-72.209,46.576,-72.225,46.582,-72.226,46.585,-72.218,46.598,-72.208,46.606,-72.207,46.623,-72.203,46.627,-72.197,46.552,-72.038,46.587,-71.881,46.693,-71.243,46.772,-71.201,46.849,-71.01,46.973,-70.715,47.109,-70.495,47.441,-70.227,47.491,-70.156,47.661,-69.906,47.77,-69.707,48.098,-70.367,48.341,-70.849,48.321,-70.751,48.389,-70.995,48.448,-71.041,48.429,-71.18,48.452,-71.209,48.444,-71.216,48.447,-71.177,48.458,-71.042,48.434,-70.984,48.461,-70.389,48.372,-69.682,48.137,-69.425,48.299,-69.229,48.573,-69.091,48.598,-69.159,48.654,-69.084,48.637,-69.051,48.777,-68.989,48.787,-68.98,48.768,-68.785,48.892,-68.639,48.899,-68.619,48.92,-68.671,48.914,-68.7,48.941,-68.765,48.941,-68.826,48.955,-68.862,48.963,-68.904,48.983,-68.906,48.989,-68.641,48.933,-68.6,49.05,-68.377,49.136,-68.465,49.044,-68.191,49.1,-68.189,49.159,-68.237,49.158,-68.32,49.182,-68.324,49.191,-68.145,49.202,-68.05,49.295,-67.379,49.316,-67.226,49.485,-67.148,49.808,-67.042,49.824,-66.922,50.006,-66.613,50.136,-66.655,50.21,-66.456,50.126,-66.54,50.212,-66.455,50.27,-66.36,50.186,-66.094,50.188,-66.107,50.23,-66.039,50.253,-66.03,50.261,-66.093,50.252,-66.17,50.268,-66.195,50.278,-66.205,50.308,-66.202,50.316,-66.029,50.268,-66.029,50.253,-66.097,50.224,-66.064,50.199,-65.95,50.29,-65.82,50.25,-65.35,50.303,-64.876,50.26,-64.458,50.319,-64.299,50.315,-64.287,50.307,-64.281,50.292,-64.191,50.455,-64.121,50.534,-64.092,50.537,-64.076,50.558,-64.057,50.561,-64.055,50.577,-64.042,50.581,-64.054,50.587,-64.057,50.595,-64.045,50.6,-64.056,50.611,-64.047,50.634,-64.037,50.636,-64.04,50.642,-64.034,50.636,-64.054,50.611,-64.042,50.599,-64.054,50.595,-64.039,50.581,-64.239,50.312,-64.281,50.29,-64.308,50.312,-64.332,50.283,-64.161,50.255,-64.03,50.306,-63.747,50.328,-63.672,50.304,-63.646,50.312,-63.627,50.312,-63.589,50.303,-63.566,50.311,-63.53,50.317,-63.476,50.311,-63.452,50.321,-63.43,50.314,-63.412,50.321,-63.366,50.33,-63.325,50.312,-63.281,50.345,-63.325,50.31,-63.367,50.329,-63.429,50.312,-63.452,50.32,-63.472,50.308,-63.593,50.301,-63.648,50.308,-63.674,50.301,-63.749,50.325,-63.806,50.295,-63.491,50.252,-63.401,50.201,-63.359,50.235,-63.221,50.221,-63.017,50.315,-62.704,50.311,-62.767,50.275,-62.738,50.286,-62.614,50.259,-62.551,50.282,-62.52,50.258,-62.489,50.274,-62.444,50.252,-62.44,50.265,-62.426,50.282,-62.41,50.291,-62.397,50.288,-62.4,50.259,-62.342,50.279,-62.213,50.229,-62.028,50.224,-62.008,50.219,-62.001,50.208,-61.964,50.231,-61.934,50.222,-61.922,50.237,-61.805,50.194,-61.81,50.127,-61.702,50.148,-61.685,50.137,-61.667,50.146,-61.655,50.144,-61.647,50.147,-61.637,50.146,-61.568,50.243,-61.583,50.252,-61.635,50.257,-61.653,50.265,-61.653,50.276,-61.646,50.282,-61.647,50.29,-61.612,50.313,-61.652,50.318,-61.668,50.329,-61.722,50.429,-61.708,50.579,-61.665,50.718,-61.628,50.736,-61.634,50.752,-61.656,50.759,-61.677,50.77,-61.699,50.789,-61.706,50.81,-61.702,50.815,-61.706,50.834,-61.703,50.838,-61.713,50.854,-61.702,50.877,-61.712,50.885,-61.716,50.895,-61.534,51.085,-61.6,51.112,-61.711,51.21,-61.731,51.244,-61.671,51.329,-61.767,51.342,-61.796,51.372,-61.874,51.551,-61.878,51.573,-61.86,51.574,-61.866,51.586,-61.861,51.599,-61.843,51.607,-61.858,51.615,-61.863,51.626,-61.859,51.652,-61.896,51.674,-61.917,51.739,-61.978,51.755,-61.985,51.769,-62.007,51.768,-62.022,51.78,-62.049,51.823,-62.041,51.86,-62.019,51.782,-62.005,51.768,-61.986,51.771,-61.913,51.741,-61.855,51.653,-61.841,51.606,-61.872,51.551,-61.786,51.48,-61.795,51.376,-61.668,51.332,-61.705,51.213,-61.598,51.113,-61.532,51.087,-61.713,50.895,-61.701,50.884,-61.698,50.865,-61.696,50.789,-61.635,50.76,-61.617,50.736,-61.671,50.673,-61.668,50.64,-61.699,50.575,-61.69,50.554,-61.691,50.471,-61.712,50.426,-61.653,50.321,-61.602,50.318,-61.641,50.279,-61.63,50.277,-61.623,50.265,-61.583,50.255,-61.563,50.245,-61.564,50.228,-61.591,50.198,-61.581,50.179,-61.581,50.16,-61.587,50.155,-61.635,50.138,-61.769,50.108,-61.761,50.106,-61.812,50.11,-61.729,50.086,-61.18,50.174,-61.169,50.182,-61.18,50.179,-61.169,50.186,-61.185,50.2,-61.176,50.204,-61.097,50.205,-61.063,50.176,-61.043,50.227,-60.971,50.201,-60.953,50.232,-60.997,50.227,-61.03,50.253,-61.029,50.26,-60.92,50.242,-60.943,50.183,-60.924,50.218,-60.795,50.261,-60.868,50.202,-60.837,50.219,-60.808,50.193,-60.752,50.237,-60.675,50.211,-60.636,50.24,-60.601,50.2,-60.507,50.255,-60.341,50.209,-60.327,50.242,-60.297,50.253,-60.318,50.321,-60.289,50.344,-60.244,50.272,-60.293,50.222,-60.2,50.282,-60.151,50.286,-60.151,50.195,-60.036,50.225,-60.018,50.28,-59.818,50.326,-59.877,50.352,-59.834,50.355,-59.828,50.364,-59.901,50.371,-59.794,50.451,-59.592,50.494,-59.629,50.543,-59.588,50.575,-59.598,50.6,-59.595,50.607,-59.603,50.615,-59.617,50.657,-59.599,50.701,-59.611,50.72,-59.634,50.722,-59.655,50.743,-59.767,50.993,-59.774,51.022,-59.766,51.036,-59.594,50.705,-59.614,50.658,-59.582,50.577,-59.617,50.543,-59.586,50.497,-59.605,50.466,-59.42,50.548,-59.478,50.566,-59.427,50.668,-59.464,50.66,-59.573,50.673,-59.438,50.673,-59.344,50.649,-59.271,50.757,-59.201,50.74,-59.209,50.731,-59.208,50.723,-59.11,50.816,-59.087,50.807,-59.091,50.77,-59.067,50.772,-59.076,50.752,-59.012,50.738,-59.061,50.775,-58.952,50.816,-58.949,50.988,-59.041,50.967,-59.047,50.995,-58.901,51.059,-58.884,51.031,-58.913,50.99,-58.92,51.001,-58.922,50.945,-58.892,50.946,-58.916,50.979,-58.825,51.026,-58.84,51.088,-58.82,51.06,-58.8,51.1,-58.771,51.081,-58.61,51.178,-58.564,51.179,-58.595,51.183,-58.633,51.198,-58.703,51.275,-58.711,51.452,-58.643,51.222,-58.477,51.309,-58.428,51.313,-58.411,51.285,-58.4,51.298,-58.38,51.277,-58.367,51.291,-58.352,51.275,-58.335,51.28,-58.308,51.261,-58.286,51.264,-58.335,51.368,-58.268,51.392,-58.228,51.263,-58.105,51.38,-58.145,51.278,-58.045,51.363,-58.061,51.3,-57.787,51.436,-57.814,51.408,-57.774,51.395,-57.79,51.419,-57.756,51.464,-57.693,51.482,-57.73,51.511,-57.733,51.55,-57.701,51.595,-57.656,51.639,-57.7,51.421,-57.244,51.505,-57.204,51.411,-56.975,51.421,-56.953,51.492,-56.856,51.459,-56.713,51.575,-56.707,51.661,-56.515,51.673,-55.888,51.963,-55.991,52.028,-55.79,52.025,-55.802,52.092,-55.698,52.095,-55.718,52.156,-55.691,52.169,-55.787,52.201,-55.754,52.222,-55.621,52.218,-55.28,52.017,-55.431,51.906,-55.316,51.938,-55.443,51.581,-55.638,51.563,-55.644,51.614,-55.761,51.561,-55.625,51.482,-55.876,51.493,-55.903,51.557,-55.883,51.592,-55.844,51.616,-55.912,51.628,-56.699,51.339,-56.691,51.327,-56.8,51.243,-56.739,51.209,-56.796,51.134,-56.934,51.052,-56.903,51.015,-56.985,50.986,-57.017,51.027,-57.104,51.018,-56.941,50.932,-57.097,50.782,-57.41,50.7,-57.158,50.623,-57.375,50.599,-57.833,49.915,-57.665,49.823,-57.825,49.867,-57.965,49.683,-57.886,49.52,-57.836,49.565,-57.705,49.454,-57.875,49.512,-57.884,49.435,-58.02,49.556,-58.218,49.42,-58.256,49.291,-58.204,49.23,-57.938,49.234,-58.118,49.186,-58.074,49.142,-57.85,49.176,-58.151,49.117,-58.062,48.993,-57.902,48.993,-57.853,48.944,-57.847,48.95,-57.821,48.96,-57.796,48.978,-57.644,49.027,-57.56,49.133,-57.237,49.348,-57.196,49.368,-57.19,49.368,-57.186,49.364,-57.192,49.351,-57.171,49.348,-57.158,49.363,-57.134,49.379,-57.125,49.382,-57.115,49.381,-57.116,49.391,-57.096,49.423,-57.104,49.43,-57.105,49.434,-57.084,49.474,-57.067,49.479,-57.074,49.482,-57.083,49.495,-57.089,49.518,-57.097,49.517,-57.1,49.52,-57.115,49.518,-57.12,49.53,-57.089,49.519,-57.073,49.483,-57.053,49.484,-57.112,49.381,-57.599,49.026,-57.853,48.943,-58.366,49.055,-58.401,49.133,-58.727,48.555,-58.268,48.513,-58.62,48.399,-58.599,48.422,-59.413,47.898,-59.368,47.86,-59.38,47.838,-59.333,47.829,-59.308,47.853,-59.235,47.854,-59.187,47.846,-59.248,47.85,-59.33,47.828,-59.331,47.768,-59.282,47.771,-59.27,47.768,-59.266,47.779,-59.253,47.776,-59.281,47.754,-59.313,47.763,-59.312,47.625,-59.131,47.563,-58.919,47.611,-58.945,47.588,-58.875,47.61,-58.892,47.59,-58.837,47.611,-58.844,47.595,-58.792,47.595,-58.736,47.608,-58.734,47.601,-58.701,47.615,-58.693,47.599,-58.6,47.676,-58.649,47.611,-58.629,47.612,-58.589,47.66,-58.543,47.64,-58.569,47.698,-58.535,47.725,-58.527,47.653,-58.505,47.663,-58.466,47.651,-58.458,47.66,-58.423,47.64,-58.316,47.799,-58.337,47.749,-58.252,47.766,-58.361,47.651,-58.277,47.684,-58.147,47.68,-58.135,47.701,-58.075,47.703,-58.078,47.673,-58.03,47.734,-57.956,47.661,-57.91,47.749,-57.914,47.652,-57.682,47.678,-57.661,47.596,-57.525,47.661,-57.348,47.643,-57.306,47.766,-57.27,47.777,-57.343,47.589,-57.18,47.614,-57.186,47.699,-57.113,47.572,-57.113,47.578,-57.104,47.588,-57.082,47.599,-57.08,47.612,-57.063,47.618,-57.073,47.658,-57.013,47.683,-57.062,47.626,-56.975,47.603,-57.071,47.606,-57.111,47.574,-57.013,47.598,-56.869,47.536,-56.876,47.619,-56.868,47.636,-56.864,47.529,-56.815,47.595,-56.806,47.533,-56.711,47.57,-56.752,47.598,-56.775,47.651,-56.683,47.579,-56.648,47.599,-56.708,47.615,-56.707,47.659,-56.526,47.616,-56.574,47.721,-56.513,47.614,-56.323,47.627,-56.349,47.678,-56.322,47.79,-56.313,47.628,-56.149,47.643,-56.177,47.645,-56.19,47.675,-56.166,47.872,-56.11,47.749,-56.075,47.792,-56.012,47.803,-56.124,47.729,-56.051,47.7,-56.004,47.758,-55.866,47.783,-55.898,47.809,-55.842,47.79,-55.842,47.892,-55.784,47.957,-55.802,47.874,-55.697,47.906,-55.822,47.783,-55.701,47.841,-55.934,47.659,-55.921,47.642,-55.882,47.632,-55.865,47.614,-55.678,47.684,-55.624,47.682,-55.925,47.555,-56.15,47.521,-56.187,47.506,-56.188,47.494,-55.984,47.497,-55.727,47.616,-55.826,47.507,-55.939,47.438,-55.808,47.456,-55.826,47.481,-55.717,47.592,-55.779,47.449,-55.581,47.581,-55.671,47.432,-55.594,47.461,-55.608,47.399,-55.413,47.484,-55.439,47.728,-55.359,47.728,-55.383,47.645,-55.309,47.7,-55.183,47.632,-55.121,47.693,-55.138,47.577,-54.938,47.8,-55.092,47.591,-54.779,47.689,-54.703,47.672,-55.271,47.409,-55.311,47.271,-55.484,47.133,-55.772,47.111,-55.975,46.995,-55.977,46.915,-55.845,46.878,-55.81,46.86,-55.804,46.842,-56.16,46.761,-56.194,46.811,-56.242,46.763,-56.178,46.756,-60.348,45.682,-60.386,45.729,-60.254,45.733,-60.298,45.706,-60.257,45.707,-60.229,45.766,-60.082,45.802,-60.151,45.878,-59.994,45.873,-59.787,45.951,-59.841,46.016,-59.936,46.006,-59.957,45.99,-59.992,45.989,-59.817,46.18,-59.953,46.156,-59.953,46.217,-60.127,46.267,-60.212,46.205,-60.186,46.145,-60.203,46.15,-60.196,46.131,-60.237,46.102,-60.228,46.187,-60.324,46.16,-60.205,46.251,-60.279,46.314,-60.295,46.246,-60.32,46.209,-60.794,45.956,-60.706,45.9,-60.544,45.942,-60.592,45.953,-60.56,45.962,-60.509,45.967,-60.398,46.016,-60.373,46.025,-60.364,46.024,-60.674,45.822,-60.745,45.793,-60.741,45.689,-60.859,45.653,-60.801,45.702,-60.81,45.734,-60.941,45.771,-61.163,45.705,-61.103,45.792,-60.971,45.855,-60.947,45.835,-60.944,45.853,-60.985,45.866,-60.994,45.879,-60.945,45.859,-60.913,45.87,-60.929,45.867,-60.978,45.889,-61.095,45.85,-61.123,45.844,-61.14,45.846,-61.029,45.894,-61.065,45.894,-61.093,45.903,-60.815,45.945,-60.737,46.069,-60.925,46.039,-60.898,46.023,-60.979,45.954,-61.126,45.932,-61.13,45.966,-60.859,46.104,-60.891,46.064,-60.707,46.13,-60.648,46.116,-60.417,46.293,-60.448,46.346,-60.621,46.209,-60.352,46.615,-60.43,46.632,-60.349,46.654,-60.405,46.673,-60.303,46.849,-60.46,46.92,-60.463,46.904,-60.499,46.897,-60.513,46.905,-60.391,47.03,-60.597,47.042,-61.053,46.62,-61.078,46.401,-61.127,46.441,-61.485,46.096,-61.368,46.07,-61.476,46.088,-61.552,46.04,-61.466,45.717,-61.331,45.565,-61.288,45.547,-61.297,45.575,-61.231,45.591,-61.241,45.647,-61.222,45.65,-61.153,45.59,-61.188,45.565,-60.909,45.656,-60.873,45.62,-60.761,45.607,-60.738,45.582,-60.995,45.339,-61.504,45.35,-61.521,45.444,-61.609,45.459,-61.507,45.443,-61.479,45.389,-61.24,45.512,-61.335,45.548,-61.49,45.693,-61.617,45.613,-61.813,45.602,-61.852,45.637,-61.786,45.644,-61.886,45.692,-61.913,45.612,-61.964,45.621,-61.894,45.673,-61.914,45.676,-61.925,45.842,-61.895,45.873,-61.93,45.887,-62.501,45.592,-62.478,45.652,-62.674,45.682,-62.695,45.663,-62.645,45.642,-62.642,45.601,-62.645,45.59,-62.653,45.578,-62.715,45.656,-62.751,45.585,-62.748,45.659,-62.796,45.631,-62.785,45.667,-62.638,45.714,-62.797,45.752,-62.723,45.746,-62.68,45.765,-63.11,45.809,-63.041,45.754,-63.279,45.706,-63.38,45.761,-63.233,45.802,-63.532,45.797,-63.562,45.832,-63.412,45.852,-63.503,45.879,-63.675,45.874,-63.65,45.847,-63.67,45.818,-63.642,45.813,-63.672,45.793,-63.711,45.797,-63.67,45.806,-63.687,45.837,-63.661,45.848,-63.75,45.85,-63.756,45.841,-63.754,45.821,-63.759,45.805,-63.78,45.776,-63.787,45.776,-63.768,45.85,-63.727,45.872,-64.093,46.024,-63.783,46.132,-64.134,46.14,-64.266,46.236,-64.561,46.212,-64.629,46.267,-64.535,46.355,-64.567,46.326,-64.711,46.316,-64.611,46.348,-64.668,46.467,-64.715,46.472,-64.716,46.458,-64.727,46.462,-64.818,46.406,-64.851,46.397,-64.691,46.529,-64.608,46.462,-64.738,46.581,-64.72,46.691,-64.85,46.684,-64.909,46.542,-64.92,46.598,-65.084,46.51,-65.101,46.515,-64.87,46.648,-64.867,46.715,-64.967,46.68,-64.913,46.744,-64.815,46.724,-64.897,46.78,-64.917,46.757,-64.971,46.744,-64.983,46.73,-65.02,46.718,-64.919,46.833,-64.952,46.822,-64.959,46.823,-64.962,46.816,-64.994,46.808,-64.997,46.801,-65.017,46.791,-65.024,46.79,-64.91,46.882,-64.916,46.874,-64.916,46.846,-64.911,46.842,-64.798,47.074,-65.019,47.03,-65.012,47.085,-65.101,47.074,-65.118,47.003,-65.267,47.009,-65.206,47.082,-65.385,47.074,-65.498,47.015,-65.55,47.01,-65.557,46.965,-65.614,46.903,-65.687,46.855,-65.739,46.84,-65.582,46.966,-65.795,46.931,-65.816,46.936,-65.827,46.945,-65.591,46.968,-65.556,47.012,-65.355,47.093,-65.356,47.104,-65.377,47.107,-65.379,47.113,-64.973,47.278,-64.962,47.338,-65.004,47.318,-65.066,47.358,-65.074,47.346,-65.11,47.334,-65.116,47.338,-65.144,47.331,-65.153,47.331,-65.155,47.334,-65.074,47.373,-64.975,47.335,-64.923,47.396,-64.909,47.366,-64.871,47.478,-64.945,47.417,-65.041,47.445,-64.883,47.479,-64.876,47.509,-64.9,47.499,-64.911,47.526,-64.939,47.525,-64.94,47.533,-64.952,47.529,-64.96,47.538,-64.94,47.554,-64.89,47.518,-64.78,47.658,-65.014,47.661,-64.672,47.717],"holes":[[-63.989,47.049,-64.399,46.724,-64.389,46.623,-64.11,46.584,-64.077,46.649,-64.134,46.402,-63.744,46.394,-63.707,46.353,-63.811,46.373,-63.813,46.32,-63.653,46.22,-63.252,46.129,-63.129,46.19,-63.325,46.209,-63.162,46.213,-63.21,46.283,-63.122,46.23,-62.837,46.376,-63.127,46.207,-62.945,46.187,-62.949,46.12,-62.863,46.193,-62.88,46.108,-63.039,46.05,-62.896,46.075,-62.94,46.043,-62.829,45.964,-62.473,45.992,-62.641,46.014,-62.447,46.105,-62.646,46.165,-62.495,46.176,-62.623,46.234,-62.41,46.212,-62.523,46.297,-62.378,46.252,-62.37,46.356,-62.183,46.347,-61.972,46.452,-63.038,46.416,-62.979,46.356,-63.267,46.444,-63.209,46.409,-63.28,46.383,-63.24,46.421,-63.343,46.413,-63.312,46.49,-63.479,46.513,-63.42,46.5,-63.441,46.42,-63.481,46.496,-63.593,46.474,-63.505,46.535,-63.667,46.568,-63.748,46.496,-63.692,46.433,-63.844,46.452,-63.83,46.518,-63.98,46.457,-63.833,46.58,-63.911,46.582,-63.878,46.639,-63.984,46.73,-64.117,46.678,-64.077,46.764,-64.174,46.745,-64.056,46.783,-64.08,46.878,-64.034,46.803,-63.974,46.88,-63.989,47.049],[-62.241,49.063,-61.826,49.063,-61.669,49.14,-61.896,49.352,-62.186,49.399,-63.014,49.752,-64.15,49.952,-64.52,49.879,-63.826,49.637,-63.635,49.513,-63.585,49.387,-62.241,49.063]]}],"names":{"ar":"خليج سانت لورنس","de":"Sankt-Lorenz-Golf","es":"Golfo de San Lorenzo","fr":"golfe du Saint-Laurent","he":"מפרץ סנט לורנס","hu":"Szent Lőrinc-öböl","it":"golfo di San Lorenzo","nl":"Saint Lawrencebaai","pt":"Golfo de São Lourenço","zh":"聖羅倫斯灣"}},{"name":"Celebes Sea","bbox":[116.719,0.801,125.614,7.848],"area":37.0175,"polygons":[{"outer":[123.577,7.848,124.014,7.647,124.155,7.402,124.25,7.406,124.203,7.262,124.278,7.225,124.193,7.253,124.161,7.203,124.214,7.196,124.239,7.172,124.052,7.129,123.943,6.819,124.04,6.76,124.035,6.424,124.185,6.211,124.924,5.859,125.081,5.864,125.164,6.105,125.278,6.066,125.273,5.918,125.173,5.812,125.333,5.555,125.446,3.737,125.397,3.674,125.503,3.608,125.485,3.468,125.569,3.436,125.614,3.342,125.428,2.657,125.452,2.785,125.359,2.752,125.423,2.639,125.165,1.688,125.159,1.681,124.981,1.75,124.806,1.577,124.828,1.465,124.532,1.353,124.578,1.189,124.353,1.165,124.298,1.01,123.937,0.835,122.986,0.968,122.939,0.959,122.976,0.938,122.848,0.801,122.773,0.879,122.452,1.032,121.984,1.022,121.954,1.039,121.927,1.103,121.651,1.041,121.436,1.152,121.457,1.31,121.254,1.23,120.973,1.344,120.894,1.304,120.903,1.356,118.981,1.003,118.838,1.05,118.587,1.35,118.462,1.364,118.47,1.395,118.41,1.4,118.41,1.487,118.127,1.64,118.047,1.785,117.872,1.881,117.861,1.829,117.846,1.829,117.877,1.973,117.647,2.042,117.607,2.112,117.618,2.144,117.719,2.059,117.698,2.108,117.724,2.138,117.705,2.165,117.661,2.178,117.608,2.21,117.594,2.202,117.603,2.195,117.6,2.188,117.555,2.167,117.501,2.178,117.512,2.152,117.506,2.13,117.487,2.117,117.454,2.118,117.455,2.104,117.453,2.099,117.444,2.092,117.431,2.09,117.422,2.115,117.506,2.158,117.473,2.151,117.457,2.174,117.434,2.161,117.402,2.155,117.389,2.159,117.371,2.138,117.379,2.123,117.374,2.113,117.358,2.1,117.342,2.106,117.308,2.084,117.296,2.089,117.293,2.1,117.3,2.105,117.316,2.107,117.295,2.129,117.276,2.126,117.276,2.096,117.273,2.094,117.245,2.104,117.224,2.123,117.293,2.131,117.317,2.111,117.309,2.086,117.388,2.161,117.61,2.217,117.653,2.209,117.683,2.198,117.687,2.193,117.686,2.181,117.689,2.18,117.906,2.211,117.946,2.181,118.098,2.288,117.803,2.622,117.783,2.751,117.585,2.774,117.571,2.843,117.542,2.805,117.491,2.821,117.543,2.807,117.558,2.861,117.545,2.841,117.497,2.839,117.495,2.841,117.497,2.846,117.477,2.849,117.473,2.858,117.522,2.869,117.543,2.843,117.557,2.863,117.618,2.82,117.661,2.846,117.649,2.856,117.631,2.848,117.594,2.858,117.584,2.888,117.575,2.883,117.57,2.883,117.552,2.887,117.55,2.891,117.547,2.889,117.542,2.899,117.544,2.906,117.541,2.909,117.515,2.909,117.487,2.921,117.472,2.936,117.449,2.903,117.423,2.903,117.411,2.909,117.417,2.897,117.413,2.886,117.406,2.884,117.401,2.888,117.4,2.877,117.391,2.869,117.365,2.863,117.365,2.828,117.358,2.798,117.335,2.802,117.326,2.818,117.335,2.779,117.304,2.75,117.151,2.719,116.879,2.812,117.079,2.798,117.15,2.72,117.195,2.777,117.275,2.745,117.327,2.824,117.356,2.8,117.323,2.896,117.325,2.9,117.34,2.909,117.344,2.932,117.336,2.916,117.321,2.918,117.389,3.032,117.365,3.031,117.376,3.033,117.376,3.044,117.353,3.048,117.346,3.071,117.346,3.076,117.364,3.115,117.461,3.14,117.364,3.119,117.341,3.182,117.258,3.125,117.308,3.207,117.266,3.182,117.204,3.167,117.266,3.228,117.246,3.217,117.263,3.232,117.177,3.248,117.156,3.26,117.144,3.276,117.137,3.272,117.131,3.278,117.265,3.289,117.371,3.226,117.351,3.274,117.388,3.307,117.368,3.294,117.348,3.294,117.338,3.298,117.331,3.297,117.414,3.389,117.305,3.448,117.333,3.39,117.327,3.361,117.319,3.359,117.303,3.367,117.296,3.363,117.307,3.34,117.297,3.32,117.307,3.309,117.287,3.307,117.294,3.363,117.329,3.397,117.302,3.409,117.287,3.426,117.285,3.411,117.279,3.403,117.284,3.395,117.278,3.383,117.272,3.382,117.268,3.391,117.26,3.39,117.286,3.428,117.308,3.411,117.298,3.445,117.321,3.463,117.223,3.489,117.237,3.434,117.224,3.414,117.215,3.422,117.214,3.411,117.137,3.444,117.22,3.443,117.194,3.537,117.222,3.609,116.969,3.591,116.951,3.61,116.946,3.692,116.921,3.613,116.804,3.567,116.778,3.57,116.719,3.617,116.796,3.569,116.952,3.703,117.01,3.595,117.076,3.635,117.158,3.644,117.513,3.618,117.17,3.75,117.435,3.69,117.446,3.703,117.488,3.722,117.481,3.728,117.483,3.744,117.461,3.771,117.467,3.786,117.448,3.779,117.409,3.794,117.401,3.801,117.392,3.825,117.393,3.811,117.387,3.809,117.337,3.826,117.34,3.803,117.337,3.799,117.328,3.797,117.283,3.834,117.277,3.819,117.272,3.82,117.262,3.833,117.253,3.832,117.255,3.819,117.252,3.813,117.232,3.811,117.215,3.815,117.208,3.833,117.206,3.823,117.189,3.818,117.198,3.817,117.2,3.814,117.199,3.811,117.184,3.806,117.171,3.812,117.168,3.832,117.164,3.822,117.16,3.823,117.158,3.829,117.151,3.819,117.143,3.821,117.13,3.802,117.103,3.799,117.098,3.81,117.108,3.815,117.104,3.824,117.113,3.829,117.102,3.839,117.109,3.841,117.107,3.848,117.114,3.852,117.106,3.865,117.116,3.851,117.11,3.839,117.103,3.839,117.115,3.828,117.104,3.801,117.131,3.805,117.143,3.823,117.158,3.831,117.163,3.823,117.164,3.831,117.17,3.834,117.185,3.807,117.198,3.813,117.189,3.813,117.188,3.819,117.208,3.835,117.232,3.814,117.254,3.834,117.264,3.834,117.275,3.82,117.278,3.835,117.304,3.833,117.33,3.798,117.337,3.828,117.392,3.827,117.467,3.79,117.477,3.759,117.489,3.771,117.496,3.775,117.595,3.797,117.599,3.795,117.601,3.791,117.599,3.776,117.616,3.777,117.596,3.819,117.617,3.781,117.664,3.788,117.783,3.755,117.793,3.76,117.686,3.805,117.734,3.835,117.675,3.877,117.598,3.876,117.534,3.924,117.496,3.913,117.444,3.931,117.443,3.923,117.451,3.926,117.464,3.913,117.465,3.909,117.442,3.922,117.442,3.932,117.457,3.931,117.459,3.969,117.442,3.965,117.444,3.943,117.438,3.939,117.413,3.932,117.4,3.948,117.398,3.94,117.39,3.932,117.392,3.917,117.389,3.917,117.388,3.931,117.398,3.949,117.457,3.974,117.409,4.021,117.357,3.932,117.177,3.903,117.133,3.935,117.122,3.973,117.11,3.933,117.1,3.923,117.095,3.924,117.094,3.952,117.106,3.968,117.072,3.973,117.073,3.977,117.082,3.988,117.064,3.985,117.065,4.001,117.05,3.984,117.047,3.984,117.039,4.002,117.047,3.984,117.065,4.002,117.107,3.969,117.096,3.925,117.123,3.975,117.175,3.905,117.351,3.942,117.374,4.036,117.439,4.014,117.471,4.081,117.354,4.098,117.314,4.08,117.296,4.086,117.418,4.112,117.295,4.161,117.273,4.201,117.522,4.102,117.547,4.141,117.593,4.17,117.574,4.179,117.548,4.167,117.531,4.172,117.485,4.214,117.519,4.251,117.508,4.241,117.499,4.241,117.492,4.245,117.493,4.239,117.492,4.236,117.482,4.234,117.477,4.243,117.473,4.236,117.468,4.236,117.477,4.27,117.559,4.258,117.551,4.267,117.542,4.295,117.547,4.312,117.558,4.32,117.542,4.334,117.589,4.34,117.623,4.306,117.628,4.348,117.596,4.387,117.572,4.359,117.547,4.351,117.534,4.359,117.536,4.369,117.527,4.355,117.521,4.36,117.58,4.426,117.598,4.397,117.623,4.398,117.619,4.402,117.598,4.407,117.591,4.412,117.58,4.448,117.584,4.453,117.978,4.223,118.18,4.336,118.325,4.325,118.294,4.398,118.412,4.325,118.551,4.351,118.644,4.441,118.595,4.51,118.503,4.437,118.44,4.525,118.484,4.568,118.305,4.63,118.131,4.84,118.124,4.882,118.21,4.961,118.334,5.022,118.47,4.923,118.679,4.934,119.224,5.153,119.744,5.006,120.04,5.181,120.097,5.151,120.122,5.186,120.115,5.218,120.119,5.235,120.125,5.241,120.224,5.124,120.23,5.234,120.256,5.254,120.215,5.302,120.221,5.326,121.072,5.896,121.195,5.953,121.282,5.855,121.385,5.904,121.426,5.959,121.419,5.995,121.316,6.002,121.984,6.409,122.182,6.453,122.213,6.58,122.315,6.597,122.067,6.751,122.293,7.324,122.394,7.39,122.355,7.467,122.485,7.538,122.431,7.587,122.544,7.735,122.81,7.748,122.818,7.729,122.82,7.713,122.786,7.615,122.831,7.523,122.779,7.487,122.815,7.434,122.903,7.544,122.992,7.457,123.036,7.656,123.116,7.734,123.196,7.591,123.097,7.602,123.116,7.514,123.177,7.466,123.301,7.53,123.409,7.358,123.451,7.434,123.339,7.571,123.478,7.669,123.424,7.815,123.577,7.848]}],"names":{"ar":"بحر سيليبس","de":"Celebessee","es":"mar de Célebes","fr":"mer de Célèbes","he":"ים סלבס","hu":"Celebesz-tenger","it":"mare di Celebes","nl":"Celebeszee","pt":"Mar de Celebes","zh":"西里伯斯海"}},{"name":"Red Sea","bbox":[33.639,12.457,43.506,28.138],"area":38.7652,"polygons":[{"outer":[34.712,28.138,34.755,28.101,34.75,28.094,34.757,28.084,34.741,28.061,35.014,28.121,35.223,28.045,35.167,27.999,35.429,27.774,35.591,27.434,35.807,27.225,35.8,27.113,36.247,26.612,36.496,26.119,36.708,26.029,36.645,25.851,36.935,25.65,37.252,25.167,37.268,24.866,37.155,24.824,37.461,24.436,37.426,24.369,37.575,24.245,37.692,24.303,37.863,24.152,37.948,24.213,37.934,24.145,38.463,23.782,38.784,23.194,38.791,23.027,38.97,22.865,38.92,22.853,38.887,22.924,38.849,22.938,38.986,22.741,39.012,22.754,39.095,22.361,39.11,22.407,39.144,22.406,39.135,22.37,39.086,22.349,39.033,22.186,39.053,22.062,38.976,21.971,38.991,22.039,38.934,22.015,39.083,21.71,39.137,21.767,39.086,21.703,39.19,21.414,39.103,21.31,39.19,21.101,39.721,20.402,39.948,20.261,39.896,20.302,40.112,20.273,40.536,19.969,40.65,19.762,40.736,19.798,40.778,19.753,40.779,19.607,40.961,19.496,40.952,19.353,41.16,19.088,41.129,18.959,41.242,18.846,41.249,18.616,41.418,18.515,41.637,17.999,42.316,17.439,42.371,17.016,42.371,17.179,42.553,17.001,42.528,16.884,42.707,16.732,42.837,16.057,42.811,15.87,42.674,15.693,42.81,15.286,42.776,15.225,42.698,15.365,42.679,15.245,42.597,15.231,42.869,15.117,42.945,14.916,42.928,14.876,42.942,14.863,42.933,14.829,42.938,14.82,42.934,14.813,42.869,14.893,43.018,14.437,42.953,14.575,42.944,14.541,43.287,13.688,43.227,13.283,43.494,12.824,43.452,12.694,43.506,12.674,43.306,12.457,43.098,12.74,43.005,12.941,42.979,12.96,42.961,12.945,42.994,12.893,42.994,12.828,42.909,12.784,42.775,12.851,42.743,13.038,42.551,13.229,42.384,13.212,42.235,13.637,42.225,13.604,42.224,13.631,42.192,13.674,42.188,13.666,42.162,13.66,42.208,13.644,42.233,13.544,41.97,13.853,41.691,13.932,41.189,14.626,40.781,14.706,40.719,14.9,40.619,14.893,40.637,14.959,40.523,15.024,40.513,14.956,40.383,14.996,40.31,14.905,40.212,14.947,40.012,15.237,40.088,15.331,39.895,15.466,39.876,15.506,39.776,15.397,39.852,15.164,39.813,15.071,39.715,15.09,39.621,15.481,39.463,15.53,39.44,15.8,39.275,15.972,38.922,17.417,38.581,18.024,38.412,18.115,38.396,18.146,38.407,18.172,38.383,18.199,38.384,18.206,38.264,18.207,38.284,18.274,38.212,18.227,38.08,18.287,38.119,18.414,37.74,18.71,37.538,18.72,37.413,18.873,37.201,19.635,37.267,19.756,37.177,20.033,37.189,20.455,37.242,20.545,37.094,21.039,37.102,21.203,37.237,21.136,37.254,20.996,37.32,21.066,36.978,21.398,36.853,21.837,36.893,22.066,36.441,22.358,36.232,22.634,35.941,22.687,35.661,22.955,35.482,23.502,35.473,23.932,35.793,23.908,35.493,24.111,35.137,24.514,34.636,25.579,33.936,26.668,33.943,26.827,34.004,26.857,33.829,27.11,33.85,27.243,33.639,27.45,34.036,27.449,34.261,27.732,34.206,27.79,34.329,27.9,34.621,27.92,34.587,27.949,34.587,27.963,34.65,28.094,34.712,28.138],"holes":[[40.139,15.777,39.953,15.887,40.044,15.819,39.923,15.725,40.084,15.66,39.943,15.699,39.962,15.619,40.426,15.561,40.316,15.691,40.164,15.634,40.139,15.777]]}],"names":{"ar":"البحر الأحمر","de":"Rotes Meer","es":"mar Rojo","fr":"mer Rouge","he":"ים סוף","hu":"Vörös-tenger","it":"Mar Rosso","nl":"Rode Zee","pt":"Mar Vermelho","zh":"红海"}},{"name":"Yellow Sea","bbox":[117.516,33.276,127.459,41.145],"area":41.2764,"polygons":[{"outer":[121.942,41.145,121.896,41.092,121.81,41.066,121.882,41.058,121.891,41.051,121.82,40.892,122.122,40.683,122.157,40.712,122.266,40.696,122.197,40.709,122.09,40.797,122.175,40.875,122.185,40.874,122.214,40.851,122.221,40.861,122.23,40.865,122.259,40.87,122.272,40.864,122.315,40.927,122.336,40.925,122.332,40.941,122.344,40.949,122.339,40.924,122.273,40.862,122.093,40.797,122.268,40.707,122.27,40.693,122.235,40.679,122.153,40.705,122.142,40.611,122.332,40.475,121.826,40.026,121.894,40.0,121.473,39.808,121.544,39.597,121.452,39.653,121.224,39.546,121.452,39.522,121.534,39.383,121.518,39.363,121.572,39.399,121.553,39.428,121.655,39.433,121.69,39.417,121.699,39.469,121.724,39.464,121.753,39.382,121.922,39.442,121.949,39.399,121.864,39.357,121.733,39.361,121.758,39.31,121.727,39.275,121.591,39.273,121.666,39.228,121.583,39.197,121.687,39.133,121.663,39.082,121.44,39.014,121.372,39.06,121.333,38.972,121.097,38.931,121.173,38.885,121.106,38.851,121.133,38.726,121.229,38.804,121.693,38.864,121.717,38.905,121.612,38.929,121.611,38.947,121.586,38.946,121.584,38.952,121.672,39.027,121.864,38.949,121.838,39.043,121.886,39.046,121.889,39.028,121.902,39.032,121.914,39.017,121.925,39.02,121.888,39.047,121.895,39.056,121.877,39.065,121.871,39.071,121.875,39.074,122.065,39.062,122.03,39.125,122.164,39.154,122.102,39.16,122.051,39.203,122.108,39.18,122.419,39.467,122.622,39.481,122.657,39.559,123.005,39.681,123.275,39.693,123.193,39.726,123.278,39.814,123.405,39.814,123.412,39.731,123.553,39.76,123.556,39.811,123.642,39.849,123.626,39.874,123.646,39.925,123.628,39.927,123.621,39.932,123.627,39.958,123.713,40.022,123.665,40.093,123.667,40.109,123.692,40.143,123.716,40.021,123.629,39.958,123.65,39.922,123.633,39.869,123.651,39.849,124.157,39.815,124.222,39.915,124.226,39.791,124.343,40.08,124.361,40.089,124.401,40.134,124.442,40.162,124.438,40.144,124.456,40.174,124.463,40.177,124.484,40.174,124.49,40.193,124.511,40.208,124.546,40.221,124.545,40.237,124.552,40.245,124.598,40.274,124.584,40.238,124.582,40.234,124.577,40.229,124.351,40.079,124.377,40.015,124.311,39.905,124.519,39.715,124.646,39.695,124.613,39.594,124.75,39.646,124.726,39.769,124.769,39.798,124.758,39.749,124.906,39.646,125.023,39.657,125.091,39.56,125.227,39.642,125.304,39.525,125.493,39.659,125.589,39.676,125.57,39.71,125.575,39.73,125.552,39.755,125.588,39.776,125.585,39.828,125.601,39.846,125.518,39.875,125.513,39.883,125.512,39.895,125.525,39.908,125.619,39.845,125.593,39.678,125.452,39.585,125.763,39.663,125.424,39.571,125.336,39.453,125.368,39.378,125.443,39.398,125.454,39.396,125.472,39.377,125.481,39.397,125.474,39.374,125.399,39.381,125.457,39.318,125.139,38.87,125.14,38.778,125.256,38.788,125.174,38.679,125.438,38.743,125.555,38.713,125.616,38.655,125.617,38.724,125.587,38.738,125.573,38.801,125.567,38.808,125.548,38.806,125.532,38.815,125.518,38.857,125.529,38.87,125.584,38.884,125.527,38.849,125.587,38.8,125.59,38.749,125.628,38.728,125.63,38.655,125.602,38.645,125.437,38.719,125.167,38.629,125.123,38.678,125.068,38.558,124.96,38.594,125.013,38.496,124.854,38.346,124.888,38.226,124.99,38.223,124.912,38.19,124.869,38.242,124.828,38.185,124.651,38.128,125.261,38.084,124.974,37.931,125.16,37.869,125.238,37.949,125.31,37.875,125.233,37.833,125.226,37.814,125.31,37.836,125.301,37.801,125.378,37.869,125.451,37.848,125.448,37.915,125.495,37.893,125.511,37.823,125.361,37.793,125.426,37.777,125.351,37.765,125.342,37.676,125.512,37.811,125.618,37.764,125.599,37.81,125.631,37.885,125.733,37.927,125.597,37.992,125.593,38.041,125.809,37.995,125.845,37.862,126.035,37.863,126.141,37.741,126.202,37.887,126.394,37.89,126.41,37.912,126.381,37.929,126.38,37.941,126.381,37.943,126.392,37.96,126.383,37.969,126.395,37.961,126.387,37.928,126.419,37.918,126.422,37.854,126.588,37.77,126.744,37.906,126.788,37.919,126.81,37.893,126.84,37.975,126.815,37.893,126.672,37.827,126.672,37.687,126.794,37.618,126.664,37.677,126.653,37.774,126.607,37.753,126.526,37.77,126.606,37.553,126.355,37.467,126.443,37.421,126.582,37.489,126.543,37.521,126.566,37.539,126.606,37.551,126.648,37.492,126.603,37.485,126.587,37.439,126.631,37.449,126.683,37.386,126.668,37.369,126.738,37.427,126.786,37.393,126.532,37.289,126.564,37.256,126.535,37.199,126.651,37.198,126.617,37.314,126.816,37.294,126.853,37.269,126.661,37.27,126.682,37.112,126.793,37.183,126.759,37.056,126.701,37.099,126.795,37.016,126.883,37.072,126.88,37.03,126.779,37.008,126.906,36.914,126.985,36.995,127.033,36.978,127.039,36.993,127.014,37.008,127.016,37.017,127.053,36.992,127.034,36.976,127.02,36.977,126.985,36.991,126.976,36.976,126.991,36.948,126.989,36.928,126.984,36.918,126.861,36.88,126.86,36.793,126.832,36.773,126.781,36.977,126.631,37.003,126.51,37.065,126.458,37.001,126.356,37.023,126.388,36.969,126.333,36.962,126.43,36.899,126.288,36.799,126.332,36.891,126.286,36.97,126.288,36.9,126.177,36.885,126.198,36.782,126.172,36.841,126.148,36.846,126.112,36.773,126.119,36.708,126.14,36.761,126.223,36.721,126.155,36.677,126.277,36.731,126.293,36.577,126.304,36.594,126.365,36.628,126.561,36.51,126.482,36.516,126.496,36.433,126.562,36.484,126.613,36.483,126.485,36.408,126.587,36.345,126.5,36.324,126.548,36.264,126.526,36.167,126.492,36.137,126.644,36.093,126.667,36.004,126.858,36.062,126.871,36.115,126.892,36.143,126.958,36.158,127.005,36.152,126.969,36.203,126.961,36.24,126.899,36.253,126.89,36.262,126.891,36.278,126.928,36.317,126.984,36.341,126.99,36.373,127.044,36.41,127.061,36.441,127.105,36.473,127.128,36.47,127.205,36.438,127.222,36.437,127.232,36.457,127.322,36.517,127.3,36.532,127.297,36.549,127.322,36.522,127.324,36.516,127.32,36.51,127.361,36.519,127.39,36.498,127.405,36.483,127.403,36.457,127.433,36.458,127.459,36.452,127.391,36.445,127.42,36.413,127.415,36.341,127.386,36.314,127.361,36.517,127.224,36.436,127.204,36.436,127.107,36.471,126.986,36.34,126.894,36.277,126.964,36.242,127.009,36.164,127.004,36.148,126.954,36.155,126.919,36.134,126.891,36.138,126.87,36.059,126.739,35.987,126.521,35.973,126.614,35.886,126.769,35.889,126.847,35.92,126.888,35.914,126.685,35.843,126.782,35.803,126.778,35.755,126.795,35.753,126.802,35.747,126.801,35.743,126.632,35.8,126.57,35.686,126.568,35.693,126.514,35.745,126.554,35.697,126.457,35.638,126.652,35.595,126.674,35.585,126.676,35.545,126.485,35.522,126.449,35.431,126.404,35.405,126.446,35.356,126.305,35.245,126.465,35.1,126.426,35.028,126.346,35.16,126.245,35.118,126.389,35.037,126.348,34.975,126.295,34.965,126.342,34.915,126.394,34.964,126.37,34.781,126.452,34.801,126.51,34.777,126.511,34.922,126.544,34.983,126.551,34.988,126.563,34.989,126.576,34.974,126.588,34.989,126.516,34.767,126.363,34.744,126.488,34.714,126.623,34.632,126.385,34.721,126.424,34.595,126.373,34.617,126.288,34.759,126.214,34.442,126.191,34.514,126.125,34.443,125.801,34.206,126.163,33.284,120.718,33.276,120.594,33.635,120.442,33.565,120.438,33.565,120.435,33.567,120.428,33.581,120.414,33.584,120.395,33.595,120.372,33.587,120.385,33.576,120.377,33.566,120.372,33.565,120.368,33.565,120.356,33.577,120.45,33.642,120.579,33.647,120.274,34.301,119.627,34.505,119.644,34.558,119.447,34.676,119.448,34.746,119.221,34.788,119.22,34.767,119.211,34.755,119.202,34.751,119.199,34.745,119.203,34.718,119.202,34.711,119.194,34.697,119.218,35.05,119.375,35.09,119.435,35.314,119.573,35.366,119.628,35.598,119.736,35.659,119.762,35.583,119.823,35.67,119.875,35.614,119.917,35.761,120.032,35.754,119.992,35.862,120.298,35.969,120.19,35.997,120.244,36.051,120.105,36.101,120.113,36.19,120.278,36.179,120.294,36.236,120.352,36.216,120.283,36.044,120.714,36.134,120.639,36.323,120.736,36.333,120.708,36.434,120.834,36.468,120.886,36.375,120.974,36.54,120.798,36.585,120.796,36.619,121.017,36.583,121.183,36.699,121.376,36.701,121.391,36.753,121.474,36.771,121.447,36.846,121.485,36.794,121.6,36.841,121.479,36.779,121.644,36.735,121.619,36.792,121.621,36.821,121.631,36.832,121.944,36.984,122.046,36.883,122.193,37.049,122.174,36.841,122.515,36.894,122.574,37.043,122.407,37.031,122.455,37.151,122.624,37.192,122.548,37.278,122.602,37.331,122.554,37.35,122.706,37.397,122.169,37.428,122.118,37.565,121.962,37.484,121.986,37.418,121.938,37.471,121.557,37.423,121.373,37.555,121.43,37.6,121.175,37.582,121.139,37.712,120.742,37.832,120.518,37.75,120.559,37.691,120.607,37.655,120.631,37.627,120.635,37.615,120.56,37.687,120.515,37.75,120.221,37.688,120.325,37.648,120.245,37.552,120.278,37.521,120.303,37.485,120.239,37.549,119.843,37.374,119.894,37.25,119.749,37.123,119.243,37.059,119.254,37.128,119.141,37.128,118.899,37.361,119.036,37.685,119.308,37.647,119.207,37.767,119.265,37.774,119.265,37.816,119.171,37.768,118.846,38.145,118.678,38.137,118.687,38.079,118.712,38.074,118.747,37.985,118.745,37.977,118.739,37.967,118.678,38.089,118.674,38.134,118.53,38.04,118.475,38.112,118.049,38.133,118.054,38.046,118.021,38.011,118.066,37.988,118.068,37.984,118.067,37.98,118.055,37.969,118.018,38.01,118.041,38.124,117.941,38.132,118.047,38.171,117.903,38.172,117.98,38.204,117.862,38.267,117.801,38.219,117.889,38.317,117.805,38.294,117.612,38.514,117.564,38.769,117.718,38.969,117.7,38.997,117.686,38.982,117.674,38.983,117.616,39.023,117.79,38.977,117.707,39.124,117.545,39.18,117.516,39.199,117.681,39.14,117.701,39.131,117.724,39.112,117.722,39.149,117.752,39.221,117.761,39.231,117.796,39.24,117.749,39.253,117.741,39.266,117.742,39.274,117.77,39.291,117.741,39.303,117.733,39.315,117.739,39.324,117.783,39.33,117.783,39.336,117.792,39.347,117.743,39.34,117.785,39.354,117.792,39.35,117.794,39.344,117.784,39.33,117.793,39.325,117.83,39.346,117.827,39.354,117.804,39.352,117.796,39.361,117.789,39.392,117.81,39.405,117.802,39.429,117.785,39.402,117.773,39.402,117.763,39.41,117.757,39.46,117.783,39.473,117.765,39.413,117.805,39.432,117.833,39.347,117.796,39.323,117.736,39.317,117.771,39.298,117.772,39.282,117.744,39.273,117.751,39.255,117.799,39.241,117.73,39.09,118.029,39.218,118.336,39.025,118.605,39.17,119.03,39.192,119.282,39.383,119.248,39.582,119.363,39.789,119.531,39.811,119.553,39.947,119.609,39.904,120.442,40.195,120.591,40.363,120.662,40.531,120.828,40.677,121.024,40.712,120.924,40.738,120.978,40.857,121.077,40.799,121.061,40.823,121.075,40.864,121.188,40.949,121.731,40.844,121.884,41.044,121.809,41.057,121.805,41.069,121.942,41.145]}],"names":{"ar":"البحر الأصفر","de":"Gelbes Meer","es":"mar Amarillo","fr":"mer Jaune","he":"הים הצהוב","hu":"Sárga-tenger","it":"Mar Giallo","nl":"Gele Zee","pt":"Mar Amarelo","zh":"黄海"}},{"name":"Java Sea","bbox":[104.527,-7.811,119.489,-1.549],"area":45.6052,"polygons":[{"outer":[115.293,-7.013,114.458,-7.811,114.108,-7.707,114.043,-7.607,113.928,-7.701,113.486,-7.699,113.28,-7.783,112.849,-7.58,112.846,-7.286,112.764,-7.196,112.66,-7.222,112.545,-6.834,112.513,-6.907,112.124,-6.902,111.964,-6.762,111.715,-6.769,111.492,-6.62,111.242,-6.692,110.98,-6.403,110.664,-6.502,110.479,-6.95,110.173,-6.843,109.875,-6.916,109.521,-6.777,109.264,-6.872,109.099,-6.838,109.068,-6.758,108.949,-6.829,108.834,-6.742,108.751,-6.815,108.606,-6.766,108.538,-6.483,108.349,-6.24,108.184,-6.228,108.21,-6.274,108.11,-6.333,107.891,-6.19,107.666,-6.245,107.305,-5.958,107.008,-5.918,107.017,-6.079,106.824,-6.122,106.679,-6.013,106.482,-6.046,106.407,-6.021,106.391,-5.979,106.355,-5.977,106.356,-5.963,106.159,-6.025,106.041,-5.876,105.881,-6.074,105.797,-6.492,105.65,-6.48,105.454,-6.827,105.389,-6.74,105.37,-6.647,105.264,-6.761,105.209,-6.763,104.599,-5.938,104.674,-5.927,104.733,-5.929,104.527,-5.543,104.707,-5.522,104.777,-5.605,105.086,-5.755,105.088,-5.795,105.152,-5.81,105.222,-5.782,105.155,-5.751,105.219,-5.719,105.17,-5.586,105.245,-5.588,105.29,-5.446,105.409,-5.623,105.562,-5.67,105.605,-5.831,105.717,-5.908,105.737,-5.881,105.759,-5.867,105.913,-4.437,105.836,-4.407,105.812,-4.131,105.775,-4.113,105.767,-4.137,105.746,-4.146,105.726,-4.132,105.706,-4.14,105.696,-4.13,105.775,-4.109,105.808,-4.121,105.844,-4.161,105.959,-3.843,105.823,-3.683,105.859,-3.392,106.097,-3.229,106.491,-3.092,106.74,-3.086,106.74,-3.006,106.694,-3.003,106.609,-2.963,106.591,-2.917,106.76,-2.6,107.627,-2.601,107.611,-2.706,107.643,-2.749,107.639,-2.79,107.528,-2.921,107.615,-2.912,107.593,-3.222,107.829,-3.167,107.81,-3.086,107.86,-3.059,107.985,-3.275,108.082,-3.244,108.081,-3.169,108.209,-3.145,108.266,-2.767,110.306,-2.996,110.619,-2.834,110.65,-3.041,110.726,-3.003,110.751,-2.946,110.781,-2.92,110.733,-2.989,110.981,-3.066,111.394,-2.917,111.508,-2.763,111.523,-2.764,111.537,-2.742,111.578,-2.725,111.526,-2.764,111.521,-2.766,111.51,-2.764,111.479,-2.836,111.46,-2.869,111.444,-2.878,111.434,-2.908,111.424,-2.917,111.548,-3.023,111.739,-2.824,111.824,-3.051,111.781,-3.52,111.902,-3.541,112.266,-3.312,112.56,-3.447,112.554,-3.378,112.557,-3.372,112.568,-3.364,112.558,-3.359,112.556,-3.353,112.574,-3.349,112.557,-3.347,112.503,-3.266,112.501,-3.244,112.49,-3.259,112.484,-3.262,112.453,-3.253,112.441,-3.242,112.435,-3.227,112.434,-3.242,112.428,-3.249,112.423,-3.248,112.415,-3.238,112.382,-3.226,112.378,-3.222,112.368,-3.197,112.37,-3.194,112.38,-3.195,112.382,-3.188,112.362,-3.191,112.343,-3.187,112.325,-3.177,112.318,-3.166,112.301,-3.112,112.302,-3.1,112.314,-3.092,112.305,-3.09,112.309,-3.079,112.301,-3.081,112.297,-3.079,112.29,-3.055,112.29,-3.014,112.294,-3.008,112.301,-3.004,112.29,-3.001,112.284,-2.993,112.286,-2.975,112.275,-2.966,112.275,-2.951,112.259,-2.947,112.254,-2.938,112.25,-2.889,112.26,-2.884,112.257,-2.877,112.258,-2.866,112.272,-2.847,112.266,-2.843,112.255,-2.817,112.244,-2.814,112.231,-2.797,112.217,-2.792,112.198,-2.744,112.199,-2.741,112.203,-2.74,112.212,-2.747,112.21,-2.737,112.216,-2.73,112.224,-2.736,112.214,-2.727,112.209,-2.699,112.218,-2.683,112.203,-2.697,112.195,-2.694,112.193,-2.692,112.197,-2.688,112.205,-2.687,112.205,-2.673,112.225,-2.654,112.211,-2.657,112.185,-2.635,112.165,-2.584,112.166,-2.578,112.176,-2.576,112.181,-2.563,112.191,-2.557,112.183,-2.549,112.191,-2.541,112.167,-2.541,112.157,-2.534,112.148,-2.52,112.193,-2.541,112.226,-2.654,112.225,-2.737,112.215,-2.731,112.213,-2.748,112.2,-2.745,112.218,-2.791,112.232,-2.796,112.257,-2.817,112.278,-2.852,112.255,-2.938,112.277,-2.95,112.302,-3.003,112.292,-3.055,112.298,-3.079,112.309,-3.078,112.315,-3.092,112.32,-3.167,112.382,-3.187,112.424,-3.247,112.435,-3.226,112.502,-3.243,112.574,-3.347,112.573,-3.433,112.918,-3.242,113.039,-3.133,112.942,-3.12,113.036,-2.979,113.347,-3.28,113.32,-3.234,113.315,-3.195,113.293,-3.163,113.291,-3.123,113.271,-3.117,113.264,-3.11,113.272,-3.073,113.249,-3.052,113.241,-3.034,113.246,-3.017,113.26,-3.001,113.263,-2.964,113.294,-2.898,113.282,-2.887,113.27,-2.832,113.288,-2.811,113.268,-2.787,113.265,-2.779,113.27,-2.774,113.296,-2.771,113.276,-2.758,113.274,-2.735,113.283,-2.724,113.302,-2.726,113.316,-2.702,113.346,-2.687,113.355,-2.665,113.343,-2.65,113.347,-2.629,113.353,-2.626,113.364,-2.633,113.334,-2.614,113.336,-2.606,113.355,-2.596,113.341,-2.595,113.336,-2.59,113.343,-2.568,113.325,-2.57,113.317,-2.586,113.308,-2.588,113.291,-2.549,113.296,-2.443,113.32,-2.412,113.343,-2.416,113.359,-2.427,113.362,-2.42,113.348,-2.408,113.346,-2.396,113.388,-2.368,113.394,-2.368,113.397,-2.374,113.392,-2.39,113.412,-2.383,113.412,-2.379,113.402,-2.378,113.4,-2.373,113.415,-2.342,113.446,-2.331,113.432,-2.289,113.434,-2.282,113.481,-2.26,113.456,-2.237,113.456,-2.216,113.476,-2.197,113.508,-2.21,113.496,-2.203,113.483,-2.166,113.485,-2.16,113.496,-2.154,113.507,-2.157,113.524,-2.138,113.516,-2.134,113.505,-2.148,113.492,-2.148,113.465,-2.09,113.466,-2.08,113.478,-2.074,113.486,-2.046,113.477,-2.044,113.476,-2.058,113.454,-2.071,113.44,-2.075,113.434,-2.07,113.435,-2.061,113.461,-2.046,113.465,-2.02,113.437,-2.042,113.412,-2.028,113.407,-2.009,113.43,-1.958,113.41,-1.97,113.388,-1.95,113.38,-1.919,113.397,-1.914,113.349,-1.927,113.309,-1.872,113.282,-1.812,113.292,-1.798,113.315,-1.792,113.315,-1.784,113.295,-1.783,113.278,-1.775,113.28,-1.768,113.269,-1.759,113.267,-1.741,113.272,-1.731,113.306,-1.724,113.29,-1.718,113.284,-1.69,113.269,-1.71,113.236,-1.681,113.237,-1.669,113.257,-1.661,113.259,-1.643,113.238,-1.645,113.221,-1.618,113.216,-1.589,113.237,-1.569,113.226,-1.577,113.195,-1.564,113.193,-1.555,113.203,-1.549,113.239,-1.568,113.262,-1.643,113.26,-1.662,113.238,-1.679,113.286,-1.688,113.308,-1.722,113.307,-1.727,113.294,-1.736,113.27,-1.74,113.319,-1.784,113.331,-1.856,113.312,-1.87,113.4,-1.913,113.432,-1.957,113.412,-2.024,113.467,-2.016,113.463,-2.048,113.436,-2.069,113.472,-2.06,113.474,-2.043,113.486,-2.043,113.526,-2.137,113.511,-2.211,113.457,-2.217,113.484,-2.257,113.463,-2.298,113.435,-2.284,113.448,-2.334,113.412,-2.386,113.392,-2.391,113.388,-2.37,113.36,-2.43,113.298,-2.444,113.294,-2.55,113.346,-2.568,113.357,-2.596,113.338,-2.611,113.366,-2.633,113.351,-2.685,113.303,-2.729,113.277,-2.732,113.298,-2.771,113.268,-2.78,113.292,-2.81,113.271,-2.833,113.297,-2.899,113.266,-3.0,113.245,-3.037,113.274,-3.072,113.348,-3.266,113.633,-3.132,113.584,-3.447,114.29,-3.34,114.294,-3.438,114.479,-3.503,114.5,-3.385,114.553,-3.323,114.54,-3.235,114.574,-3.154,114.598,-3.117,114.631,-3.109,114.635,-3.085,114.682,-3.043,114.779,-3.004,114.754,-2.972,114.752,-2.949,114.803,-2.877,114.782,-2.842,114.79,-2.821,114.763,-2.785,114.792,-2.818,114.806,-2.881,114.759,-2.973,114.773,-2.963,114.789,-2.964,114.804,-2.951,114.812,-2.954,114.819,-2.969,114.825,-2.971,114.837,-2.956,114.861,-2.95,114.858,-2.932,114.86,-2.926,114.866,-2.923,114.885,-2.927,114.9,-2.937,114.913,-2.915,114.9,-2.903,114.898,-2.898,114.9,-2.889,114.907,-2.879,114.912,-2.876,114.93,-2.882,114.902,-2.938,114.895,-2.938,114.881,-2.927,114.864,-2.925,114.863,-2.949,114.827,-2.972,114.771,-2.965,114.784,-3.001,114.739,-3.051,114.684,-3.046,114.633,-3.114,114.597,-3.122,114.543,-3.236,114.568,-3.296,114.511,-3.534,114.619,-3.667,114.631,-4.171,115.955,-3.612,116.078,-3.813,116.048,-4.062,119.489,-5.603,119.186,-6.818,117.133,-7.433,115.898,-7.18,115.906,-7.103,115.892,-7.16,115.843,-7.162,115.293,-7.013],"holes":[[113.907,-6.863,112.864,-6.891,112.673,-7.073,112.722,-7.176,113.1,-7.23,113.14,-7.144,113.176,-7.228,113.511,-7.255,113.604,-7.128,113.892,-7.133,113.906,-7.022,114.125,-6.978,113.907,-6.863]]}],"names":{"ar":"بحر جاوة","de":"Javasee","es":"mar de Java","fr":"mer de Java","he":"ים ג'אווה","hu":"Jáva-tenger","it":"Mar di Giava","nl":"Javazee","pt":"Mar de Java","zh":"爪哇海"}},{"name":"Black Sea","bbox":[27.444,40.909,42.349,47.31],"area":46.6541,"polygons":[{"outer":[31.939,46.939,32.009,46.947,32.026,46.919,31.923,46.788,31.98,46.66,32.141,46.557,32.25,46.592,32.386,46.547,32.527,46.613,32.589,46.604,32.668,46.659,32.794,46.676,32.918,46.734,33.141,46.786,33.185,46.789,33.282,46.785,33.307,46.774,33.351,46.784,33.373,46.776,33.352,46.76,33.288,46.749,33.138,46.781,32.82,46.677,32.696,46.661,32.538,46.538,32.46,46.504,32.334,46.485,32.286,46.489,32.322,46.467,32.427,46.492,32.366,46.463,32.022,46.486,32.02,46.496,31.99,46.49,31.907,46.511,31.911,46.524,31.886,46.531,31.755,46.547,31.58,46.537,31.512,46.577,31.706,46.433,31.767,46.487,32.06,46.397,31.978,46.348,31.784,46.323,31.779,46.265,31.896,46.316,31.971,46.257,32.076,46.249,32.09,46.222,32.116,46.208,32.218,46.19,32.248,46.168,32.051,46.17,32.409,46.09,33.136,46.125,33.225,46.216,33.203,46.107,33.284,46.133,33.284,46.077,33.502,46.028,33.567,46.075,33.51,46.121,33.61,46.144,33.618,45.946,33.74,45.952,33.759,45.934,33.683,45.844,33.614,45.89,33.265,45.755,33.167,45.787,32.479,45.396,32.653,45.315,32.983,45.327,32.925,45.358,32.989,45.347,33.188,45.449,33.205,45.443,32.988,45.325,33.257,45.149,33.444,45.179,33.601,45.008,33.51,44.625,33.592,44.613,33.379,44.583,33.487,44.497,33.738,44.387,34.127,44.428,34.347,44.548,34.461,44.713,34.761,44.817,35.082,44.792,35.256,44.963,35.386,44.947,35.396,45.061,35.536,45.126,35.84,44.999,36.633,45.141,36.902,45.105,37.262,44.976,37.483,44.694,37.737,44.65,37.801,44.733,38.202,44.417,38.791,44.281,39.923,43.416,40.245,43.316,40.34,43.147,41.042,42.995,41.15,42.796,41.447,42.732,41.64,42.208,41.692,42.196,41.738,42.173,41.77,42.181,41.756,42.195,41.79,42.214,41.958,42.208,42.349,42.12,41.955,42.206,41.737,42.167,41.654,42.197,41.701,42.093,41.694,42.141,41.768,42.125,41.704,42.089,41.772,41.811,41.412,41.388,40.155,40.913,39.419,41.107,38.357,40.909,37.884,40.985,37.683,41.137,37.508,41.028,37.051,41.175,36.876,41.337,36.652,41.383,36.4,41.25,36.131,41.463,36.056,41.687,35.954,41.735,35.567,41.631,35.278,41.715,35.09,41.921,35.122,42.013,35.211,42.02,34.945,42.098,34.724,41.944,33.339,42.02,32.581,41.827,31.4,41.316,31.325,41.129,31.012,41.073,30.273,41.213,30.02,41.133,29.162,41.218,28.22,41.523,27.984,41.819,28.058,41.884,28.016,42.027,27.75,42.252,27.795,42.307,27.729,42.41,27.444,42.461,27.521,42.557,27.651,42.555,27.727,42.708,27.901,42.701,27.947,43.171,27.916,43.174,27.873,43.196,27.814,43.181,27.705,43.196,27.666,43.184,27.648,43.19,27.794,43.204,27.832,43.194,27.858,43.21,27.922,43.19,27.997,43.215,28.152,43.404,28.466,43.36,28.609,43.54,28.594,43.799,28.58,43.792,28.569,43.801,28.552,43.799,28.546,43.807,28.54,43.801,28.528,43.807,28.668,43.987,28.652,44.169,28.657,44.17,28.657,44.157,28.708,44.108,28.621,44.259,28.69,44.324,28.628,44.325,29.095,44.754,29.617,44.865,29.453,44.955,29.457,44.97,29.428,44.971,29.402,44.948,29.389,44.944,29.37,44.947,29.366,44.952,29.382,44.985,29.364,44.962,29.361,44.959,29.353,44.957,29.313,44.969,29.184,45.048,29.186,45.104,29.134,45.106,29.13,45.093,29.158,45.094,29.162,45.088,29.158,45.08,29.137,45.07,29.128,45.07,28.371,45.307,28.334,45.325,28.28,45.394,28.276,45.443,28.213,45.469,28.281,45.448,28.354,45.321,28.718,45.225,28.792,45.243,28.759,45.264,28.749,45.285,28.76,45.292,28.796,45.297,28.779,45.325,28.799,45.338,28.822,45.337,28.848,45.318,28.879,45.316,28.944,45.282,28.947,45.314,28.985,45.356,29.231,45.446,29.289,45.428,29.326,45.45,29.37,45.439,29.479,45.456,29.509,45.453,29.524,45.448,29.54,45.436,29.558,45.406,29.607,45.392,29.62,45.408,29.656,45.432,29.719,45.448,29.716,45.458,29.723,45.471,29.709,45.458,29.688,45.467,29.682,45.454,29.66,45.476,29.65,45.451,29.604,45.481,29.736,45.622,29.834,45.645,29.768,45.612,29.767,45.61,29.77,45.611,29.855,45.653,30.133,45.818,29.824,45.682,29.789,45.734,29.883,45.787,29.936,45.738,29.981,45.904,30.063,45.798,30.123,45.914,30.142,45.822,30.473,46.074,30.147,46.282,30.141,46.382,30.234,46.391,30.281,46.298,30.323,46.372,30.51,46.141,30.474,46.077,30.68,46.314,30.627,46.34,30.64,46.384,30.678,46.32,30.753,46.375,30.742,46.536,31.011,46.596,31.0,46.658,31.019,46.599,31.348,46.6,31.486,46.691,31.488,46.729,31.433,46.757,31.427,46.801,31.505,46.727,31.588,46.837,31.503,46.663,31.425,46.626,31.888,46.638,31.874,46.791,31.994,46.92,31.903,46.943,31.956,47.003,31.892,46.988,31.856,47.005,31.855,47.065,31.898,47.095,31.832,47.118,31.825,47.185,31.759,47.206,31.747,47.214,31.74,47.232,31.741,47.245,31.755,47.267,31.777,47.281,31.745,47.291,31.727,47.31,31.779,47.283,31.75,47.214,31.838,47.189,31.912,47.094,31.857,47.028,31.987,47.012,31.939,46.939],"holes":[[29.652,45.336,29.326,45.446,28.943,45.277,28.785,45.329,28.74,45.229,29.196,45.103,29.314,44.971,29.617,44.882,29.72,45.164,29.677,45.152,29.681,45.16,29.669,45.157,29.657,45.157,29.611,45.159,29.538,45.183,29.511,45.176,29.534,45.185,29.697,45.165,29.63,45.206,29.728,45.217,29.652,45.336]]}],"names":{"ar":"البحر الأسود","de":"Schwarzes Meer","es":"mar Negro","fr":"mer Noire","he":"הים השחור","hu":"Fekete-tenger","it":"mar Nero","nl":"Zwarte Zee","pt":"Mar Negro","zh":"黑海"}},{"name":"Mediterranean Sea","bbox":[-2.193,35.578,12.424,43.92],"area":49.4891,"polygons":[{"outer":[4.628,43.844,4.653,43.812,4.652,43.788,4.63,43.681,4.601,43.649,4.655,43.618,4.717,43.548,4.846,43.335,4.862,43.356,4.885,43.354,4.91,43.373,4.912,43.377,4.833,43.375,4.85,43.452,5.059,43.406,5.021,43.556,5.23,43.466,5.13,43.398,4.997,43.402,5.031,43.333,5.315,43.36,5.327,43.355,5.342,43.341,5.328,43.344,5.351,43.334,5.355,43.321,5.361,43.323,5.373,43.295,5.337,43.214,5.687,43.179,5.83,43.049,5.947,43.067,5.896,43.082,5.924,43.123,6.16,43.027,6.206,43.116,6.648,43.166,6.697,43.266,6.583,43.278,6.732,43.411,6.856,43.411,6.959,43.54,7.138,43.545,7.157,43.653,7.527,43.785,9.337,42.864,9.301,42.676,9.124,42.733,8.725,42.585,8.656,42.417,8.535,42.372,8.692,42.269,8.538,42.237,8.557,42.146,8.748,42.049,8.593,41.965,8.607,41.894,8.804,41.895,8.66,41.738,8.919,41.686,8.784,41.564,9.125,41.443,9.173,41.176,8.543,40.826,8.284,40.849,8.238,40.953,8.176,40.938,8.223,40.886,8.132,40.732,8.207,40.685,8.149,40.583,8.295,40.593,8.374,40.49,8.493,40.088,8.377,40.036,8.399,39.901,8.437,39.858,8.508,39.926,8.46,39.943,8.504,39.99,8.517,39.991,8.526,39.927,8.502,39.919,8.49,39.902,8.57,39.865,8.504,39.729,8.564,39.694,8.444,39.758,8.471,39.601,8.373,39.375,8.438,39.288,8.371,39.208,8.474,39.111,8.469,39.054,8.54,39.068,8.571,39.041,8.641,38.865,12.424,37.801,10.907,37.054,10.759,36.903,10.608,36.883,10.479,36.739,10.496,36.722,10.292,36.765,10.354,36.873,10.201,36.984,10.218,37.11,10.136,37.142,10.281,37.18,9.889,37.272,9.821,37.226,9.927,37.177,9.816,37.141,9.76,37.222,9.882,37.272,9.866,37.332,9.744,37.346,9.215,37.24,8.782,36.952,8.426,36.895,8.227,36.952,7.911,36.844,7.769,36.864,7.792,36.97,7.622,36.962,7.388,37.084,7.17,37.081,7.262,36.983,6.931,36.881,6.402,37.085,6.263,37.025,6.212,36.909,5.688,36.819,5.5,36.678,5.302,36.637,4.788,36.891,3.893,36.923,3.489,36.768,2.895,36.805,2.567,36.594,2.348,36.645,1.084,36.494,0.338,36.191,-0.099,35.79,-0.386,35.908,-0.622,35.714,-0.824,35.77,-1.188,35.578,-2.193,36.721,-1.904,36.938,-1.677,37.353,-1.355,37.551,-0.92,37.554,-0.687,37.633,-0.745,37.785,-0.727,37.634,-0.859,37.723,-0.767,37.843,-0.762,37.792,-0.748,37.791,-0.633,38.147,-0.512,38.199,-0.512,38.324,-0.375,38.442,0.083,38.631,0.235,38.737,0.178,38.814,1.572,38.65,2.955,39.125,4.297,39.8,4.259,39.897,4.31,39.87,4.328,39.877,4.27,39.996,3.179,41.878,3.234,41.962,3.114,42.216,3.322,42.319,3.163,42.361,3.142,42.516,3.052,42.541,3.054,42.872,3.02,42.805,2.95,42.835,3.011,42.928,3.03,42.855,3.054,42.873,3.031,43.101,3.086,43.059,3.195,43.207,3.507,43.268,4.007,43.552,4.838,43.331,4.596,43.652,4.611,43.854,4.621,43.842,4.618,43.859,4.625,43.867,4.74,43.92,4.628,43.844]}],"names":{"ar":"البحر الأبيض المتوسط","de":"Mittelmeer","es":"Mediterráneo","fr":"mer Méditerranée","he":"הים התיכון","hu":"Földközi-tenger","it":"mar Mediterraneo","nl":"Middellandse Zee","pt":"Mar Mediterrâneo","zh":"地中海"}},{"name":"Andaman Sea","bbox":[92.39,5.534,99.139,17.754],"area":50.7418,"polygons":[{"outer":[96.868,17.666,96.898,17.683,96.905,17.679,96.883,17.65,96.881,17.574,96.879,17.564,96.87,17.542,96.901,17.375,96.888,17.326,96.97,17.32,97.032,17.268,96.932,17.178,96.946,17.18,96.954,17.175,97.01,17.098,96.996,17.146,97.009,17.149,97.016,17.026,97.079,16.933,97.147,17.133,97.21,17.141,97.215,17.095,97.236,17.114,97.255,17.098,97.259,17.111,97.276,17.118,97.258,17.135,97.262,17.145,97.273,17.153,97.257,17.15,97.252,17.153,97.236,17.205,97.249,17.272,97.281,17.121,97.254,17.093,97.212,17.094,97.207,17.131,97.148,17.104,97.168,16.948,97.267,16.849,97.211,16.866,97.279,16.653,97.306,16.645,97.319,16.711,97.348,16.718,97.335,16.724,97.339,16.74,97.349,16.72,97.372,16.5,97.43,16.537,97.514,16.547,97.53,16.544,97.547,16.533,97.568,16.538,97.618,16.523,97.572,16.709,97.594,16.829,97.609,16.842,97.611,16.875,97.645,16.939,97.598,16.992,97.594,17.022,97.589,17.036,97.588,17.044,97.633,17.316,97.663,17.348,97.667,17.379,97.677,17.392,97.76,17.408,97.663,17.345,97.667,17.313,97.663,17.301,97.668,17.276,97.657,17.226,97.674,17.204,97.609,17.079,97.619,17.018,97.602,17.0,97.646,16.962,97.658,16.917,97.601,16.676,97.63,16.647,97.633,16.627,97.647,16.621,97.652,16.616,97.663,16.559,97.678,16.569,97.701,16.574,97.739,16.565,97.793,16.514,97.826,16.575,97.908,16.628,98.007,16.602,97.911,16.626,97.815,16.513,97.808,16.511,97.79,16.512,97.765,16.524,97.75,16.54,97.74,16.56,97.694,16.562,97.663,16.476,97.62,16.502,97.628,16.396,97.61,16.36,97.637,16.309,97.637,16.224,97.577,16.1,97.598,16.099,97.643,16.05,97.556,16.081,97.718,15.857,97.729,15.499,97.797,15.408,97.729,15.265,97.768,15.194,97.802,15.233,97.809,15.236,97.813,15.235,97.816,15.225,97.826,15.228,97.835,15.217,97.838,15.236,97.837,15.217,97.835,15.216,97.832,15.217,97.822,15.226,97.826,15.214,97.816,15.205,97.808,15.205,97.806,15.193,97.785,15.178,97.77,15.019,97.815,15.015,97.822,14.754,97.887,14.72,97.88,14.853,97.926,14.875,98.03,14.638,97.936,14.725,97.873,14.677,98.095,14.201,98.122,13.552,98.175,13.546,98.146,13.567,98.173,13.796,98.215,13.885,98.173,14.037,98.177,14.05,98.184,14.055,98.173,14.098,98.182,14.111,98.192,14.115,98.186,14.163,98.19,14.188,98.242,13.909,98.234,13.716,98.275,13.769,98.262,13.71,98.378,13.682,98.28,13.689,98.479,13.412,98.486,13.252,98.512,13.244,98.529,13.228,98.536,13.23,98.558,13.198,98.574,13.253,98.585,13.258,98.59,13.256,98.645,12.771,98.718,12.811,98.713,12.674,98.646,12.661,98.656,12.64,98.666,12.642,98.676,12.639,98.694,12.651,98.698,12.652,98.705,12.649,98.69,12.592,98.653,12.638,98.629,12.58,98.674,12.583,98.677,12.58,98.68,12.571,98.679,12.559,98.618,12.549,98.675,12.529,98.684,12.541,98.717,12.554,98.728,12.58,98.769,12.62,98.785,12.626,98.8,12.587,98.679,12.521,98.801,12.513,98.758,12.504,98.784,12.405,98.784,12.396,98.781,12.39,98.764,12.391,98.782,12.379,98.786,12.359,98.792,12.363,98.795,12.362,98.798,12.353,98.794,12.348,98.811,12.344,98.812,12.342,98.805,12.335,98.813,12.335,98.815,12.334,98.813,12.326,98.82,12.328,98.824,12.328,98.826,12.326,98.826,12.323,98.819,12.315,98.812,12.313,98.815,12.299,98.841,12.285,98.872,12.22,98.888,12.139,98.906,12.124,99.058,12.119,99.036,12.289,99.056,12.296,99.083,12.297,99.109,12.277,99.106,12.292,99.11,12.297,99.137,12.284,99.122,12.329,99.12,12.343,99.12,12.367,99.1,12.389,99.099,12.395,99.104,12.397,99.121,12.366,99.139,12.284,99.109,12.275,99.083,12.295,99.037,12.288,99.086,12.198,99.064,12.189,99.066,12.129,99.056,12.112,99.033,12.105,99.036,12.089,98.889,12.127,98.877,12.145,98.861,12.201,98.773,12.135,98.76,12.139,98.73,12.193,98.71,12.204,98.67,12.181,98.642,12.178,98.697,12.171,98.734,12.105,98.691,12.155,98.616,12.133,98.661,12.049,98.734,12.075,98.803,12.018,98.684,12.028,98.799,11.977,98.735,11.954,98.817,11.853,98.788,11.729,98.888,11.714,98.916,11.669,98.929,11.611,99.001,11.595,99.025,11.572,99.03,11.547,99.022,11.519,99.0,11.504,98.991,11.504,98.999,11.467,98.996,11.435,98.99,11.426,98.989,11.503,99.024,11.57,98.912,11.565,98.882,11.698,98.849,11.705,98.846,11.695,98.84,11.69,98.838,11.683,98.813,11.68,98.807,11.659,98.825,11.631,98.827,11.608,98.823,11.602,98.793,11.682,98.726,11.658,98.746,11.577,98.711,11.52,98.75,11.54,98.756,11.537,98.768,11.542,98.779,11.541,98.791,11.547,98.794,11.545,98.748,11.476,98.784,11.452,98.778,11.427,98.793,11.423,98.807,11.409,98.822,11.387,98.829,11.366,98.718,11.458,98.78,11.269,98.696,11.133,98.649,11.131,98.72,11.115,98.745,11.032,98.679,10.998,98.787,10.999,98.713,10.99,98.726,10.904,98.664,10.917,98.726,10.886,98.692,10.844,98.642,10.923,98.668,10.872,98.629,10.9,98.573,10.877,98.623,10.873,98.652,10.824,98.593,10.848,98.562,10.703,98.521,10.758,98.562,10.686,98.547,10.651,98.489,10.747,98.448,10.688,98.548,9.976,98.624,10.241,98.696,10.22,98.67,10.236,98.648,10.321,98.698,10.232,98.717,10.278,98.715,10.284,98.703,10.279,98.7,10.28,98.697,10.289,98.699,10.297,98.773,10.423,98.69,10.181,98.704,10.184,98.735,10.162,98.752,10.163,98.757,10.157,98.749,10.147,98.698,10.18,98.594,9.878,98.516,9.854,98.578,9.813,98.507,9.783,98.565,9.757,98.549,9.663,98.485,9.686,98.498,9.652,98.515,9.662,98.52,9.656,98.535,9.658,98.543,9.652,98.525,9.597,98.509,9.625,98.463,9.628,98.576,9.559,98.467,9.453,98.456,9.563,98.344,9.243,98.38,9.249,98.387,9.181,98.316,9.199,98.401,9.085,98.318,9.034,98.406,9.04,98.391,8.911,98.323,8.971,98.216,8.736,98.235,8.721,98.247,8.653,98.223,8.569,98.274,8.509,98.226,8.539,98.223,8.524,98.234,8.508,98.237,8.471,98.217,8.528,98.219,8.588,98.206,8.551,98.305,8.193,98.309,7.759,95.359,5.572,95.199,5.534,95.133,5.687,93.896,6.842,93.948,7.002,93.847,7.244,93.686,7.19,93.662,7.103,93.088,8.267,93.205,8.216,93.129,8.355,93.085,8.276,92.715,9.142,92.395,10.531,92.531,10.518,92.597,10.681,92.521,10.897,92.39,10.781,92.52,11.829,92.711,11.477,92.756,11.666,92.695,11.623,92.666,11.654,92.756,11.702,92.789,11.901,92.747,11.932,92.718,11.824,92.71,11.815,92.692,11.809,92.68,11.819,92.739,12.041,92.784,12.047,92.735,12.065,92.699,12.239,92.611,11.871,92.563,11.946,92.53,11.896,92.722,12.783,92.788,12.674,92.685,12.608,92.711,12.31,92.895,12.32,92.794,12.438,92.791,12.47,92.913,12.406,92.968,12.496,92.922,12.912,92.898,12.927,92.896,12.899,92.874,12.881,92.846,12.875,92.792,12.89,92.782,12.808,92.733,12.832,92.793,13.058,92.843,12.876,92.939,12.971,92.888,13.046,92.893,13.095,92.937,13.084,92.971,13.013,93.035,13.081,93.032,13.178,93.066,13.273,93.012,13.277,92.951,13.344,93.041,13.367,93.065,13.303,93.09,13.335,93.055,13.382,93.049,13.374,93.029,13.373,93.045,13.529,92.92,13.538,93.032,13.629,92.99,13.667,94.238,15.966,94.25,15.956,94.276,15.996,94.432,16.08,94.637,16.395,94.643,16.399,94.651,16.399,94.676,16.389,94.663,16.531,94.693,16.618,94.699,16.625,94.732,16.635,94.711,16.649,94.71,16.66,94.712,16.699,94.74,16.731,94.715,16.741,94.71,16.761,94.729,16.96,94.862,17.14,94.922,17.205,94.731,16.96,94.743,16.73,94.714,16.653,94.736,16.636,94.672,16.526,94.71,16.389,94.673,16.132,94.578,15.934,94.686,15.996,94.684,16.013,94.69,16.028,94.798,16.213,94.83,16.222,94.84,16.249,94.866,16.253,94.877,16.286,94.943,16.334,94.968,16.494,95.035,16.558,94.948,16.333,94.834,16.217,94.802,16.209,94.79,16.089,94.803,16.126,94.828,16.125,94.836,16.116,94.871,16.194,95.007,16.265,95.002,16.368,95.038,16.453,95.074,16.457,95.049,16.477,95.051,16.493,95.093,16.562,95.128,16.581,95.111,16.595,95.11,16.613,95.155,16.701,95.17,16.705,95.178,16.69,95.182,16.74,95.194,16.756,95.184,16.695,95.112,16.611,95.133,16.583,95.124,16.561,95.156,16.565,95.155,16.584,95.159,16.605,95.267,16.701,95.273,16.703,95.285,16.703,95.301,16.686,95.303,16.674,95.337,16.674,95.316,16.708,95.311,16.752,95.497,16.863,95.555,16.873,95.559,16.866,95.518,16.982,95.527,17.024,95.544,17.033,95.592,17.046,95.54,16.959,95.596,16.926,95.62,16.851,95.667,16.83,95.67,16.797,95.648,16.772,95.663,16.743,95.661,16.724,95.708,16.732,95.716,16.72,95.725,16.735,95.745,16.731,95.781,16.685,95.781,16.628,95.932,16.722,95.974,16.716,96.096,16.764,96.117,16.769,96.125,16.762,96.114,16.782,96.113,16.82,96.017,16.845,95.985,16.869,96.112,16.826,95.991,17.03,95.868,16.993,95.864,17.002,95.849,17.006,95.839,17.018,95.826,17.018,95.821,17.022,95.969,17.029,95.952,17.036,95.946,17.041,95.933,17.077,95.932,17.089,95.949,17.095,95.934,17.105,95.931,17.121,95.934,17.128,95.956,17.128,95.967,17.15,95.956,17.156,95.95,17.173,95.944,17.171,95.939,17.172,95.908,17.193,95.894,17.243,95.784,17.17,95.871,17.247,95.895,17.244,95.905,17.236,95.967,17.152,95.968,17.149,95.96,17.131,95.957,17.126,95.933,17.122,95.951,17.095,95.934,17.088,95.949,17.041,96.002,17.033,96.051,16.969,96.116,16.827,96.12,16.782,96.134,16.773,96.302,16.833,96.305,16.88,96.365,16.933,96.361,16.966,96.381,16.98,96.403,16.981,96.404,16.993,96.38,16.997,96.374,17.007,96.391,17.039,96.403,17.053,96.408,17.054,96.415,17.046,96.423,17.05,96.42,17.061,96.427,17.066,96.422,17.076,96.424,17.082,96.428,17.066,96.423,17.048,96.377,17.003,96.406,16.996,96.406,16.981,96.31,16.831,96.216,16.77,96.26,16.605,96.331,16.634,96.368,16.633,96.408,16.614,96.418,16.621,96.445,16.621,96.466,16.614,96.459,16.622,96.463,16.661,96.496,16.655,96.467,16.612,96.283,16.597,96.412,16.477,96.653,16.554,96.757,16.877,96.813,16.877,96.792,16.99,96.839,16.952,96.836,17.087,96.886,17.079,96.832,17.184,97.005,17.276,96.88,17.319,96.837,17.559,96.847,17.581,96.875,17.584,96.875,17.651,96.903,17.678,96.872,17.664,96.865,17.666,96.86,17.723,96.864,17.733,96.886,17.754,96.868,17.666],"holes":[[95.738,16.119,95.701,16.392,95.393,16.299,95.284,15.72,95.51,15.782,95.738,16.119],[95.482,16.74,95.278,16.455,95.391,16.302,95.633,16.383,95.582,16.432,95.714,16.393,95.706,16.486,95.744,16.495,95.704,16.523,95.711,16.573,95.745,16.494,95.708,16.487,95.729,16.449,95.881,16.544,95.852,16.66,95.776,16.625,95.739,16.73,95.657,16.723,95.59,16.922,95.482,16.74],[95.859,16.646,95.826,16.477,95.886,16.417,96.116,16.318,96.329,16.424,96.174,16.757,95.859,16.646]]}],"names":{"ar":"بحر أندامان","de":"Andamanensee","es":"mar de Andamán","fr":"mer d'Andaman","he":"ים אנדמן","hu":"Andamán-tenger","it":"Mare delle Andamane","nl":"Andamanse Zee","pt":"Mar de Andamão","zh":"安达曼海"}},{"name":"Banda Sea","bbox":[120.469,-8.865,133.151,-0.845],"area":56.111,"polygons":[{"outer":[123.112,-0.845,123.142,-0.953,123.326,-1.049,123.323,-1.292,123.242,-1.397,123.192,-1.151,123.146,-1.21,122.905,-1.182,122.785,-1.457,122.877,-1.599,122.999,-1.516,123.149,-1.297,123.184,-1.514,123.098,-1.592,123.155,-1.64,123.228,-1.598,123.255,-1.639,123.297,-1.412,123.355,-1.524,123.472,-1.475,123.498,-1.513,123.474,-1.681,123.512,-1.725,124.388,-1.671,124.337,-1.89,124.404,-2.033,124.861,-1.892,125.009,-1.943,125.043,-1.887,125.316,-1.893,125.316,-1.838,125.434,-1.886,125.421,-1.95,125.842,-1.895,125.917,-1.939,125.88,-2.21,126.05,-2.477,126.091,-3.112,126.017,-3.175,126.01,-3.367,126.182,-3.614,126.685,-3.861,127.22,-3.663,127.269,-3.391,127.076,-3.374,127.035,-3.265,127.123,-3.275,127.06,-3.183,126.716,-3.056,128.161,-2.867,128.071,-2.972,128.114,-3.064,127.837,-3.158,127.943,-3.352,127.921,-3.569,128.091,-3.132,128.184,-3.065,128.179,-3.228,128.269,-3.204,128.46,-3.459,128.676,-3.432,128.887,-3.204,128.966,-3.227,128.933,-3.356,129.532,-3.47,129.522,-3.298,129.928,-3.338,130.017,-3.483,130.415,-3.631,130.619,-3.792,131.324,-4.032,131.631,-4.451,131.74,-4.788,131.759,-4.742,133.151,-5.274,132.844,-6.007,131.91,-7.111,131.523,-7.154,131.452,-7.346,131.227,-7.493,131.156,-7.672,131.272,-7.702,131.252,-7.734,131.106,-7.706,131.155,-7.772,131.082,-7.848,131.103,-7.996,131.227,-8.008,131.082,-8.012,130.759,-8.342,128.234,-8.267,128.194,-8.201,128.046,-8.259,128.119,-8.148,127.787,-8.104,127.793,-8.183,127.673,-8.16,127.296,-8.392,127.001,-8.322,126.566,-8.483,126.416,-8.414,126.237,-8.503,125.85,-8.474,125.211,-8.606,125.005,-8.865,125.001,-8.357,125.14,-8.319,125.095,-8.147,124.609,-8.197,124.526,-8.122,124.398,-8.232,124.325,-8.232,124.28,-8.175,124.11,-8.388,124.073,-8.301,123.782,-8.17,123.691,-8.257,123.605,-8.22,123.635,-8.292,123.551,-8.369,123.495,-8.36,123.539,-8.235,123.344,-8.303,123.147,-8.231,123.021,-8.305,122.968,-8.129,121.827,-7.332,121.168,-7.353,120.915,-7.283,120.735,-7.134,120.786,-7.06,120.653,-7.015,120.484,-6.498,120.568,-6.056,120.469,-5.616,121.852,-5.089,121.883,-5.153,121.795,-5.277,121.948,-5.494,122.034,-5.473,122.035,-5.242,122.071,-5.203,121.968,-5.063,121.867,-5.074,121.867,-4.868,122.007,-4.899,122.12,-4.819,122.019,-4.648,122.106,-4.508,122.588,-4.396,122.744,-4.51,122.663,-4.335,122.834,-4.449,122.907,-4.324,122.854,-4.071,122.762,-4.066,122.835,-4.153,122.677,-4.157,122.651,-4.083,122.659,-4.009,122.531,-3.97,122.666,-3.898,122.525,-3.894,122.443,-3.75,122.195,-3.606,122.302,-3.468,122.24,-3.39,122.328,-3.374,122.381,-3.463,122.376,-3.384,122.277,-3.313,122.326,-3.234,122.427,-3.279,122.471,-3.15,122.252,-3.027,122.315,-2.911,122.01,-2.719,121.825,-2.294,121.552,-2.169,121.409,-1.906,121.343,-2.004,121.299,-1.798,121.445,-1.805,121.449,-1.867,121.554,-1.956,121.676,-1.946,121.863,-1.672,122.391,-1.495,122.803,-0.942,123.112,-0.845],"holes":[[123.073,-4.367,122.867,-4.526,122.832,-4.921,122.737,-5.053,122.801,-5.025,122.737,-5.174,122.812,-5.219,122.556,-5.501,122.643,-5.699,122.713,-5.62,122.801,-5.698,122.894,-5.566,122.872,-5.456,123.13,-5.4,123.222,-5.292,123.033,-5.136,122.92,-5.18,123.025,-4.74,123.119,-4.793,123.113,-4.691,123.214,-4.858,123.209,-4.613,123.046,-4.424,123.073,-4.367],[122.529,-5.442,122.641,-5.354,122.578,-5.25,122.612,-5.097,122.764,-4.965,122.682,-4.612,122.374,-4.741,122.318,-4.834,122.411,-5.09,122.319,-5.117,122.268,-5.382,122.446,-5.315,122.383,-5.339,122.465,-5.403,122.538,-5.261,122.529,-5.442],[125.806,-7.978,126.07,-7.888,126.468,-7.975,126.561,-7.816,126.842,-7.733,126.625,-7.556,126.185,-7.727,125.973,-7.653,125.807,-7.861,125.806,-7.978],[128.093,-3.794,128.299,-3.695,128.261,-3.618,128.354,-3.633,128.341,-3.503,127.92,-3.682,127.995,-3.783,128.24,-3.631,128.093,-3.794]]}],"names":{"ar":"بحر باندا","de":"Bandasee","es":"mar de Banda","fr":"mer de Banda","he":"ים בנדה","hu":"Banda-tenger","it":"Mar di Banda","nl":"Bandazee","pt":"Mar de Banda","zh":"班达海"}},{"name":"Solomon Sea","bbox":[146.948,-11.882,162.348,-4.833],"area":60.5374,"polygons":[{"outer":[154.278,-11.403,153.947,-11.731,153.692,-11.513,153.358,-11.406,153.021,-11.212,153.036,-11.15,152.996,-11.2,150.975,-11.103,150.565,-10.626,150.608,-10.582,150.696,-10.561,150.658,-10.484,150.341,-10.39,150.409,-10.298,150.627,-10.352,150.67,-10.325,150.718,-10.316,150.734,-10.297,150.761,-10.301,150.874,-10.225,150.612,-10.285,150.567,-10.248,150.587,-10.209,150.296,-10.187,149.861,-10.018,149.714,-9.809,150.057,-9.684,149.781,-9.59,149.775,-9.618,149.455,-9.613,149.229,-9.49,149.177,-9.361,149.329,-9.135,149.293,-9.014,149.139,-8.989,149.087,-9.062,148.747,-9.118,148.597,-9.08,148.437,-8.68,148.249,-8.592,148.233,-8.484,148.221,-8.491,148.201,-8.487,148.195,-8.471,148.193,-8.476,148.136,-8.485,148.133,-8.481,148.14,-8.472,148.135,-8.475,148.122,-8.474,148.117,-8.479,148.079,-8.476,148.081,-8.485,148.076,-8.487,148.073,-8.484,148.071,-8.466,148.069,-8.475,148.039,-8.479,148.036,-8.468,148.07,-8.464,148.075,-8.469,148.076,-8.486,148.077,-8.476,148.099,-8.472,148.195,-8.469,148.22,-8.49,148.234,-8.48,148.131,-8.063,148.027,-8.086,147.867,-7.94,147.715,-7.942,147.466,-7.59,147.179,-7.463,147.07,-7.014,147.04,-7.059,146.948,-6.975,146.965,-6.742,147.577,-6.747,147.864,-6.63,147.819,-6.588,147.811,-6.325,147.347,-5.923,148.021,-5.833,148.084,-5.635,147.844,-5.466,148.415,-5.443,148.319,-5.565,148.346,-5.73,148.749,-5.863,148.847,-5.994,148.866,-5.977,149.01,-6.043,149.033,-6.016,149.005,-6.072,149.03,-6.069,149.053,-6.092,149.038,-6.161,149.332,-6.054,149.47,-6.093,149.607,-6.291,149.954,-6.253,150.003,-6.267,149.999,-6.317,150.167,-6.217,150.226,-6.281,150.324,-6.258,150.329,-6.261,150.33,-6.267,150.32,-6.275,150.326,-6.28,150.35,-6.257,150.381,-6.279,150.606,-6.12,150.747,-6.126,150.786,-6.002,151.01,-6.034,151.187,-5.956,151.508,-5.681,151.471,-5.522,151.727,-5.516,151.812,-5.587,152.084,-5.449,152.133,-5.33,151.949,-5.141,151.953,-4.988,152.22,-4.971,152.334,-4.833,154.617,-5.001,154.516,-5.125,154.554,-5.098,154.57,-5.109,154.618,-5.461,154.681,-5.414,154.773,-5.546,154.695,-5.746,154.741,-5.935,154.97,-6.197,155.207,-6.308,155.23,-6.446,155.157,-6.523,155.34,-6.729,155.706,-6.881,155.914,-6.717,155.916,-6.801,156.388,-6.662,157.009,-7.3,157.045,-7.272,157.084,-7.292,157.098,-7.362,157.121,-7.37,157.128,-7.342,157.193,-7.337,157.268,-7.349,157.261,-7.367,157.282,-7.379,157.326,-7.339,157.442,-7.429,157.52,-7.371,157.559,-7.39,157.551,-7.317,157.755,-7.478,158.229,-7.466,158.36,-7.552,158.371,-7.59,158.337,-7.563,158.33,-7.577,158.311,-7.567,158.297,-7.571,158.365,-7.6,158.353,-7.628,158.42,-7.604,158.513,-7.653,158.469,-7.591,158.491,-7.593,158.555,-7.65,158.571,-7.679,158.567,-7.705,158.589,-7.702,158.586,-7.749,158.677,-7.789,158.945,-8.033,159.086,-8.084,159.091,-8.116,159.255,-8.225,159.294,-8.216,159.33,-8.269,159.708,-8.412,159.873,-8.569,160.564,-8.357,160.727,-8.557,160.651,-8.608,160.67,-8.747,160.752,-8.86,160.844,-9.133,161.199,-9.384,161.385,-9.598,161.41,-9.558,161.375,-9.493,161.373,-9.483,161.379,-9.473,161.413,-9.557,161.401,-9.618,161.566,-9.729,161.278,-10.212,161.273,-10.339,161.49,-10.376,161.469,-10.427,161.522,-10.462,161.525,-10.521,161.513,-10.571,161.57,-10.558,161.76,-10.741,162.062,-10.832,162.348,-10.846,160.658,-11.831,160.094,-11.488,159.963,-11.486,160.033,-11.618,160.3,-11.664,160.561,-11.882,154.278,-11.403],"holes":[[151.212,-10.17,151.274,-9.917,151.109,-10.04,150.757,-9.712,150.89,-9.991,150.985,-9.998,150.957,-10.1,151.212,-10.17],[150.847,-9.711,150.939,-9.657,150.798,-9.414,150.426,-9.37,150.524,-9.529,150.436,-9.631,150.844,-9.657,150.847,-9.711],[150.31,-9.522,150.387,-9.387,150.31,-9.24,150.195,-9.199,150.105,-9.371,150.31,-9.522],[152.653,-9.197,152.961,-9.257,152.908,-9.195,153.024,-9.161,152.793,-9.004,152.45,-8.942,152.399,-8.98,152.427,-9.087,152.447,-8.963,152.51,-8.995,152.471,-9.044,152.7,-9.088,152.653,-9.197],[159.699,-9.25,159.597,-9.305,159.586,-9.524,159.818,-9.799,160.322,-9.812,160.626,-9.947,160.83,-9.862,160.794,-9.747,160.384,-9.422,159.908,-9.426,159.699,-9.25],[157.483,-7.957,157.368,-8.029,157.311,-8.19,157.214,-8.192,157.21,-8.318,157.545,-8.254,157.624,-8.373,157.559,-8.359,157.568,-8.428,157.745,-8.486,157.801,-8.637,157.806,-8.541,157.88,-8.579,157.885,-8.427,157.769,-8.236,157.619,-8.235,157.599,-8.024,157.483,-7.957]]}],"names":{"ar":"بحر سليمان","de":"Salomonensee","es":"mar de Salomón","fr":"mer des Salomon","he":"ים שלמה","hu":"Salamon-tenger","it":"Mare delle Salomone","nl":"Salomonzee","pt":"Mar de Salomão","zh":"所罗门海"}},{"name":"Gulf of Guinea","bbox":[-7.725,-0.936,10.181,6.623],"area":61.1114,"polygons":[{"outer":[8.975,-0.936,8.904,-0.804,8.918,-0.807,8.905,-0.789,8.916,-0.771,8.853,-0.846,8.713,-0.62,6.532,0.027,6.746,0.231,6.689,0.405,6.489,0.311,6.512,0.033,-7.725,4.366,-7.533,4.364,-7.555,4.375,-7.57,4.399,-7.561,4.42,-7.54,4.436,-7.56,4.447,-7.568,4.458,-7.574,4.584,-7.572,4.59,-7.558,4.594,-7.548,4.611,-7.577,4.654,-7.571,4.664,-7.56,4.667,-7.567,4.677,-7.571,4.687,-7.568,4.69,-7.558,4.694,-7.563,4.718,-7.559,4.739,-7.565,4.75,-7.564,4.763,-7.568,4.768,-7.561,4.783,-7.582,4.789,-7.594,4.8,-7.594,4.821,-7.583,4.829,-7.59,4.841,-7.593,4.852,-7.56,4.783,-7.556,4.696,-7.559,4.69,-7.57,4.686,-7.558,4.668,-7.575,4.654,-7.547,4.612,-7.558,4.591,-7.573,4.587,-7.538,4.437,-7.564,4.398,-7.482,4.362,-6.914,4.664,-6.085,4.943,-6.211,5.056,-6.27,5.225,-6.265,5.238,-6.24,5.267,-6.281,5.288,-6.29,5.32,-6.306,5.321,-6.342,5.344,-6.346,5.349,-6.346,5.366,-6.334,5.38,-6.351,5.388,-6.352,5.393,-6.361,5.383,-6.374,5.39,-6.383,5.407,-6.38,5.436,-6.395,5.447,-6.398,5.452,-6.407,5.449,-6.415,5.456,-6.43,5.485,-6.431,5.492,-6.429,5.502,-6.452,5.504,-6.476,5.524,-6.486,5.545,-6.45,5.507,-6.446,5.505,-6.439,5.509,-6.433,5.509,-6.431,5.507,-6.41,5.453,-6.398,5.454,-6.376,5.436,-6.375,5.396,-6.351,5.395,-6.332,5.383,-6.341,5.346,-6.288,5.321,-6.239,5.268,-6.207,5.058,-6.076,4.989,-6.068,4.974,-6.076,4.961,-5.61,5.115,-5.543,5.085,-4.759,5.154,-4.002,5.246,-4.129,5.266,-4.197,5.298,-4.223,5.275,-4.286,5.256,-4.563,5.23,-4.57,5.196,-4.657,5.22,-4.67,5.193,-4.7,5.182,-4.718,5.185,-4.729,5.189,-4.744,5.173,-4.798,5.166,-4.752,5.28,-4.666,5.322,-4.714,5.245,-4.567,5.313,-4.521,5.261,-4.46,5.316,-4.041,5.304,-4.043,5.358,-3.724,5.285,-3.808,5.383,-3.717,5.301,-3.73,5.251,-3.716,5.231,-3.721,5.218,-3.704,5.233,-3.677,5.231,-3.665,5.256,-3.659,5.26,-3.644,5.259,-3.633,5.25,-3.626,5.252,-3.619,5.298,-3.61,5.265,-3.629,5.249,-3.662,5.258,-3.678,5.228,-3.727,5.214,-3.724,5.196,-3.872,5.303,-4.02,5.266,-4.003,5.249,-3.715,5.19,-3.287,5.127,-3.352,5.19,-3.172,5.373,-3.13,5.336,-3.218,5.212,-3.144,5.149,-2.97,5.115,-2.962,5.201,-2.866,5.184,-2.85,5.144,-2.951,5.085,-3.276,5.124,-2.352,4.932,-2.055,4.74,-1.735,4.881,-1.618,5.088,-1.617,5.025,-0.807,5.216,-0.366,5.494,0.007,5.623,0.072,5.725,0.52,5.785,0.436,5.795,0.417,5.849,0.559,5.845,0.495,5.792,0.662,5.772,0.647,5.777,0.623,5.797,0.62,5.853,0.646,5.871,0.639,5.91,0.634,5.914,0.626,5.926,0.626,5.94,0.532,6.015,0.514,6.049,0.368,6.039,0.297,6.057,0.266,6.097,0.179,6.093,0.139,6.106,0.128,6.122,0.264,6.106,0.364,6.046,0.509,6.058,0.58,6.008,0.642,5.926,0.653,5.887,0.643,5.834,0.678,5.795,0.68,5.783,0.705,5.783,0.709,5.775,0.687,5.774,0.91,5.784,0.969,5.825,0.994,5.882,0.915,5.809,0.899,5.826,0.878,5.821,0.883,5.828,0.865,5.83,0.861,5.833,0.858,5.839,0.922,5.865,0.857,5.956,0.937,6.039,1.004,6.025,1.024,5.97,1.439,6.192,2.428,6.341,2.353,6.42,2.43,6.512,2.538,6.437,2.586,6.48,2.674,6.445,2.714,6.46,2.799,6.461,2.834,6.443,2.888,6.562,2.882,6.411,3.111,6.427,3.112,6.415,3.199,6.418,3.212,6.417,3.225,6.409,3.221,6.435,3.187,6.433,3.171,6.441,3.162,6.428,3.149,6.42,3.114,6.427,3.204,6.454,3.243,6.427,3.284,6.442,3.286,6.431,3.304,6.439,3.322,6.433,3.33,6.437,3.356,6.439,3.393,6.434,3.371,6.465,3.402,6.564,3.466,6.611,3.527,6.529,3.718,6.623,3.908,6.614,3.947,6.578,4.089,6.564,4.096,6.547,4.206,6.585,4.154,6.497,4.23,6.446,4.216,6.425,4.255,6.421,4.276,6.406,4.305,6.441,4.376,6.438,4.395,6.426,4.421,6.435,4.461,6.427,4.33,6.416,4.434,6.406,4.579,6.376,4.615,6.36,4.631,6.339,4.643,6.341,4.655,6.336,4.674,6.293,4.693,6.298,4.711,6.29,4.719,6.278,4.73,6.283,4.736,6.277,4.717,6.277,4.691,6.297,4.673,6.292,4.534,6.383,4.27,6.402,4.251,6.416,4.161,6.421,4.143,6.434,4.104,6.424,3.899,6.514,4.089,6.492,4.082,6.544,4.034,6.54,3.999,6.57,3.933,6.58,3.874,6.612,3.537,6.459,3.482,6.47,3.45,6.437,3.415,6.437,3.404,6.441,3.407,6.42,3.402,6.403,3.404,6.396,3.89,6.438,4.413,6.358,4.78,6.1,5.06,5.77,5.209,5.907,5.267,5.905,5.263,5.937,5.249,5.952,5.241,5.981,5.242,5.991,5.262,6.054,5.273,5.913,5.383,5.99,5.453,6.0,5.647,5.944,5.694,5.903,5.374,5.979,5.367,5.961,5.383,5.957,5.389,5.968,5.395,5.968,5.422,5.922,5.306,5.922,5.375,5.851,5.325,5.813,5.494,5.846,5.323,5.721,5.454,5.755,5.511,5.708,5.515,5.72,5.498,5.741,5.496,5.752,5.507,5.768,5.537,5.758,5.507,5.782,5.518,5.813,5.53,5.816,5.555,5.683,5.508,5.654,5.467,5.664,5.459,5.684,5.436,5.656,5.539,5.624,5.537,5.642,5.58,5.65,5.591,5.701,5.603,5.713,5.644,5.671,5.593,5.672,5.578,5.639,5.538,5.638,5.541,5.607,5.565,5.603,5.578,5.582,5.578,5.599,5.58,5.605,5.595,5.605,5.6,5.592,5.61,5.625,5.635,5.634,5.642,5.627,5.639,5.617,5.644,5.616,5.661,5.649,5.672,5.657,5.684,5.656,5.661,5.647,5.664,5.636,5.649,5.617,5.639,5.615,5.636,5.618,5.635,5.609,5.611,5.624,5.602,5.591,5.595,5.592,5.593,5.604,5.58,5.603,5.577,5.58,5.508,5.589,5.516,5.571,5.497,5.569,5.493,5.541,5.46,5.481,5.455,5.477,5.444,5.477,5.443,5.464,5.432,5.462,5.461,5.441,5.419,5.426,5.474,5.406,5.52,5.523,5.57,5.521,5.546,5.543,5.58,5.518,5.618,5.55,5.649,5.533,5.659,5.564,5.645,5.566,5.641,5.568,5.636,5.575,5.63,5.601,5.635,5.609,5.795,5.529,5.777,5.487,5.657,5.434,5.692,5.42,5.702,5.41,5.703,5.395,5.709,5.392,5.719,5.386,5.729,5.329,5.756,5.348,5.776,5.342,5.787,5.356,5.795,5.361,5.808,5.358,5.818,5.347,5.818,5.336,5.797,5.312,5.83,5.309,5.848,5.287,5.85,5.231,5.829,5.205,5.793,5.202,5.827,5.193,5.837,5.178,5.871,5.193,5.899,5.196,6.052,5.097,6.065,5.137,6.079,5.151,6.196,5.164,6.2,5.176,6.193,5.217,6.195,5.23,6.331,5.318,6.401,5.311,6.43,5.332,6.449,5.334,6.457,5.331,6.403,5.253,6.31,5.189,6.311,5.168,6.356,5.176,6.366,5.159,6.305,5.085,6.287,5.074,6.232,5.078,6.247,5.03,6.286,5.029,6.294,5.023,6.295,5.014,6.259,4.713,6.237,4.627,6.212,4.682,6.203,4.623,6.198,4.603,6.192,4.6,6.199,4.592,6.2,4.588,6.199,4.58,6.205,4.579,6.208,4.575,6.203,4.567,6.199,4.564,6.202,4.555,6.199,4.545,6.195,4.539,6.21,4.521,6.204,4.492,6.212,4.469,6.207,4.458,6.228,4.45,6.226,4.435,6.21,4.43,6.246,4.4,6.233,4.496,6.235,4.512,6.263,4.559,6.26,4.567,6.252,4.568,6.247,4.575,6.246,4.578,6.247,4.588,6.253,4.569,6.26,4.588,6.285,4.572,6.278,4.602,6.279,4.611,6.287,4.614,6.292,4.577,6.239,4.499,6.264,4.51,6.263,4.385,6.28,4.51,6.354,4.489,6.33,4.534,6.324,4.557,6.328,4.603,6.402,4.335,6.473,4.493,6.444,4.316,6.595,4.333,6.519,4.329,6.501,4.336,6.493,4.344,6.584,4.344,6.565,4.36,6.562,4.392,6.547,4.392,6.551,4.42,6.527,4.44,6.53,4.466,6.506,4.474,6.497,4.483,6.47,4.584,6.502,4.48,6.546,4.478,6.559,4.463,6.564,4.472,6.579,4.472,6.514,4.529,6.563,4.549,6.55,4.554,6.539,4.567,6.532,4.599,6.536,4.616,6.55,4.625,6.55,4.628,6.547,4.632,6.547,4.634,6.554,4.638,6.548,4.643,6.553,4.657,6.565,4.548,6.544,4.514,6.58,4.496,6.604,4.464,6.595,4.354,6.704,4.341,6.637,4.437,6.662,4.49,6.595,4.542,6.598,4.564,6.591,4.556,6.587,4.556,6.578,4.559,6.577,4.56,6.577,4.563,6.58,4.57,6.571,4.575,6.57,4.578,6.599,4.567,6.59,4.584,6.596,4.593,6.589,4.596,6.587,4.618,6.593,4.626,6.586,4.633,6.585,4.642,6.603,4.653,6.595,4.656,6.578,4.67,6.56,4.691,6.562,4.698,6.571,4.699,6.575,4.711,6.588,4.717,6.594,4.727,6.569,4.738,6.563,4.745,6.561,4.749,6.562,4.762,6.564,4.765,6.571,4.771,6.58,4.774,6.581,4.776,6.579,4.779,6.579,4.781,6.586,4.783,6.574,4.796,6.575,4.805,6.578,4.808,6.582,4.809,6.586,4.814,6.595,4.811,6.588,4.818,6.574,4.85,6.574,4.857,6.579,4.869,6.606,4.809,6.606,4.774,6.587,4.814,6.594,4.725,6.588,4.716,6.575,4.709,6.571,4.696,6.561,4.694,6.65,4.628,6.68,4.54,6.678,4.528,6.66,4.506,6.694,4.422,6.697,4.659,6.764,4.651,6.697,4.496,6.773,4.457,6.709,4.444,6.782,4.415,6.735,4.35,6.872,4.362,6.699,4.866,6.753,4.807,6.826,4.826,6.92,4.789,6.896,4.882,6.925,4.843,6.922,4.8,7.007,4.774,7.007,4.737,7.011,4.737,7.011,4.748,7.014,4.752,7.027,4.758,7.033,4.755,7.027,4.715,7.055,4.765,7.033,4.768,7.028,4.771,7.026,4.777,7.027,4.785,7.011,4.787,7.009,4.79,7.021,4.794,7.055,4.767,7.068,4.775,7.06,4.787,7.07,4.802,7.048,4.812,7.046,4.815,7.047,4.821,7.083,4.818,7.064,4.788,7.095,4.748,7.084,4.722,7.105,4.675,7.122,4.698,7.093,4.729,7.095,4.734,7.098,4.738,7.151,4.662,7.156,4.699,7.199,4.711,7.19,4.717,7.196,4.725,7.185,4.724,7.178,4.732,7.191,4.73,7.201,4.722,7.192,4.718,7.201,4.712,7.193,4.699,7.162,4.691,7.161,4.65,7.17,4.669,7.227,4.69,7.244,4.682,7.169,4.657,7.176,4.63,7.197,4.624,7.202,4.614,7.205,4.64,7.227,4.674,7.272,4.499,7.266,4.605,7.292,4.611,7.429,4.571,7.442,4.543,7.472,4.591,7.525,4.542,7.498,4.812,7.528,4.549,7.626,4.563,7.545,4.531,7.609,4.473,7.663,4.494,7.65,4.514,7.641,4.511,7.636,4.51,7.634,4.514,7.648,4.514,7.644,4.528,7.645,4.532,7.67,4.545,7.984,4.54,7.944,4.552,7.932,4.586,7.902,4.579,7.896,4.579,7.891,4.583,7.888,4.596,7.89,4.603,7.925,4.602,7.924,4.608,7.911,4.613,7.906,4.619,7.913,4.641,7.894,4.641,7.882,4.648,7.878,4.656,7.879,4.668,7.894,4.685,7.887,4.699,7.894,4.685,7.879,4.656,7.913,4.643,7.927,4.603,7.893,4.584,7.902,4.58,7.927,4.59,7.939,4.587,7.948,4.553,8.283,4.537,8.243,4.605,8.327,4.644,8.297,4.68,8.225,4.644,8.204,4.672,8.287,4.689,8.32,4.662,8.318,4.75,8.222,4.832,8.181,4.881,8.145,4.943,8.129,4.935,8.117,4.941,8.115,4.949,8.124,4.959,8.104,4.97,8.099,4.991,8.144,4.945,8.101,5.025,8.099,5.108,8.061,5.15,8.062,5.206,7.989,5.201,7.978,5.204,7.972,5.209,7.968,5.217,7.973,5.254,7.999,5.301,8.051,5.331,8.092,5.332,8.09,5.379,8.102,5.342,8.086,5.3,7.978,5.216,7.995,5.208,8.017,5.246,8.027,5.248,8.037,5.243,8.069,5.201,8.072,5.188,8.072,5.151,8.071,5.145,8.125,5.098,8.149,5.048,8.154,5.057,8.16,5.057,8.182,5.043,8.191,5.032,8.185,5.02,8.215,5.006,8.258,4.91,8.321,4.979,8.303,4.98,8.293,4.992,8.302,5.057,8.26,5.067,8.248,5.083,8.252,5.147,8.274,5.13,8.277,5.146,8.288,5.144,8.324,4.977,8.267,4.895,8.395,4.782,8.345,4.902,8.359,4.951,8.399,4.977,8.347,4.903,8.393,4.865,8.41,4.84,8.396,4.735,8.47,4.781,8.456,4.699,8.532,4.71,8.52,4.747,8.537,4.799,8.561,4.82,8.625,4.822,8.6,4.873,8.619,4.906,8.65,4.914,8.651,4.933,8.654,4.92,8.649,4.91,8.605,4.882,8.632,4.842,8.632,4.823,8.558,4.763,8.675,4.765,8.696,4.667,8.699,4.711,8.72,4.731,8.718,4.736,8.711,4.735,8.709,4.737,8.709,4.747,8.71,4.748,8.716,4.749,8.715,4.76,8.716,4.763,8.717,4.764,8.725,4.765,8.735,4.776,8.744,4.778,8.74,4.787,8.754,4.821,8.775,4.84,8.712,4.679,8.678,4.654,8.703,4.581,8.797,4.545,8.76,4.64,8.74,4.645,8.73,4.677,8.759,4.778,8.744,4.647,8.83,4.705,8.833,4.732,8.781,4.784,8.782,4.79,8.788,4.796,8.842,4.729,8.819,4.631,8.831,4.623,8.84,4.634,8.866,4.635,8.879,4.65,8.907,4.647,8.88,4.647,8.864,4.632,8.838,4.628,8.893,4.504,8.937,4.539,8.938,4.576,8.959,4.602,8.904,4.388,8.904,4.452,8.879,4.422,8.977,4.097,9.2,4.011,9.212,3.951,9.377,4.068,9.393,4.073,9.398,4.062,9.398,4.047,9.431,4.078,9.448,4.084,9.468,4.077,9.473,4.114,9.485,4.121,9.491,4.119,9.497,4.107,9.492,4.07,9.529,4.025,9.625,4.019,9.696,4.116,9.653,4.119,9.629,4.135,9.691,4.123,9.697,4.129,9.701,4.131,9.711,4.128,9.71,4.134,9.722,4.15,9.735,4.153,9.752,4.15,9.749,4.172,9.766,4.204,9.76,4.224,9.782,4.236,9.783,4.258,9.789,4.265,9.799,4.27,9.815,4.262,9.818,4.277,9.83,4.282,9.826,4.294,9.837,4.327,9.863,4.359,9.874,4.365,9.867,4.379,9.873,4.397,9.883,4.407,9.889,4.405,9.895,4.4,9.751,4.11,9.667,4.034,9.679,4.004,9.697,4.008,9.728,3.991,9.728,3.988,9.622,3.962,9.635,3.94,9.778,3.964,9.839,3.942,9.82,4.048,9.844,4.068,9.878,4.083,9.914,4.091,9.822,4.048,9.853,3.947,9.836,3.94,9.801,3.948,9.775,3.961,9.782,3.914,9.676,3.912,9.836,3.811,9.799,3.814,9.803,3.76,9.793,3.749,9.795,3.812,9.693,3.85,9.708,3.772,9.763,3.778,9.767,3.776,9.769,3.771,9.695,3.694,9.69,3.825,9.613,3.864,9.68,3.671,9.623,3.716,9.594,3.753,9.582,3.793,9.55,3.818,9.638,3.586,9.644,3.59,9.68,3.587,9.722,3.596,9.759,3.627,9.779,3.636,9.796,3.64,9.979,3.644,10.044,3.765,10.103,3.793,10.116,3.822,10.127,3.827,10.125,3.804,10.052,3.76,9.975,3.632,9.951,3.623,9.93,3.621,9.911,3.611,9.871,3.602,9.783,3.626,9.755,3.612,9.742,3.591,9.71,3.567,9.644,3.545,9.783,3.432,9.906,3.262,10.035,3.336,10.051,3.399,10.073,3.421,10.066,3.445,10.065,3.487,10.067,3.493,10.073,3.496,10.075,3.42,10.053,3.399,10.057,3.377,10.049,3.37,10.031,3.328,9.927,3.248,9.919,3.246,9.909,3.251,9.895,3.273,9.975,3.088,9.818,2.511,9.882,2.231,9.822,2.347,9.778,2.327,9.81,1.914,9.726,1.776,9.609,1.666,9.646,1.576,9.679,1.569,9.701,1.585,9.718,1.593,9.733,1.597,9.758,1.595,9.796,1.579,9.801,1.573,9.804,1.562,9.658,1.553,9.609,1.586,9.341,1.174,9.41,1.1,9.534,1.129,9.588,1.03,9.751,1.242,9.736,1.176,9.762,1.169,9.79,1.207,9.801,1.208,9.809,1.203,9.694,1.083,9.877,1.116,9.821,1.062,9.832,1.049,9.851,1.06,9.856,1.057,9.858,1.048,9.873,1.054,9.893,1.049,9.896,1.046,9.828,1.044,9.789,1.06,9.796,1.002,9.84,1.008,9.876,0.994,9.92,0.944,9.781,0.981,9.744,1.06,9.553,0.955,9.608,0.519,9.69,0.479,9.592,0.512,9.591,0.483,9.636,0.445,9.642,0.43,9.636,0.418,9.647,0.413,9.649,0.41,9.648,0.401,9.537,0.482,9.567,0.507,9.561,0.531,9.57,0.555,9.557,0.597,9.55,0.55,9.525,0.555,9.515,0.515,9.51,0.513,9.528,0.575,9.454,0.583,9.441,0.498,9.454,0.59,9.533,0.591,9.499,0.669,9.323,0.619,9.308,0.516,9.493,0.284,9.572,0.364,9.596,0.264,9.679,0.211,9.729,0.267,9.8,0.169,9.789,0.203,9.813,0.218,9.816,0.168,9.924,0.198,9.987,0.178,10.034,0.201,10.088,0.166,10.181,0.262,10.095,0.168,10.154,0.184,10.181,0.167,10.09,0.154,10.027,0.192,9.991,0.167,9.957,0.167,9.922,0.186,9.838,0.085,9.762,0.132,9.794,0.051,9.857,0.021,9.85,0.003,9.624,0.153,9.592,0.126,9.605,0.044,9.601,0.017,9.586,-0.009,9.577,0.153,9.509,0.17,9.502,0.054,9.475,0.04,9.464,0.165,9.361,0.18,9.378,0.307,9.317,0.331,9.294,-0.385,9.105,-0.64,9.165,-0.799,9.162,-0.819,9.143,-0.831,9.148,-0.854,9.144,-0.866,9.113,-0.7,9.093,-0.79,9.052,-0.856,9.078,-0.704,9.04,-0.762,9.057,-0.782,9.01,-0.932,8.975,-0.936],"holes":[[8.68,3.212,8.932,3.636,8.898,3.765,8.691,3.777,8.573,3.468,8.436,3.432,8.445,3.275,8.68,3.212],[5.967,4.346,5.916,4.479,6.052,4.736,5.989,4.835,6.127,4.943,6.052,5.011,6.183,5.156,6.036,5.092,5.933,5.162,5.867,5.068,5.658,5.011,5.378,5.127,5.515,4.827,5.549,4.904,5.546,4.832,5.641,4.841,5.663,4.915,5.69,4.869,5.611,4.802,5.803,4.813,5.683,4.676,5.543,4.732,5.686,4.546,5.794,4.689,5.686,4.535,5.796,4.533,5.815,4.551,5.815,4.59,5.831,4.611,5.816,4.585,5.819,4.554,5.81,4.541,5.795,4.529,5.726,4.506,5.795,4.458,5.872,4.53,5.791,4.449,5.839,4.413,5.871,4.488,5.847,4.401,5.967,4.346],[6.233,4.916,6.084,4.804,6.031,4.487,6.071,4.392,6.124,4.576,6.09,4.434,6.139,4.445,6.122,4.483,6.146,4.68,6.158,4.394,6.097,4.358,6.195,4.374,6.197,4.308,6.233,4.916]]}],"names":{"ar":"خليج غينيا","de":"Golf von Guinea","es":"Golfo de Guinea","fr":"golfe de Guinée","he":"מפרץ גינאה","hu":"Guineai-öböl","it":"Golfo di Guinea","nl":"Golf van Guinee","pt":"Golfo da Guiné","zh":"几内亚湾"}},{"name":"Gulf of Alaska","bbox":[-163.358,54.298,-136.647,61.549],"area":63.8223,"polygons":[{"outer":[-136.647,58.212,-159.53,54.756,-159.589,54.833,-159.542,54.754,-162.663,54.298,-162.689,54.301,-162.881,54.435,-163.208,54.693,-163.061,54.658,-163.1,54.73,-163.286,54.754,-163.358,54.811,-163.028,54.947,-163.214,55.027,-163.183,55.133,-162.91,55.018,-162.967,55.001,-162.882,54.933,-162.555,54.957,-162.588,55.141,-162.724,55.217,-162.668,55.297,-162.561,55.298,-162.634,55.275,-162.348,55.107,-162.521,55.115,-162.479,55.054,-162.297,55.085,-162.232,55.022,-162.177,55.154,-162.077,55.173,-162.139,55.113,-162.058,55.072,-161.96,55.106,-162.039,55.228,-161.878,55.223,-161.593,55.618,-161.392,55.627,-161.137,55.53,-161.361,55.593,-161.493,55.486,-161.49,55.357,-161.238,55.356,-160.902,55.526,-160.67,55.46,-160.662,55.519,-160.716,55.527,-160.742,55.524,-160.782,55.529,-160.594,55.608,-160.502,55.476,-160.468,55.584,-160.355,55.609,-160.42,55.662,-160.13,55.663,-160.153,55.738,-160.055,55.696,-160.025,55.791,-159.683,55.838,-159.69,55.858,-159.676,55.88,-159.649,55.904,-159.654,55.924,-159.641,55.957,-159.625,55.974,-159.573,55.988,-159.448,55.984,-159.413,56.014,-159.435,55.984,-159.621,55.967,-159.644,55.904,-159.686,55.858,-159.583,55.802,-159.709,55.659,-159.601,55.641,-159.748,55.599,-159.597,55.565,-159.494,55.768,-159.53,55.887,-159.455,55.896,-159.417,55.787,-159.356,55.876,-158.908,55.919,-158.891,55.962,-158.95,55.985,-158.99,56.019,-158.98,56.005,-159.118,56.074,-159.122,56.122,-159.075,56.063,-158.985,56.029,-158.908,55.973,-158.769,56.019,-158.672,55.95,-158.639,56.019,-158.682,56.005,-158.726,56.049,-158.65,56.088,-158.724,56.154,-158.637,56.113,-158.551,56.171,-158.623,56.201,-158.46,56.194,-158.6,56.05,-158.495,56.07,-158.56,56.025,-158.433,55.992,-158.49,56.114,-158.401,56.062,-158.453,56.132,-158.383,56.19,-158.351,56.125,-158.115,56.241,-158.334,56.177,-158.298,56.212,-158.406,56.241,-158.197,56.287,-158.438,56.345,-158.533,56.252,-158.7,56.261,-158.406,56.453,-158.13,56.46,-158.133,56.522,-157.86,56.473,-157.84,56.567,-158.134,56.54,-157.795,56.673,-157.467,56.622,-157.6,56.723,-157.4,56.763,-157.466,56.815,-157.472,56.844,-157.451,56.862,-157.442,56.866,-157.202,56.761,-157.141,56.79,-157.178,56.857,-157.126,56.845,-157.083,56.819,-156.932,56.917,-156.953,56.983,-156.83,56.895,-156.774,57.037,-156.559,56.976,-156.622,57.06,-156.484,57.109,-156.47,57.078,-156.438,57.082,-156.488,57.119,-156.336,57.172,-156.404,57.225,-156.318,57.292,-156.538,57.28,-156.545,57.305,-156.533,57.329,-156.19,57.481,-156.023,57.435,-156.094,57.525,-156.032,57.568,-155.731,57.544,-155.775,57.641,-155.588,57.663,-155.594,57.786,-155.302,57.725,-155.339,57.825,-155.083,57.873,-155.117,57.945,-155.04,58.02,-154.567,58.023,-154.613,58.119,-154.602,58.125,-154.463,58.079,-154.481,58.192,-154.34,58.082,-154.339,58.159,-154.214,58.133,-154.297,58.191,-154.177,58.189,-154.147,58.231,-154.236,58.254,-154.105,58.282,-154.352,58.285,-154.198,58.354,-153.972,58.39,-154.067,58.419,-154.077,58.432,-154.068,58.438,-154.086,58.482,-154.084,58.491,-153.931,58.495,-153.898,58.61,-153.593,58.633,-153.249,58.852,-153.708,59.077,-153.787,59.04,-154.065,59.073,-154.16,59.021,-154.155,58.999,-154.162,58.995,-154.174,59.12,-154.265,59.14,-153.944,59.361,-154.094,59.344,-154.14,59.371,-153.729,59.435,-153.734,59.505,-153.863,59.543,-153.583,59.554,-153.56,59.605,-153.71,59.634,-153.597,59.697,-153.561,59.624,-153.477,59.644,-153.464,59.795,-153.336,59.722,-153.423,59.641,-153.217,59.635,-153.049,59.695,-152.99,59.81,-153.242,59.827,-153.21,59.863,-152.697,59.924,-152.57,60.071,-152.678,60.166,-152.696,60.164,-152.73,60.187,-152.834,60.219,-152.87,60.233,-152.892,60.246,-152.898,60.246,-152.928,60.294,-152.997,60.28,-152.991,60.299,-153.022,60.283,-153.033,60.29,-153.031,60.292,-153.032,60.295,-153.046,60.294,-153.083,60.251,-153.132,60.237,-153.138,60.242,-153.128,60.251,-153.149,60.242,-153.156,60.247,-153.128,60.268,-153.061,60.293,-153.041,60.298,-152.946,60.302,-152.555,60.222,-152.235,60.394,-152.318,60.496,-152.119,60.577,-152.419,60.627,-152.104,60.585,-151.848,60.734,-151.856,60.746,-151.878,60.754,-151.887,60.765,-151.706,60.713,-151.799,60.842,-151.739,60.941,-151.76,60.947,-151.788,60.945,-151.803,60.948,-151.877,60.986,-151.875,61.023,-151.905,61.025,-151.914,61.03,-151.936,61.019,-151.953,61.015,-151.962,61.014,-151.985,61.018,-151.818,61.064,-151.863,60.986,-151.742,60.949,-151.721,60.908,-151.166,61.046,-150.932,61.221,-150.939,61.229,-150.968,61.222,-150.981,61.224,-150.982,61.256,-150.989,61.25,-150.998,61.247,-151.02,61.251,-150.984,61.258,-150.956,61.245,-150.979,61.225,-150.937,61.23,-150.929,61.219,-150.945,61.207,-150.931,61.204,-150.693,61.259,-150.663,61.323,-150.623,61.374,-150.595,61.384,-150.612,61.402,-150.611,61.406,-150.573,61.435,-150.52,61.454,-150.537,61.47,-150.52,61.462,-150.517,61.454,-150.522,61.446,-150.622,61.35,-150.546,61.408,-150.473,61.402,-150.489,61.462,-150.562,61.481,-150.574,61.487,-150.585,61.502,-150.575,61.53,-150.517,61.549,-150.552,61.503,-150.529,61.498,-150.53,61.478,-150.484,61.466,-150.457,61.412,-150.559,61.287,-150.463,61.246,-150.303,61.254,-150.329,61.28,-150.28,61.32,-150.286,61.257,-149.985,61.238,-149.761,61.444,-149.732,61.455,-149.713,61.454,-149.659,61.469,-149.658,61.467,-149.654,61.477,-149.643,61.465,-149.597,61.495,-149.419,61.511,-149.534,61.497,-149.535,61.48,-148.958,61.522,-148.724,61.481,-148.737,61.479,-148.705,61.466,-148.707,61.451,-148.684,61.439,-148.685,61.427,-148.633,61.423,-148.592,61.401,-148.592,61.392,-148.644,61.423,-148.681,61.422,-148.768,61.464,-148.709,61.445,-148.713,61.46,-148.731,61.463,-148.744,61.471,-148.752,61.478,-148.776,61.458,-148.852,61.467,-148.972,61.512,-149.173,61.475,-149.378,61.469,-149.704,61.382,-149.708,61.311,-150.075,61.157,-149.61,60.984,-149.185,60.943,-148.961,60.856,-148.988,60.824,-149.087,60.9,-149.424,60.897,-149.767,60.968,-150.053,60.918,-150.014,60.903,-150.002,60.87,-150.014,60.871,-150.378,61.04,-151.41,60.72,-151.264,60.545,-151.282,60.335,-151.378,60.365,-151.412,60.223,-151.703,60.034,-151.862,59.756,-151.415,59.599,-151.496,59.634,-151.089,59.786,-150.992,59.811,-150.909,59.792,-151.202,59.644,-151.157,59.593,-151.192,59.563,-151.454,59.534,-151.372,59.508,-151.343,59.463,-151.475,59.502,-151.291,59.41,-151.647,59.485,-151.721,59.452,-151.686,59.395,-151.743,59.428,-151.742,59.454,-151.887,59.424,-151.763,59.323,-151.922,59.359,-151.978,59.255,-151.591,59.162,-151.407,59.281,-151.124,59.211,-151.321,59.31,-151.085,59.27,-151.029,59.325,-150.97,59.278,-151.016,59.218,-150.963,59.197,-150.884,59.266,-150.952,59.31,-150.737,59.424,-150.582,59.444,-150.663,59.541,-150.56,59.595,-150.498,59.601,-150.57,59.528,-150.477,59.459,-150.217,59.736,-150.362,59.46,-150.084,59.578,-150.161,59.621,-150.099,59.61,-150.134,59.694,-150.035,59.613,-149.942,59.657,-149.92,59.692,-149.919,59.711,-149.929,59.729,-150.077,59.76,-150.078,59.844,-149.74,59.638,-149.759,59.793,-149.87,59.852,-149.748,59.823,-149.692,59.952,-149.616,59.879,-149.652,59.771,-149.573,59.757,-149.642,59.738,-149.523,59.703,-149.625,59.82,-149.387,59.983,-149.411,60.13,-149.287,60.014,-149.289,59.867,-149.208,60.007,-149.058,60.058,-149.124,59.964,-148.635,59.916,-148.548,60.032,-148.427,59.948,-148.341,60.187,-148.28,60.165,-148.302,60.115,-148.239,60.116,-148.087,60.214,-148.215,60.147,-148.188,60.213,-148.298,60.208,-148.214,60.26,-148.449,60.179,-148.339,60.235,-148.411,60.274,-148.324,60.245,-148.326,60.262,-148.209,60.3,-148.264,60.362,-147.938,60.443,-148.098,60.454,-147.961,60.501,-148.094,60.521,-148.035,60.561,-148.071,60.562,-148.102,60.598,-148.279,60.431,-148.268,60.489,-148.384,60.451,-148.327,60.498,-148.45,60.545,-148.668,60.446,-148.709,60.444,-148.526,60.564,-148.401,60.594,-148.324,60.525,-148.19,60.609,-148.294,60.676,-148.255,60.701,-148.254,60.745,-148.226,60.764,-148.349,60.691,-148.343,60.649,-148.429,60.615,-148.45,60.651,-148.363,60.767,-148.52,60.766,-148.684,60.647,-148.67,60.719,-148.452,60.8,-148.717,60.786,-148.338,60.807,-148.401,60.846,-148.306,60.835,-148.266,60.914,-148.324,60.955,-148.19,60.969,-148.165,61.07,-148.43,60.968,-148.399,61.058,-148.143,61.128,-148.068,61.001,-147.689,61.276,-147.761,61.207,-147.694,61.2,-147.947,61.073,-148.066,60.931,-148.035,60.934,-147.935,60.906,-147.878,60.825,-147.801,60.846,-147.825,60.813,-147.744,60.833,-147.83,60.876,-147.732,60.942,-147.739,60.89,-147.665,60.839,-147.603,60.849,-147.64,60.893,-147.594,60.95,-147.688,60.955,-147.548,61.15,-147.475,61.068,-147.549,60.907,-147.496,61.012,-147.411,61.015,-147.472,60.957,-147.381,60.977,-147.455,60.922,-147.387,60.93,-147.453,60.896,-147.383,60.872,-147.257,60.919,-147.256,60.948,-147.281,60.961,-147.288,60.993,-147.224,61.011,-147.18,60.931,-147.165,60.99,-147.093,61.016,-146.983,61.008,-146.971,60.97,-147.057,60.944,-146.979,60.93,-146.606,61.141,-146.254,61.108,-146.217,61.116,-146.195,61.137,-146.186,61.137,-146.215,61.111,-146.236,61.11,-146.242,61.103,-146.26,61.1,-146.224,61.091,-146.196,61.078,-146.622,61.082,-146.483,61.002,-146.506,61.0,-146.636,61.032,-146.646,61.024,-146.667,61.035,-146.719,60.967,-146.591,60.928,-146.762,60.946,-146.631,60.885,-146.638,60.818,-146.236,60.88,-146.117,60.848,-146.094,60.833,-146.7,60.743,-146.665,60.694,-146.492,60.67,-146.279,60.777,-146.311,60.717,-146.16,60.724,-146.057,60.797,-145.978,60.746,-146.261,60.628,-145.909,60.704,-146.011,60.618,-145.62,60.67,-145.797,60.525,-145.965,60.463,-145.458,60.435,-145.589,60.403,-145.506,60.376,-145.506,60.361,-145.67,60.362,-145.565,60.346,-145.622,60.338,-145.566,60.309,-145.62,60.32,-145.557,60.301,-145.517,60.352,-145.456,60.355,-145.434,60.31,-145.49,60.3,-145.331,60.243,-144.974,60.219,-144.98,60.241,-145.005,60.225,-145.081,60.23,-144.869,60.438,-144.858,60.439,-144.849,60.452,-144.836,60.458,-144.752,60.465,-144.742,60.479,-144.657,60.448,-144.828,60.455,-144.855,60.411,-144.901,60.373,-144.886,60.378,-144.964,60.312,-144.957,60.284,-144.891,60.294,-144.853,60.279,-144.793,60.282,-144.782,60.274,-144.759,60.285,-144.756,60.285,-144.744,60.285,-144.759,60.264,-144.713,60.28,-144.668,60.276,-144.728,60.243,-144.68,60.234,-144.669,60.226,-144.769,60.22,-144.373,60.167,-144.37,60.174,-144.324,60.184,-144.207,60.188,-144.198,60.284,-144.16,60.308,-144.109,60.319,-144.075,60.334,-144.037,60.345,-144.193,60.284,-144.166,60.249,-144.19,60.194,-144.227,60.176,-144.313,60.162,-144.3,60.12,-144.238,60.13,-144.232,60.119,-144.216,60.142,-144.195,60.148,-144.185,60.148,-144.178,60.147,-144.215,60.139,-144.23,60.11,-144.055,60.05,-144.254,60.022,-143.876,59.987,-143.457,60.075,-143.627,60.079,-143.61,60.15,-143.279,60.177,-143.229,60.132,-143.237,60.187,-143.222,60.196,-143.138,60.208,-143.22,60.133,-143.287,60.119,-143.338,60.133,-143.439,60.082,-143.438,60.071,-143.469,60.054,-143.551,60.046,-142.507,60.103,-141.602,59.961,-141.367,60.027,-141.539,60.085,-141.448,60.087,-141.539,60.148,-141.344,60.083,-141.15,60.164,-141.329,60.059,-141.281,59.97,-141.198,59.992,-141.193,59.976,-141.483,59.914,-140.881,59.738,-140.319,59.692,-139.847,59.805,-140.017,59.776,-139.98,59.798,-139.888,59.807,-139.921,59.833,-139.956,59.886,-139.958,59.901,-139.876,59.83,-139.865,59.807,-139.78,59.829,-139.739,59.904,-139.531,60.045,-139.252,59.86,-139.045,59.852,-138.914,59.813,-139.3,59.82,-139.228,59.607,-139.302,59.562,-139.359,59.595,-139.281,59.62,-139.363,59.759,-139.311,59.852,-139.488,59.992,-139.637,59.873,-139.471,59.701,-139.639,59.567,-139.696,59.62,-139.723,59.602,-139.694,59.583,-139.71,59.558,-139.856,59.535,-139.328,59.353,-139.377,59.381,-139.073,59.438,-139.059,59.437,-139.058,59.421,-139.049,59.432,-139.04,59.434,-139.028,59.429,-139.031,59.418,-139.017,59.418,-138.876,59.51,-138.896,59.421,-139.26,59.364,-138.618,59.126,-138.649,59.142,-138.529,59.199,-138.477,59.2,-138.447,59.177,-138.415,59.192,-138.345,59.194,-138.177,59.179,-138.174,59.199,-138.201,59.204,-138.28,59.293,-138.298,59.335,-138.281,59.358,-138.24,59.381,-138.088,59.394,-138.285,59.341,-138.267,59.329,-138.269,59.31,-138.263,59.309,-138.257,59.307,-138.253,59.299,-138.224,59.294,-138.212,59.281,-138.197,59.206,-138.165,59.238,-138.083,59.236,-138.087,59.172,-138.161,59.16,-138.328,59.191,-138.446,59.175,-138.488,59.191,-138.605,59.122,-138.075,58.962,-137.669,58.614,-137.617,58.647,-137.496,58.675,-137.463,58.657,-137.656,58.608,-137.095,58.381,-136.917,58.399,-136.888,58.342,-136.876,58.383,-136.806,58.39,-136.851,58.423,-136.852,58.431,-136.835,58.433,-136.796,58.392,-136.877,58.312,-136.679,58.298,-136.678,58.273,-136.732,58.259,-136.647,58.212],"holes":[[-152.047,58.181,-151.973,58.229,-151.985,58.352,-152.138,58.218,-152.123,58.396,-152.24,58.341,-152.358,58.423,-152.427,58.301,-152.536,58.41,-152.481,58.461,-152.652,58.482,-152.674,58.419,-152.889,58.408,-152.727,58.342,-152.808,58.337,-152.721,58.262,-153.104,58.26,-152.85,58.155,-153.223,58.169,-152.797,57.989,-152.807,58.078,-152.627,58.077,-152.574,58.219,-152.546,58.083,-152.272,58.125,-152.37,58.191,-152.289,58.177,-152.323,58.246,-152.254,58.272,-152.201,58.175,-152.047,58.181],[-152.461,57.884,-152.59,57.925,-152.618,57.837,-152.79,57.859,-152.86,57.722,-152.912,57.819,-152.803,57.913,-153.283,58.0,-153.041,57.819,-153.235,57.894,-153.18,57.797,-153.191,57.703,-153.207,57.791,-153.477,57.841,-153.31,57.719,-153.489,57.774,-153.431,57.695,-153.519,57.718,-153.488,57.617,-153.571,57.832,-153.672,57.861,-153.62,57.883,-153.847,57.872,-153.935,57.803,-153.92,57.713,-153.559,57.596,-153.88,57.632,-153.674,57.533,-153.847,57.574,-153.749,57.325,-153.625,57.274,-153.78,57.321,-153.926,57.533,-154.103,57.526,-153.976,57.551,-154.011,57.647,-154.195,57.664,-154.632,57.506,-154.793,57.286,-154.581,57.247,-154.528,56.991,-154.275,56.955,-154.356,56.949,-154.312,56.842,-154.225,56.876,-154.299,56.852,-154.305,56.912,-154.153,56.949,-154.096,57.121,-154.379,57.05,-154.474,57.124,-153.952,57.122,-154.112,57.054,-154.086,56.964,-153.741,57.139,-153.975,56.958,-153.763,56.995,-154.151,56.747,-153.968,56.745,-153.693,56.859,-153.774,56.868,-153.689,56.969,-153.588,56.935,-153.541,57.001,-153.67,57.007,-153.579,57.048,-153.706,57.064,-153.493,57.066,-153.54,57.177,-153.457,57.11,-153.39,57.208,-152.941,57.257,-153.205,57.305,-153.154,57.357,-152.878,57.353,-152.914,57.318,-152.82,57.265,-152.602,57.357,-152.818,57.472,-153.052,57.428,-152.888,57.485,-152.958,57.523,-152.319,57.431,-152.153,57.619,-152.461,57.59,-152.395,57.688,-152.519,57.647,-152.443,57.725,-152.57,57.704,-152.325,57.822,-152.461,57.884],[-147.877,59.763,-147.435,59.871,-147.496,59.939,-147.35,59.962,-147.36,60.044,-146.914,60.301,-147.113,60.263,-147.002,60.343,-147.212,60.347,-147.186,60.255,-147.697,59.996,-147.814,59.911,-147.757,59.878,-147.89,59.862,-147.932,59.785,-147.877,59.763],[-146.375,60.45,-146.623,60.479,-146.725,60.343,-146.499,60.361,-146.701,60.271,-146.079,60.377,-146.375,60.45],[-160.541,55.383,-160.664,55.301,-160.664,55.398,-160.811,55.373,-160.867,55.321,-160.825,55.117,-160.754,55.195,-160.538,55.128,-160.549,55.186,-160.463,55.181,-160.571,55.234,-160.541,55.383]]}],"names":{"ar":"خليج ألاسكا","de":"Golf von Alaska","es":"Golfo de Alaska","fr":"golfe d'Alaska","he":"מפרץ אלסקה","hu":"Alaszkai-öböl","it":"golfo dell'Alaska","nl":"Golf van Alaska","pt":"Golfo do Alasca","zh":"阿拉斯加灣"}},{"name":"Laccadive Sea","bbox":[71.982,-0.689,80.589,14.793],"area":68.4501,"polygons":[{"outer":[79.191,9.282,79.914,8.941,79.96,8.725,79.79,8.233,79.835,8.138,79.789,8.086,79.835,8.014,79.832,7.985,79.823,7.982,79.825,7.97,79.822,7.935,79.826,7.923,79.821,7.914,79.736,8.016,79.738,8.202,79.777,8.26,79.693,8.209,79.863,6.825,79.978,6.452,79.993,6.44,79.998,6.427,80.012,6.439,80.024,6.431,80.03,6.42,80.038,6.42,80.05,6.415,80.064,6.407,80.068,6.4,80.074,6.398,80.068,6.399,80.038,6.419,80.027,6.42,80.015,6.434,79.996,6.426,79.991,6.44,79.987,6.441,80.124,6.101,80.589,5.919,73.153,-0.689,72.936,0.396,71.982,9.536,72.167,13.7,74.103,14.793,74.25,14.739,74.292,14.602,74.428,14.633,74.291,14.596,74.31,14.521,74.376,14.573,74.457,14.449,74.389,14.473,74.382,14.481,74.379,14.497,74.369,14.502,74.375,14.524,74.361,14.52,74.438,14.278,74.614,14.238,74.527,14.233,74.44,14.262,74.425,14.296,74.651,13.666,74.668,13.716,74.756,13.64,74.668,13.628,74.691,13.459,74.709,13.437,74.716,13.44,74.716,13.458,74.719,13.463,74.724,13.466,74.748,13.457,74.76,13.382,74.694,13.447,74.695,13.348,74.711,13.342,74.736,13.29,74.729,13.277,74.725,13.279,74.722,13.284,74.72,13.305,74.713,13.33,74.705,13.341,74.691,13.349,74.821,12.852,74.814,12.908,74.836,12.944,74.853,12.951,74.841,12.844,74.909,12.865,74.937,12.859,74.948,12.871,74.959,12.871,74.966,12.856,74.977,12.864,74.987,12.862,74.963,12.855,74.95,12.87,74.938,12.858,74.899,12.853,74.874,12.835,74.829,12.843,75.12,12.203,75.116,12.223,75.119,12.231,75.166,12.256,75.118,12.225,75.22,12.07,75.202,12.005,75.295,11.942,75.305,11.949,75.265,11.999,75.265,12.017,75.271,12.024,75.296,12.015,75.293,12.028,75.308,12.047,75.339,12.046,75.334,12.007,75.265,12.007,75.357,11.931,75.411,12.016,75.443,12.022,75.471,12.002,75.412,12.011,75.399,11.913,75.297,11.939,75.5,11.798,75.733,11.353,75.729,11.449,75.753,11.429,75.802,11.162,75.84,11.211,75.855,11.21,75.863,11.206,75.877,11.224,75.896,11.231,75.9,11.244,75.952,11.25,75.802,11.159,75.825,11.125,75.846,11.144,75.851,11.144,75.858,11.139,75.862,11.129,75.91,10.793,75.964,10.82,75.947,10.796,75.913,10.786,76.023,10.536,76.042,10.575,76.108,10.503,76.035,10.53,76.163,10.182,76.192,10.27,76.256,10.253,76.234,10.203,76.215,10.2,76.224,10.187,76.329,10.152,76.362,10.114,76.39,10.125,76.451,10.122,76.456,10.133,76.43,10.144,76.431,10.156,76.471,10.183,76.514,10.192,76.45,10.119,76.312,10.076,76.304,10.076,76.301,10.078,76.294,10.094,76.285,10.094,76.287,10.026,76.267,10.029,76.377,9.864,76.416,9.566,76.502,9.531,76.48,9.511,76.357,9.525,76.371,9.82,76.349,9.828,76.321,9.871,76.319,9.844,76.336,9.809,76.348,9.712,76.322,9.808,76.317,9.846,76.319,9.876,76.285,9.833,76.237,9.964,76.32,9.482,76.535,8.936,76.607,8.988,76.615,8.908,76.546,8.903,76.975,8.385,77.316,8.121,77.551,8.077,77.576,8.136,78.067,8.371,78.261,9.019,78.833,9.269,79.191,9.282]}],"names":{"ar":"بحر لكديف","de":"Lakkadivensee","es":"mar de Laquedivas","fr":"mer des Laquedives","he":"ים לקדיב","hu":"Lakkadív-tenger","it":"Mar delle Laccadive","nl":"Laccadivenzee","pt":"Mar das Laquedivas","zh":"拉克代夫海"}},{"name":"East China Sea","bbox":[118.478,24.063,131.132,33.372],"area":70.4378,"polygons":[{"outer":[130.726,33.372,130.767,33.365,130.774,33.36,130.778,33.352,130.783,33.349,130.787,33.349,130.796,33.356,130.81,33.361,130.815,33.36,130.832,33.348,130.782,33.348,130.723,33.369,130.703,33.361,130.648,33.362,130.62,33.344,130.6,33.341,130.582,33.326,130.541,33.323,130.51,33.331,130.484,33.304,130.457,33.302,130.455,33.285,130.446,33.275,130.436,33.27,130.443,33.264,130.36,33.209,130.443,32.915,130.543,32.908,130.523,32.854,130.613,32.797,130.605,32.721,130.632,32.711,130.646,32.723,130.662,32.73,130.67,32.727,130.451,32.624,130.65,32.641,130.656,32.584,130.608,32.594,130.544,32.538,130.628,32.499,130.632,32.492,130.631,32.478,130.339,32.113,130.176,32.105,130.191,31.858,130.317,31.827,130.167,31.791,130.335,31.551,130.302,31.438,130.225,31.398,130.187,31.41,130.159,31.44,130.106,31.411,130.209,31.348,130.215,31.249,130.46,31.251,130.588,31.157,130.666,31.266,130.567,31.319,130.515,31.495,130.686,31.735,130.823,31.652,130.754,31.559,130.679,31.629,130.591,31.589,130.703,31.549,130.804,31.334,130.658,30.996,131.132,31.281,130.866,30.354,129.985,28.354,128.328,26.75,128.257,26.876,128.125,26.66,128.101,26.67,128.079,26.645,128.027,26.629,128.007,26.63,127.877,26.712,127.895,26.606,127.983,26.576,127.711,26.44,127.76,26.308,127.637,26.199,127.679,26.074,125.455,24.736,125.265,24.916,125.262,24.735,125.443,24.727,125.0,24.4,123.759,24.063,121.936,25.022,121.925,25.129,121.742,25.131,121.577,25.299,119.761,25.414,119.887,25.568,119.819,25.574,119.778,25.668,119.683,25.602,119.733,25.596,119.74,25.509,119.695,25.514,119.671,25.475,119.727,25.492,119.741,25.411,119.65,25.4,119.566,25.505,119.614,25.538,119.572,25.554,119.597,25.589,119.511,25.557,119.489,25.57,119.448,25.694,119.595,25.69,119.581,25.879,119.684,25.914,119.709,26.016,119.659,26.015,119.533,26.074,119.473,25.952,119.257,25.978,119.228,25.928,119.172,25.888,119.181,25.914,119.221,25.925,119.244,25.975,119.263,25.983,119.25,25.993,119.22,26.03,119.201,26.102,119.144,26.114,119.05,26.208,118.98,26.192,118.923,26.224,118.89,26.203,118.81,26.301,118.892,26.208,118.918,26.228,118.943,26.227,118.969,26.207,118.976,26.196,119.055,26.216,119.158,26.12,119.179,26.113,119.206,26.115,119.235,26.109,119.274,26.058,119.347,26.05,119.424,26.007,119.45,25.982,119.66,26.274,119.944,26.356,119.859,26.351,119.836,26.429,119.679,26.347,119.566,26.482,119.829,26.446,119.783,26.586,119.604,26.584,119.544,26.659,119.658,26.724,119.56,26.718,119.554,26.757,119.58,26.778,119.564,26.801,119.568,26.815,119.583,26.787,119.58,26.753,119.585,26.785,119.62,26.798,119.613,26.757,119.652,26.759,119.697,26.707,119.736,26.727,119.638,26.898,119.647,26.987,119.683,26.998,119.704,26.986,119.718,27.003,119.697,27.001,119.676,27.008,119.674,27.015,119.677,27.027,119.683,27.032,119.693,27.032,119.694,27.038,119.692,27.038,119.684,27.036,119.68,27.036,119.637,27.073,119.628,27.09,119.635,27.1,119.629,27.11,119.627,27.118,119.629,27.122,119.696,27.037,119.694,27.031,119.677,27.025,119.677,27.008,119.712,27.006,119.718,27.004,119.721,27.001,119.646,26.902,119.683,26.864,119.727,26.743,119.779,26.838,119.852,26.848,119.781,26.805,119.848,26.805,119.794,26.695,119.836,26.709,119.867,26.646,119.926,26.773,120.074,26.803,119.824,26.528,120.092,26.617,120.156,26.724,120.094,26.749,120.136,26.798,120.042,26.83,120.051,26.895,120.228,26.914,120.174,26.974,120.268,26.997,120.259,27.112,120.387,27.084,120.44,27.15,120.405,27.151,120.357,27.239,120.312,27.205,120.224,27.252,120.194,27.204,120.214,27.263,120.197,27.278,120.306,27.299,120.305,27.218,120.369,27.248,120.476,27.164,120.544,27.403,120.645,27.364,120.688,27.476,120.628,27.502,120.6,27.588,120.565,27.578,120.554,27.585,120.546,27.596,120.512,27.575,120.507,27.581,120.545,27.598,120.624,27.593,120.676,27.638,120.684,27.698,120.573,27.798,120.575,27.834,120.55,27.821,120.551,27.801,120.542,27.793,120.524,27.798,120.515,27.819,120.513,27.8,120.506,27.782,120.5,27.778,120.427,27.79,120.515,27.822,120.544,27.795,120.541,27.827,120.544,27.837,120.551,27.84,120.559,27.837,120.579,27.843,120.578,27.809,120.711,27.71,120.872,27.924,120.596,28.033,120.552,28.138,120.461,28.136,120.405,28.158,120.363,28.132,120.349,28.132,120.317,28.139,120.302,28.126,120.295,28.128,120.225,28.18,120.209,28.258,120.18,28.259,120.16,28.275,120.139,28.267,120.124,28.269,120.109,28.28,120.107,28.301,120.089,28.314,120.083,28.321,120.069,28.313,120.084,28.322,120.105,28.304,120.109,28.298,120.11,28.281,120.127,28.269,120.159,28.277,120.208,28.261,120.295,28.129,120.315,28.14,120.361,28.133,120.402,28.167,120.555,28.142,120.614,28.033,120.66,28.035,120.679,28.062,120.687,28.064,120.701,28.061,120.69,28.099,120.701,28.12,120.722,28.129,120.697,28.137,120.695,28.139,120.695,28.145,120.705,28.163,120.744,28.166,120.699,28.152,120.696,28.138,120.708,28.141,120.721,28.133,120.724,28.127,120.706,28.064,120.674,28.042,120.774,28.024,120.824,27.987,120.949,27.98,121.133,28.246,121.044,28.289,121.185,28.39,121.274,28.363,121.213,28.33,121.249,28.269,121.241,28.254,121.226,28.238,121.224,28.231,121.266,28.225,121.341,28.141,121.463,28.33,121.578,28.302,121.609,28.248,121.67,28.35,121.605,28.326,121.573,28.439,121.609,28.477,121.527,28.493,121.568,28.5,121.616,28.5,121.617,28.545,121.554,28.537,121.51,28.665,121.32,28.684,121.118,28.839,121.158,28.853,121.339,28.707,121.66,28.717,121.637,28.756,121.682,28.867,121.513,28.946,121.702,28.915,121.711,29.014,121.531,29.004,121.65,29.103,121.431,29.154,121.604,29.14,121.535,29.277,121.636,29.183,121.641,29.294,121.659,29.314,121.652,29.186,121.635,29.181,121.759,29.185,121.767,29.357,121.805,29.378,121.848,29.324,121.802,29.302,121.786,29.17,121.841,29.159,121.792,29.196,121.801,29.211,121.829,29.24,121.802,29.252,121.807,29.292,121.824,29.297,121.845,29.161,121.95,29.227,121.974,29.221,121.958,29.251,121.996,29.282,121.907,29.297,121.985,29.457,121.916,29.536,121.995,29.601,121.793,29.605,121.742,29.545,121.837,29.544,121.802,29.477,121.722,29.541,121.489,29.386,121.532,29.466,121.449,29.414,121.426,29.473,121.49,29.544,121.701,29.561,121.871,29.743,121.913,29.748,121.933,29.787,122.139,29.889,122.117,29.91,122.013,29.877,121.935,29.891,121.746,29.974,121.718,29.948,121.693,29.945,121.683,29.925,121.641,29.897,121.566,29.89,121.555,29.865,121.542,29.852,121.532,29.853,121.531,29.843,121.523,29.84,121.522,29.829,121.51,29.827,121.518,29.82,121.519,29.815,121.503,29.795,121.509,29.828,121.521,29.829,121.521,29.84,121.53,29.852,121.555,29.867,121.556,29.873,121.542,29.889,121.538,29.906,121.507,29.905,121.49,29.925,121.476,29.908,121.468,29.909,121.312,29.975,121.554,29.92,121.556,29.876,121.562,29.888,121.569,29.893,121.612,29.907,121.64,29.899,121.694,29.948,121.716,29.95,121.73,29.969,121.755,29.978,121.44,30.295,121.232,30.347,120.902,30.166,120.752,30.225,120.646,30.128,120.768,30.098,120.865,30.025,120.877,30.007,120.88,29.807,120.874,29.799,120.854,29.791,120.855,29.769,120.847,29.737,120.834,29.732,120.842,29.725,120.839,29.713,120.832,29.707,120.832,29.733,120.845,29.737,120.851,29.789,120.878,29.808,120.875,30.007,120.605,30.119,120.744,30.228,120.656,30.365,120.503,30.383,120.376,30.252,120.264,30.281,120.204,30.219,120.127,30.187,120.252,30.037,120.253,30.027,120.246,30.01,120.256,30.007,120.256,29.982,120.266,29.984,120.276,29.98,120.274,29.967,120.276,29.979,120.254,29.982,120.245,30.009,120.25,30.037,120.17,30.101,119.883,29.952,119.95,30.049,120.05,30.096,120.057,30.095,120.075,30.083,120.087,30.082,120.122,30.11,120.175,30.112,120.107,30.18,120.24,30.281,120.29,30.298,120.37,30.269,120.426,30.39,120.695,30.394,120.795,30.296,121.012,30.573,121.505,30.814,121.89,30.848,121.973,30.93,121.717,31.289,121.292,31.515,121.118,31.709,120.823,31.778,120.739,31.986,120.454,32.025,120.438,31.97,120.144,31.913,120.02,31.95,119.924,32.006,119.786,32.116,119.79,32.18,119.811,32.203,119.771,32.207,119.703,32.248,119.611,32.184,119.537,32.194,119.49,32.236,119.439,32.219,119.407,32.238,119.355,32.195,119.292,32.185,119.113,32.243,119.012,32.174,118.868,32.175,118.74,32.109,118.692,31.96,118.628,31.952,118.517,31.79,118.499,31.777,118.478,31.786,118.512,31.877,118.766,32.143,118.758,32.206,118.795,32.233,118.84,32.231,118.871,32.185,118.922,32.183,119.091,32.251,119.47,32.277,119.545,32.241,119.602,32.238,119.606,32.242,119.581,32.244,119.576,32.246,119.572,32.25,119.586,32.285,119.667,32.223,119.7,32.309,119.531,32.322,119.55,32.333,119.532,32.33,119.521,32.339,119.421,32.596,119.437,32.603,119.384,32.666,119.354,32.623,119.318,32.659,119.38,32.678,119.371,32.692,119.355,32.738,119.361,32.743,119.337,32.766,119.334,32.782,119.205,32.69,119.07,32.847,119.153,32.945,119.167,32.862,119.208,32.869,119.231,32.83,119.276,32.853,119.226,32.898,119.234,32.926,119.242,32.934,119.229,32.969,119.238,32.997,119.352,33.043,119.235,33.185,119.14,33.051,119.204,33.078,119.11,33.032,119.155,33.077,119.158,33.142,119.161,33.149,119.168,33.154,119.228,33.187,119.249,33.189,119.319,33.132,119.367,33.048,119.323,32.992,119.324,32.976,119.407,33.012,119.408,32.991,119.397,32.979,119.395,32.964,119.403,32.881,119.425,32.808,119.42,32.79,119.424,32.768,119.418,32.757,119.471,32.559,119.488,32.542,119.467,32.601,119.431,32.769,119.423,32.777,119.421,32.79,119.427,32.804,119.417,32.861,119.404,32.88,119.412,32.892,119.401,32.947,119.398,32.977,119.409,32.989,119.411,32.996,119.405,33.041,119.412,32.993,119.402,32.948,119.427,32.818,119.428,32.803,119.424,32.779,119.433,32.767,119.471,32.599,119.494,32.538,119.526,32.337,119.601,32.359,119.584,32.363,119.571,32.373,119.558,32.4,119.838,32.304,119.87,32.516,119.841,32.301,120.025,31.979,120.25,31.946,120.464,32.074,120.759,32.088,120.95,31.814,121.109,31.785,121.303,31.885,121.858,31.685,121.928,31.742,121.827,31.955,121.427,32.114,121.381,32.392,120.905,32.617,120.908,32.67,120.856,32.734,120.85,32.786,120.896,33.005,120.744,33.279,126.163,33.284,126.265,33.196,128.641,32.592,129.738,32.57,130.085,32.795,130.207,32.753,130.126,32.688,130.168,32.591,130.349,32.674,130.339,32.858,130.162,32.843,130.117,32.875,130.181,32.86,130.225,32.967,130.111,33.129,130.252,33.217,130.326,33.143,130.32,33.17,130.34,33.207,130.445,33.276,130.454,33.303,130.506,33.333,130.581,33.328,130.598,33.342,130.648,33.364,130.726,33.372],"holes":[[130.46,32.53,130.205,32.452,130.187,32.548,130.014,32.533,129.977,32.329,130.078,32.315,129.967,32.244,130.024,32.15,130.213,32.337,130.211,32.43,130.381,32.383,130.46,32.53],[121.8,31.612,121.303,31.868,121.161,31.789,121.603,31.522,121.832,31.438,121.963,31.489,121.8,31.612],[129.688,28.53,129.655,28.425,129.543,28.464,129.499,28.382,129.21,28.307,129.274,28.262,129.135,28.253,129.293,28.221,129.379,28.107,129.337,28.169,129.478,28.211,129.408,28.251,129.711,28.422,129.688,28.53]]}],"names":{"ar":"بحر الصين الشرقي","de":"Ostchinesisches Meer","es":"mar de China Oriental","fr":"mer de Chine orientale","he":"ים סין המזרחי","hu":"Kelet-kínai-tenger","it":"Mar Cinese Orientale","nl":"Oost-Chinese Zee","pt":"Mar da China Oriental","zh":"东海"}},{"name":"North Sea","bbox":[-4.443,50.995,12.006,61.009],"area":77.5586,"polygons":[{"outer":[5.203,60.832,5.259,60.793,5.277,60.818,5.239,60.832,5.527,60.917,5.488,60.882,5.587,60.873,5.513,60.858,5.366,60.864,5.382,60.819,5.266,60.771,5.452,60.714,5.468,60.659,5.38,60.685,5.458,60.645,5.461,60.635,4.944,60.811,5.043,60.738,5.174,60.693,5.238,60.662,5.266,60.62,5.11,60.676,5.205,60.623,5.197,60.617,5.23,60.586,5.229,60.574,5.209,60.587,5.185,60.617,5.109,60.656,5.083,60.686,5.105,60.683,5.035,60.713,4.946,60.729,4.888,60.748,4.909,60.73,4.873,60.741,4.865,60.737,4.878,60.736,4.928,60.705,4.853,60.722,5.285,60.541,5.569,60.636,5.549,60.701,5.702,60.765,5.678,60.7,5.797,60.735,5.734,60.688,5.75,60.661,5.719,60.538,5.737,60.457,5.529,60.421,5.459,60.454,5.465,60.421,5.298,60.527,5.254,60.479,5.351,60.38,5.17,60.373,5.267,60.315,5.205,60.311,5.254,60.263,5.231,60.241,5.265,60.268,5.332,60.264,5.332,60.257,5.299,60.238,5.291,60.241,5.282,60.224,5.247,60.223,5.245,60.209,5.269,60.203,5.29,60.225,5.361,60.231,5.383,60.211,5.321,60.19,5.398,60.178,5.441,60.133,5.647,60.374,5.757,60.399,5.584,60.253,5.746,60.239,5.577,60.157,5.769,60.187,5.721,60.105,5.836,60.09,5.715,60.072,5.766,59.979,5.913,60.03,5.9,60.159,6.015,60.27,6.195,60.279,6.144,60.373,6.279,60.383,6.231,60.418,6.209,60.469,6.299,60.392,6.715,60.522,6.63,60.466,6.701,60.431,7.026,60.586,6.913,60.493,7.14,60.494,6.836,60.474,6.686,60.386,6.546,60.07,6.67,60.406,6.353,60.37,6.082,60.191,6.135,60.107,6.28,60.164,6.315,60.127,6.038,60.067,5.993,59.955,5.675,59.848,5.781,59.83,5.881,59.769,5.957,59.809,5.968,59.853,5.992,59.858,5.956,59.79,6.022,59.753,6.08,59.76,6.096,59.787,6.122,59.782,6.228,59.835,6.34,59.853,6.368,59.875,6.379,59.874,6.354,59.852,6.091,59.736,5.928,59.769,5.94,59.734,5.783,59.683,5.923,59.643,5.803,59.604,5.593,59.684,5.55,59.524,5.46,59.483,5.537,59.569,5.526,59.733,5.413,59.662,5.471,59.614,5.413,59.623,5.407,59.652,5.372,59.646,5.316,59.62,5.224,59.527,5.353,59.268,5.38,59.42,5.4,59.301,5.454,59.314,5.515,59.269,5.623,59.427,5.687,59.329,5.645,59.322,5.648,59.26,5.743,59.343,5.892,59.35,5.843,59.437,5.671,59.394,5.734,59.475,5.855,59.463,5.853,59.543,5.9,59.475,6.198,59.479,5.914,59.45,5.942,59.361,5.977,59.352,6.006,59.405,6.218,59.473,6.307,59.639,6.366,59.64,6.269,59.517,6.439,59.557,6.601,59.559,6.27,59.506,6.02,59.341,6.231,59.324,6.239,59.329,6.217,59.39,6.29,59.318,6.114,59.302,6.157,59.26,6.375,59.322,6.558,59.321,6.19,59.257,6.023,59.143,6.174,59.14,5.889,59.083,5.95,59.022,6.029,59.022,6.09,58.986,5.996,58.971,6.073,58.904,6.1,58.943,6.266,59.013,6.645,59.056,6.085,58.901,6.28,58.84,6.121,58.852,5.966,58.904,5.893,58.968,5.744,58.852,5.77,58.965,5.728,58.972,5.585,59.037,5.574,59.031,5.685,58.928,5.576,58.958,5.604,58.883,5.488,58.752,5.656,58.558,5.983,58.465,5.899,58.461,5.874,58.423,5.983,58.459,6.386,58.272,6.644,58.234,6.655,58.295,6.719,58.22,6.816,58.262,6.923,58.286,6.551,58.117,6.639,58.066,6.769,58.071,6.789,58.055,6.954,58.106,6.962,58.048,7.025,58.112,7.061,58.119,6.996,57.995,8.59,57.122,8.268,56.854,8.237,56.701,8.264,56.776,8.356,56.677,8.424,56.665,8.515,56.589,8.553,56.578,8.582,56.688,8.503,56.666,8.399,56.678,8.513,56.68,8.469,56.781,8.67,56.952,9.131,57.039,9.366,57.024,9.179,56.919,9.244,56.749,9.16,56.678,9.307,56.692,9.287,56.561,9.369,56.535,9.3,56.521,9.247,56.573,9.286,56.62,9.151,56.654,9.148,56.605,9.096,56.603,9.093,56.57,9.05,56.562,9.177,56.709,9.068,56.802,8.864,56.765,8.824,56.707,8.883,56.716,8.918,56.699,8.679,56.624,8.763,56.554,8.732,56.478,8.525,56.483,8.551,56.502,8.583,56.5,8.633,56.521,8.661,56.582,8.62,56.516,8.566,56.581,8.518,56.541,8.299,56.552,8.185,56.633,8.212,56.71,8.123,56.557,8.097,56.054,8.175,55.761,8.073,55.56,8.293,55.477,8.237,55.567,8.3,55.592,8.676,55.333,8.53,55.279,8.659,55.304,8.685,55.156,8.519,55.209,8.464,55.102,8.475,55.103,8.521,55.08,8.464,55.098,8.494,55.067,8.507,55.059,8.517,55.06,8.557,55.15,8.686,55.144,8.631,54.9,8.375,54.894,8.396,55.046,8.463,55.046,8.4,55.053,8.299,54.912,8.28,54.752,8.31,54.874,8.591,54.885,8.813,54.597,8.89,54.592,8.807,54.47,8.982,54.521,9.023,54.473,8.609,54.386,8.685,54.357,8.582,54.3,8.852,54.266,8.843,54.288,8.991,54.332,8.994,54.339,8.982,54.346,8.98,54.357,8.989,54.368,9.007,54.379,9.022,54.379,9.065,54.364,9.089,54.371,9.104,54.365,9.135,54.35,9.138,54.339,9.155,54.336,9.147,54.32,9.171,54.317,9.161,54.328,9.168,54.333,9.177,54.335,9.197,54.331,9.191,54.316,9.212,54.327,9.225,54.323,9.22,54.346,9.226,54.322,9.191,54.315,9.176,54.334,9.174,54.317,9.161,54.316,9.148,54.319,9.144,54.322,9.151,54.336,9.134,54.338,9.135,54.349,9.093,54.369,9.069,54.362,9.008,54.378,8.994,54.332,8.933,54.279,8.844,54.263,8.826,54.145,8.927,54.132,8.986,54.061,8.82,54.024,8.915,53.925,9.138,53.891,9.283,53.987,9.329,54.127,9.341,54.141,9.381,54.157,9.445,54.174,9.522,54.191,9.345,54.141,9.285,53.986,9.134,53.887,9.407,53.835,9.677,53.57,9.966,53.545,10.04,53.529,10.061,53.514,10.067,53.461,10.176,53.397,10.307,53.435,10.705,53.37,11.078,53.142,11.099,53.141,11.129,53.167,11.147,53.17,11.251,53.136,11.339,53.056,11.399,53.073,11.456,53.079,11.638,53.041,11.845,52.953,11.859,52.942,11.857,52.934,11.832,52.912,11.871,52.91,11.953,52.888,11.971,52.874,12.006,52.875,11.971,52.871,11.83,52.91,11.634,53.039,11.338,53.054,11.149,53.167,11.106,53.141,11.08,53.139,10.702,53.369,10.173,53.395,9.96,53.482,9.938,53.503,9.937,53.532,9.929,53.539,9.686,53.548,9.59,53.585,9.54,53.612,9.27,53.859,9.08,53.86,9.046,53.799,8.667,53.891,8.484,53.694,8.577,53.538,8.501,53.477,8.519,53.319,8.484,53.235,8.87,53.06,8.877,53.054,8.868,53.034,8.884,53.024,8.481,53.227,8.48,53.458,8.55,53.538,8.272,53.61,8.231,53.521,8.316,53.522,8.315,53.46,8.211,53.402,8.064,53.478,8.065,53.506,8.155,53.514,8.172,53.537,8.11,53.642,7.911,53.718,7.297,53.68,7.092,53.58,7.132,53.532,7.038,53.537,7.014,53.343,7.34,53.321,7.401,53.288,7.43,53.225,7.424,53.21,7.412,53.199,7.333,53.318,7.111,53.251,6.751,53.466,5.878,53.396,5.581,53.296,5.378,53.094,5.29,53.065,5.048,52.941,5.378,53.092,5.4,53.055,5.379,53.05,5.408,53.021,5.41,52.921,5.359,52.88,5.712,52.837,5.595,52.763,5.65,52.616,5.432,52.527,5.469,52.569,5.458,52.593,5.417,52.636,5.413,52.636,5.399,52.65,5.361,52.675,5.302,52.691,5.287,52.691,5.28,52.695,5.282,52.743,5.103,52.775,5.038,52.943,4.874,52.888,4.728,52.96,4.567,52.467,4.601,52.474,4.659,52.464,4.751,52.43,4.869,52.428,5.015,52.371,5.138,52.461,5.064,52.437,5.012,52.472,5.085,52.504,5.019,52.63,5.28,52.691,5.456,52.593,5.417,52.512,5.439,52.516,5.394,52.49,5.222,52.418,5.227,52.424,5.121,52.385,5.137,52.326,5.032,52.334,4.86,52.397,4.853,52.415,4.774,52.4,4.573,52.462,4.387,52.197,4.099,51.984,4.29,51.9,4.505,51.919,4.516,51.914,4.525,51.901,4.562,51.909,4.625,51.89,4.721,51.898,4.833,51.941,4.914,51.943,4.937,51.964,4.999,51.978,5.03,51.97,5.041,51.981,5.067,51.996,5.096,52.003,5.119,52.003,5.343,51.962,5.444,51.916,5.465,51.887,5.358,51.801,5.007,51.819,5.254,51.733,5.042,51.71,4.82,51.71,4.737,51.723,4.579,51.676,4.422,51.699,4.386,51.645,4.204,51.622,4.279,51.482,4.226,51.494,4.245,51.366,4.402,51.233,4.384,51.209,4.336,51.191,4.33,51.142,4.324,51.129,4.31,51.121,4.383,51.08,4.212,51.119,4.174,51.096,4.191,51.082,4.187,51.061,4.2,51.058,4.191,51.04,4.187,51.041,4.197,51.055,4.185,51.061,4.188,51.081,4.172,51.088,4.174,51.102,4.397,51.233,4.205,51.375,3.975,51.406,3.815,51.33,3.504,51.407,3.182,51.36,2.369,51.037,1.931,50.995,1.402,51.166,1.363,51.32,1.426,51.394,0.896,51.336,0.76,51.361,0.764,51.384,0.727,51.399,0.717,51.412,0.726,51.423,0.675,51.376,0.466,51.374,0.702,51.472,0.404,51.443,0.273,51.455,0.212,51.483,0.789,51.522,0.96,51.62,0.833,51.574,0.732,51.577,0.866,51.615,0.647,51.637,0.92,51.626,0.949,51.732,0.739,51.688,0.77,51.709,0.712,51.715,0.692,51.727,0.694,51.734,0.852,51.742,0.849,51.784,0.879,51.783,0.89,51.778,0.894,51.774,0.894,51.783,0.903,51.795,0.937,51.806,0.924,51.807,0.969,51.813,0.991,51.807,0.964,51.852,1.041,51.771,1.159,51.784,1.289,51.861,1.212,51.88,1.293,51.948,1.12,51.94,1.064,51.946,1.057,51.95,1.279,51.96,1.267,51.988,1.186,52.007,1.167,52.019,1.155,52.033,1.156,52.038,1.284,51.992,1.319,51.932,1.391,51.99,1.363,52.005,1.341,52.03,1.313,52.079,1.318,52.088,1.325,52.09,1.327,52.095,1.394,51.986,1.466,52.049,1.5,52.071,1.51,52.069,1.523,52.081,1.537,52.08,1.537,52.09,1.566,52.101,1.567,52.111,1.595,52.141,1.583,52.14,1.572,52.143,1.568,52.156,1.511,52.151,1.502,52.165,1.555,52.165,1.598,52.14,1.568,52.099,1.54,52.089,1.539,52.076,1.517,52.067,1.491,52.061,1.468,52.048,1.463,52.041,1.58,52.086,1.764,52.48,1.698,52.722,1.342,52.921,0.994,52.978,0.528,52.975,0.358,52.814,0.389,52.759,0.037,52.904,0.349,53.128,0.155,53.476,-0.287,53.712,-0.689,53.698,-0.685,53.672,-0.695,53.653,-0.712,53.641,-0.698,53.612,-0.708,53.607,-0.728,53.606,-0.737,53.597,-0.698,53.698,-0.852,53.685,-0.863,53.691,-0.867,53.701,-0.849,53.728,-0.865,53.721,-0.889,53.727,-0.846,53.729,-0.851,53.686,-0.28,53.741,-0.108,53.636,0.106,53.631,0.111,53.573,0.124,53.656,-0.212,54.005,-0.075,54.117,-0.364,54.245,-0.574,54.482,-1.137,54.648,-1.14,54.615,-1.152,54.619,-1.158,54.605,-1.2,54.581,-1.211,54.581,-1.176,54.697,-1.296,54.764,-1.351,54.919,-1.373,54.91,-1.386,54.91,-1.392,54.912,-1.397,54.917,-1.402,54.913,-1.406,54.913,-1.441,54.913,-1.351,54.921,-1.408,55.008,-1.462,54.984,-1.526,54.984,-1.539,54.96,-1.555,54.959,-1.582,54.964,-1.59,54.97,-1.629,54.958,-1.666,54.963,-1.404,55.014,-1.564,55.165,-1.5,55.186,-1.624,55.389,-1.619,55.552,-1.842,55.643,-2.136,55.916,-2.679,56.063,-3.067,55.945,-3.674,56.012,-3.857,56.114,-3.687,56.047,-3.39,56.007,-2.957,56.21,-2.601,56.264,-2.889,56.361,-2.812,56.446,-3.258,56.351,-3.298,56.352,-3.372,56.381,-2.734,56.464,-2.538,56.566,-2.433,56.701,-2.515,56.699,-2.533,56.711,-2.232,56.863,-2.047,57.128,-2.01,57.355,-1.984,57.311,-1.772,57.469,-1.824,57.613,-2.005,57.699,-3.027,57.664,-3.347,57.725,-4.058,57.592,-4.181,57.485,-4.443,57.483,-4.235,57.5,-3.993,57.677,-4.422,57.581,-4.299,57.676,-3.976,57.693,-3.771,57.866,-4.049,57.815,-4.3,57.853,-4.356,57.877,-4.353,57.898,-4.38,57.9,-4.395,57.909,-4.401,57.918,-4.015,57.857,-4.082,57.958,-4.006,57.934,-3.107,58.371,-3.049,58.478,-3.131,58.519,-3.023,58.643,-3.344,58.648,-3.29,58.776,-3.231,58.771,-3.13,58.788,-3.258,58.787,-3.17,58.819,-3.212,58.837,-3.212,58.877,-3.393,58.924,-3.354,58.963,-3.262,58.962,-3.298,59.016,-3.212,58.996,-3.244,59.058,-3.197,58.994,-3.252,58.974,-3.203,58.912,-2.981,58.962,-2.833,58.871,-2.713,58.919,-2.707,58.973,-2.786,58.916,-2.847,58.953,-2.798,58.992,-2.887,58.96,-3.115,59.008,-3.005,59.074,-3.195,59.153,-2.963,59.289,-2.837,59.247,-2.989,59.317,-2.957,59.36,-2.381,59.399,-1.271,59.853,-1.278,59.989,-1.201,59.973,-1.223,60.1,-1.131,60.148,-1.217,60.174,-1.166,60.223,-1.229,60.23,-1.076,60.304,-1.185,60.352,-1.074,60.356,-1.14,60.383,-1.047,60.44,-1.257,60.399,-1.165,60.421,-1.235,60.494,-1.358,60.4,-1.308,60.494,-1.359,60.529,-1.309,60.634,-1.166,60.648,-1.187,60.522,-1.04,60.49,-1.001,60.579,-1.084,60.606,-0.985,60.701,-1.073,60.733,-0.936,60.784,-0.981,60.684,-0.83,60.685,-0.864,60.757,-0.801,60.759,-0.759,60.819,-0.924,60.816,-0.883,61.0,5.188,61.009,5.114,60.869,5.124,60.959,5.009,60.968,5.014,60.957,4.999,60.957,4.995,60.955,5.034,60.925,5.063,60.844,5.203,60.832],"holes":[[5.344,60.53,5.533,60.428,5.708,60.471,5.736,60.674,5.65,60.709,5.439,60.569,5.49,60.525,5.344,60.53],[4.679,51.822,5.354,51.805,5.457,51.897,5.092,52.001,4.621,51.889,4.679,51.822],[4.401,51.701,3.999,51.847,3.679,51.704,3.97,51.614,4.401,51.701],[4.183,51.649,3.983,51.582,4.22,51.436,3.628,51.575,3.896,51.559,3.849,51.604,3.575,51.594,3.434,51.526,3.575,51.439,3.72,51.474,3.677,51.448,3.812,51.386,3.974,51.462,4.234,51.393,4.183,51.649],[8.688,56.677,8.879,56.784,8.827,56.865,8.939,56.983,8.636,56.885,8.666,56.806,8.503,56.738,8.688,56.677]]}],"names":{"ar":"بحر الشمال","de":"Nordsee","es":"mar del Norte","fr":"mer du Nord","he":"הים הצפוני","hu":"Északi-tenger","it":"mare del Nord","nl":"Noordzee","pt":"Mar do Norte","zh":"北海"}},{"name":"Chukchi Sea","bbox":[-180.0,65.934,180.0,71.711],"area":79.555,"polygons":[{"outer":[-179.495,71.595,-179.275,71.592,-179.247,71.581,-179.124,71.591,-156.48,71.38,-157.065,71.153,-156.881,71.133,-156.9,71.115,-157.065,71.093,-157.068,71.153,-157.293,70.996,-157.767,70.876,-158.652,70.785,-159.072,70.817,-158.937,70.769,-159.276,70.758,-159.079,70.657,-159.475,70.785,-158.772,70.904,-159.178,70.874,-159.636,70.798,-160.118,70.599,-159.958,70.641,-159.726,70.485,-159.511,70.523,-159.261,70.518,-159.68,70.451,-159.792,70.486,-159.877,70.389,-159.826,70.347,-159.856,70.307,-159.82,70.284,-159.838,70.254,-159.758,70.215,-159.758,70.204,-159.764,70.194,-159.762,70.189,-159.746,70.174,-159.697,70.163,-159.678,70.149,-159.672,70.124,-159.57,70.118,-159.507,70.126,-159.382,70.126,-159.114,70.093,-159.04,70.078,-159.038,70.062,-159.464,70.126,-159.598,70.112,-159.607,70.116,-159.6,70.108,-159.605,70.101,-159.572,70.095,-159.551,70.094,-159.539,70.092,-159.543,70.086,-159.608,70.102,-159.612,70.117,-159.672,70.121,-159.673,70.116,-159.685,70.112,-159.671,70.103,-159.682,70.082,-159.692,70.08,-159.71,70.079,-159.71,70.072,-159.716,70.068,-159.735,70.058,-159.745,70.058,-159.685,70.149,-159.703,70.163,-159.742,70.17,-159.771,70.184,-159.856,70.252,-159.859,70.262,-159.853,70.268,-159.898,70.275,-159.96,70.373,-160.153,70.305,-160.227,70.263,-160.249,70.26,-160.25,70.255,-160.269,70.252,-159.902,70.467,-159.908,70.484,-160.062,70.46,-160.082,70.463,-160.104,70.458,-160.097,70.462,-160.18,70.46,-160.21,70.463,-159.898,70.524,-159.905,70.583,-160.165,70.578,-160.114,70.61,-160.798,70.378,-160.482,70.463,-160.524,70.442,-160.98,70.318,-160.893,70.285,-160.889,70.282,-160.893,70.279,-161.014,70.33,-161.728,70.2,-161.665,70.183,-161.664,70.165,-161.731,70.192,-161.835,70.152,-162.078,70.148,-162.028,70.101,-162.097,70.125,-162.11,70.142,-162.03,70.177,-161.727,70.213,-161.87,70.247,-161.735,70.234,-161.657,70.242,-161.899,70.307,-162.156,70.153,-162.35,70.093,-162.299,70.075,-162.316,70.07,-162.296,70.064,-162.301,70.057,-162.309,70.054,-162.226,70.039,-162.128,70.002,-162.105,69.976,-162.083,69.973,-162.031,69.958,-162.049,69.935,-162.024,69.942,-162.01,69.942,-161.867,69.923,-161.854,69.912,-161.815,69.926,-161.789,69.926,-161.77,69.921,-161.753,69.91,-161.752,69.905,-161.762,69.9,-161.76,69.898,-161.689,69.895,-161.678,69.88,-162.052,69.935,-162.036,69.959,-162.108,69.976,-162.188,70.022,-162.305,70.045,-162.332,70.084,-162.465,70.058,-162.437,70.045,-162.405,70.059,-162.388,70.054,-162.378,70.057,-162.364,70.055,-162.352,70.043,-162.316,70.045,-162.35,70.042,-162.359,70.045,-162.367,70.054,-162.375,70.056,-162.408,70.044,-162.456,70.045,-162.626,69.882,-162.66,69.895,-162.841,69.813,-162.902,69.809,-162.934,69.796,-162.927,69.791,-162.927,69.785,-162.922,69.783,-162.906,69.793,-162.89,69.792,-162.88,69.79,-162.872,69.785,-162.872,69.783,-162.924,69.782,-162.93,69.785,-162.94,69.796,-162.987,69.76,-163.01,69.757,-162.92,69.695,-163.05,69.656,-162.948,69.635,-162.947,69.629,-162.929,69.626,-162.904,69.618,-162.897,69.611,-163.114,69.591,-163.017,69.542,-163.115,69.332,-163.144,69.346,-163.219,69.288,-163.286,69.275,-163.19,69.338,-163.152,69.416,-163.499,69.146,-164.25,68.93,-165.668,68.845,-166.215,68.881,-166.226,68.577,-166.401,68.419,-166.336,68.437,-166.217,68.379,-166.286,68.374,-166.298,68.377,-166.312,68.393,-166.339,68.383,-166.34,68.392,-166.391,68.411,-166.418,68.4,-166.261,68.367,-166.752,68.359,-166.402,68.417,-166.41,68.418,-166.781,68.357,-166.84,68.339,-166.256,68.321,-166.316,68.305,-166.21,68.302,-165.964,68.136,-165.237,68.0,-164.138,67.639,-163.821,67.346,-163.867,67.416,-163.753,67.388,-163.821,67.345,-163.744,67.126,-163.667,67.1,-162.843,66.991,-162.964,67.024,-162.659,67.066,-162.744,67.05,-162.535,66.981,-161.821,67.048,-161.47,66.965,-161.709,66.94,-161.818,66.798,-161.733,66.807,-161.693,66.8,-161.683,66.808,-161.657,66.813,-161.648,66.812,-161.639,66.807,-161.615,66.802,-161.597,66.826,-161.564,66.832,-161.569,66.841,-161.56,66.846,-161.548,66.848,-161.56,66.852,-161.56,66.855,-161.545,66.862,-161.495,66.866,-161.482,66.862,-161.494,66.855,-161.482,66.854,-161.48,66.868,-161.473,66.872,-161.374,66.889,-161.304,66.893,-161.287,66.892,-161.238,66.882,-161.224,66.874,-161.2,66.874,-161.131,66.906,-161.078,66.905,-161.042,66.929,-161.029,66.925,-161.027,66.916,-160.995,66.912,-160.992,66.916,-161.008,66.921,-161.011,66.925,-161.006,66.932,-160.994,66.934,-160.977,66.935,-160.964,66.932,-160.924,66.908,-160.912,66.911,-160.893,66.929,-160.887,66.932,-160.879,66.933,-160.656,66.932,-160.646,66.93,-160.635,66.919,-160.612,66.917,-160.611,66.912,-160.595,66.916,-160.587,66.921,-160.577,66.923,-160.525,66.929,-160.517,66.927,-160.598,66.901,-160.816,66.867,-160.841,66.873,-160.868,66.897,-160.904,66.901,-160.94,66.887,-160.914,66.85,-160.918,66.842,-160.925,66.839,-160.935,66.837,-160.942,66.837,-160.946,66.838,-160.993,66.86,-160.986,66.834,-160.993,66.829,-161.019,66.827,-161.044,66.838,-161.075,66.788,-161.108,66.782,-161.067,66.774,-161.091,66.757,-161.046,66.763,-161.036,66.758,-161.036,66.752,-161.094,66.744,-161.137,66.752,-161.124,66.734,-161.145,66.728,-161.177,66.729,-161.204,66.695,-161.283,66.677,-161.266,66.622,-161.299,66.617,-161.288,66.614,-161.281,66.61,-161.258,66.606,-161.235,66.595,-161.233,66.59,-161.227,66.593,-161.227,66.579,-161.135,66.637,-160.946,66.655,-160.519,66.58,-160.527,66.605,-160.204,66.669,-160.185,66.656,-160.243,66.641,-160.255,66.646,-160.254,66.649,-160.3,66.649,-160.316,66.639,-160.243,66.636,-160.264,66.641,-160.25,66.643,-160.202,66.615,-160.172,66.654,-159.993,66.678,-160.01,66.638,-159.981,66.628,-159.91,66.679,-159.892,66.647,-159.885,66.647,-159.885,66.653,-159.876,66.655,-159.847,66.648,-159.841,66.654,-159.804,66.661,-159.785,66.647,-159.976,66.613,-159.877,66.643,-159.984,66.622,-160.179,66.63,-160.112,66.585,-160.319,66.597,-160.141,66.472,-160.124,66.531,-159.877,66.55,-159.977,66.548,-160.008,66.579,-160.019,66.611,-159.888,66.554,-159.799,66.58,-159.748,66.575,-159.724,66.562,-159.663,66.578,-159.616,66.563,-159.566,66.575,-159.471,66.579,-159.445,66.589,-159.372,66.602,-159.256,66.6,-159.247,66.599,-159.247,66.596,-159.273,66.587,-159.251,66.591,-159.24,66.59,-159.237,66.587,-159.261,66.571,-159.239,66.576,-159.229,66.577,-159.188,66.556,-159.189,66.553,-159.194,66.551,-159.235,66.548,-159.23,66.537,-159.158,66.533,-159.13,66.558,-159.142,66.559,-159.157,66.545,-159.166,66.542,-159.179,66.542,-159.184,66.545,-159.172,66.555,-159.196,66.563,-159.197,66.567,-159.131,66.599,-159.1,66.605,-159.092,66.602,-159.074,66.575,-159.022,66.567,-159.02,66.564,-159.079,66.576,-159.099,66.604,-159.195,66.566,-159.127,66.558,-159.137,66.538,-159.163,66.532,-159.233,66.537,-159.238,66.548,-159.189,66.554,-159.264,66.571,-159.275,66.589,-159.253,66.599,-159.297,66.58,-159.624,66.562,-159.666,66.575,-159.718,66.56,-159.817,66.574,-159.875,66.551,-159.809,66.543,-159.7,66.509,-159.653,66.408,-159.898,66.385,-159.985,66.496,-160.152,66.449,-160.196,66.483,-160.262,66.38,-160.784,66.364,-161.144,66.489,-161.169,66.506,-161.173,66.531,-161.178,66.538,-161.187,66.539,-161.637,66.455,-162.048,66.627,-162.011,66.78,-162.305,66.938,-162.604,66.896,-162.501,66.734,-162.266,66.761,-162.26,66.755,-162.296,66.719,-161.888,66.52,-161.942,66.324,-161.906,66.265,-161.863,66.263,-161.823,66.271,-161.906,66.355,-161.538,66.398,-161.128,66.333,-160.987,66.233,-161.004,66.215,-160.99,66.203,-161.006,66.178,-161.032,66.162,-161.053,66.155,-161.039,66.14,-161.041,66.134,-161.093,66.111,-161.188,66.1,-161.152,66.084,-161.133,66.041,-161.141,66.004,-161.184,66.006,-161.119,65.979,-161.112,65.955,-161.123,65.945,-161.101,65.94,-161.057,65.936,-161.052,65.934,-161.101,65.939,-161.123,65.944,-161.193,66.001,-161.151,66.061,-161.213,66.057,-161.19,66.041,-161.232,66.023,-161.204,66.096,-161.153,66.122,-161.095,66.123,-161.127,66.17,-161.077,66.195,-161.087,66.212,-161.054,66.228,-161.488,66.261,-161.779,66.063,-161.71,66.074,-161.829,66.0,-161.812,65.988,-161.791,65.988,-161.776,65.982,-161.774,65.978,-161.877,65.967,-161.878,65.938,-161.884,65.936,-161.898,65.936,-161.904,65.975,-161.869,65.999,-161.884,66.018,-161.95,65.993,-161.965,66.006,-161.927,66.034,-161.892,66.026,-161.841,66.023,-162.034,66.071,-162.618,66.039,-162.65,65.973,-162.671,65.963,-162.684,65.962,-162.678,66.036,-162.631,66.038,-162.746,66.101,-163.753,66.061,-163.905,66.196,-163.966,66.167,-163.964,66.197,-164.008,66.179,-164.191,66.182,-164.255,66.197,-163.828,66.27,-163.872,66.386,-163.726,66.515,-163.755,66.556,-163.852,66.549,-163.849,66.564,-163.908,66.556,-163.931,66.579,-163.98,66.577,-163.991,66.578,-163.992,66.583,-164.0,66.583,-163.98,66.585,-163.985,66.591,-163.962,66.594,-171.081,66.563,-171.363,66.674,-171.346,66.764,-171.676,66.854,-171.631,66.912,-171.72,66.951,-173.035,67.045,-173.006,67.014,-172.923,67.021,-172.539,66.985,-172.367,66.936,-172.758,66.89,-173.031,66.922,-173.074,66.995,-173.249,66.908,-173.082,66.839,-173.22,66.811,-173.324,66.834,-173.271,66.88,-173.317,66.968,-173.142,66.99,-173.314,67.058,-173.055,67.044,-173.648,67.114,-173.657,67.089,-173.727,67.076,-174.025,67.082,-174.277,67.081,-174.615,67.06,-174.169,67.08,-174.561,67.034,-174.208,67.054,-174.296,67.041,-174.194,67.024,-174.268,66.976,-174.026,66.954,-173.922,66.677,-174.264,66.569,-174.261,66.498,-174.261,66.567,-174.222,66.573,-173.689,66.431,-173.895,66.333,-173.799,66.305,-173.997,66.226,-173.938,66.205,-173.905,66.155,-173.954,66.115,-173.908,66.153,-173.948,66.196,-173.996,66.213,-174.002,66.216,-174.01,66.227,-173.969,66.262,-174.019,66.269,-174.008,66.232,-174.031,66.246,-174.019,66.226,-174.023,66.222,-174.034,66.22,-174.102,66.252,-173.928,66.328,-173.941,66.447,-174.209,66.469,-174.147,66.421,-174.408,66.277,-174.462,66.334,-174.393,66.35,-174.475,66.389,-174.465,66.462,-174.382,66.459,-174.443,66.532,-174.973,66.663,-174.7,66.723,-174.763,66.899,-174.691,66.915,-174.911,67.088,-174.847,67.256,-174.867,67.272,-174.829,67.298,-174.762,67.307,-174.741,67.233,-174.737,67.289,-174.816,67.384,-174.914,67.425,-175.052,67.469,-174.955,67.436,-175.063,67.353,-175.334,67.339,-175.166,67.429,-175.21,67.512,-175.064,67.469,-175.25,67.562,-175.282,67.668,-175.822,67.81,-175.759,67.785,-176.025,67.727,-175.868,67.716,-175.904,67.636,-175.736,67.646,-175.664,67.616,-175.718,67.569,-175.606,67.553,-175.655,67.534,-175.462,67.543,-175.517,67.518,-175.403,67.501,-175.363,67.466,-175.369,67.459,-175.403,67.448,-175.378,67.421,-175.4,67.403,-175.52,67.364,-175.55,67.362,-175.593,67.369,-175.673,67.352,-175.736,67.352,-175.739,67.336,-175.796,67.29,-176.095,67.278,-175.733,67.357,-175.691,67.352,-175.625,67.367,-175.552,67.371,-175.515,67.367,-175.391,67.414,-175.41,67.446,-175.367,67.464,-175.914,67.582,-175.888,67.632,-176.475,67.663,-176.746,67.731,-176.755,67.722,-176.844,67.702,-176.82,67.685,-176.825,67.681,-176.838,67.678,-176.822,67.676,-176.819,67.673,-176.819,67.672,-176.828,67.67,-176.816,67.665,-176.812,67.656,-176.814,67.625,-176.79,67.618,-176.783,67.613,-176.863,67.508,-176.953,67.472,-176.966,67.47,-176.987,67.477,-177.033,67.45,-177.053,67.446,-177.082,67.449,-177.098,67.441,-177.115,67.44,-177.148,67.399,-177.155,67.396,-177.186,67.391,-177.157,67.384,-177.188,67.36,-177.208,67.356,-177.249,67.362,-177.124,67.438,-176.996,67.479,-176.959,67.471,-176.865,67.509,-176.817,67.57,-176.834,67.613,-176.824,67.664,-176.844,67.676,-176.844,67.678,-176.831,67.67,-176.82,67.673,-176.841,67.678,-176.847,67.698,-177.004,67.685,-177.099,67.701,-176.661,67.738,-176.483,67.677,-176.384,67.682,-176.057,67.726,-176.13,67.791,-176.049,67.852,-175.843,67.813,-175.8,67.848,-175.982,67.861,-176.225,67.917,-176.531,67.999,-176.283,67.916,-176.29,67.924,-176.242,67.92,-176.207,67.907,-176.191,67.907,-176.174,67.901,-176.967,67.977,-176.726,68.037,-177.017,68.143,-177.113,68.111,-177.089,68.092,-177.11,68.065,-177.198,68.043,-177.154,68.031,-177.142,68.014,-177.146,68.005,-177.202,68.042,-177.103,68.097,-177.342,68.243,-177.52,68.194,-177.507,68.187,-177.507,68.168,-177.515,68.145,-177.499,68.136,-177.508,68.132,-177.515,68.131,-177.494,68.114,-177.507,68.096,-177.491,68.094,-177.459,68.078,-177.458,67.982,-177.473,67.941,-177.532,67.913,-177.586,67.893,-177.611,67.889,-177.653,67.891,-177.681,67.905,-177.721,67.904,-177.765,67.862,-177.824,67.839,-177.812,67.84,-178.223,67.748,-178.319,67.743,-178.376,67.762,-178.401,67.754,-178.431,67.754,-178.402,67.741,-178.407,67.742,-178.408,67.734,-178.425,67.727,-178.444,67.726,-178.51,67.744,-178.505,67.725,-178.537,67.707,-178.617,67.697,-178.647,67.696,-178.666,67.699,-178.659,67.703,-178.68,67.699,-178.695,67.647,-178.711,67.644,-178.668,67.614,-178.667,67.582,-178.773,67.406,-178.767,67.396,-178.724,67.386,-178.717,67.383,-178.715,67.378,-178.712,67.345,-178.758,67.327,-178.703,67.313,-178.697,67.305,-178.73,67.275,-178.786,67.267,-178.732,67.25,-178.717,67.208,-178.718,67.204,-178.723,67.201,-178.747,67.193,-178.764,67.193,-178.789,67.267,-178.698,67.305,-178.759,67.326,-178.717,67.379,-178.769,67.394,-178.777,67.408,-178.673,67.588,-178.706,67.613,-178.706,67.617,-178.693,67.624,-178.723,67.631,-178.714,67.665,-179.136,67.645,-179.361,67.659,-179.417,67.705,-179.41,67.719,-179.432,67.725,-179.452,67.744,-179.409,67.719,-179.361,67.66,-178.979,67.679,-178.949,67.669,-178.716,67.666,-178.706,67.687,-178.721,67.692,-178.718,67.696,-178.511,67.745,-178.454,67.738,-178.427,67.728,-178.434,67.754,-178.378,67.763,-178.226,67.75,-178.176,67.792,-178.094,67.81,-178.102,67.807,-177.979,67.815,-177.97,67.83,-177.945,67.841,-177.806,67.859,-177.802,67.868,-177.766,67.893,-177.73,67.906,-177.663,67.907,-177.641,67.917,-177.6,67.922,-177.597,67.92,-177.568,67.925,-177.556,67.969,-177.551,67.971,-177.532,67.972,-177.516,67.978,-177.539,67.981,-177.528,68.0,-177.538,68.005,-177.545,68.014,-177.541,68.021,-177.569,68.025,-177.622,68.063,-177.637,68.094,-177.632,68.106,-177.664,68.112,-177.67,68.128,-177.684,68.124,-177.758,68.236,-177.708,68.257,-177.61,68.272,-178.041,68.248,-177.908,68.317,-178.002,68.415,-178.321,68.481,-178.366,68.401,-178.399,68.395,-178.418,68.394,-178.431,68.384,-178.44,68.381,-178.452,68.383,-178.457,68.375,-178.477,68.371,-178.337,68.486,-178.715,68.531,-178.698,68.646,-178.82,68.741,-179.323,68.809,-179.392,68.88,-179.325,68.858,-179.206,68.832,-179.491,68.933,-179.727,68.892,-179.942,68.946,-180.0,68.977,-180.0,70.976,-179.457,70.88,-179.572,70.863,-179.382,70.887,-179.45,70.881,-179.453,70.9,-179.415,70.886,-179.185,70.892,-179.435,70.875,-179.241,70.883,-178.741,70.957,-177.923,71.02,-177.713,71.067,-177.995,71.072,-178.116,71.098,-177.697,71.072,-177.433,71.224,-177.716,71.259,-177.67,71.282,-177.712,71.293,-177.793,71.344,-177.856,71.373,-177.736,71.302,-177.8,71.336,-177.816,71.328,-177.834,71.329,-177.821,71.331,-177.876,71.34,-177.881,71.345,-177.889,71.341,-177.913,71.336,-177.93,71.337,-177.939,71.333,-177.958,71.332,-177.956,71.329,-177.976,71.323,-177.974,71.319,-177.98,71.313,-178.009,71.305,-178.039,71.304,-178.041,71.296,-178.056,71.291,-178.075,71.287,-178.106,71.284,-177.957,71.333,-177.878,71.346,-177.812,71.34,-177.973,71.402,-178.273,71.382,-178.022,71.402,-177.986,71.434,-178.512,71.554,-179.059,71.588,-178.939,71.523,-179.287,71.524,-179.569,71.583,-179.987,71.509,-180.0,71.711,-179.495,71.595],"holes":[[-161.816,66.686,-161.437,66.564,-161.041,66.755,-161.111,66.824,-161.26,66.795,-161.195,66.78,-161.212,66.763,-161.316,66.754,-161.197,66.78,-161.236,66.78,-161.262,66.792,-161.262,66.797,-160.821,66.92,-161.862,66.804,-161.497,66.764,-161.78,66.714,-161.685,66.745,-161.863,66.783,-161.816,66.686]]},{"outer":[178.296,69.427,178.265,69.451,178.001,69.47,177.687,69.54,177.738,69.518,177.671,69.514,177.479,69.591,178.762,70.783,180.0,70.976,180.0,68.977,179.333,69.207,179.391,69.229,179.332,69.245,179.319,69.253,178.795,69.26,178.709,69.301,178.748,69.367,178.951,69.34,178.969,69.33,178.945,69.295,178.949,69.283,178.962,69.278,179.101,69.309,179.078,69.311,178.962,69.352,179.19,69.291,179.207,69.269,179.25,69.274,179.393,69.232,179.4,69.231,179.221,69.285,178.932,69.363,178.492,69.451,178.37,69.459,178.234,69.454,178.333,69.456,178.354,69.451,178.355,69.431,178.296,69.427]}],"names":{"ar":"بحر تشوكشي","de":"Tschuktschensee","es":"mar de Chukotka.","fr":"mer des Tchouktches","he":"ים צ'וקצ'י","hu":"Csukcs-tenger","it":"Mare dei Ciukci","nl":"Tsjoektsjenzee","pt":"Mar de Chukchi","zh":"楚科奇海"}},{"name":"Arafura Sea","bbox":[130.759,-18.007,142.199,-2.903],"area":84.308,"polygons":[{"outer":[136.712,-15.95,136.705,-15.953,136.708,-15.958,136.723,-15.965,136.714,-15.958,136.714,-15.952,136.726,-15.959,136.721,-15.949,136.726,-15.952,136.725,-15.946,136.726,-15.936,136.728,-15.934,136.727,-15.972,136.746,-15.965,136.734,-15.956,136.742,-15.956,136.747,-15.945,136.733,-15.944,136.746,-15.939,136.729,-15.938,136.758,-15.903,136.752,-15.97,136.769,-15.916,136.793,-15.909,136.775,-15.92,136.781,-15.938,136.769,-15.944,136.773,-15.952,136.761,-15.959,136.762,-15.968,136.757,-15.963,136.761,-15.97,136.737,-15.979,136.769,-15.98,136.781,-15.952,136.771,-15.946,136.773,-15.944,136.824,-15.988,136.747,-16.006,136.657,-15.975,136.716,-15.876,136.676,-15.813,136.638,-15.862,136.628,-15.861,136.619,-15.851,136.599,-15.895,136.578,-15.91,136.477,-15.939,136.448,-15.903,136.418,-15.958,136.414,-15.961,136.405,-15.958,136.448,-15.902,136.526,-15.911,136.536,-15.887,136.53,-15.874,136.534,-15.861,136.598,-15.801,136.599,-15.754,136.604,-15.791,136.593,-15.818,136.532,-15.871,136.578,-15.908,136.724,-15.697,136.643,-15.673,136.576,-15.771,136.526,-15.759,136.32,-15.612,136.211,-15.393,135.746,-15.115,135.633,-15.223,135.41,-15.34,135.631,-15.198,135.633,-15.169,135.673,-15.105,135.705,-15.093,135.674,-15.075,135.655,-15.132,135.617,-15.148,135.668,-15.062,135.413,-14.954,135.367,-14.803,135.539,-14.654,135.534,-14.568,135.703,-14.272,135.902,-14.191,135.91,-13.992,136.081,-13.656,135.914,-13.745,135.825,-13.591,135.862,-13.436,135.967,-13.428,135.88,-13.363,135.912,-13.28,136.009,-13.215,136.047,-13.31,136.142,-13.125,136.135,-13.208,136.155,-13.265,136.182,-13.281,136.334,-13.045,136.389,-13.227,136.331,-13.325,136.469,-13.247,136.567,-13.059,136.454,-12.979,136.667,-13.004,136.481,-12.843,136.466,-12.777,136.484,-12.769,136.513,-12.804,136.543,-12.807,136.549,-12.74,136.586,-12.819,136.621,-12.839,136.622,-12.69,136.695,-12.711,136.693,-12.626,136.762,-12.578,136.747,-12.551,136.735,-12.57,136.697,-12.569,136.695,-12.572,136.697,-12.578,136.696,-12.586,136.678,-12.601,136.669,-12.592,136.729,-12.473,136.793,-12.452,136.76,-12.528,136.772,-12.556,136.98,-12.343,136.874,-12.375,136.928,-12.282,136.782,-12.159,136.683,-12.183,136.668,-12.204,136.698,-12.19,136.748,-12.219,136.766,-12.234,136.748,-12.267,136.648,-12.27,136.531,-11.992,136.484,-12.038,136.461,-11.996,136.582,-11.916,136.563,-11.876,136.167,-12.163,136.364,-12.251,136.394,-12.236,136.395,-12.225,136.382,-12.227,136.379,-12.225,136.377,-12.214,136.396,-12.195,136.401,-12.195,136.403,-12.204,136.412,-12.205,136.41,-12.19,136.42,-12.17,136.425,-12.167,136.412,-12.207,136.38,-12.212,136.397,-12.226,136.393,-12.241,136.344,-12.263,136.352,-12.272,136.362,-12.263,136.371,-12.265,136.38,-12.282,136.38,-12.285,136.218,-12.48,136.222,-12.542,136.212,-12.531,136.218,-12.519,136.213,-12.505,136.217,-12.479,136.225,-12.467,136.223,-12.462,136.065,-12.429,136.012,-12.577,136.018,-12.461,135.95,-12.459,136.016,-12.396,135.978,-12.394,135.949,-12.444,135.95,-12.422,135.925,-12.445,135.919,-12.447,135.915,-12.444,136.063,-12.24,135.959,-12.278,135.928,-12.223,135.973,-12.186,136.046,-12.063,135.881,-12.249,135.885,-12.15,135.725,-12.345,135.665,-12.322,135.724,-12.284,135.655,-12.157,135.913,-11.953,135.656,-12.038,135.584,-12.09,135.59,-12.094,135.579,-12.113,135.535,-12.151,135.518,-12.151,135.497,-12.145,135.481,-12.158,135.443,-12.163,135.426,-12.186,135.394,-12.179,135.344,-12.245,135.312,-12.257,135.279,-12.257,135.269,-12.254,135.271,-12.302,135.219,-12.294,135.251,-12.313,135.285,-12.369,135.308,-12.378,135.32,-12.386,135.283,-12.37,135.204,-12.29,135.165,-12.33,135.148,-12.237,135.051,-12.277,135.046,-12.275,135.041,-12.267,135.032,-12.284,135.053,-12.282,135.054,-12.285,135.048,-12.296,135.039,-12.305,135.031,-12.305,135.017,-12.297,135.018,-12.287,134.998,-12.318,135.018,-12.286,135.048,-12.296,135.052,-12.283,135.043,-12.29,135.034,-12.287,135.03,-12.283,135.041,-12.266,135.052,-12.275,135.056,-12.261,134.948,-12.147,134.8,-12.154,134.848,-12.143,134.854,-12.13,134.842,-12.113,134.841,-12.085,134.844,-12.063,134.803,-12.049,134.829,-12.086,134.829,-12.094,134.821,-12.107,134.829,-12.12,134.829,-12.123,134.827,-12.125,134.76,-11.95,134.598,-12.052,134.608,-12.092,134.592,-12.12,134.578,-12.124,134.597,-12.138,134.593,-12.164,134.576,-12.123,134.603,-12.089,134.582,-12.057,134.246,-11.983,134.253,-12.035,134.195,-12.074,134.19,-12.14,134.161,-12.167,134.154,-12.171,134.147,-12.171,134.125,-12.148,134.139,-12.179,134.136,-12.184,134.126,-12.183,134.124,-12.187,134.138,-12.187,134.142,-12.193,134.139,-12.198,134.121,-12.199,134.124,-12.147,134.183,-12.138,134.194,-12.029,134.151,-11.964,134.171,-11.972,134.177,-11.956,134.037,-11.855,133.904,-11.931,133.816,-11.863,133.922,-11.744,133.866,-11.755,133.837,-11.722,133.806,-11.735,133.801,-11.734,133.796,-11.728,133.63,-11.837,133.566,-11.806,133.525,-11.873,133.467,-11.903,133.448,-11.9,133.535,-11.761,133.159,-11.711,132.917,-11.332,132.864,-11.341,132.895,-11.428,132.665,-11.514,132.451,-11.218,132.374,-11.296,132.382,-11.209,132.333,-11.117,132.283,-11.275,132.135,-11.124,132.173,-11.324,132.237,-11.341,132.202,-11.417,131.994,-11.126,131.963,-11.252,131.882,-11.184,131.77,-11.311,130.759,-8.342,131.144,-8.158,131.066,-8.161,131.083,-8.04,131.286,-7.924,131.292,-8.031,131.342,-7.992,131.363,-7.871,131.632,-7.623,131.707,-7.423,131.634,-7.253,131.742,-7.223,131.718,-7.189,131.72,-7.163,131.974,-7.245,131.917,-7.103,131.98,-7.011,132.953,-5.875,132.962,-5.744,133.121,-5.563,133.156,-5.274,133.639,-4.306,133.395,-3.749,133.403,-3.596,133.368,-3.595,133.328,-3.554,133.305,-3.579,133.279,-3.586,133.269,-3.607,133.232,-3.594,133.33,-3.544,133.433,-3.597,133.423,-3.572,133.443,-3.553,133.438,-3.544,133.433,-3.55,133.426,-3.545,133.461,-3.543,133.424,-3.516,133.468,-3.516,133.472,-3.538,133.427,-3.576,133.439,-3.594,133.415,-3.618,133.413,-3.691,133.583,-3.572,133.551,-3.455,133.488,-3.455,133.439,-3.416,133.432,-3.416,133.431,-3.427,133.427,-3.428,133.431,-3.418,133.41,-3.415,133.406,-3.394,133.415,-3.371,133.429,-3.363,133.395,-3.344,133.39,-3.337,133.442,-3.358,133.417,-3.414,133.442,-3.408,133.482,-3.441,133.489,-3.308,133.538,-3.428,133.606,-3.41,133.634,-3.415,133.648,-3.122,133.793,-3.029,133.797,-2.903,133.855,-2.925,133.923,-3.122,133.835,-3.019,133.779,-3.213,133.701,-3.17,133.668,-3.227,133.664,-3.321,133.699,-3.404,133.701,-3.439,133.621,-3.476,133.69,-3.67,133.816,-3.728,133.82,-3.714,133.794,-3.66,133.774,-3.575,133.833,-3.556,133.96,-3.862,134.136,-3.742,134.172,-3.776,134.191,-3.833,134.105,-3.898,134.168,-3.895,134.29,-4.048,134.332,-3.88,134.506,-4.044,134.674,-3.921,134.933,-3.936,134.868,-3.999,134.681,-3.937,134.721,-4.058,134.629,-4.118,134.74,-4.123,134.733,-4.207,135.199,-4.463,135.356,-4.415,135.908,-4.484,136.05,-4.52,135.987,-4.543,136.039,-4.579,136.047,-4.558,136.068,-4.553,136.065,-4.545,136.06,-4.55,136.057,-4.55,136.055,-4.548,136.06,-4.534,136.069,-4.528,136.045,-4.509,136.047,-4.496,136.054,-4.497,136.052,-4.487,136.072,-4.481,136.069,-4.473,136.071,-4.461,136.067,-4.458,136.067,-4.456,136.068,-4.453,136.073,-4.452,136.073,-4.482,136.054,-4.487,136.055,-4.498,136.048,-4.497,136.047,-4.51,136.058,-4.514,136.066,-4.522,136.07,-4.53,136.068,-4.558,136.046,-4.568,136.053,-4.587,136.114,-4.623,136.133,-4.604,136.132,-4.59,136.141,-4.584,136.135,-4.57,136.14,-4.565,136.16,-4.584,136.157,-4.558,136.163,-4.552,136.162,-4.587,136.133,-4.625,136.187,-4.617,136.141,-4.632,136.209,-4.659,136.203,-4.649,136.2,-4.634,136.212,-4.612,136.232,-4.583,136.232,-4.663,136.35,-4.634,136.324,-4.666,136.458,-4.682,136.545,-4.791,136.58,-4.736,136.569,-4.797,136.691,-4.796,136.743,-4.873,136.743,-4.735,136.824,-4.785,136.787,-4.832,136.845,-4.829,136.845,-4.824,136.834,-4.824,136.831,-4.816,136.844,-4.771,136.861,-4.749,136.849,-4.734,136.853,-4.729,136.851,-4.726,136.854,-4.724,136.863,-4.75,136.835,-4.82,136.847,-4.822,136.866,-4.791,136.886,-4.784,136.856,-4.82,136.884,-4.82,136.873,-4.837,136.885,-4.847,136.883,-4.86,137.009,-4.832,136.925,-4.891,136.976,-4.94,137.062,-4.919,137.032,-4.883,137.052,-4.815,137.064,-4.859,137.101,-4.843,137.092,-4.841,137.082,-4.827,137.092,-4.806,137.071,-4.807,137.068,-4.803,137.092,-4.787,137.131,-4.824,137.058,-4.939,137.108,-4.974,137.152,-4.775,137.158,-4.875,137.178,-4.885,137.182,-4.86,137.197,-4.845,137.212,-4.844,137.165,-4.907,137.133,-4.904,137.18,-5.009,137.223,-4.962,137.197,-4.932,137.199,-4.886,137.217,-4.842,137.223,-4.848,137.242,-4.837,137.237,-4.838,137.235,-4.835,137.239,-4.824,137.236,-4.824,137.233,-4.821,137.235,-4.815,137.24,-4.824,137.236,-4.835,137.244,-4.836,137.202,-4.887,137.21,-4.941,137.263,-4.863,137.321,-4.889,137.236,-4.99,137.285,-5.026,137.295,-4.952,137.369,-4.925,137.367,-4.903,137.369,-4.891,137.354,-4.884,137.35,-4.862,137.337,-4.861,137.331,-4.858,137.329,-4.852,137.332,-4.846,137.331,-4.856,137.351,-4.861,137.369,-4.889,137.371,-4.91,137.437,-4.838,137.424,-4.913,137.379,-4.904,137.336,-5.006,137.291,-5.008,137.336,-5.043,137.395,-4.952,137.439,-4.919,137.459,-4.918,137.469,-4.924,137.455,-4.904,137.453,-4.896,137.453,-4.894,137.454,-4.892,137.459,-4.89,137.46,-4.886,137.487,-4.934,137.465,-4.995,137.417,-5.007,137.434,-5.054,137.415,-5.116,137.432,-5.119,137.456,-5.044,137.515,-5.005,137.49,-4.935,137.503,-4.938,137.497,-4.927,137.503,-4.917,137.494,-4.901,137.495,-4.865,137.498,-4.863,137.52,-5.003,137.529,-5.002,137.524,-4.994,137.534,-4.974,137.554,-4.954,137.568,-4.953,137.551,-5.008,137.454,-5.069,137.497,-5.146,137.595,-5.009,137.571,-4.988,137.576,-4.973,137.594,-4.975,137.603,-4.955,137.601,-4.953,137.598,-4.957,137.594,-4.958,137.58,-4.947,137.618,-4.934,137.584,-4.901,137.615,-4.929,137.633,-4.923,137.615,-4.918,137.596,-4.879,137.677,-4.862,137.614,-4.983,137.634,-5.007,137.645,-5.004,137.657,-5.009,137.668,-4.987,137.673,-4.987,137.68,-4.991,137.547,-5.142,137.629,-5.129,137.602,-5.106,137.602,-5.086,137.666,-5.021,137.683,-5.011,137.691,-5.015,137.691,-5.005,137.699,-4.997,137.67,-5.068,137.682,-5.053,137.71,-5.055,137.71,-5.05,137.713,-5.044,137.719,-5.043,137.717,-5.038,137.72,-5.03,137.726,-5.027,137.722,-5.024,137.725,-5.018,137.75,-5.011,137.734,-5.087,137.804,-5.045,137.828,-5.046,137.736,-5.09,137.748,-5.138,137.758,-5.103,137.811,-5.07,137.816,-5.076,137.82,-5.069,137.825,-5.077,137.838,-5.076,137.837,-5.069,137.829,-5.072,137.825,-5.072,137.824,-5.07,137.831,-5.062,137.835,-5.061,137.84,-5.065,137.847,-5.048,137.856,-5.054,137.838,-5.077,137.779,-5.092,137.777,-5.109,137.793,-5.111,137.797,-5.13,137.745,-5.184,137.688,-5.2,137.719,-5.276,137.836,-5.129,137.891,-5.097,137.893,-5.097,137.896,-5.1,137.894,-5.114,137.91,-5.117,137.912,-5.114,137.901,-5.113,137.898,-5.109,137.916,-5.094,137.962,-5.092,137.967,-5.081,137.959,-5.076,137.931,-5.048,137.93,-5.046,137.93,-5.041,137.969,-5.082,137.965,-5.091,137.948,-5.105,137.914,-5.096,137.899,-5.109,137.913,-5.112,137.914,-5.117,137.909,-5.119,137.893,-5.115,137.89,-5.099,137.806,-5.179,137.825,-5.231,137.728,-5.326,137.741,-5.374,137.746,-5.356,137.832,-5.298,137.836,-5.273,137.862,-5.222,137.88,-5.213,137.902,-5.214,137.916,-5.167,137.952,-5.139,138.021,-5.138,137.96,-5.142,137.91,-5.214,137.899,-5.218,137.88,-5.217,137.865,-5.224,137.882,-5.322,137.942,-5.202,137.987,-5.285,138.02,-5.246,138.061,-5.233,138.043,-5.19,138.054,-5.18,138.095,-5.176,138.093,-5.154,138.069,-5.148,138.065,-5.14,138.098,-5.098,138.07,-5.147,138.179,-5.098,138.182,-5.138,138.248,-5.141,138.237,-5.13,138.216,-5.124,138.214,-5.122,138.232,-5.086,138.26,-5.08,138.254,-5.071,138.256,-5.066,138.263,-5.066,138.271,-5.075,138.279,-5.07,138.27,-5.051,138.273,-5.021,138.301,-4.994,138.281,-5.071,138.216,-5.122,138.24,-5.13,138.25,-5.141,138.219,-5.146,138.262,-5.156,138.308,-5.102,138.339,-5.098,138.264,-5.16,138.187,-5.146,138.182,-5.181,138.142,-5.198,138.151,-5.268,138.05,-5.371,137.991,-5.369,138.024,-5.437,138.055,-5.374,138.127,-5.374,138.128,-5.349,138.114,-5.352,138.111,-5.35,138.109,-5.344,138.114,-5.319,138.13,-5.312,138.127,-5.301,138.129,-5.295,138.14,-5.37,138.178,-5.333,138.236,-5.323,138.274,-5.187,138.419,-5.145,138.429,-5.149,138.435,-5.166,138.465,-5.151,138.48,-5.156,138.483,-5.135,138.509,-5.123,138.499,-5.105,138.515,-5.102,138.501,-5.106,138.511,-5.122,138.482,-5.157,138.436,-5.168,138.43,-5.163,138.427,-5.15,138.421,-5.148,138.377,-5.174,138.369,-5.241,138.374,-5.176,138.273,-5.193,138.284,-5.271,138.239,-5.327,138.184,-5.339,138.164,-5.383,138.058,-5.388,138.056,-5.512,138.091,-5.518,138.077,-5.464,138.084,-5.452,138.091,-5.446,138.126,-5.533,138.133,-5.483,138.164,-5.439,138.174,-5.434,138.186,-5.44,138.201,-5.481,138.22,-5.452,138.276,-5.411,138.286,-5.378,138.302,-5.363,138.315,-5.368,138.337,-5.419,138.343,-5.453,138.371,-5.416,138.401,-5.421,138.419,-5.44,138.452,-5.351,138.501,-5.27,138.432,-5.426,138.441,-5.42,138.47,-5.421,138.473,-5.434,138.485,-5.421,138.509,-5.417,138.528,-5.42,138.544,-5.39,138.539,-5.417,138.524,-5.422,138.485,-5.422,138.473,-5.435,138.44,-5.422,138.416,-5.444,138.37,-5.419,138.343,-5.456,138.299,-5.368,138.278,-5.416,138.242,-5.445,138.297,-5.508,138.244,-5.597,138.353,-5.552,138.307,-5.59,138.342,-5.596,138.346,-5.599,138.349,-5.606,138.353,-5.601,138.368,-5.617,138.373,-5.631,138.375,-5.644,138.344,-5.659,138.402,-5.66,138.463,-5.544,138.466,-5.542,138.473,-5.541,138.491,-5.548,138.506,-5.542,138.526,-5.542,138.523,-5.526,138.528,-5.539,138.528,-5.542,138.524,-5.544,138.488,-5.549,138.465,-5.544,138.463,-5.579,138.676,-5.607,138.706,-5.625,138.679,-5.635,138.66,-5.63,138.633,-5.645,138.598,-5.655,138.539,-5.66,138.506,-5.657,138.454,-5.7,138.386,-5.716,138.383,-5.728,138.424,-5.728,138.464,-5.766,138.477,-5.714,138.49,-5.697,138.504,-5.696,138.466,-5.77,138.404,-5.733,138.415,-5.81,138.406,-5.832,138.445,-5.847,138.465,-5.865,138.466,-5.883,138.468,-5.884,138.484,-5.864,138.488,-5.862,138.516,-5.888,138.526,-5.878,138.539,-5.888,138.516,-5.889,138.487,-5.864,138.472,-5.882,138.466,-5.885,138.402,-5.833,138.254,-5.871,138.421,-6.347,138.647,-6.573,138.675,-6.571,138.68,-6.529,138.691,-6.525,138.701,-6.534,138.706,-6.522,138.71,-6.523,138.666,-6.588,138.825,-6.691,138.841,-6.788,138.874,-6.798,138.911,-6.781,138.936,-6.786,139.21,-6.987,139.215,-6.913,139.283,-6.896,139.28,-6.872,139.285,-6.861,139.294,-6.855,139.282,-6.901,139.219,-6.913,139.228,-6.999,139.219,-7.021,139.236,-7.013,139.258,-7.017,139.343,-7.043,139.475,-7.148,139.507,-7.264,139.562,-7.267,139.56,-7.218,139.573,-7.188,139.597,-7.166,139.631,-7.155,139.656,-7.18,139.665,-7.262,139.689,-7.285,139.698,-7.248,139.745,-7.215,139.745,-7.202,139.76,-7.17,139.759,-7.136,139.765,-7.115,139.792,-7.085,139.81,-7.076,139.894,-7.109,139.908,-7.184,139.928,-7.193,139.944,-7.184,139.997,-7.192,140.013,-7.15,140.058,-7.146,140.085,-7.114,140.13,-7.102,140.103,-7.081,140.092,-7.014,140.109,-6.981,140.131,-6.966,140.17,-6.956,140.166,-6.885,140.205,-6.833,140.223,-6.822,140.274,-6.82,140.326,-6.785,140.318,-6.75,140.333,-6.699,140.392,-6.68,140.328,-6.787,140.274,-6.823,140.221,-6.829,140.169,-6.885,140.175,-6.955,140.101,-7.007,140.131,-7.105,139.999,-7.195,139.941,-7.198,139.903,-7.185,139.868,-7.109,139.803,-7.083,139.767,-7.186,139.747,-7.222,139.683,-7.293,139.657,-7.293,139.639,-7.263,139.635,-7.228,139.648,-7.178,139.639,-7.185,139.613,-7.174,139.598,-7.185,139.58,-7.263,139.544,-7.285,139.466,-7.251,139.404,-7.131,139.354,-7.165,139.432,-7.2,139.437,-7.22,139.424,-7.234,139.266,-7.226,139.244,-7.15,139.171,-7.237,139.023,-7.24,138.915,-7.198,138.672,-7.199,138.869,-7.322,138.966,-7.521,139.11,-7.569,139.146,-7.513,139.16,-7.51,139.017,-7.855,138.914,-7.918,138.925,-8.075,138.836,-8.141,138.971,-8.33,139.003,-8.292,138.948,-8.186,138.957,-8.159,138.988,-8.203,139.223,-8.095,139.242,-8.068,139.226,-8.05,139.241,-7.996,139.236,-7.993,139.227,-8.006,139.219,-8.007,139.215,-7.999,139.219,-7.989,139.221,-8.005,139.236,-7.99,139.245,-7.997,139.246,-8.068,139.232,-8.121,139.341,-8.206,139.964,-8.1,140.061,-8.063,140.046,-8.017,140.069,-7.903,140.105,-7.909,140.131,-7.926,140.139,-7.88,140.213,-7.868,140.174,-7.835,140.192,-7.699,140.223,-7.695,140.255,-7.702,140.273,-7.715,140.27,-7.703,140.275,-7.713,140.272,-7.717,140.194,-7.7,140.215,-7.87,140.132,-7.929,140.071,-7.907,140.056,-7.992,140.078,-8.033,140.074,-8.07,139.967,-8.162,140.276,-8.348,140.227,-8.369,140.333,-8.468,140.386,-8.439,140.453,-8.477,140.458,-8.471,140.452,-8.455,140.456,-8.447,140.494,-8.438,140.453,-8.48,140.354,-8.475,140.834,-9.031,141.026,-9.117,142.163,-11.048,142.133,-11.391,142.098,-11.454,142.125,-11.433,142.089,-11.463,142.067,-11.538,142.089,-11.525,142.121,-11.527,142.13,-11.53,142.063,-11.548,142.057,-11.539,142.001,-11.744,142.091,-11.736,142.139,-11.771,141.985,-11.769,141.941,-11.969,141.971,-11.895,142.016,-12.044,142.04,-11.986,142.063,-11.978,142.136,-11.976,142.199,-12.038,142.059,-11.983,142.022,-12.076,141.957,-12.043,141.948,-12.15,141.928,-12.167,141.902,-12.176,141.92,-12.201,141.897,-12.234,141.945,-12.255,141.955,-12.273,141.982,-12.258,141.986,-12.261,141.991,-12.274,141.994,-12.264,142.007,-12.261,142.035,-12.263,142.046,-12.28,142.059,-12.273,142.046,-12.282,142.005,-12.263,141.997,-12.272,141.991,-12.275,141.982,-12.259,141.955,-12.274,141.894,-12.236,141.897,-12.178,141.946,-12.146,141.874,-11.959,141.726,-12.229,141.814,-12.242,141.716,-12.235,141.593,-12.558,141.695,-12.559,141.658,-12.466,141.683,-12.418,141.72,-12.387,141.665,-12.462,141.745,-12.482,141.763,-12.465,141.773,-12.469,141.692,-12.514,141.82,-12.602,142.061,-12.525,141.825,-12.652,141.926,-12.729,141.97,-12.712,141.965,-12.724,142.027,-12.705,142.038,-12.715,142.046,-12.705,142.061,-12.702,142.067,-12.704,142.01,-12.73,141.905,-12.747,141.956,-12.886,141.942,-12.913,141.808,-12.678,141.745,-12.848,141.6,-12.953,141.696,-13.263,141.655,-13.341,141.734,-13.259,141.684,-13.346,141.838,-13.372,141.723,-13.382,141.731,-13.393,141.732,-13.413,141.722,-13.43,141.705,-13.449,141.691,-13.433,141.712,-13.479,141.695,-13.568,141.684,-13.445,141.692,-13.429,141.692,-13.418,141.694,-13.404,141.704,-13.38,141.659,-13.395,141.661,-13.382,141.639,-13.378,141.631,-13.366,141.524,-13.57,141.466,-13.874,141.469,-13.921,141.472,-13.935,141.469,-13.906,141.469,-13.883,141.476,-13.864,141.493,-13.863,141.481,-13.862,141.48,-13.854,141.482,-13.848,141.518,-13.78,141.5,-13.767,141.506,-13.755,141.601,-13.889,141.594,-13.932,141.549,-13.916,141.56,-13.933,141.552,-13.95,141.47,-13.887,141.615,-14.185,141.526,-14.487,141.663,-15.004,141.636,-15.093,141.651,-15.106,141.655,-15.113,141.654,-15.122,141.667,-15.121,141.671,-15.131,141.658,-15.175,141.688,-15.188,141.691,-15.194,141.677,-15.244,141.694,-15.25,141.724,-15.22,141.727,-15.219,141.741,-15.222,141.77,-15.233,141.786,-15.249,141.801,-15.269,141.714,-15.232,141.71,-15.341,141.58,-15.21,141.54,-15.348,141.551,-15.362,141.555,-15.359,141.551,-15.349,141.555,-15.342,141.579,-15.338,141.594,-15.344,141.595,-15.36,141.602,-15.35,141.63,-15.341,141.633,-15.342,141.632,-15.346,141.637,-15.351,141.598,-15.364,141.556,-15.344,141.555,-15.362,141.537,-15.37,141.395,-15.905,141.409,-15.909,141.415,-15.879,141.422,-15.87,141.433,-15.87,141.427,-15.863,141.424,-15.853,141.423,-15.836,141.429,-15.832,141.438,-15.833,141.436,-15.847,141.469,-15.826,141.492,-15.831,141.492,-15.836,141.484,-15.843,141.486,-15.851,141.493,-15.853,141.49,-15.843,141.493,-15.839,141.497,-15.837,141.521,-15.846,141.528,-15.874,141.486,-15.853,141.482,-15.844,141.49,-15.833,141.436,-15.849,141.428,-15.833,141.435,-15.87,141.42,-15.896,141.41,-15.911,141.377,-15.919,141.432,-16.079,141.296,-16.398,141.329,-16.382,141.341,-16.392,141.353,-16.383,141.363,-16.393,141.377,-16.386,141.385,-16.395,141.392,-16.38,141.396,-16.378,141.405,-16.384,141.413,-16.368,141.429,-16.367,141.592,-16.455,141.29,-16.398,141.304,-16.478,141.259,-16.593,141.245,-16.597,141.256,-16.61,141.27,-16.6,141.275,-16.6,141.279,-16.603,141.282,-16.625,141.277,-16.63,141.271,-16.626,141.268,-16.633,141.279,-16.635,141.286,-16.64,141.295,-16.64,141.297,-16.654,141.302,-16.648,141.307,-16.648,141.313,-16.684,141.267,-16.634,141.278,-16.603,141.255,-16.612,141.244,-16.599,141.256,-16.554,140.958,-17.0,140.954,-17.178,140.837,-17.485,140.789,-17.525,140.901,-17.518,140.915,-17.544,140.894,-17.581,140.915,-17.578,140.892,-17.582,140.897,-17.519,140.8,-17.548,140.788,-17.532,140.789,-17.518,140.836,-17.48,140.814,-17.465,140.765,-17.479,140.736,-17.536,140.71,-17.552,140.712,-17.556,140.728,-17.553,140.737,-17.559,140.723,-17.591,140.745,-17.581,140.76,-17.585,140.763,-17.589,140.745,-17.63,140.754,-17.626,140.756,-17.627,140.755,-17.639,140.767,-17.64,140.769,-17.657,140.785,-17.647,140.789,-17.647,140.792,-17.65,140.807,-17.688,140.796,-17.728,140.805,-17.727,140.808,-17.734,140.807,-17.739,140.803,-17.742,140.79,-17.74,140.783,-17.749,140.786,-17.754,140.797,-17.756,140.802,-17.761,140.833,-17.84,140.829,-17.853,140.782,-17.75,140.784,-17.74,140.807,-17.736,140.793,-17.728,140.79,-17.65,140.77,-17.658,140.744,-17.63,140.762,-17.589,140.722,-17.592,140.708,-17.554,140.727,-17.512,140.596,-17.595,140.625,-17.604,140.638,-17.623,140.637,-17.628,140.631,-17.631,140.614,-17.623,140.62,-17.638,140.606,-17.653,140.612,-17.658,140.62,-17.651,140.624,-17.649,140.627,-17.651,140.65,-17.692,140.637,-17.705,140.605,-17.654,140.611,-17.625,140.614,-17.622,140.62,-17.622,140.633,-17.629,140.636,-17.623,140.584,-17.596,140.221,-17.732,140.0,-17.713,139.821,-17.569,139.819,-17.698,139.806,-17.704,139.787,-17.69,139.78,-17.696,139.778,-17.712,139.795,-17.712,139.798,-17.716,139.8,-17.723,139.775,-17.743,139.787,-17.765,139.775,-17.788,139.753,-17.802,139.762,-17.813,139.764,-17.823,139.761,-17.829,139.746,-17.842,139.751,-17.852,139.768,-17.858,139.774,-17.867,139.771,-17.88,139.753,-17.904,139.753,-17.916,139.784,-17.908,139.801,-17.916,139.818,-17.934,139.818,-18.007,139.816,-17.933,139.751,-17.916,139.744,-17.845,139.796,-17.573,139.722,-17.67,139.661,-17.688,139.649,-17.708,139.661,-17.724,139.656,-17.741,139.644,-17.705,139.755,-17.566,139.608,-17.505,139.602,-17.535,139.579,-17.546,139.592,-17.554,139.591,-17.56,139.585,-17.566,139.576,-17.568,139.559,-17.563,139.554,-17.55,139.55,-17.575,139.547,-17.577,139.539,-17.578,139.549,-17.574,139.543,-17.558,139.554,-17.549,139.591,-17.558,139.578,-17.545,139.604,-17.515,139.59,-17.48,139.497,-17.524,139.532,-17.437,139.238,-17.319,139.157,-17.03,139.038,-16.912,138.629,-16.768,138.139,-16.712,137.747,-16.265,137.747,-16.284,137.731,-16.321,137.675,-16.371,137.655,-16.368,137.651,-16.393,137.657,-16.399,137.655,-16.406,137.624,-16.439,137.593,-16.456,137.732,-16.237,137.414,-16.178,137.266,-16.034,137.26,-16.056,137.242,-16.08,137.219,-16.102,137.198,-16.104,137.176,-16.159,137.113,-16.204,137.174,-16.159,137.197,-16.103,137.243,-16.076,137.247,-16.039,137.266,-16.026,137.0,-15.87,137.021,-15.901,137.031,-15.941,137.03,-15.948,137.027,-15.951,136.956,-15.874,136.938,-15.938,136.77,-15.89,136.696,-15.914,136.724,-15.936,136.725,-15.943,136.712,-15.95],"holes":[[139.474,-16.442,139.242,-16.504,139.162,-16.666,139.288,-16.735,139.375,-16.625,139.452,-16.668,139.548,-16.463,139.594,-16.555,139.712,-16.531,139.604,-16.403,139.474,-16.442],[136.901,-14.307,136.963,-14.167,136.729,-14.191,136.699,-14.124,136.81,-14.027,136.717,-13.936,136.858,-13.913,136.91,-13.771,136.728,-13.843,136.728,-13.652,136.579,-13.687,136.639,-13.784,136.409,-13.831,136.443,-14.121,136.326,-14.245,136.429,-14.196,136.901,-14.307],[137.672,-8.438,138.301,-8.392,138.332,-8.3,138.34,-8.379,138.472,-8.349,138.679,-8.15,138.913,-8.076,138.896,-7.9,138.995,-7.849,139.088,-7.574,138.913,-7.535,138.745,-7.37,138.195,-7.484,137.976,-7.71,138.058,-7.767,138.076,-7.696,138.106,-7.788,137.96,-7.734,137.798,-8.0,138.055,-8.046,137.873,-8.047,138.006,-8.131,137.989,-8.251,138.004,-8.132,137.789,-8.051,137.672,-8.438],[138.811,-8.14,138.68,-8.17,138.559,-8.348,138.905,-8.382,138.811,-8.14],[139.033,-7.201,139.18,-7.213,139.238,-7.14,139.273,-7.217,139.43,-7.217,139.342,-7.183,139.345,-7.048,138.936,-6.843,138.566,-6.917,138.759,-7.116,139.033,-7.201],[134.199,-6.941,134.531,-6.585,134.466,-6.566,134.46,-6.433,134.326,-6.409,134.304,-6.293,134.112,-6.16,134.116,-6.417,134.216,-6.428,134.297,-6.632,134.116,-6.449,134.051,-6.774,134.094,-6.85,134.194,-6.836,134.199,-6.941],[134.31,-6.006,134.429,-6.019,134.625,-5.858,134.586,-5.935,134.633,-5.867,134.745,-5.967,134.701,-5.911,134.769,-5.853,134.671,-5.851,134.714,-5.817,134.504,-5.532,134.349,-5.725,134.251,-5.662,134.214,-5.709,134.442,-5.799,134.299,-5.898,134.31,-6.006],[134.667,-5.978,134.535,-5.919,134.283,-6.045,134.284,-6.146,134.623,-6.374,134.728,-6.308,134.767,-6.159,134.678,-6.191,134.782,-6.094,134.667,-5.978]]}],"names":{"ar":"بحر آرافورا","de":"Arafurasee","es":"mar de Arafura","fr":"mer d'Arafura","he":"ים אראפורה","hu":"Arafura-tenger","it":"Mare degli Arafura","nl":"Arafurazee","pt":"Mar de Arafura","zh":"阿拉弗拉海"}},{"name":"Mediterranean Sea","bbox":[10.016,30.068,36.216,37.793],"area":113.5667,"polygons":[{"outer":[12.431,37.793,12.673,37.559,12.916,37.577,13.165,37.493,13.751,37.149,14.251,37.062,14.493,36.786,15.077,36.644,22.483,36.385,22.477,36.614,22.565,36.775,22.768,36.803,22.968,36.524,22.908,36.199,23.594,35.634,23.517,35.288,24.727,35.091,24.734,34.921,25.375,34.998,25.814,34.999,25.977,35.039,26.116,34.993,26.231,35.033,26.318,35.196,27.143,35.398,27.242,35.493,27.201,35.532,27.953,36.045,28.087,36.054,28.247,36.386,28.303,36.73,28.234,36.807,28.277,36.852,28.271,36.797,28.406,36.788,28.446,36.881,28.846,36.588,28.929,36.755,29.092,36.679,29.008,36.544,29.126,36.542,29.101,36.387,29.352,36.23,29.632,36.205,29.584,36.186,29.645,36.196,29.683,36.13,29.923,36.234,30.116,36.247,30.189,36.314,30.408,36.275,30.414,36.215,30.53,36.337,30.477,36.399,30.591,36.589,30.575,36.795,30.698,36.885,31.324,36.811,32.025,36.539,32.389,36.163,32.803,36.015,33.147,36.138,33.473,36.159,33.561,36.125,33.648,36.194,33.69,36.135,33.699,36.14,33.699,36.174,33.881,36.318,33.963,36.232,34.261,36.571,34.654,36.807,35.325,36.559,35.164,36.676,35.181,36.693,35.251,36.678,35.346,36.603,35.339,36.539,35.559,36.564,35.527,36.673,35.568,36.565,35.724,36.712,35.585,36.679,35.564,36.713,35.795,36.766,36.017,36.93,36.199,36.794,36.216,36.66,35.78,36.319,35.981,36.011,35.8,35.854,35.845,35.743,35.717,35.585,35.915,35.417,35.953,35.194,35.854,34.91,35.986,34.53,35.651,34.281,35.644,34.003,35.469,33.901,35.355,33.515,35.108,33.107,35.071,32.863,34.954,32.825,34.653,31.835,34.332,31.421,33.869,31.159,33.54,31.115,33.465,31.131,33.41,31.175,33.274,31.215,33.413,31.159,33.135,31.043,33.104,31.135,33.16,31.11,33.11,31.19,32.954,31.101,33.063,31.121,33.008,31.049,32.983,31.086,32.928,31.103,32.71,31.029,32.669,31.048,32.794,31.079,32.853,31.109,32.928,31.137,32.602,31.058,32.359,31.25,32.31,31.094,32.346,30.709,32.327,30.621,32.305,30.568,32.441,30.351,32.45,30.286,32.563,30.235,32.576,30.199,32.573,30.068,32.524,30.245,32.303,30.341,32.357,30.412,32.27,30.583,32.343,30.706,32.323,30.747,32.314,30.789,32.302,31.229,32.302,31.255,32.309,31.257,32.343,31.298,32.189,31.296,31.923,31.525,31.559,31.443,31.051,31.602,30.369,31.475,30.386,31.444,30.421,31.439,30.429,31.432,30.461,31.337,30.476,31.348,30.485,31.349,30.522,31.326,30.52,31.271,30.497,31.239,30.526,31.229,30.522,31.201,30.556,31.209,30.581,31.193,30.724,31.068,30.728,31.05,30.721,31.022,30.755,31.004,30.786,30.932,30.795,30.853,30.786,30.839,30.764,30.821,30.795,30.829,30.805,30.828,30.813,30.823,30.791,30.757,30.754,30.766,30.811,30.823,30.77,30.816,30.762,30.822,30.782,30.931,30.716,31.019,30.723,31.066,30.639,31.113,30.569,31.198,30.539,31.186,30.522,31.189,30.495,31.239,30.518,31.326,30.462,31.333,30.363,31.466,30.245,31.294,30.07,31.33,29.876,31.211,29.827,31.139,29.464,30.929,29.02,30.822,28.558,31.053,27.927,31.097,27.896,31.118,27.892,31.17,27.871,31.19,27.869,31.238,27.855,31.247,27.675,31.177,27.45,31.214,27.342,31.373,26.983,31.443,25.894,31.629,25.212,31.519,24.98,31.971,24.088,32.012,23.719,32.178,23.317,32.214,23.317,32.154,23.096,32.312,23.159,32.456,23.105,32.636,22.145,32.938,21.708,32.94,21.409,32.787,21.061,32.761,20.553,32.533,20.089,32.185,19.947,31.975,19.921,31.759,20.175,31.152,20.07,30.874,19.615,30.415,19.056,30.268,18.673,30.41,18.224,30.765,17.852,30.925,16.764,31.212,16.124,31.252,15.733,31.402,15.483,31.678,15.358,32.166,15.226,32.374,14.491,32.507,14.244,32.693,13.598,32.791,13.386,32.898,13.212,32.921,12.756,32.794,12.341,32.823,11.324,33.276,11.492,33.164,11.179,33.205,11.129,33.315,11.159,33.319,11.31,33.271,11.314,33.271,11.322,33.277,11.092,33.362,11.139,33.388,11.124,33.491,11.048,33.626,10.919,33.669,10.931,33.526,10.75,33.474,10.681,33.533,10.733,33.701,10.587,33.631,10.457,33.633,10.123,33.881,10.016,34.168,10.088,34.28,10.723,34.652,11.167,35.23,11.035,35.344,11.05,35.624,10.832,35.698,10.818,35.717,10.834,35.79,10.652,35.81,10.484,36.053,10.523,36.345,10.81,36.458,10.954,36.718,11.128,36.849,11.042,37.085,12.431,37.793],"holes":[[32.302,34.95,32.434,34.735,32.704,34.64,32.875,34.666,32.933,34.566,33.029,34.562,33.065,34.686,33.604,34.817,33.679,34.972,34.085,34.956,33.906,35.257,34.591,35.697,33.647,35.357,32.921,35.404,32.882,35.163,32.551,35.175,32.402,35.039,32.285,35.106,32.302,34.95]]}],"names":{"ar":"البحر الأبيض المتوسط","de":"Mittelmeer","es":"Mediterráneo","fr":"mer Méditerranée","he":"הים התיכון","hu":"Földközi-tenger","it":"mar Mediterraneo","nl":"Middellandse Zee","pt":"Mar Mediterrâneo","zh":"地中海"}},{"name":"Sea of Japan","bbox":[125.801,32.575,142.264,51.747],"area":113.6219,"polygons":[{"outer":[141.787,51.689,142.072,51.48,142.254,51.142,142.086,50.821,142.044,50.55,142.153,50.365,142.175,50.05,142.085,49.135,142.031,49.148,141.979,48.876,141.847,48.767,142.085,48.412,142.024,48.523,142.12,48.485,142.064,48.449,142.149,48.309,142.204,47.983,141.962,47.62,141.964,47.298,142.062,47.107,141.81,46.601,141.916,46.047,142.079,45.895,141.932,45.52,141.797,45.407,141.642,45.449,141.568,45.204,141.729,44.905,141.713,44.948,141.715,44.955,141.727,44.964,141.763,44.986,141.792,44.993,141.849,44.995,141.861,44.993,141.874,44.969,141.877,44.956,141.875,44.931,141.917,44.919,141.919,44.916,141.91,44.907,141.988,44.917,141.991,44.916,142.0,44.899,142.016,44.896,142.05,44.878,142.066,44.862,142.078,44.84,142.068,44.773,142.045,44.738,142.126,44.753,142.132,44.749,142.137,44.737,142.158,44.734,142.173,44.724,142.175,44.726,142.18,44.739,142.184,44.743,142.252,44.731,142.264,44.719,142.185,44.742,142.182,44.739,142.178,44.729,142.173,44.724,142.167,44.725,142.137,44.736,142.124,44.752,142.044,44.737,142.061,44.86,141.911,44.906,141.874,44.931,141.858,44.992,141.718,44.954,141.776,44.766,141.791,44.625,141.647,44.313,141.648,43.945,141.329,43.725,141.431,43.397,141.424,43.328,141.351,43.235,141.38,43.226,141.449,43.156,141.574,43.118,141.614,43.138,141.615,43.172,141.661,43.222,141.654,43.294,141.665,43.309,141.679,43.318,141.663,43.224,141.611,43.131,141.571,43.113,141.449,43.151,141.347,43.232,141.369,43.268,141.162,43.144,141.015,43.239,140.795,43.194,140.463,43.372,140.346,43.333,140.326,43.229,140.53,43.024,140.364,42.879,140.387,42.865,140.393,42.845,140.405,42.846,140.403,42.837,140.421,42.84,140.428,42.83,140.402,42.835,140.359,42.877,140.26,42.761,140.186,42.824,139.822,42.611,139.853,42.465,139.775,42.259,140.135,41.974,139.982,41.608,140.041,41.447,140.2,41.398,140.409,41.516,140.445,41.684,140.673,41.822,140.701,41.74,140.968,41.71,141.185,41.806,141.462,41.431,141.291,41.351,140.898,41.526,140.764,41.146,141.058,41.183,141.194,41.291,141.278,41.157,141.133,40.873,140.89,41.011,140.849,40.876,140.723,40.838,140.636,41.187,140.344,41.261,140.249,41.125,140.412,41.031,140.368,41.019,140.383,41.004,140.393,40.985,140.393,40.977,140.382,40.962,140.385,40.946,140.406,40.939,140.415,40.929,140.425,40.9,140.422,40.887,140.322,41.036,140.241,40.789,139.994,40.745,139.861,40.616,140.025,40.354,140.004,40.223,140.043,40.215,140.051,40.194,140.066,40.207,140.153,40.23,140.192,40.23,140.211,40.212,140.223,40.194,140.251,40.216,140.224,40.192,140.154,40.229,140.054,40.192,140.043,40.194,140.037,40.216,140.007,40.221,139.886,40.0,139.7,40.008,139.705,39.928,139.86,39.854,139.959,39.906,139.922,39.956,139.929,40.023,139.983,40.093,140.0,40.094,140.056,40.069,140.074,40.04,140.073,40.012,140.047,39.875,139.949,39.89,140.191,39.594,140.06,39.69,140.052,39.394,139.919,39.28,139.811,38.945,139.849,38.885,139.867,38.877,139.888,38.884,139.923,38.88,139.94,38.873,139.964,38.823,139.976,38.819,139.992,38.809,139.996,38.793,140.013,38.795,140.027,38.79,140.033,38.777,139.997,38.792,139.964,38.822,139.938,38.873,139.917,38.88,139.869,38.874,139.851,38.88,139.807,38.921,139.456,38.397,139.444,38.139,139.135,37.959,139.177,37.87,139.155,37.81,139.177,37.805,139.179,37.787,139.217,37.77,139.226,37.75,139.32,37.744,139.328,37.733,139.344,37.728,139.361,37.713,139.373,37.723,139.383,37.718,139.361,37.712,139.328,37.732,139.317,37.743,139.228,37.747,139.175,37.786,139.151,37.813,139.171,37.87,139.153,37.89,139.129,37.958,139.07,37.953,139.064,37.928,139.061,37.931,139.049,37.915,139.013,37.897,139.025,37.871,139.027,37.844,139.057,37.84,139.063,37.834,139.062,37.828,139.024,37.845,139.001,37.909,138.788,37.725,138.776,37.669,138.791,37.662,138.812,37.65,138.84,37.616,138.852,37.49,138.848,37.466,138.837,37.457,138.834,37.444,138.824,37.431,138.816,37.403,138.81,37.398,138.828,37.344,138.814,37.293,138.808,37.399,138.849,37.494,138.838,37.615,138.774,37.666,138.544,37.368,138.246,37.185,138.268,37.11,138.246,37.181,138.101,37.174,137.49,36.954,137.391,36.805,137.206,36.716,136.985,36.865,137.057,37.108,136.973,37.047,136.86,37.108,136.914,37.229,137.011,37.184,137.234,37.296,137.346,37.514,136.756,37.364,136.67,37.147,136.77,36.997,136.729,36.796,136.419,36.421,136.124,36.25,136.149,36.212,136.159,36.185,136.149,36.172,136.163,36.159,136.168,36.145,136.178,36.138,136.184,36.11,136.17,36.116,136.177,36.136,136.166,36.143,136.146,36.171,136.133,36.218,135.965,35.998,136.1,35.778,136.075,35.662,136.017,35.763,135.98,35.629,135.818,35.644,135.834,35.536,135.715,35.569,135.717,35.484,135.582,35.49,135.66,35.546,135.516,35.495,135.518,35.55,135.462,35.603,135.339,35.547,135.397,35.479,135.314,35.452,135.32,35.525,135.291,35.514,135.284,35.461,135.255,35.428,135.248,35.424,135.217,35.424,135.287,35.516,135.255,35.599,135.19,35.566,135.303,35.662,135.309,35.694,135.223,35.779,134.805,35.603,134.798,35.67,134.503,35.663,134.216,35.504,133.569,35.53,133.371,35.443,133.263,35.551,133.198,35.529,133.321,35.433,133.32,35.413,133.222,35.455,133.126,35.434,133.055,35.467,132.891,35.405,132.834,35.425,132.79,35.348,132.786,35.397,132.874,35.459,132.972,35.48,133.077,35.473,133.122,35.451,133.155,35.537,133.326,35.567,133.141,35.561,133.095,35.604,132.642,35.441,132.686,35.359,132.63,35.288,132.417,35.191,131.826,34.678,131.603,34.662,131.394,34.456,131.394,34.398,131.156,34.372,130.973,34.442,130.936,34.391,131.017,34.404,131.036,34.371,130.891,34.346,130.933,34.188,130.861,34.105,130.915,34.044,130.777,33.946,130.842,33.938,130.808,33.877,130.742,33.874,130.815,33.903,130.769,33.947,130.659,33.909,130.68,33.893,130.707,33.817,130.661,33.899,130.524,33.889,130.448,33.726,130.285,33.68,130.43,33.686,130.435,33.635,130.393,33.597,130.28,33.58,130.208,33.667,130.026,33.447,129.932,33.55,129.875,33.516,129.878,33.536,129.852,33.556,129.845,33.556,129.858,33.454,129.807,33.485,129.782,33.451,129.867,33.407,129.855,33.273,129.788,33.372,129.568,33.377,129.625,33.31,129.553,33.216,129.64,33.233,129.666,33.101,129.715,33.166,129.785,33.141,129.742,33.061,129.768,33.1,129.932,33.027,130.009,32.847,129.788,32.867,129.822,32.983,129.738,32.989,129.733,33.017,129.762,33.049,129.677,33.099,129.633,32.925,129.776,32.819,129.819,32.72,129.869,32.748,129.755,32.584,128.774,32.575,128.77,32.644,128.897,32.65,128.804,32.753,128.81,32.799,128.773,32.734,128.651,32.776,128.675,32.671,128.645,32.603,126.291,33.195,126.83,33.305,126.947,33.456,126.821,33.561,126.313,33.47,126.161,33.29,125.801,34.206,126.013,34.36,125.995,34.326,126.038,34.33,126.06,34.32,126.041,34.407,126.181,34.351,126.319,34.425,126.337,34.401,126.383,34.502,126.244,34.588,126.266,34.637,126.462,34.544,126.528,34.293,126.606,34.316,126.617,34.402,126.749,34.473,126.739,34.485,126.766,34.499,126.77,34.616,126.787,34.64,126.786,34.469,126.845,34.451,126.885,34.421,126.934,34.494,126.979,34.471,127.009,34.607,126.994,34.623,126.971,34.621,126.954,34.631,127.326,34.752,127.316,34.666,127.252,34.711,127.112,34.546,127.208,34.547,127.328,34.441,127.326,34.476,127.436,34.535,127.353,34.585,127.389,34.6,127.487,34.573,127.509,34.594,127.377,34.726,127.428,34.814,127.359,34.837,127.517,34.879,127.603,34.745,127.548,34.713,127.58,34.63,127.588,34.652,127.637,34.626,127.651,34.635,127.614,34.707,127.657,34.755,127.699,34.72,127.735,34.738,127.769,34.744,127.778,34.858,127.639,34.822,127.574,34.89,127.785,35.008,127.769,35.032,127.766,35.05,127.741,35.06,127.715,35.082,127.693,35.104,127.691,35.13,127.645,35.162,127.639,35.172,127.693,35.133,127.697,35.102,127.769,35.052,127.788,35.01,127.766,34.97,127.786,34.941,127.924,35.017,128.008,34.982,128.027,35.11,128.04,34.941,128.133,34.889,128.273,34.9,128.332,34.956,128.355,34.908,128.309,34.885,128.42,34.865,128.33,34.826,128.336,34.817,128.359,34.826,128.379,34.815,128.424,34.761,128.475,34.89,128.422,34.873,128.408,34.995,128.501,35.02,128.469,35.076,128.439,35.046,128.359,35.01,128.366,35.047,128.538,35.12,128.583,35.091,128.542,35.069,128.556,35.053,128.635,35.068,128.575,35.132,128.598,35.141,128.587,35.21,128.692,35.098,128.792,35.119,128.828,34.988,128.822,35.097,128.884,35.079,128.904,35.201,128.937,35.226,128.992,35.232,128.906,35.198,128.912,35.079,128.956,35.133,128.961,35.186,128.991,35.209,128.997,35.239,129.01,35.271,129.017,35.271,128.997,35.207,128.964,35.174,128.965,35.033,129.051,35.122,129.127,35.097,129.114,35.193,129.225,35.185,129.264,35.318,129.363,35.359,129.329,35.483,129.389,35.488,129.264,35.558,129.368,35.545,129.406,35.472,129.446,35.491,129.584,36.016,129.558,36.087,129.452,35.991,129.401,36.039,129.364,36.001,129.271,35.98,129.44,36.11,129.376,36.38,129.479,36.767,129.357,37.236,129.059,37.673,128.633,38.08,128.363,38.688,128.266,38.759,128.189,38.722,127.753,39.13,127.52,39.137,127.481,39.2,127.371,39.218,127.388,39.34,127.473,39.344,127.366,39.372,127.469,39.403,127.415,39.441,127.41,39.451,127.505,39.434,127.516,39.308,127.569,39.304,127.511,39.518,127.565,39.629,127.465,39.622,127.422,39.629,127.405,39.646,127.384,39.646,127.363,39.652,127.407,39.648,127.48,39.625,127.506,39.752,127.573,39.748,127.51,39.918,127.663,39.804,127.884,39.888,127.878,39.964,127.994,40.06,128.331,40.03,128.419,40.103,128.335,40.187,128.426,40.104,128.66,40.19,128.627,40.308,129.099,40.491,129.222,40.703,129.714,40.833,129.716,41.199,129.808,41.382,129.648,41.55,129.76,41.763,129.851,41.763,129.896,41.908,130.078,42.085,130.169,42.069,130.141,42.101,130.199,42.181,130.403,42.25,130.371,42.339,130.633,42.268,130.625,42.426,130.651,42.332,130.702,42.295,130.871,42.529,130.774,42.577,130.795,42.633,130.771,42.602,130.745,42.609,130.721,42.583,130.694,42.57,130.685,42.575,130.711,42.698,130.948,42.651,130.808,42.634,130.87,42.595,131.109,42.672,131.218,42.554,131.2,42.634,131.258,42.771,131.363,42.764,131.471,42.882,131.385,42.868,131.373,42.943,131.575,43.001,131.687,43.202,131.778,43.179,131.695,43.204,131.761,43.233,131.755,43.348,131.806,43.416,131.822,43.293,132.072,43.314,131.842,43.079,131.962,43.058,132.297,43.339,132.353,43.283,132.305,43.229,132.381,43.227,132.407,43.182,132.314,43.166,132.284,43.071,132.317,42.843,132.404,42.948,132.571,42.837,132.776,42.909,132.86,42.733,132.899,42.829,132.997,42.839,133.01,42.869,133.03,42.876,133.031,42.887,133.04,42.896,133.05,42.899,132.986,42.823,133.09,42.743,133.0,42.716,133.04,42.672,133.648,42.875,133.718,42.806,133.748,42.845,133.866,42.908,133.927,42.892,133.923,42.866,134.439,43.159,134.379,43.197,134.479,43.179,135.143,43.506,135.226,43.635,135.198,43.746,135.292,43.739,135.278,43.686,135.437,43.765,135.528,43.896,135.461,43.878,135.454,43.949,135.519,43.921,135.529,44.007,135.633,44.035,135.648,44.175,135.88,44.412,136.15,44.497,136.351,44.796,136.45,44.771,136.801,45.197,137.687,45.809,138.102,46.236,138.177,46.447,138.344,46.539,138.349,46.692,138.563,47.031,138.853,47.285,138.991,47.316,139.278,47.816,140.173,48.445,140.182,48.749,140.39,48.976,140.333,49.024,140.201,48.951,140.325,49.068,140.24,49.069,140.346,49.094,140.311,49.215,140.388,49.267,140.275,49.265,140.225,49.294,140.379,49.291,140.386,49.356,140.424,49.295,140.418,49.402,140.547,49.564,140.529,49.786,140.401,49.883,140.688,50.089,140.515,50.144,140.531,50.342,140.413,50.542,140.493,50.629,140.43,50.718,140.499,50.868,140.679,51.033,140.659,51.22,140.596,51.235,140.86,51.371,140.728,51.465,140.887,51.495,140.876,51.625,140.965,51.666,141.672,51.747,141.787,51.689],"holes":[[138.504,38.333,138.241,38.084,138.233,37.974,138.353,37.996,138.216,37.811,138.5,37.92,138.574,38.062,138.434,38.089,138.504,38.333],[129.45,34.707,129.319,34.646,129.286,34.561,129.344,34.533,129.227,34.361,129.349,34.376,129.29,34.324,129.353,34.287,129.205,34.331,129.167,34.102,129.283,34.13,129.393,34.295,129.37,34.453,129.498,34.667,129.45,34.707]]}],"names":{"ar":"بحر اليابان","de":"Japanisches Meer","es":"mar del Japón","fr":"mer du Japon","he":"ים יפן","hu":"Japán-tenger","it":"Mar del Giappone","nl":"Japanse Zee","pt":"Mar do Japão","zh":"日本海"}},{"name":"Beaufort Sea","bbox":[-156.665,67.597,-122.653,76.363],"area":114.9328,"polygons":[{"outer":[-132.862,68.959,-132.8,68.955,-132.767,68.928,-132.654,68.927,-132.852,68.913,-132.714,68.866,-132.778,68.829,-132.506,68.87,-132.596,68.898,-132.568,68.901,-132.406,68.841,-132.464,68.787,-132.719,68.829,-132.726,68.774,-132.973,68.848,-132.93,68.782,-133.409,68.835,-133.13,68.754,-133.294,68.777,-133.179,68.707,-133.022,68.693,-132.972,68.705,-132.927,68.673,-133.126,68.686,-133.247,68.73,-133.254,68.716,-133.502,68.796,-133.517,68.842,-133.256,68.894,-133.227,68.975,-133.322,68.987,-133.023,69.024,-133.078,69.074,-132.941,69.067,-132.951,68.988,-132.872,68.959,-132.903,68.992,-132.921,69.068,-132.853,69.091,-132.93,69.121,-132.867,69.205,-132.586,69.272,-132.486,69.143,-132.51,69.274,-132.476,69.217,-132.417,69.299,-132.332,69.235,-132.355,69.303,-132.121,69.348,-132.162,69.408,-132.048,69.447,-132.125,69.465,-132.006,69.518,-131.047,69.625,-130.952,69.61,-131.055,69.589,-130.941,69.531,-130.891,69.568,-130.93,69.577,-130.945,69.585,-130.944,69.591,-130.857,69.572,-130.799,69.651,-130.572,69.7,-130.599,69.744,-130.526,69.773,-129.538,69.993,-129.58,70.037,-129.456,70.031,-129.389,70.096,-129.619,70.146,-129.589,70.202,-129.719,70.219,-129.671,70.254,-129.754,70.196,-129.862,70.187,-129.748,70.173,-129.979,70.031,-129.99,70.057,-130.13,70.078,-130.189,70.019,-130.203,70.049,-130.153,70.084,-130.223,70.04,-130.338,70.064,-130.404,70.149,-130.392,70.112,-130.405,70.097,-130.436,70.089,-130.412,70.115,-130.504,70.086,-130.556,70.113,-130.483,70.15,-130.572,70.137,-130.597,70.072,-130.647,70.127,-130.699,70.113,-130.702,70.066,-130.969,70.061,-130.932,70.093,-130.983,70.064,-131.029,70.01,-130.97,70.06,-130.943,69.997,-131.039,70.0,-131.092,69.885,-131.146,69.855,-131.207,69.858,-131.202,69.805,-131.237,69.836,-131.24,69.918,-131.431,69.95,-131.325,69.972,-131.407,69.96,-131.466,69.942,-131.438,69.889,-131.752,69.818,-131.747,69.813,-131.704,69.804,-131.717,69.794,-131.971,69.76,-132.0,69.7,-132.114,69.706,-132.015,69.736,-132.136,69.717,-132.13,69.668,-132.314,69.729,-132.436,69.708,-132.549,69.732,-132.655,69.651,-132.364,69.712,-132.473,69.652,-132.349,69.657,-132.712,69.606,-132.921,69.642,-133.016,69.573,-132.922,69.42,-132.997,69.368,-132.976,69.424,-132.997,69.445,-133.032,69.446,-133.036,69.456,-133.04,69.427,-133.01,69.427,-133.035,69.39,-133.58,69.387,-133.667,69.329,-133.697,69.335,-133.709,69.332,-133.678,69.292,-133.726,69.343,-133.958,69.264,-134.188,69.247,-134.179,69.209,-134.18,69.202,-134.189,69.191,-134.18,69.193,-134.179,69.192,-134.369,69.088,-134.592,69.064,-134.639,68.982,-134.454,68.846,-134.419,68.78,-134.247,68.743,-134.211,68.727,-134.207,68.693,-134.169,68.7,-134.139,68.692,-134.123,68.664,-133.992,68.584,-133.972,68.557,-133.864,68.531,-133.851,68.515,-133.847,68.524,-133.789,68.457,-133.866,68.462,-133.807,68.438,-133.673,68.322,-133.673,68.316,-133.776,68.29,-133.784,68.289,-133.797,68.293,-133.816,68.283,-133.803,68.285,-133.789,68.284,-133.796,68.273,-133.83,68.276,-133.776,68.268,-133.768,68.262,-133.772,68.254,-133.811,68.247,-133.829,68.238,-133.818,68.235,-133.81,68.244,-133.805,68.244,-133.796,68.242,-133.78,68.231,-133.773,68.222,-133.787,68.128,-133.865,68.101,-133.837,68.069,-133.909,68.007,-133.928,68.002,-133.976,68.005,-133.977,67.979,-134.028,67.973,-134.029,67.951,-134.083,67.935,-134.009,67.892,-133.999,67.872,-134.005,67.837,-134.038,67.811,-134.189,67.777,-134.097,67.597,-134.414,67.651,-134.47,67.67,-134.51,67.696,-134.558,67.687,-134.595,67.69,-134.622,67.714,-134.638,67.694,-134.676,67.747,-134.667,67.768,-134.652,67.774,-134.692,67.775,-134.773,67.808,-134.933,67.905,-134.926,67.937,-134.891,67.941,-134.943,67.949,-134.943,67.972,-134.921,67.982,-134.964,67.978,-134.98,67.984,-134.976,67.99,-134.949,67.992,-134.947,68.0,-135.115,68.01,-135.204,68.061,-135.139,68.1,-135.164,68.091,-135.181,68.094,-135.185,68.101,-135.169,68.115,-135.177,68.119,-135.247,68.101,-135.274,68.129,-135.093,68.226,-135.003,68.225,-135.523,68.416,-135.754,68.553,-135.781,68.593,-135.723,68.628,-135.811,68.679,-135.825,68.68,-135.886,68.689,-135.912,68.7,-135.93,68.698,-135.943,68.701,-135.987,68.716,-135.997,68.728,-135.796,68.69,-135.844,68.712,-135.846,68.743,-135.874,68.754,-135.891,68.768,-135.911,68.768,-135.953,68.777,-135.961,68.772,-135.968,68.774,-135.972,68.781,-135.997,68.788,-135.93,68.755,-135.946,68.761,-135.953,68.747,-135.979,68.731,-135.992,68.731,-135.949,68.762,-136.031,68.798,-136.052,68.83,-136.537,68.901,-136.53,68.893,-136.697,68.858,-136.674,68.855,-136.665,68.853,-136.663,68.85,-136.669,68.841,-136.668,68.834,-136.628,68.821,-136.626,68.806,-136.776,68.857,-136.722,68.837,-136.738,68.825,-136.721,68.824,-136.704,68.815,-136.708,68.79,-137.01,68.937,-137.939,69.072,-138.374,69.294,-138.366,69.231,-138.428,69.246,-138.323,69.218,-138.289,69.201,-138.293,69.19,-138.28,69.19,-138.295,69.19,-138.347,69.221,-138.389,69.212,-138.325,69.205,-138.303,69.178,-138.454,69.208,-138.499,69.251,-138.607,69.236,-138.591,69.269,-138.734,69.344,-138.926,69.347,-138.893,69.387,-139.095,69.495,-139.444,69.547,-139.497,69.49,-139.899,69.614,-140.529,69.592,-141.201,69.683,-141.35,69.628,-141.613,69.749,-141.818,69.693,-141.811,69.781,-141.783,69.786,-141.782,69.791,-142.095,69.78,-142.079,69.824,-142.101,69.83,-142.061,69.835,-142.126,69.838,-142.14,69.823,-142.148,69.821,-142.15,69.817,-142.165,69.812,-142.18,69.811,-142.179,69.804,-142.184,69.802,-142.186,69.81,-142.132,69.839,-142.238,69.848,-142.512,69.959,-142.47,69.965,-142.584,69.958,-142.619,70.01,-143.25,70.124,-143.293,70.086,-143.908,70.055,-144.03,70.08,-143.983,70.045,-144.1,70.078,-144.097,70.055,-144.167,70.034,-144.647,69.965,-145.34,69.981,-145.181,70.025,-145.665,70.029,-145.605,70.046,-145.783,70.07,-145.716,70.073,-145.615,70.054,-145.564,70.071,-145.854,70.163,-145.858,70.132,-145.912,70.14,-145.88,70.129,-145.872,70.12,-145.848,70.116,-146.01,70.051,-145.92,70.119,-145.89,70.121,-147.679,70.2,-147.869,70.239,-147.77,70.249,-147.796,70.259,-147.776,70.278,-147.804,70.294,-147.825,70.268,-147.885,70.272,-147.901,70.301,-148.05,70.258,-148.182,70.261,-148.206,70.279,-148.185,70.322,-148.234,70.325,-148.2,70.348,-148.49,70.296,-148.519,70.389,-148.973,70.367,-148.921,70.376,-148.892,70.41,-148.917,70.392,-149.015,70.444,-149.052,70.419,-149.161,70.488,-149.464,70.474,-149.461,70.516,-150.496,70.388,-150.516,70.38,-150.485,70.358,-150.48,70.349,-150.493,70.346,-150.486,70.343,-150.498,70.343,-150.486,70.357,-150.521,70.367,-150.527,70.378,-150.673,70.289,-150.682,70.312,-150.694,70.304,-150.726,70.301,-150.729,70.296,-150.737,70.293,-150.75,70.295,-150.71,70.336,-150.828,70.253,-150.825,70.21,-150.891,70.177,-150.911,70.172,-150.924,70.172,-150.942,70.188,-150.901,70.221,-150.985,70.219,-151.027,70.239,-151.035,70.316,-151.115,70.308,-151.07,70.365,-151.102,70.385,-151.1,70.411,-151.166,70.432,-151.104,70.405,-151.195,70.416,-151.136,70.374,-151.274,70.371,-151.263,70.365,-151.276,70.358,-151.31,70.353,-151.286,70.351,-151.268,70.345,-151.27,70.342,-151.32,70.339,-151.314,70.316,-151.353,70.313,-151.324,70.311,-151.321,70.306,-151.324,70.301,-151.32,70.304,-151.302,70.302,-151.303,70.3,-151.321,70.297,-151.314,70.295,-151.322,70.296,-151.315,70.3,-151.32,70.304,-151.323,70.3,-151.334,70.304,-151.334,70.308,-151.326,70.311,-151.336,70.309,-151.341,70.304,-151.373,70.32,-151.332,70.316,-151.333,70.338,-151.297,70.347,-151.273,70.343,-151.315,70.351,-151.324,70.355,-151.323,70.362,-151.28,70.36,-151.312,70.371,-151.286,70.372,-151.287,70.376,-151.254,70.379,-151.243,70.383,-151.296,70.4,-151.289,70.391,-151.296,70.387,-151.335,70.383,-151.344,70.389,-151.337,70.378,-151.334,70.38,-151.322,70.382,-151.339,70.359,-151.416,70.387,-151.362,70.409,-151.439,70.408,-151.456,70.425,-151.515,70.429,-151.45,70.398,-151.491,70.381,-151.572,70.439,-152.133,70.42,-151.916,70.475,-151.729,70.497,-151.869,70.504,-151.773,70.55,-152.619,70.545,-152.056,70.58,-152.455,70.635,-152.488,70.622,-152.586,70.629,-152.381,70.715,-152.547,70.714,-152.489,70.736,-152.545,70.772,-152.407,70.731,-152.202,70.797,-152.465,70.863,-152.425,70.825,-152.482,70.804,-152.562,70.88,-152.7,70.883,-152.72,70.877,-152.612,70.844,-152.615,70.742,-152.662,70.735,-152.78,70.882,-152.782,70.87,-152.798,70.866,-152.812,70.851,-152.82,70.846,-152.825,70.845,-153.194,70.922,-153.933,70.879,-154.079,70.789,-154.469,70.726,-154.347,70.768,-154.332,70.82,-154.597,70.82,-154.659,70.853,-154.693,70.855,-154.659,70.853,-154.618,70.835,-154.598,70.839,-154.632,70.86,-154.617,70.862,-154.601,70.871,-154.623,70.866,-154.652,70.891,-154.736,70.865,-154.669,70.848,-154.71,70.801,-154.776,70.815,-154.792,70.848,-154.715,70.874,-154.78,70.888,-154.607,70.908,-154.676,70.945,-154.561,70.985,-154.819,71.089,-154.862,71.045,-154.733,71.016,-154.777,70.948,-154.865,71.03,-154.963,71.009,-154.872,71.035,-154.844,71.074,-154.953,71.106,-154.927,71.051,-155.051,71.0,-155.016,71.104,-155.026,71.116,-155.056,71.125,-155.07,71.135,-155.06,71.038,-155.133,71.109,-155.262,71.071,-155.175,70.98,-155.511,70.943,-155.481,70.844,-155.213,70.801,-155.354,70.784,-155.485,70.832,-155.511,70.861,-155.581,70.835,-155.53,70.836,-155.469,70.822,-155.386,70.769,-155.582,70.833,-155.59,70.814,-155.657,70.827,-155.639,70.819,-155.632,70.81,-155.649,70.794,-155.59,70.787,-155.652,70.761,-155.733,70.764,-155.635,70.809,-155.817,70.834,-155.929,70.807,-155.883,70.74,-155.997,70.748,-155.934,70.837,-156.039,70.824,-156.136,70.828,-156.181,70.841,-156.207,70.822,-156.229,70.851,-156.03,70.828,-155.925,70.858,-156.089,70.85,-156.119,70.867,-156.147,70.859,-156.201,70.859,-156.212,70.863,-156.213,70.871,-156.23,70.873,-156.251,70.862,-156.28,70.855,-156.248,70.875,-156.225,70.875,-155.99,70.857,-156.0,70.878,-155.978,70.874,-155.981,70.878,-155.968,70.891,-155.957,70.891,-155.955,70.896,-156.114,70.913,-156.135,70.885,-156.161,70.874,-156.176,70.874,-156.135,70.907,-156.192,70.909,-156.207,70.923,-156.227,70.922,-156.232,70.926,-156.244,70.919,-156.251,70.918,-156.289,70.925,-156.301,70.919,-156.388,70.904,-156.416,70.914,-156.433,70.907,-156.489,70.902,-156.523,70.907,-156.538,70.898,-156.62,70.884,-156.63,70.887,-156.634,70.899,-156.662,70.896,-156.16,70.974,-156.118,70.945,-156.198,70.919,-155.98,70.912,-156.0,70.963,-155.518,71.074,-155.566,71.163,-155.935,71.213,-155.96,71.185,-156.089,71.171,-156.086,71.163,-156.13,71.153,-156.139,71.153,-156.145,71.156,-156.15,71.155,-156.028,71.203,-156.665,71.33,-156.476,71.388,-122.656,76.363,-122.653,76.362,-122.864,76.198,-122.819,76.176,-122.96,76.195,-122.912,76.139,-124.773,74.342,-126.004,71.971,-128.0,70.528,-128.157,70.496,-128.213,70.388,-127.882,70.389,-127.941,70.335,-128.131,70.347,-128.037,70.271,-127.608,70.254,-127.679,70.245,-127.511,70.218,-127.458,70.144,-127.4,70.2,-127.342,70.217,-127.302,70.218,-127.257,70.2,-127.331,70.151,-127.237,70.143,-127.287,70.134,-127.355,70.157,-127.318,70.214,-127.42,70.145,-127.475,70.14,-127.517,70.144,-127.548,70.167,-127.514,70.197,-127.548,70.218,-127.934,70.177,-127.849,70.199,-128.151,70.153,-127.998,70.116,-127.911,70.073,-127.834,70.064,-127.833,70.054,-127.808,70.051,-127.803,70.045,-127.799,70.048,-127.802,70.044,-128.341,70.124,-128.403,70.074,-128.346,69.959,-128.198,69.888,-128.386,69.939,-128.989,69.721,-128.894,69.673,-128.894,69.668,-128.918,69.667,-128.946,69.668,-128.905,69.66,-128.925,69.647,-128.874,69.647,-128.862,69.64,-128.84,69.644,-128.858,69.64,-129.027,69.635,-128.976,69.658,-129.151,69.687,-129.151,69.795,-129.236,69.816,-129.139,69.843,-130.341,69.683,-130.615,69.49,-130.601,69.479,-130.577,69.476,-130.574,69.473,-130.563,69.471,-130.56,69.469,-130.546,69.469,-130.53,69.467,-130.519,69.461,-130.519,69.457,-130.643,69.48,-130.63,69.409,-130.895,69.329,-130.875,69.301,-130.969,69.249,-130.946,69.159,-130.978,69.144,-130.889,69.106,-130.877,69.092,-130.913,69.115,-130.958,69.115,-130.931,69.112,-130.903,69.101,-130.898,69.082,-131.157,69.076,-130.971,69.127,-131.005,69.136,-131.011,69.126,-131.045,69.127,-131.054,69.19,-130.904,69.309,-130.95,69.381,-131.024,69.285,-131.066,69.294,-130.943,69.423,-131.014,69.527,-130.987,69.433,-131.055,69.331,-131.152,69.297,-131.024,69.444,-131.147,69.601,-131.2,69.587,-131.118,69.535,-131.221,69.556,-131.121,69.522,-131.163,69.521,-131.091,69.483,-131.091,69.391,-131.135,69.335,-131.227,69.296,-131.113,69.442,-131.253,69.568,-131.157,69.418,-131.208,69.345,-131.383,69.287,-131.205,69.37,-131.293,69.497,-131.248,69.365,-131.342,69.478,-131.328,69.392,-131.387,69.331,-131.347,69.407,-131.382,69.459,-131.447,69.47,-131.366,69.392,-131.439,69.394,-131.419,69.359,-131.43,69.344,-131.601,69.287,-131.501,69.327,-131.518,69.392,-131.441,69.419,-131.647,69.458,-131.587,69.419,-131.983,69.389,-131.728,69.377,-131.817,69.259,-131.857,69.285,-131.853,69.262,-131.909,69.265,-131.901,69.26,-131.961,69.215,-131.987,69.213,-131.919,69.27,-131.848,69.297,-131.924,69.287,-131.99,69.239,-132.096,69.211,-132.081,69.197,-132.15,69.157,-132.143,69.226,-132.239,69.116,-132.186,69.182,-132.342,69.162,-132.287,69.178,-132.301,69.219,-132.318,69.222,-132.486,69.086,-132.384,69.187,-132.468,69.216,-132.472,69.142,-132.562,69.074,-132.885,69.045,-132.862,68.959],"holes":[[-134.229,69.657,-134.503,69.704,-134.434,69.613,-134.622,69.491,-134.414,69.471,-134.62,69.385,-134.619,69.454,-134.741,69.42,-134.662,69.495,-134.772,69.485,-134.776,69.42,-134.616,69.373,-134.712,69.344,-134.798,69.422,-134.853,69.385,-134.779,69.343,-134.862,69.344,-134.777,69.32,-134.949,69.356,-134.899,69.255,-134.993,69.233,-134.963,69.147,-134.732,69.117,-134.583,69.222,-134.707,69.119,-134.645,69.128,-134.797,69.058,-134.804,69.106,-134.879,69.097,-135.039,69.181,-135.007,69.113,-135.109,69.082,-134.669,69.0,-134.601,69.081,-134.388,69.097,-134.274,69.177,-134.372,69.125,-134.505,69.182,-134.276,69.184,-133.749,69.524,-134.151,69.514,-134.273,69.569,-134.158,69.619,-134.229,69.657],[-133.98,68.558,-133.902,68.527,-134.033,68.541,-133.92,68.419,-133.8,68.386,-133.901,68.41,-133.931,68.407,-133.997,68.491,-134.026,68.463,-133.964,68.412,-133.825,68.364,-133.854,68.363,-133.738,68.349,-133.801,68.324,-133.769,68.338,-133.948,68.365,-133.911,68.393,-133.991,68.413,-133.934,68.398,-134.004,68.374,-133.973,68.334,-134.019,68.324,-134.0,68.32,-133.973,68.293,-133.895,68.277,-133.832,68.223,-134.007,68.31,-134.001,68.319,-134.019,68.322,-134.02,68.326,-133.976,68.334,-134.007,68.375,-134.062,68.347,-133.962,68.278,-133.974,68.238,-134.08,68.257,-134.008,68.256,-134.033,68.302,-134.203,68.174,-134.381,68.237,-134.433,68.187,-134.231,68.005,-134.375,68.074,-134.432,67.984,-134.189,67.791,-134.026,67.822,-134.087,67.932,-134.032,67.957,-134.115,67.936,-133.884,68.031,-133.796,68.167,-133.857,68.225,-133.782,68.211,-133.836,68.282,-133.677,68.322,-133.951,68.497,-133.812,68.437,-133.865,68.53,-133.98,68.558],[-134.559,67.722,-134.467,67.852,-134.542,67.865,-134.448,67.923,-134.574,67.985,-134.523,68.076,-134.648,68.094,-134.654,68.158,-134.759,68.129,-134.742,68.102,-134.797,68.07,-134.756,68.078,-134.735,68.078,-134.725,68.074,-134.789,68.043,-134.762,68.021,-134.794,68.0,-134.803,68.002,-134.797,68.042,-134.732,68.075,-134.789,68.064,-134.8,68.07,-134.745,68.102,-134.796,68.137,-134.673,68.191,-134.781,68.214,-134.739,68.246,-134.978,68.264,-135.033,68.243,-134.979,68.202,-135.268,68.129,-135.134,68.1,-135.198,68.062,-135.12,68.015,-134.943,68.0,-134.93,67.906,-134.559,67.722],[-135.028,68.249,-134.721,68.269,-134.703,68.318,-134.653,68.342,-134.766,68.379,-134.668,68.368,-134.648,68.342,-134.704,68.312,-134.676,68.289,-134.72,68.268,-134.765,68.263,-134.696,68.239,-134.726,68.244,-134.779,68.216,-134.507,68.078,-134.424,68.139,-134.483,68.114,-134.451,68.176,-134.484,68.206,-134.533,68.214,-134.563,68.233,-134.532,68.246,-134.56,68.231,-134.482,68.206,-134.461,68.186,-134.385,68.267,-134.609,68.375,-134.538,68.425,-134.685,68.501,-134.948,68.502,-135.009,68.561,-134.951,68.575,-135.122,68.566,-135.173,68.649,-135.234,68.657,-135.159,68.619,-135.236,68.546,-135.138,68.527,-135.206,68.515,-135.037,68.491,-135.004,68.473,-135.146,68.505,-135.162,68.494,-135.214,68.516,-135.216,68.53,-135.231,68.507,-135.169,68.489,-135.121,68.492,-134.99,68.385,-135.154,68.431,-135.104,68.468,-135.228,68.498,-135.255,68.545,-135.235,68.437,-135.313,68.435,-135.26,68.546,-135.455,68.6,-135.55,68.59,-135.327,68.503,-135.419,68.386,-135.142,68.297,-135.198,68.346,-135.164,68.382,-135.198,68.389,-135.2,68.398,-135.221,68.399,-135.22,68.413,-135.247,68.413,-135.243,68.419,-135.221,68.426,-135.225,68.434,-135.167,68.44,-135.2,68.476,-135.188,68.474,-135.166,68.44,-135.242,68.419,-135.214,68.411,-135.22,68.401,-135.197,68.398,-135.195,68.389,-135.179,68.388,-135.161,68.381,-135.196,68.347,-135.028,68.249],[-135.62,69.322,-135.546,69.187,-135.31,69.19,-135.516,69.269,-135.223,69.157,-135.316,69.162,-135.158,69.083,-135.013,69.115,-135.197,69.262,-135.62,69.322],[-135.047,69.036,-135.184,69.044,-134.993,68.995,-135.14,69.028,-135.036,68.985,-135.097,68.964,-135.194,69.051,-135.588,69.115,-135.236,68.963,-135.463,69.008,-135.448,68.92,-134.98,68.876,-134.906,68.915,-134.942,68.962,-134.857,68.955,-135.047,69.036],[-134.24,68.625,-134.69,68.644,-134.563,68.603,-134.615,68.57,-134.755,68.643,-134.612,68.546,-134.429,68.515,-134.61,68.542,-134.769,68.648,-134.934,68.654,-134.5,68.447,-134.098,68.481,-134.27,68.499,-134.191,68.596,-134.365,68.572,-134.172,68.603,-134.24,68.625]]}],"names":{"ar":"بحر بيوفورت","de":"Beaufortsee","es":"mar de Beaufort","fr":"mer de Beaufort","he":"ים בופור","hu":"Beaufort-tenger","it":"Mare di Beaufort","nl":"Beaufortzee","pt":"Mar de Beaufort","zh":"波弗特海"}},{"name":"Mozambique Channel","bbox":[32.431,-26.842,49.242,-10.499],"area":119.3929,"polygons":[{"outer":[43.382,-11.37,49.242,-11.955,49.163,-12.041,49.219,-12.115,49.148,-12.058,49.086,-12.105,49.194,-12.146,49.149,-12.262,48.972,-12.342,48.938,-12.492,48.762,-12.393,48.72,-12.444,48.885,-12.552,48.853,-12.665,48.891,-12.672,48.966,-12.895,48.807,-13.126,48.863,-13.266,48.81,-13.373,48.648,-13.48,48.472,-13.368,48.479,-13.437,48.52,-13.52,48.548,-13.521,48.548,-13.528,48.441,-13.574,48.416,-13.529,48.379,-13.558,48.393,-13.582,48.371,-13.596,48.327,-13.551,48.329,-13.769,48.263,-13.818,48.169,-13.756,48.147,-13.601,48.072,-13.627,48.063,-13.525,47.993,-13.592,47.969,-13.503,47.97,-13.617,47.894,-13.592,47.892,-13.899,48.004,-13.918,48.028,-13.972,48.006,-13.996,47.921,-14.01,48.039,-14.025,48.002,-14.215,48.065,-14.216,47.983,-14.384,47.972,-14.368,47.981,-14.334,47.967,-14.326,47.957,-14.308,47.999,-14.134,47.953,-14.141,47.942,-14.119,47.961,-14.12,47.94,-14.103,47.909,-14.088,47.888,-14.095,47.934,-14.246,47.795,-14.218,47.689,-14.445,47.738,-14.615,47.791,-14.559,47.923,-14.585,47.925,-14.548,47.959,-14.509,47.929,-14.588,47.978,-14.611,47.985,-14.592,48.011,-14.589,48.013,-14.58,48.003,-14.57,48.001,-14.558,48.004,-14.55,48.009,-14.547,48.026,-14.539,48.037,-14.523,48.042,-14.521,48.047,-14.524,48.056,-14.548,48.075,-14.553,48.08,-14.545,48.096,-14.535,48.088,-14.519,48.088,-14.513,48.091,-14.51,48.096,-14.509,48.105,-14.514,48.116,-14.508,48.124,-14.514,48.078,-14.554,48.006,-14.551,48.015,-14.587,47.998,-14.614,47.987,-14.616,48.006,-14.636,48.004,-14.694,48.034,-14.676,47.964,-14.863,47.906,-14.908,47.975,-14.624,47.915,-14.734,47.794,-14.57,47.551,-15.008,47.52,-14.998,47.503,-15.079,47.491,-15.076,47.485,-15.089,47.448,-15.098,47.452,-15.104,47.428,-15.137,47.423,-15.137,47.399,-14.984,47.515,-14.819,47.461,-14.672,47.279,-14.861,47.385,-14.866,47.363,-14.912,47.045,-15.186,47.184,-15.421,47.285,-15.408,47.302,-15.414,47.331,-15.436,47.358,-15.441,47.368,-15.446,47.409,-15.433,47.448,-15.451,47.486,-15.436,47.536,-15.428,47.546,-15.429,47.562,-15.443,47.581,-15.417,47.584,-15.414,47.594,-15.412,47.611,-15.426,47.618,-15.436,47.629,-15.46,47.684,-15.446,47.688,-15.448,47.694,-15.456,47.696,-15.443,47.722,-15.434,47.777,-15.427,47.786,-15.398,47.807,-15.374,47.81,-15.372,47.812,-15.372,47.779,-15.427,47.657,-15.469,47.625,-15.46,47.594,-15.413,47.562,-15.446,47.447,-15.456,47.441,-15.455,47.413,-15.437,47.375,-15.449,47.337,-15.441,47.302,-15.415,47.285,-15.409,47.244,-15.413,47.225,-15.422,47.228,-15.428,47.216,-15.445,47.201,-15.457,47.197,-15.459,47.176,-15.457,47.225,-15.479,47.179,-15.478,47.098,-15.45,47.102,-15.498,47.048,-15.603,47.084,-15.507,47.057,-15.488,47.055,-15.536,47.035,-15.602,47.009,-15.611,47.031,-15.639,47.026,-15.655,47.034,-15.655,47.039,-15.661,46.994,-15.683,46.991,-15.49,46.98,-15.573,46.926,-15.612,46.881,-15.611,47.071,-15.335,46.944,-15.199,46.295,-15.712,46.387,-15.723,46.307,-15.833,46.36,-15.834,46.422,-15.86,46.642,-16.038,46.64,-16.054,46.624,-16.073,46.617,-16.103,46.608,-16.111,46.611,-16.134,46.55,-16.214,46.571,-16.293,46.551,-16.32,46.576,-16.352,46.579,-16.364,46.573,-16.376,46.531,-16.385,46.536,-16.406,46.533,-16.417,46.641,-16.397,46.65,-16.399,46.658,-16.405,46.663,-16.433,46.678,-16.44,46.688,-16.429,46.697,-16.43,46.713,-16.481,46.705,-16.6,46.758,-16.623,46.758,-16.642,46.746,-16.626,46.729,-16.628,46.721,-16.626,46.713,-16.62,46.689,-16.591,46.689,-16.553,46.706,-16.528,46.687,-16.508,46.702,-16.452,46.666,-16.443,46.64,-16.416,46.606,-16.411,46.553,-16.43,46.53,-16.422,46.519,-16.407,46.523,-16.387,46.57,-16.354,46.543,-16.319,46.542,-16.207,46.544,-16.192,46.547,-16.181,46.559,-16.167,46.607,-16.128,46.588,-16.081,46.571,-16.056,46.537,-16.061,46.406,-16.033,46.361,-16.017,46.335,-16.001,46.219,-15.842,46.231,-15.717,46.065,-15.745,46.003,-15.815,46.033,-15.819,46.077,-15.782,46.099,-15.781,46.035,-15.837,46.092,-15.866,46.065,-15.885,45.963,-15.861,45.901,-15.765,45.827,-15.866,45.804,-15.843,45.791,-15.857,45.785,-15.858,45.775,-15.854,45.772,-15.849,45.772,-15.844,45.776,-15.83,45.767,-15.807,45.73,-15.813,45.763,-15.797,45.669,-15.773,45.608,-15.844,45.64,-16.068,45.604,-16.031,45.618,-16.068,45.596,-16.046,45.587,-16.072,45.567,-16.071,45.588,-16.058,45.582,-16.023,45.555,-16.073,45.541,-16.061,45.528,-16.073,45.497,-16.068,45.577,-16.007,45.559,-15.957,45.35,-15.985,45.371,-16.034,45.414,-16.032,45.326,-16.101,45.38,-16.107,45.378,-16.12,45.306,-16.148,45.25,-15.937,44.944,-16.167,44.997,-16.188,45.009,-16.204,45.006,-16.207,45.0,-16.21,44.994,-16.207,44.994,-16.189,44.948,-16.185,44.935,-16.196,44.891,-16.185,44.863,-16.266,44.45,-16.188,44.423,-16.588,44.464,-16.615,44.467,-16.624,44.464,-16.647,44.48,-16.645,44.49,-16.651,44.463,-16.648,44.431,-16.61,44.38,-16.812,43.974,-17.397,43.968,-17.556,43.928,-17.513,43.963,-17.722,44.017,-17.696,44.05,-17.703,44.089,-17.727,44.1,-17.704,44.143,-17.7,44.16,-17.667,44.172,-17.673,44.182,-17.691,44.186,-17.691,44.198,-17.676,44.208,-17.669,44.228,-17.69,44.252,-17.652,44.26,-17.65,44.255,-17.632,44.28,-17.596,44.285,-17.59,44.31,-17.578,44.285,-17.594,44.266,-17.652,44.227,-17.693,44.181,-17.693,44.164,-17.669,44.147,-17.701,44.091,-17.73,43.99,-17.72,44.035,-18.389,44.265,-18.734,44.214,-18.694,44.245,-19.095,44.354,-19.298,44.365,-19.301,44.37,-19.296,44.373,-19.295,44.408,-19.292,44.425,-19.182,44.481,-19.16,44.44,-19.2,44.439,-19.244,44.42,-19.25,44.43,-19.269,44.411,-19.296,44.407,-19.299,44.403,-19.299,44.375,-19.295,44.361,-19.304,44.35,-19.303,44.472,-19.459,44.484,-19.604,44.468,-19.623,44.495,-19.632,44.536,-19.714,44.621,-19.702,44.674,-19.727,44.672,-19.733,44.721,-19.688,44.697,-19.719,44.676,-19.734,44.535,-19.72,44.46,-19.685,44.459,-19.681,44.461,-19.671,44.459,-19.666,44.458,-19.659,44.463,-19.652,44.458,-19.646,44.398,-19.64,44.391,-19.781,44.435,-19.802,44.42,-19.821,44.412,-19.823,44.407,-19.833,44.423,-19.85,44.479,-19.801,44.496,-20.012,44.464,-20.015,44.458,-20.001,44.471,-19.962,44.15,-20.477,44.203,-20.48,44.312,-20.542,44.473,-20.743,44.494,-20.754,44.54,-20.764,44.577,-20.828,44.668,-20.848,44.79,-20.914,44.797,-20.919,44.8,-20.941,44.81,-20.951,44.819,-20.957,44.836,-20.952,44.848,-20.957,44.87,-20.989,44.88,-21.01,44.874,-21.058,44.89,-21.064,44.895,-21.053,44.921,-21.053,44.954,-21.073,44.918,-21.053,44.891,-21.065,44.873,-21.058,44.867,-20.987,44.846,-20.958,44.817,-20.957,44.8,-20.944,44.791,-20.916,44.72,-20.889,44.757,-20.944,44.766,-20.977,44.764,-20.983,44.449,-20.739,44.397,-20.686,44.392,-20.662,44.377,-20.652,44.321,-20.575,44.308,-20.551,44.118,-20.52,44.114,-20.486,43.796,-21.234,43.726,-21.325,43.703,-21.267,43.508,-21.357,43.503,-21.346,43.508,-21.336,43.502,-21.316,43.505,-21.303,43.462,-21.433,43.581,-21.441,43.709,-21.541,43.766,-21.616,43.772,-21.712,43.815,-21.729,43.834,-21.743,43.856,-21.782,43.86,-21.765,43.887,-21.865,43.997,-21.906,44.069,-21.84,44.165,-21.802,44.286,-21.815,44.31,-21.832,44.319,-21.858,44.334,-21.842,44.353,-21.834,44.434,-21.835,44.511,-21.793,44.565,-21.795,44.598,-21.754,44.622,-21.743,45.117,-21.658,45.174,-21.693,45.193,-21.556,45.174,-21.696,45.258,-21.669,45.301,-21.669,45.469,-21.432,45.487,-21.432,45.528,-21.452,45.381,-21.606,45.385,-21.624,45.406,-21.645,45.414,-21.669,45.407,-21.666,45.377,-21.611,45.361,-21.619,45.362,-21.628,45.354,-21.637,45.342,-21.641,45.34,-21.649,45.302,-21.677,45.133,-21.709,45.283,-22.191,45.13,-21.81,45.128,-21.835,45.082,-21.859,45.085,-21.864,45.079,-21.899,45.092,-21.918,45.082,-21.96,45.088,-21.967,45.093,-22.013,45.086,-22.064,45.069,-22.161,45.066,-22.168,45.048,-22.195,45.04,-22.204,45.029,-22.207,45.062,-22.17,45.034,-22.175,45.013,-22.17,44.99,-22.19,44.977,-22.192,45.011,-22.17,45.066,-22.165,45.073,-21.884,45.137,-21.676,44.821,-21.765,44.611,-21.752,44.566,-21.797,44.437,-21.838,44.361,-21.843,44.401,-21.902,44.404,-21.916,44.402,-21.92,44.399,-21.923,44.392,-21.922,44.387,-21.927,44.393,-21.949,44.433,-21.968,44.439,-21.978,44.429,-22.035,44.438,-21.978,44.4,-21.96,44.389,-21.944,44.359,-21.84,44.313,-21.866,44.281,-21.819,44.25,-21.862,44.163,-21.807,44.066,-21.866,44.066,-21.883,44.1,-21.887,44.121,-21.897,44.123,-21.904,44.13,-21.903,44.135,-21.906,44.145,-21.914,44.151,-21.925,44.166,-22.066,44.16,-22.087,44.157,-22.089,44.149,-22.091,44.136,-21.908,43.978,-21.915,43.884,-21.874,43.754,-21.709,43.686,-21.543,43.45,-21.442,43.493,-21.697,43.431,-21.653,43.348,-21.75,43.345,-21.895,43.311,-21.959,43.304,-21.862,43.276,-21.906,43.23,-22.165,43.314,-22.216,43.237,-22.223,43.228,-22.326,43.371,-22.846,43.464,-22.964,43.576,-22.897,43.466,-22.975,43.609,-23.101,43.633,-23.333,43.639,-23.32,43.666,-23.298,43.687,-23.293,43.714,-23.292,43.754,-23.297,43.782,-23.278,43.826,-23.281,43.845,-23.249,43.99,-23.161,43.82,-23.291,43.641,-23.322,43.767,-23.462,43.743,-23.555,43.841,-23.538,43.852,-23.473,43.868,-23.451,43.886,-23.447,43.947,-23.451,43.99,-23.505,44.059,-23.488,44.213,-23.541,44.223,-23.569,44.311,-23.517,44.526,-23.514,44.554,-23.525,44.566,-23.541,44.58,-23.54,44.598,-23.587,44.63,-23.59,44.689,-23.536,44.743,-23.533,44.82,-23.562,44.841,-23.516,44.855,-23.503,44.877,-23.5,45.061,-23.538,45.09,-23.444,45.104,-23.438,45.129,-23.456,45.069,-23.539,44.965,-23.54,44.86,-23.505,44.82,-23.567,44.618,-23.61,44.605,-23.603,44.584,-23.583,44.58,-23.544,44.564,-23.544,44.536,-23.52,44.399,-23.571,44.325,-23.564,44.309,-23.531,44.233,-23.577,44.145,-23.522,44.094,-23.542,44.063,-23.498,43.98,-23.51,43.873,-23.457,43.849,-23.556,43.658,-23.614,43.633,-23.75,43.683,-24.375,43.933,-24.632,44.027,-25.001,44.416,-25.205,44.368,-25.262,44.355,-25.211,44.324,-25.171,44.305,-25.158,44.344,-25.269,44.807,-25.34,45.139,-25.596,32.889,-26.842,32.961,-26.084,32.869,-26.2,32.866,-26.26,32.89,-26.288,32.887,-26.308,32.689,-26.196,32.711,-26.321,32.706,-26.331,32.673,-26.355,32.691,-26.411,32.67,-26.443,32.667,-26.358,32.708,-26.316,32.664,-26.12,32.556,-25.974,32.497,-25.971,32.496,-26.032,32.451,-26.088,32.439,-26.067,32.493,-26.025,32.445,-26.003,32.457,-25.994,32.435,-25.975,32.431,-25.96,32.442,-25.949,32.464,-25.994,32.518,-25.934,32.593,-25.984,32.699,-25.877,32.701,-25.822,32.722,-25.808,32.702,-25.805,32.689,-25.795,32.686,-25.786,32.686,-25.773,32.691,-25.769,32.709,-25.765,32.718,-25.759,32.68,-25.746,32.675,-25.704,32.685,-25.682,32.718,-25.681,32.718,-25.67,32.703,-25.673,32.693,-25.669,32.692,-25.667,32.698,-25.661,32.72,-25.652,32.723,-25.647,32.707,-25.595,32.706,-25.582,32.713,-25.571,32.732,-25.559,32.728,-25.631,32.722,-25.652,32.694,-25.668,32.719,-25.669,32.722,-25.683,32.68,-25.741,32.734,-25.764,32.737,-25.777,32.728,-25.795,32.736,-25.827,32.834,-25.582,33.317,-25.247,33.258,-25.317,35.043,-24.648,35.5,-24.106,35.539,-23.793,35.469,-23.883,35.385,-23.841,35.329,-24.005,35.377,-23.697,35.345,-23.693,35.344,-23.704,35.334,-23.715,35.322,-23.712,35.355,-23.665,35.392,-23.663,35.409,-23.728,35.485,-23.177,35.601,-22.945,35.512,-22.806,35.544,-22.161,35.523,-22.312,35.484,-22.1,35.398,-22.215,35.336,-22.208,35.272,-21.637,35.115,-21.254,35.091,-21.352,35.054,-21.308,35.044,-21.225,35.092,-21.246,35.104,-21.21,34.992,-21.073,35.086,-21.087,35.114,-20.964,34.756,-21.132,34.52,-21.144,34.337,-21.317,34.308,-21.313,34.279,-21.295,34.245,-21.265,34.333,-21.309,34.458,-21.165,34.641,-21.091,34.679,-21.092,34.701,-21.102,34.726,-21.122,34.763,-21.115,34.822,-21.058,34.949,-20.985,35.048,-20.97,35.041,-20.939,35.069,-20.919,35.026,-20.817,35.03,-20.841,35.012,-20.849,34.95,-20.829,35.024,-20.79,34.946,-20.762,35.007,-20.753,34.997,-20.715,34.905,-20.691,34.89,-20.76,34.859,-20.758,34.904,-20.653,34.787,-20.702,34.85,-20.636,34.745,-20.516,34.685,-20.61,34.66,-20.56,34.674,-20.517,34.599,-20.539,34.74,-20.231,34.725,-20.251,34.742,-20.216,34.717,-20.227,34.728,-20.212,34.664,-20.172,34.663,-20.142,34.78,-20.138,34.755,-19.889,34.737,-19.907,34.634,-19.915,34.588,-19.884,34.777,-19.843,34.611,-19.612,34.558,-19.595,34.56,-19.624,34.55,-19.631,34.535,-19.622,34.522,-19.592,34.533,-19.572,34.522,-19.569,34.517,-19.565,34.516,-19.561,34.518,-19.553,34.552,-19.518,34.517,-19.559,34.535,-19.57,34.524,-19.592,34.536,-19.621,34.559,-19.621,34.537,-19.608,34.535,-19.595,34.541,-19.589,34.599,-19.594,34.616,-19.601,34.891,-19.849,35.386,-19.474,35.677,-19.149,35.657,-19.146,35.684,-19.089,35.952,-18.954,36.005,-18.847,36.031,-18.865,36.02,-18.906,35.988,-18.935,36.118,-18.911,36.121,-18.826,36.154,-18.774,36.192,-18.764,36.153,-18.735,36.15,-18.729,36.151,-18.719,36.148,-18.716,36.112,-18.728,36.109,-18.726,36.098,-18.715,36.149,-18.715,36.195,-18.762,36.153,-18.889,36.224,-18.861,36.147,-18.9,36.288,-18.88,36.293,-18.857,36.266,-18.857,36.248,-18.834,36.281,-18.852,36.234,-18.795,36.207,-18.682,36.217,-18.629,36.243,-18.606,36.236,-18.595,36.238,-18.587,36.243,-18.582,36.213,-18.571,36.162,-18.542,36.133,-18.515,36.118,-18.488,36.1,-18.426,36.073,-18.402,36.052,-18.353,36.028,-18.347,35.987,-18.3,35.984,-18.301,35.948,-18.286,35.929,-18.258,35.883,-18.251,35.883,-18.214,35.841,-18.171,35.823,-18.113,35.732,-18.068,35.711,-18.047,35.69,-18.055,35.583,-18.022,35.53,-18.0,35.532,-17.995,35.566,-17.987,35.592,-18.021,35.616,-18.012,35.632,-18.014,35.634,-18.015,35.636,-18.023,35.645,-18.017,35.655,-18.019,35.735,-18.049,35.753,-18.071,35.838,-18.102,35.852,-18.129,35.853,-18.178,35.883,-18.195,35.892,-18.217,35.892,-18.243,35.936,-18.255,36.043,-18.325,36.275,-18.607,36.337,-18.591,36.362,-18.621,36.396,-18.592,36.42,-18.599,36.413,-18.58,36.425,-18.53,36.4,-18.54,36.36,-18.534,36.355,-18.515,36.367,-18.502,36.362,-18.532,36.402,-18.538,36.429,-18.525,36.496,-18.571,36.487,-18.49,36.524,-18.529,36.833,-18.21,36.757,-18.25,36.718,-18.21,36.71,-18.167,36.642,-18.212,36.641,-18.191,36.709,-18.164,36.757,-18.245,36.756,-18.184,36.813,-18.18,36.804,-18.168,36.807,-18.13,36.788,-18.115,36.79,-18.106,36.783,-18.106,36.782,-18.094,36.807,-18.126,36.819,-18.178,36.846,-18.101,36.932,-18.053,36.922,-18.04,36.921,-18.029,36.907,-18.014,36.884,-17.89,36.811,-17.91,36.813,-17.951,36.81,-17.958,36.756,-17.976,36.752,-17.976,36.75,-17.972,36.753,-17.962,36.749,-17.938,36.751,-17.923,36.722,-17.929,36.752,-17.921,36.756,-17.958,36.753,-17.975,36.806,-17.959,36.809,-17.908,36.859,-17.878,36.874,-17.877,36.907,-17.894,36.921,-17.96,36.989,-17.997,36.981,-18.021,37.198,-17.764,37.131,-17.694,37.12,-17.753,37.015,-17.751,37.054,-17.701,37.089,-17.713,37.048,-17.676,37.094,-17.667,37.098,-17.729,37.123,-17.692,37.136,-17.691,37.155,-17.709,37.165,-17.7,37.142,-17.694,37.139,-17.69,37.141,-17.682,37.178,-17.656,37.204,-17.655,37.142,-17.688,37.183,-17.706,37.214,-17.763,37.357,-17.65,37.352,-17.597,37.353,-17.595,37.355,-17.595,37.361,-17.599,37.365,-17.583,37.35,-17.588,37.343,-17.585,37.342,-17.564,37.336,-17.573,37.329,-17.573,37.336,-17.544,37.304,-17.531,37.29,-17.516,37.293,-17.478,37.283,-17.474,37.271,-17.451,37.253,-17.45,37.233,-17.434,37.214,-17.383,37.205,-17.382,37.186,-17.369,37.179,-17.354,37.169,-17.352,37.163,-17.351,37.162,-17.344,37.125,-17.337,37.102,-17.314,37.062,-17.227,37.051,-17.139,37.104,-17.311,37.126,-17.336,37.161,-17.341,37.164,-17.35,37.179,-17.352,37.218,-17.384,37.294,-17.476,37.3,-17.525,37.336,-17.542,37.337,-17.559,37.331,-17.571,37.343,-17.563,37.366,-17.581,37.369,-17.653,37.364,-17.661,37.345,-17.671,37.349,-17.671,37.422,-17.628,37.425,-17.611,37.446,-17.608,37.431,-17.58,37.452,-17.604,37.438,-17.62,37.581,-17.542,37.558,-17.532,37.552,-17.548,37.545,-17.553,37.539,-17.554,37.517,-17.545,37.515,-17.537,37.561,-17.524,37.607,-17.537,37.717,-17.453,37.732,-17.446,37.756,-17.451,37.747,-17.439,37.748,-17.429,37.757,-17.427,37.767,-17.432,37.777,-17.429,37.76,-17.416,37.768,-17.4,37.778,-17.429,37.759,-17.452,37.701,-17.476,37.718,-17.476,37.964,-17.355,38.047,-17.328,37.985,-17.282,37.961,-17.253,38.086,-17.323,38.051,-17.137,38.15,-17.202,38.143,-17.296,38.513,-17.086,38.527,-17.134,38.681,-17.085,38.645,-17.092,38.637,-16.974,38.684,-16.989,38.71,-17.082,39.073,-17.013,39.122,-16.936,39.093,-16.931,39.091,-16.956,39.077,-16.963,39.083,-16.959,39.022,-16.959,39.019,-16.953,39.136,-16.837,39.118,-16.822,39.112,-16.795,39.087,-16.784,39.079,-16.775,39.046,-16.718,39.039,-16.666,39.016,-16.661,39.003,-16.656,38.986,-16.634,38.984,-16.615,38.998,-16.541,39.016,-16.53,39.039,-16.529,39.041,-16.523,39.022,-16.496,39.021,-16.488,39.042,-16.517,39.042,-16.526,38.988,-16.632,39.003,-16.654,39.038,-16.662,39.047,-16.674,39.138,-16.836,39.142,-16.894,39.269,-16.79,39.175,-16.774,39.243,-16.767,39.226,-16.697,39.236,-16.675,39.299,-16.665,39.237,-16.703,39.297,-16.77,39.859,-16.442,39.791,-16.418,39.747,-16.249,40.027,-16.187,40.051,-16.129,40.105,-16.078,40.139,-15.976,40.114,-16.006,40.08,-16.006,40.047,-16.083,40.027,-16.105,40.021,-16.107,40.053,-16.011,40.473,-15.581,40.44,-15.597,40.423,-15.595,40.563,-15.498,40.671,-15.276,40.66,-15.291,40.679,-15.254,40.626,-15.157,40.567,-15.203,40.494,-15.2,40.708,-15.082,40.622,-14.992,40.633,-14.951,40.772,-15.008,40.739,-14.935,40.663,-14.929,40.648,-14.915,40.633,-14.868,40.831,-14.839,40.799,-14.629,40.832,-14.553,40.76,-14.555,40.826,-14.454,40.676,-14.458,40.673,-14.556,40.62,-14.603,40.621,-14.33,40.678,-14.383,40.739,-14.276,40.699,-14.187,40.61,-14.279,40.507,-14.172,40.61,-14.143,40.638,-14.027,40.575,-13.995,40.538,-13.676,40.6,-13.554,40.277,-13.48,40.366,-13.451,40.411,-13.453,40.525,-13.508,40.588,-12.967,40.484,-12.961,40.514,-12.993,40.49,-13.053,40.406,-12.923,40.51,-12.93,40.539,-12.767,40.547,-12.768,40.572,-12.797,40.58,-12.817,40.63,-12.753,40.601,-12.706,40.564,-12.721,40.619,-12.66,40.569,-12.531,40.455,-12.505,40.57,-12.399,40.54,-12.376,40.538,-12.398,40.502,-12.383,40.487,-12.398,40.477,-12.397,40.458,-12.253,40.554,-11.999,40.416,-11.706,40.475,-11.409,40.428,-11.414,40.328,-11.31,40.506,-11.198,40.565,-11.03,40.495,-10.945,40.618,-10.831,40.482,-10.798,40.64,-10.691,40.471,-10.559,40.536,-10.499,43.382,-11.37],"holes":[[43.382,-11.37,43.27,-11.416,43.229,-11.737,43.47,-11.94,43.518,-11.892,43.396,-11.645,43.382,-11.37]]}],"names":{"ar":"قناة موزنبيق","de":"Straße von Mosambik","es":"Canal de Mozambique","fr":"canal du Mozambique","he":"מצר מוזמביק","hu":"Mozambiki-csatorna","it":"Canale del Mozambico","nl":"Straat Mozambique","pt":"Canal de Moçambique","zh":"莫桑比克海峡"}},{"name":"Labrador Sea","bbox":[-64.306,47.386,-43.675,60.397],"area":125.604,"polygons":[{"outer":[-44.067,60.331,-43.9,60.317,-44.135,60.231,-44.16,60.148,-44.132,60.18,-44.073,60.181,-43.71,60.147,-43.675,60.119,-44.109,60.171,-44.125,60.093,-43.715,60.051,-43.935,59.993,-43.857,59.99,-43.806,59.931,-43.895,59.926,-43.978,59.974,-44.119,59.876,-44.095,59.792,-43.924,59.88,-43.877,59.86,-52.623,47.526,-52.705,47.56,-52.66,47.647,-52.804,47.806,-52.918,47.543,-53.13,47.386,-53.149,47.464,-53.209,47.442,-53.168,47.509,-53.224,47.458,-53.186,47.572,-53.281,47.547,-53.174,47.61,-53.268,47.573,-53.269,47.59,-53.199,47.621,-53.287,47.605,-53.175,47.675,-53.257,47.669,-53.08,47.937,-52.849,48.099,-52.889,48.152,-53.301,48.009,-53.507,47.748,-53.555,47.522,-53.666,47.514,-53.681,47.687,-53.705,47.694,-53.732,47.633,-53.772,47.609,-53.942,47.857,-53.799,47.77,-53.727,47.887,-53.793,47.893,-53.814,47.906,-53.731,47.912,-53.699,47.893,-53.754,47.927,-53.611,48.052,-53.698,48.007,-53.936,48.013,-53.675,48.06,-53.935,48.077,-53.986,48.183,-53.939,48.236,-53.642,48.174,-53.39,48.275,-53.374,48.395,-53.335,48.363,-53.206,48.35,-53.051,48.46,-52.996,48.599,-53.045,48.635,-53.031,48.658,-53.089,48.703,-53.262,48.522,-53.361,48.619,-53.449,48.627,-53.546,48.443,-53.651,48.387,-53.56,48.485,-53.599,48.534,-53.653,48.436,-53.674,48.426,-53.744,48.423,-53.735,48.435,-53.636,48.497,-53.699,48.492,-53.639,48.54,-53.789,48.477,-53.848,48.369,-53.91,48.38,-53.83,48.476,-54.165,48.325,-54.194,48.393,-53.72,48.565,-53.969,48.536,-53.946,48.58,-53.622,48.692,-53.749,48.651,-53.761,48.72,-53.884,48.7,-53.848,48.756,-53.959,48.669,-53.798,48.688,-53.87,48.649,-53.888,48.651,-53.921,48.63,-53.964,48.623,-53.906,48.652,-53.968,48.666,-53.942,48.709,-54.028,48.673,-53.956,48.718,-54.049,48.74,-53.82,48.809,-53.915,48.844,-53.976,48.77,-54.056,48.812,-54.335,48.67,-54.459,48.629,-54.514,48.623,-54.562,48.634,-54.326,48.681,-54.154,48.824,-54.0,48.823,-53.896,48.892,-53.985,48.884,-54.003,48.903,-53.814,48.936,-53.917,48.972,-53.907,48.992,-53.747,49.008,-53.877,49.016,-53.874,49.038,-53.604,49.036,-53.61,49.115,-53.626,49.101,-53.672,49.105,-53.545,49.15,-53.507,49.282,-53.94,49.457,-54.048,49.425,-54.007,49.473,-54.163,49.457,-54.18,49.383,-54.201,49.372,-54.215,49.412,-54.375,49.44,-54.481,49.211,-54.555,49.13,-54.565,49.122,-54.584,49.124,-54.602,49.111,-54.634,49.106,-54.637,49.091,-54.648,49.09,-54.653,49.082,-54.676,49.075,-54.701,49.075,-54.704,49.078,-54.717,49.068,-54.752,49.066,-54.77,49.047,-54.781,49.041,-54.794,49.038,-54.742,49.077,-54.739,49.068,-54.704,49.079,-54.676,49.078,-54.638,49.107,-54.589,49.126,-54.564,49.124,-54.488,49.203,-54.448,49.482,-54.568,49.423,-54.474,49.569,-54.838,49.27,-54.854,49.416,-54.892,49.292,-55.049,49.217,-55.026,49.351,-55.098,49.36,-55.081,49.268,-55.312,49.189,-55.291,49.098,-55.279,49.093,-55.241,49.115,-55.196,49.129,-55.19,49.128,-55.4,49.03,-55.497,49.005,-55.541,48.969,-55.627,48.924,-55.651,48.922,-55.494,49.009,-55.404,49.033,-55.391,49.051,-55.32,49.09,-55.391,49.161,-55.243,49.257,-55.249,49.338,-55.24,49.367,-55.231,49.367,-55.241,49.378,-55.136,49.488,-55.336,49.538,-55.248,49.405,-55.322,49.302,-55.316,49.41,-55.405,49.32,-55.399,49.383,-55.452,49.349,-55.467,49.353,-55.334,49.452,-55.456,49.421,-55.404,49.507,-55.582,49.358,-55.544,49.491,-55.681,49.381,-55.747,49.416,-55.665,49.471,-55.808,49.44,-55.807,49.465,-55.731,49.48,-55.801,49.485,-55.848,49.531,-56.141,49.43,-55.823,49.606,-55.864,49.619,-55.882,49.593,-55.902,49.605,-55.932,49.583,-55.997,49.562,-56.004,49.566,-55.847,49.691,-56.013,49.662,-55.913,49.699,-55.893,49.71,-55.892,49.717,-56.037,49.681,-56.186,49.574,-56.046,49.691,-56.126,49.698,-55.473,49.967,-55.778,49.917,-55.929,50.038,-56.044,49.975,-56.021,50.038,-56.094,50.01,-56.153,49.944,-56.21,49.924,-56.072,50.075,-56.16,50.158,-56.396,49.963,-56.376,49.891,-56.582,49.847,-56.51,49.821,-56.618,49.815,-56.871,49.543,-56.785,49.7,-56.923,49.755,-56.773,49.83,-56.763,50.024,-56.637,50.11,-56.666,50.125,-56.711,50.12,-56.749,50.134,-56.613,50.126,-56.434,50.332,-56.508,50.414,-56.411,50.377,-56.276,50.502,-56.276,50.513,-56.317,50.512,-56.348,50.52,-56.36,50.528,-56.255,50.522,-56.266,50.617,-56.111,50.672,-56.175,50.687,-56.156,50.866,-56.215,50.831,-56.272,50.897,-56.189,50.85,-56.189,50.877,-56.119,50.912,-56.15,50.759,-56.1,50.816,-56.089,50.724,-55.829,50.922,-55.779,51.039,-55.837,51.057,-55.729,51.093,-55.721,51.188,-55.846,51.203,-55.863,51.231,-55.964,51.149,-56.1,51.134,-56.027,51.164,-56.005,51.189,-56.103,51.338,-55.608,51.299,-55.582,51.385,-55.496,51.371,-55.464,51.481,-55.492,51.458,-55.542,51.501,-55.445,51.507,-55.464,51.514,-55.445,51.536,-55.46,51.54,-55.461,51.525,-55.472,51.528,-55.485,51.558,-55.428,51.552,-55.277,52.0,-55.621,52.218,-55.842,52.232,-55.717,52.292,-55.832,52.28,-56.248,52.449,-56.211,52.45,-55.816,52.339,-55.762,52.343,-55.876,52.365,-55.778,52.361,-55.847,52.385,-55.834,52.387,-55.628,52.367,-55.699,52.409,-55.633,52.434,-55.774,52.446,-55.748,52.498,-56.272,52.534,-56.332,52.569,-56.405,52.564,-56.667,52.638,-56.709,52.657,-56.337,52.58,-56.259,52.543,-56.131,52.573,-55.992,52.559,-56.075,52.582,-55.969,52.622,-56.141,52.659,-55.974,52.65,-55.915,52.616,-55.855,52.622,-55.802,52.601,-55.754,52.617,-55.892,52.639,-55.774,52.693,-55.965,52.681,-56.208,52.843,-56.017,52.778,-55.918,52.854,-55.805,52.837,-55.949,52.992,-55.881,53.009,-56.117,52.964,-56.028,53.016,-56.058,53.032,-56.089,53.018,-56.186,53.036,-56.055,53.046,-55.892,53.02,-55.919,53.038,-55.866,53.095,-55.963,53.11,-55.748,53.135,-55.852,53.204,-55.775,53.194,-55.793,53.213,-55.74,53.251,-55.922,53.301,-55.798,53.336,-56.029,53.371,-55.962,53.401,-56.037,53.472,-56.172,53.501,-56.285,53.561,-55.96,53.544,-56.724,53.682,-56.603,53.668,-56.402,53.715,-56.48,53.789,-56.619,53.758,-56.598,53.705,-56.703,53.76,-56.77,53.72,-57.021,53.713,-57.296,53.464,-57.251,53.429,-57.247,53.42,-57.25,53.413,-57.352,53.378,-57.398,53.379,-57.252,53.42,-57.373,53.426,-57.305,53.455,-57.303,53.569,-57.455,53.563,-57.425,53.622,-57.518,53.58,-57.526,53.579,-57.534,53.582,-57.126,53.734,-57.161,53.787,-57.092,53.808,-57.141,53.822,-57.17,53.808,-57.286,53.822,-57.493,53.859,-57.519,53.87,-57.523,53.879,-57.539,53.882,-57.523,53.883,-57.499,53.862,-57.285,53.823,-57.136,53.827,-57.075,53.808,-57.222,53.897,-57.128,53.94,-57.29,54.001,-57.36,54.144,-57.428,54.138,-57.42,54.187,-57.607,54.165,-58.171,54.237,-58.413,54.131,-58.314,54.103,-58.33,54.089,-58.107,54.136,-57.783,54.074,-58.108,54.106,-58.85,53.949,-59.069,53.789,-58.984,53.759,-59.022,53.713,-59.316,53.697,-59.591,53.529,-59.908,53.53,-59.792,53.492,-59.905,53.487,-59.879,53.465,-59.88,53.42,-59.836,53.415,-59.832,53.412,-59.832,53.406,-59.882,53.419,-59.89,53.449,-59.889,53.467,-59.914,53.475,-60.06,53.342,-60.389,53.337,-60.405,53.395,-60.539,53.346,-60.624,53.339,-60.664,53.355,-60.693,53.382,-60.623,53.342,-60.458,53.397,-60.404,53.399,-60.365,53.378,-60.358,53.361,-60.33,53.401,-60.135,53.465,-60.03,53.438,-60.146,53.522,-60.08,53.568,-60.108,53.685,-60.082,53.707,-60.016,53.712,-60.073,53.72,-60.115,53.765,-59.807,53.793,-59.897,53.821,-59.852,53.849,-59.485,53.832,-58.981,53.965,-58.936,54.018,-59.063,54.025,-58.978,54.044,-58.613,54.039,-58.379,54.226,-58.703,54.139,-58.63,54.121,-59.362,53.999,-59.567,54.035,-59.402,54.028,-58.439,54.238,-58.251,54.246,-58.262,54.283,-58.091,54.322,-58.245,54.332,-58.084,54.385,-57.602,54.384,-57.411,54.462,-57.704,54.482,-57.404,54.494,-57.401,54.56,-57.337,54.581,-57.458,54.659,-57.72,54.639,-57.844,54.741,-58.193,54.746,-58.026,54.754,-57.97,54.785,-57.886,54.786,-57.84,54.812,-57.997,54.786,-57.943,54.931,-58.172,54.844,-58.226,54.878,-58.22,54.788,-58.408,54.787,-58.395,54.735,-58.563,54.758,-58.543,54.811,-58.693,54.841,-58.774,54.783,-58.812,54.853,-58.857,54.812,-59.123,54.825,-59.183,54.767,-59.224,54.755,-59.215,54.741,-59.234,54.733,-59.225,54.756,-59.125,54.827,-58.894,54.861,-59.031,54.893,-58.898,54.943,-58.992,54.987,-58.981,55.028,-59.052,55.0,-58.958,55.073,-59.011,55.15,-59.104,55.069,-59.12,55.126,-59.153,55.067,-59.226,55.088,-59.267,55.027,-59.331,55.02,-59.37,54.982,-59.405,54.97,-59.242,55.113,-59.142,55.149,-59.149,55.234,-59.209,55.163,-59.197,55.234,-59.279,55.195,-59.4,55.024,-59.434,55.078,-59.791,54.886,-59.802,54.786,-59.932,54.744,-59.828,54.823,-59.864,54.866,-59.715,54.961,-59.745,54.957,-59.751,54.966,-59.525,55.043,-59.415,55.159,-59.562,55.068,-59.511,55.218,-59.617,55.14,-59.597,55.186,-59.639,55.191,-59.67,55.113,-59.735,55.148,-59.81,55.107,-59.921,55.139,-59.962,55.092,-59.99,55.11,-59.966,55.167,-59.747,55.195,-59.778,55.332,-59.921,55.227,-60.031,55.228,-60.024,55.202,-60.116,55.119,-60.33,54.974,-60.428,54.934,-60.435,54.873,-60.438,54.867,-60.469,54.851,-60.462,54.91,-60.223,55.118,-60.153,55.127,-60.033,55.264,-60.116,55.228,-60.139,55.235,-60.16,55.194,-60.246,55.156,-60.274,55.119,-60.293,55.105,-60.322,55.1,-60.16,55.231,-60.237,55.205,-60.225,55.22,-60.137,55.267,-60.239,55.228,-60.252,55.274,-60.364,55.167,-60.695,54.985,-60.363,55.24,-60.489,55.23,-60.555,55.168,-60.621,55.153,-60.626,55.156,-60.477,55.255,-60.531,55.241,-60.531,55.264,-60.39,55.288,-60.356,55.42,-60.196,55.425,-60.277,55.507,-60.282,55.425,-60.318,55.456,-60.37,55.419,-60.306,55.481,-60.337,55.494,-60.383,55.444,-60.423,55.43,-60.404,55.386,-60.488,55.328,-60.519,55.337,-60.459,55.442,-60.352,55.489,-60.368,55.502,-60.308,55.575,-60.458,55.496,-60.398,55.608,-60.531,55.598,-60.376,55.668,-60.328,55.777,-60.526,55.782,-60.606,55.612,-60.65,55.588,-60.658,55.551,-60.694,55.533,-60.598,55.811,-60.765,55.722,-61.034,55.723,-60.795,55.751,-60.726,55.836,-60.773,55.852,-60.891,55.847,-60.931,55.862,-61.07,55.84,-61.238,55.856,-61.165,55.901,-61.249,55.908,-61.266,55.921,-61.541,55.896,-61.341,55.93,-61.13,55.91,-61.16,55.902,-61.181,55.885,-61.073,55.887,-61.143,55.977,-61.216,55.95,-61.42,55.958,-61.476,55.981,-61.404,56.012,-61.523,56.023,-61.509,56.003,-61.527,55.99,-61.703,55.961,-61.879,55.968,-61.905,55.974,-61.924,55.989,-62.099,55.871,-62.126,55.872,-62.144,55.878,-62.194,55.867,-62.213,55.87,-62.144,55.879,-62.103,55.874,-62.121,55.905,-61.951,55.987,-61.92,55.989,-61.884,55.97,-61.708,55.966,-61.523,55.997,-61.538,56.021,-61.49,56.032,-61.291,56.023,-61.35,56.052,-61.259,56.049,-61.243,56.064,-61.31,56.085,-61.423,56.037,-61.493,56.069,-61.326,56.105,-61.436,56.146,-61.336,56.234,-61.539,56.19,-61.693,56.223,-61.725,56.178,-61.759,56.159,-61.766,56.158,-61.775,56.224,-61.926,56.207,-62.046,56.221,-62.105,56.291,-61.578,56.281,-61.923,56.342,-61.773,56.356,-61.858,56.384,-61.772,56.389,-61.921,56.413,-61.918,56.436,-62.078,56.412,-62.224,56.471,-61.647,56.536,-62.226,56.604,-61.904,56.612,-62.044,56.623,-62.522,56.756,-61.672,56.618,-61.832,56.706,-61.701,56.708,-61.744,56.75,-61.915,56.7,-61.933,56.803,-61.655,56.81,-61.652,56.882,-61.486,56.986,-61.356,56.95,-61.362,57.097,-61.732,57.156,-61.638,57.236,-61.797,57.152,-61.985,57.21,-62.04,57.264,-61.863,57.289,-61.932,57.363,-61.808,57.366,-61.892,57.407,-62.186,57.47,-62.383,57.423,-62.58,57.502,-62.363,57.45,-62.288,57.537,-61.893,57.632,-62.021,57.787,-62.153,57.809,-62.165,57.832,-62.079,57.887,-62.133,57.977,-62.186,57.89,-62.284,57.947,-62.306,57.91,-62.358,57.894,-62.349,57.975,-62.427,57.858,-62.457,57.971,-62.678,57.929,-62.32,58.022,-62.409,58.042,-62.38,58.107,-62.542,58.054,-62.53,58.107,-62.445,58.171,-62.542,58.178,-62.538,58.112,-62.571,58.168,-62.949,58.123,-63.126,58.0,-63.367,57.968,-63.158,58.014,-63.14,58.069,-63.192,58.045,-63.24,58.058,-63.008,58.136,-63.07,58.159,-62.589,58.202,-62.656,58.267,-62.92,58.198,-62.61,58.311,-62.705,58.406,-62.557,58.479,-62.805,58.501,-63.177,58.363,-63.094,58.461,-63.283,58.464,-63.608,58.289,-63.336,58.462,-63.563,58.464,-63.444,58.499,-63.466,58.548,-63.166,58.507,-63.159,58.528,-62.904,58.601,-62.929,58.622,-62.851,58.699,-63.09,58.664,-62.899,58.785,-63.066,58.862,-63.026,58.897,-63.111,58.883,-63.177,58.763,-63.148,58.877,-63.192,58.848,-63.361,58.864,-63.138,58.923,-63.235,58.936,-63.176,58.989,-63.317,58.94,-63.219,59.006,-63.356,59.026,-63.14,59.054,-63.391,59.095,-63.538,59.024,-63.847,59.047,-63.881,58.95,-63.913,59.062,-64.05,59.018,-63.951,59.082,-63.448,59.1,-63.359,59.197,-63.464,59.177,-63.46,59.238,-63.379,59.275,-63.443,59.284,-63.572,59.209,-63.642,59.214,-63.514,59.312,-63.644,59.361,-63.756,59.246,-63.839,59.241,-63.703,59.37,-64.064,59.382,-63.82,59.405,-63.724,59.512,-64.005,59.631,-64.123,59.516,-64.041,59.643,-63.948,59.635,-63.913,59.67,-64.174,59.689,-64.274,59.78,-64.173,59.782,-64.178,59.853,-64.129,59.905,-64.306,59.948,-64.16,60.0,-44.796,60.006,-44.752,59.984,-44.692,60.088,-44.743,60.123,-44.664,60.1,-44.613,59.972,-44.461,60.166,-44.313,60.138,-44.26,60.189,-44.331,60.228,-44.211,60.219,-44.274,60.328,-44.17,60.229,-44.081,60.281,-44.126,60.397,-44.067,60.331],"holes":[[-61.466,56.95,-61.633,56.866,-61.615,56.725,-61.714,56.665,-61.563,56.693,-61.382,56.602,-61.421,56.652,-61.346,56.749,-61.519,56.789,-61.346,56.93,-61.466,56.95]]}],"names":{"ar":"بحر لبرادور","de":"Labradorsee","es":"mar de Labrador","fr":"mer du Labrador","he":"ים לברדור","hu":"Labrador-tenger","it":"Mare del Labrador","nl":"Labradorzee","pt":"Mar do Labrador","zh":"拉布拉多海"}},{"name":"Hudson Bay","bbox":[-95.344,51.144,-75.885,66.026],"area":132.4955,"polygons":[{"outer":[-94.512,58.823,-94.642,58.891,-94.597,58.907,-94.606,58.952,-94.692,58.893,-94.673,58.893,-94.642,58.888,-94.634,58.882,-94.719,58.87,-94.727,58.873,-94.687,58.911,-94.675,58.908,-94.644,58.939,-94.621,58.946,-94.846,59.012,-94.776,59.021,-94.825,59.218,-94.737,59.312,-94.787,59.321,-94.807,59.343,-94.79,59.352,-94.716,59.322,-94.666,59.349,-94.734,59.337,-94.833,59.602,-94.829,59.707,-94.77,59.787,-94.851,59.967,-94.772,60.067,-94.71,60.093,-94.816,60.179,-94.708,60.207,-94.684,60.193,-94.639,60.392,-94.779,60.489,-94.838,60.482,-94.853,60.483,-94.859,60.487,-94.864,60.482,-95.015,60.514,-95.025,60.507,-95.059,60.502,-95.145,60.5,-95.17,60.504,-95.28,60.461,-95.344,60.477,-95.253,60.505,-95.035,60.523,-94.996,60.523,-94.957,60.515,-94.943,60.51,-94.935,60.505,-94.917,60.504,-94.931,60.503,-94.913,60.497,-94.783,60.536,-94.551,60.534,-94.372,60.793,-94.415,60.804,-94.501,60.864,-94.357,60.805,-94.132,61.05,-94.204,61.058,-93.982,61.092,-94.085,61.113,-94.112,61.14,-94.028,61.141,-93.967,61.135,-94.106,61.16,-94.003,61.164,-94.064,61.175,-94.066,61.183,-94.013,61.206,-94.034,61.268,-94.07,61.292,-94.087,61.292,-94.085,61.308,-94.172,61.324,-94.226,61.377,-94.235,61.372,-94.245,61.381,-94.324,61.396,-94.324,61.401,-94.214,61.42,-93.924,61.404,-93.979,61.455,-93.882,61.537,-93.292,61.725,-93.419,61.718,-93.484,61.738,-93.457,61.708,-93.532,61.716,-93.525,61.71,-93.541,61.707,-93.574,61.707,-93.592,61.712,-93.589,61.71,-93.61,61.708,-93.656,61.726,-93.648,61.767,-93.248,61.762,-93.609,61.842,-93.683,61.912,-93.592,61.875,-93.534,61.888,-93.483,61.878,-93.55,61.919,-93.599,61.927,-93.636,61.941,-93.638,61.957,-93.502,61.943,-93.33,61.876,-93.175,61.885,-93.111,61.832,-92.985,61.852,-92.966,61.884,-93.045,61.894,-93.021,61.897,-93.079,61.947,-93.28,61.889,-93.409,61.942,-93.404,61.95,-93.349,61.935,-93.378,61.946,-93.357,61.956,-93.325,61.938,-93.324,61.956,-93.213,61.928,-93.195,61.943,-93.324,62.0,-93.289,62.026,-93.353,62.06,-93.372,62.086,-93.368,62.097,-93.17,61.971,-93.101,61.981,-93.118,62.0,-93.135,62.003,-93.169,62.021,-93.183,62.051,-93.067,62.041,-93.107,62.068,-93.104,62.088,-92.959,62.071,-93.278,62.173,-93.246,62.176,-93.24,62.181,-93.166,62.169,-93.173,62.176,-93.144,62.178,-93.13,62.168,-93.091,62.16,-93.026,62.16,-93.107,62.171,-93.124,62.178,-93.106,62.182,-93.126,62.181,-93.178,62.196,-93.213,62.212,-93.211,62.213,-93.147,62.206,-93.103,62.182,-93.005,62.175,-92.958,62.152,-92.975,62.17,-93.045,62.202,-92.738,62.223,-92.846,62.279,-92.991,62.298,-93.071,62.332,-92.842,62.314,-92.788,62.29,-92.752,62.305,-92.721,62.297,-92.639,62.244,-92.68,62.229,-92.586,62.212,-92.593,62.145,-92.526,62.174,-92.573,62.247,-92.532,62.297,-92.777,62.369,-92.665,62.384,-92.746,62.4,-92.736,62.483,-92.603,62.482,-92.546,62.456,-92.557,62.413,-92.622,62.434,-92.641,62.429,-92.587,62.39,-92.496,62.405,-92.358,62.389,-92.324,62.395,-92.348,62.402,-92.322,62.405,-92.332,62.411,-92.306,62.403,-92.314,62.415,-92.286,62.412,-92.318,62.357,-92.184,62.375,-92.177,62.365,-92.163,62.357,-92.176,62.391,-92.104,62.403,-92.098,62.408,-92.227,62.397,-92.294,62.428,-92.386,62.432,-92.327,62.407,-92.354,62.415,-92.366,62.396,-92.545,62.452,-92.512,62.478,-92.577,62.492,-92.494,62.494,-92.528,62.5,-92.638,62.606,-92.45,62.529,-92.342,62.572,-92.296,62.572,-92.26,62.559,-92.263,62.568,-92.224,62.562,-92.272,62.584,-92.267,62.597,-92.0,62.516,-91.838,62.611,-92.228,62.676,-92.148,62.676,-92.296,62.703,-92.346,62.731,-92.409,62.83,-92.359,62.827,-92.367,62.843,-92.326,62.847,-92.298,62.821,-92.299,62.846,-92.257,62.818,-92.248,62.828,-92.217,62.83,-92.118,62.784,-92.157,62.808,-92.079,62.794,-92.056,62.804,-92.135,62.848,-92.098,62.862,-91.359,62.794,-91.393,62.833,-91.038,62.934,-90.916,62.947,-90.916,62.939,-90.848,62.932,-90.846,62.922,-90.8,62.916,-90.825,62.924,-90.782,62.967,-90.745,62.956,-90.734,62.964,-90.766,62.978,-90.699,62.97,-90.744,63.052,-90.627,63.073,-90.744,63.251,-90.713,63.3,-90.799,63.335,-90.725,63.335,-90.711,63.325,-90.718,63.335,-90.678,63.339,-90.678,63.356,-90.763,63.35,-90.917,63.379,-90.979,63.426,-90.908,63.393,-90.813,63.391,-90.938,63.425,-91.0,63.472,-91.378,63.47,-91.384,63.514,-91.414,63.515,-91.503,63.542,-91.516,63.56,-91.511,63.576,-91.66,63.576,-91.711,63.644,-91.613,63.611,-91.604,63.656,-91.729,63.711,-91.855,63.712,-91.86,63.7,-92.008,63.674,-91.986,63.662,-92.044,63.667,-92.027,63.626,-92.201,63.611,-92.269,63.566,-92.226,63.531,-92.466,63.513,-92.545,63.543,-92.138,63.631,-92.269,63.668,-92.094,63.684,-92.399,63.723,-92.504,63.813,-92.608,63.759,-93.102,63.865,-93.135,63.902,-93.365,63.967,-93.448,63.95,-93.438,63.948,-93.449,63.937,-93.422,63.927,-93.447,63.926,-93.42,63.917,-93.448,63.917,-93.408,63.899,-93.425,63.887,-93.245,63.819,-93.29,63.82,-93.361,63.847,-93.352,63.842,-93.386,63.842,-93.371,63.827,-93.342,63.821,-93.33,63.809,-93.63,63.859,-93.73,63.955,-93.812,63.921,-94.031,63.914,-94.303,63.992,-94.093,63.988,-94.221,64.076,-93.525,63.982,-93.805,64.215,-93.617,64.088,-93.523,64.067,-93.541,64.062,-93.454,64.022,-93.451,64.0,-92.95,63.94,-92.912,63.89,-92.739,63.895,-92.711,63.837,-92.517,63.834,-92.378,63.769,-92.043,63.739,-92.088,63.789,-91.97,63.735,-91.81,63.749,-91.75,63.734,-91.681,63.796,-91.68,63.723,-91.406,63.719,-91.378,63.695,-91.415,63.684,-91.376,63.648,-91.288,63.658,-91.225,63.616,-91.221,63.628,-91.137,63.626,-91.136,63.63,-91.15,63.633,-91.157,63.64,-91.121,63.635,-91.083,63.603,-91.039,63.623,-90.979,63.58,-90.924,63.582,-90.913,63.566,-90.894,63.578,-90.851,63.559,-90.707,63.575,-90.775,63.623,-90.823,63.682,-90.725,63.606,-90.705,63.578,-90.669,63.591,-90.642,63.584,-90.642,63.59,-90.535,63.593,-90.601,63.682,-90.611,63.651,-90.72,63.65,-90.647,63.7,-90.575,63.693,-90.547,63.668,-90.43,63.658,-90.557,63.66,-90.474,63.606,-90.306,63.623,-90.315,63.633,-90.38,63.638,-90.4,63.648,-90.389,63.652,-90.217,63.632,-90.22,63.627,-90.302,63.632,-90.284,63.606,-90.286,63.616,-90.265,63.625,-90.262,63.605,-90.258,63.612,-90.249,63.614,-90.213,63.611,-90.235,63.617,-90.171,63.647,-90.187,63.652,-90.188,63.658,-90.142,63.667,-90.146,63.708,-90.085,63.696,-90.112,63.727,-90.08,63.725,-90.067,63.718,-90.053,63.723,-90.076,63.724,-90.079,63.741,-90.062,63.765,-90.133,63.785,-90.007,63.795,-90.066,63.813,-90.104,63.84,-89.998,63.799,-89.998,63.772,-89.971,63.82,-90.129,63.875,-90.095,63.877,-90.131,63.894,-90.107,63.89,-90.127,63.911,-90.122,63.911,-90.124,63.913,-90.102,63.916,-90.097,63.914,-90.094,63.916,-90.236,63.995,-90.211,63.998,-90.249,64.0,-90.243,63.992,-90.403,63.99,-90.379,63.998,-90.306,63.997,-90.303,64.014,-90.153,64.006,-90.069,63.946,-90.053,63.987,-89.989,63.979,-89.937,63.952,-89.95,63.939,-90.028,63.937,-89.971,63.927,-89.944,63.9,-89.949,63.922,-89.908,63.933,-89.822,63.925,-89.935,64.025,-89.812,64.02,-89.942,64.059,-89.94,64.092,-89.915,64.086,-89.925,64.106,-89.897,64.106,-89.93,64.107,-89.947,64.119,-89.949,64.13,-89.956,64.132,-90.018,64.1,-90.212,64.134,-90.098,64.125,-90.056,64.142,-90.005,64.13,-89.98,64.163,-89.912,64.15,-89.917,64.154,-89.918,64.164,-89.91,64.17,-89.929,64.188,-89.959,64.189,-89.958,64.196,-89.986,64.203,-90.0,64.21,-90.002,64.215,-89.926,64.192,-89.901,64.17,-89.903,64.148,-89.79,64.137,-89.886,64.197,-89.865,64.207,-89.783,64.186,-89.809,64.238,-89.783,64.253,-89.753,64.256,-89.718,64.13,-89.819,64.125,-89.706,64.066,-89.71,64.032,-89.672,64.043,-89.722,64.1,-89.659,64.099,-89.671,64.062,-89.512,63.974,-89.562,64.027,-89.562,64.086,-89.548,64.096,-89.538,64.076,-89.54,64.072,-89.497,64.027,-89.519,64.062,-89.501,64.046,-89.502,64.055,-89.523,64.07,-89.531,64.085,-89.534,64.115,-89.448,64.025,-89.389,64.049,-89.225,63.957,-89.255,63.981,-89.283,64.017,-89.25,64.008,-89.282,64.028,-89.307,64.06,-89.31,64.071,-89.137,63.95,-89.11,63.966,-89.085,63.96,-89.137,64.005,-89.094,63.98,-89.069,63.974,-89.188,64.056,-89.224,64.115,-89.076,64.015,-89.091,64.037,-89.075,64.037,-89.059,64.03,-89.043,64.03,-89.012,64.019,-89.001,64.02,-88.966,64.006,-88.905,64.008,-88.87,63.99,-88.866,63.984,-88.76,63.982,-88.698,64.016,-88.603,64.014,-88.854,64.088,-88.85,64.101,-88.565,64.019,-88.657,64.069,-88.628,64.079,-88.742,64.099,-88.779,64.113,-88.78,64.119,-88.612,64.077,-88.583,64.052,-88.553,64.057,-88.47,64.041,-88.506,64.068,-88.487,64.06,-88.49,64.066,-88.467,64.07,-88.445,64.065,-88.404,64.088,-88.474,64.086,-88.511,64.094,-88.513,64.101,-88.563,64.103,-88.681,64.124,-88.692,64.132,-88.117,64.143,-88.011,64.223,-88.058,64.248,-88.052,64.249,-88.085,64.253,-88.097,64.258,-88.111,64.259,-88.116,64.265,-87.866,64.358,-87.8,64.503,-87.823,64.511,-87.553,64.568,-87.498,64.758,-87.464,64.7,-87.34,64.775,-87.33,64.75,-87.296,64.747,-87.208,64.892,-87.26,64.908,-87.029,64.959,-87.131,64.991,-86.971,65.047,-86.97,65.077,-87.012,65.055,-87.029,65.099,-86.921,65.14,-87.029,65.184,-87.01,65.238,-87.093,65.233,-87.294,65.269,-87.752,65.28,-87.652,65.247,-87.651,65.226,-87.632,65.216,-87.635,65.211,-87.818,65.281,-88.0,65.283,-88.02,65.263,-87.968,65.219,-88.308,65.278,-88.265,65.252,-88.235,65.204,-88.344,65.283,-89.035,65.323,-89.682,65.716,-89.811,65.743,-90.005,65.809,-90.446,65.868,-90.47,65.883,-90.037,65.895,-89.711,65.834,-90.061,65.95,-89.873,65.953,-89.71,65.934,-89.788,65.959,-89.8,65.97,-89.849,65.987,-89.852,65.993,-89.823,65.993,-89.388,65.864,-89.026,65.704,-88.828,65.708,-88.369,65.627,-88.844,65.648,-88.552,65.59,-88.017,65.344,-87.518,65.339,-87.566,65.352,-87.593,65.368,-87.602,65.381,-87.349,65.319,-87.14,65.412,-87.241,65.412,-87.358,65.434,-87.352,65.441,-87.166,65.441,-87.064,65.394,-87.099,65.481,-87.158,65.478,-87.171,65.488,-87.173,65.494,-87.1,65.491,-87.085,65.467,-87.016,65.475,-86.945,65.516,-86.978,65.542,-86.97,65.582,-86.956,65.577,-86.936,65.586,-86.911,65.538,-86.582,65.67,-86.637,65.667,-86.637,65.677,-86.562,65.674,-86.57,65.67,-86.519,65.657,-86.526,65.686,-86.432,65.778,-86.486,65.772,-86.504,65.78,-86.398,65.89,-86.277,65.925,-86.23,65.925,-85.989,66.026,-85.505,65.923,-85.958,65.745,-86.133,65.42,-86.078,65.358,-86.157,65.286,-86.127,65.102,-86.22,64.967,-86.144,64.922,-86.41,64.479,-86.365,64.269,-86.179,64.148,-86.181,64.09,-86.959,63.89,-87.224,63.718,-87.151,63.554,-86.543,63.648,-86.26,63.629,-85.771,63.696,-85.633,63.687,-85.615,63.179,-85.504,63.107,-85.209,63.103,-84.583,63.291,-84.276,63.6,-84.086,63.592,-83.763,63.769,-83.605,63.763,-83.662,64.008,-83.531,64.102,-83.164,64.11,-83.012,64.174,-82.936,64.13,-83.136,63.961,-82.462,63.921,-82.328,63.837,-82.515,63.762,-82.463,63.668,-82.06,63.686,-80.952,63.452,-78.097,62.348,-78.193,62.256,-78.124,62.295,-78.157,62.013,-77.963,61.683,-77.814,61.683,-77.818,61.738,-77.703,61.588,-77.602,61.593,-77.582,61.646,-77.398,61.684,-77.374,61.681,-77.344,61.668,-77.301,61.682,-77.252,61.686,-77.256,61.671,-77.239,61.678,-77.218,61.669,-77.186,61.673,-77.172,61.671,-77.169,61.669,-77.553,61.64,-77.571,61.577,-77.651,61.571,-77.506,61.552,-77.491,61.522,-77.599,61.558,-77.713,61.538,-77.56,61.479,-77.622,61.459,-77.72,61.467,-77.633,61.388,-77.813,61.463,-77.769,61.236,-77.712,61.214,-77.706,61.171,-77.783,61.179,-77.829,61.053,-78.226,60.811,-78.172,60.819,-78.22,60.779,-78.074,60.815,-77.987,60.785,-77.913,60.825,-77.902,60.827,-77.883,60.822,-77.867,60.836,-77.838,60.835,-77.997,60.778,-77.991,60.775,-77.903,60.791,-77.897,60.787,-77.846,60.799,-77.893,60.775,-77.839,60.78,-77.923,60.746,-77.489,60.849,-77.534,60.825,-77.772,60.76,-77.52,60.763,-77.838,60.642,-77.817,60.631,-77.812,60.613,-77.806,60.615,-77.794,60.613,-77.799,60.592,-77.729,60.581,-77.718,60.562,-77.689,60.555,-77.676,60.542,-77.516,60.561,-77.413,60.539,-77.508,60.546,-77.58,60.539,-77.588,60.496,-77.773,60.405,-77.654,60.366,-77.638,60.342,-77.652,60.329,-77.556,60.309,-77.611,60.297,-77.542,60.282,-77.587,60.259,-77.475,60.202,-77.571,60.186,-77.435,60.118,-77.504,60.129,-77.503,60.11,-77.614,60.111,-77.594,60.065,-77.635,60.058,-77.403,60.061,-77.332,60.017,-77.216,60.047,-77.26,60.069,-77.272,60.114,-77.21,60.09,-77.166,60.104,-77.122,60.095,-77.114,60.082,-77.09,60.083,-77.158,60.127,-77.2,60.11,-77.223,60.153,-77.136,60.145,-76.942,60.089,-76.899,60.095,-76.868,60.11,-76.867,60.111,-76.854,60.108,-76.891,60.134,-76.747,60.157,-76.8,60.174,-76.843,60.232,-76.889,60.241,-76.938,60.292,-76.933,60.306,-76.955,60.307,-76.952,60.316,-76.926,60.305,-76.895,60.256,-76.86,60.269,-76.839,60.268,-76.825,60.276,-76.802,60.273,-76.798,60.278,-76.788,60.282,-76.66,60.319,-76.671,60.383,-76.663,60.384,-76.665,60.386,-76.658,60.393,-76.651,60.395,-76.597,60.403,-76.605,60.427,-76.568,60.42,-76.613,60.438,-76.533,60.425,-76.548,60.432,-76.531,60.445,-76.544,60.448,-76.537,60.449,-76.544,60.465,-76.535,60.474,-76.556,60.48,-76.498,60.491,-76.536,60.495,-76.54,60.532,-76.443,60.527,-76.452,60.518,-76.357,60.509,-76.28,60.482,-76.297,60.51,-76.158,60.52,-76.109,60.507,-76.142,60.583,-76.113,60.567,-76.125,60.598,-76.036,60.595,-76.121,60.602,-76.149,60.621,-76.134,60.647,-76.125,60.651,-76.095,60.645,-76.119,60.651,-76.116,60.658,-76.056,60.65,-76.088,60.657,-76.061,60.675,-76.0,60.67,-76.08,60.66,-76.008,60.656,-76.122,60.641,-76.134,60.629,-75.972,60.609,-75.954,60.636,-75.914,60.64,-75.885,60.625,-76.046,60.581,-75.979,60.566,-75.982,60.544,-76.099,60.57,-76.066,60.551,-76.108,60.506,-76.164,60.504,-76.143,60.479,-76.152,60.481,-76.17,60.492,-76.198,60.497,-76.232,60.478,-76.353,60.48,-76.525,60.518,-76.486,60.48,-76.53,60.437,-76.481,60.432,-76.593,60.396,-76.657,60.392,-76.639,60.336,-76.662,60.315,-76.807,60.269,-76.819,60.275,-76.839,60.266,-76.861,60.268,-76.889,60.254,-76.843,60.233,-76.826,60.217,-76.814,60.216,-76.786,60.171,-76.745,60.156,-76.755,60.137,-76.874,60.102,-76.792,60.071,-76.781,60.023,-76.892,60.093,-76.966,60.082,-77.078,60.102,-77.045,60.058,-77.087,60.046,-77.237,60.083,-77.19,60.003,-77.338,60.008,-77.241,59.984,-77.297,59.988,-77.41,59.941,-77.338,59.94,-77.371,59.909,-77.459,59.921,-77.467,59.91,-77.397,59.897,-77.288,59.791,-77.512,59.732,-77.578,59.747,-77.512,59.707,-77.559,59.668,-77.533,59.676,-77.487,59.669,-77.466,59.624,-77.476,59.612,-77.585,59.659,-77.58,59.673,-77.597,59.697,-77.594,59.66,-77.626,59.653,-77.628,59.678,-77.691,59.668,-77.726,59.685,-77.726,59.707,-77.809,59.707,-77.764,59.686,-77.783,59.675,-77.826,59.678,-77.734,59.603,-77.749,59.554,-77.405,59.547,-77.466,59.483,-77.391,59.468,-77.341,59.452,-77.618,59.421,-77.696,59.44,-77.529,59.5,-77.473,59.488,-77.527,59.502,-77.526,59.509,-77.544,59.507,-77.579,59.519,-77.611,59.52,-77.755,59.548,-77.77,59.526,-77.889,59.507,-77.842,59.435,-77.867,59.424,-77.94,59.437,-77.921,59.423,-77.952,59.408,-77.931,59.386,-77.926,59.396,-77.85,59.417,-77.755,59.421,-77.816,59.408,-77.702,59.401,-77.651,59.376,-77.846,59.363,-77.781,59.341,-77.773,59.317,-77.917,59.307,-77.848,59.279,-77.89,59.26,-77.896,59.261,-77.918,59.271,-77.944,59.254,-77.989,59.262,-77.989,59.247,-78.023,59.244,-77.922,59.231,-77.96,59.192,-78.151,59.199,-78.096,59.185,-78.12,59.148,-78.191,59.151,-78.119,59.12,-78.164,59.099,-78.106,59.117,-78.075,59.085,-78.208,59.068,-78.244,59.107,-78.218,59.039,-78.316,59.05,-78.294,59.022,-78.282,58.981,-78.35,58.946,-78.289,58.932,-78.388,58.899,-78.468,58.915,-78.454,58.863,-78.512,58.835,-78.504,58.93,-78.576,58.918,-78.508,58.811,-78.535,58.775,-78.494,58.776,-78.493,58.761,-78.4,58.739,-78.404,58.726,-78.377,58.729,-78.342,58.702,-78.55,58.774,-78.49,58.686,-78.554,58.693,-78.486,58.657,-78.496,58.648,-78.527,58.664,-78.528,58.654,-78.542,58.667,-78.596,58.675,-78.494,58.63,-78.48,58.576,-78.46,58.568,-78.459,58.563,-78.427,58.547,-78.424,58.541,-78.346,58.533,-78.447,58.576,-78.487,58.645,-78.336,58.625,-78.394,58.59,-78.29,58.509,-78.21,58.558,-78.253,58.507,-78.147,58.446,-78.041,58.506,-77.927,58.527,-77.884,58.512,-77.907,58.589,-77.807,58.618,-77.794,58.617,-77.782,58.612,-77.883,58.595,-77.833,58.519,-77.892,58.505,-78.039,58.502,-78.102,58.427,-77.534,58.239,-77.448,58.165,-77.526,58.161,-77.217,58.022,-76.993,58.022,-76.939,57.976,-76.951,57.959,-77.036,58.0,-77.2,58.001,-76.676,57.405,-76.552,57.096,-76.568,56.926,-76.378,56.918,-76.376,56.914,-76.379,56.912,-76.41,56.9,-76.198,56.872,-76.198,56.856,-76.058,56.871,-76.026,56.869,-76.124,56.857,-76.18,56.854,-76.198,56.854,-76.209,56.858,-76.217,56.855,-76.55,56.907,-76.527,56.379,-76.654,56.168,-76.471,56.167,-76.493,56.182,-76.589,56.172,-76.612,56.182,-76.374,56.313,-76.457,56.41,-76.381,56.471,-76.43,56.534,-76.372,56.547,-76.32,56.419,-76.124,56.413,-76.337,56.348,-76.223,56.325,-76.15,56.287,-76.179,56.286,-76.214,56.288,-76.227,56.286,-76.114,56.257,-76.032,56.2,-75.944,56.181,-75.899,56.126,-76.326,56.104,-76.535,56.133,-76.659,56.164,-76.778,56.008,-76.733,56.001,-76.703,55.992,-76.674,55.965,-76.602,55.964,-76.597,55.928,-76.796,56.001,-77.198,55.67,-77.079,55.72,-77.116,55.666,-77.792,55.268,-77.591,55.289,-77.408,55.215,-77.367,55.148,-77.179,55.165,-77.148,55.193,-77.073,55.228,-76.992,55.239,-76.971,55.231,-77.18,55.159,-77.374,55.144,-77.304,55.111,-77.295,55.104,-77.225,55.088,-77.192,55.069,-77.178,55.053,-77.172,55.014,-77.059,55.007,-76.936,54.931,-77.176,55.015,-77.594,55.288,-77.847,55.258,-78.317,55.042,-79.453,54.755,-79.485,54.734,-79.568,54.721,-79.714,54.653,-79.503,54.59,-79.542,54.555,-79.585,54.555,-79.511,54.54,-79.471,54.439,-79.493,54.434,-79.476,54.433,-79.473,54.424,-79.502,54.414,-79.012,54.492,-79.08,54.458,-79.013,54.456,-78.992,54.428,-79.015,54.431,-79.021,54.441,-79.081,54.444,-79.06,54.452,-79.173,54.454,-79.287,54.428,-79.231,54.43,-79.242,54.415,-79.061,54.395,-79.035,54.385,-79.026,54.375,-79.227,54.397,-79.329,54.427,-79.509,54.365,-79.455,54.35,-79.454,54.32,-79.485,54.304,-79.415,54.299,-79.427,54.268,-79.361,54.273,-79.414,54.251,-79.376,54.258,-79.279,54.236,-79.281,54.22,-79.391,54.176,-79.308,54.17,-79.247,54.187,-79.229,54.183,-79.217,54.169,-79.252,54.141,-79.18,54.165,-79.059,54.165,-79.075,54.147,-79.115,54.138,-79.056,54.11,-79.055,54.09,-79.174,54.083,-79.13,54.077,-79.147,54.068,-79.127,54.073,-79.124,54.071,-79.142,54.065,-79.071,54.054,-79.093,54.024,-78.951,53.996,-79.131,53.946,-78.999,53.929,-78.952,53.899,-78.98,53.885,-79.104,53.901,-79.059,53.881,-79.062,53.843,-78.938,53.838,-78.845,53.771,-78.568,53.736,-78.657,53.733,-78.851,53.763,-78.987,53.821,-78.996,53.814,-79.065,53.807,-79.057,53.739,-79.082,53.737,-79.095,53.743,-79.103,53.741,-79.094,53.732,-79.014,53.727,-79.083,53.713,-79.049,53.715,-78.981,53.701,-79.121,53.713,-79.154,53.705,-78.988,53.683,-79.016,53.664,-79.052,53.664,-78.994,53.636,-79.067,53.585,-78.995,53.616,-78.887,53.558,-79.143,53.49,-78.93,53.405,-78.94,53.305,-78.963,53.283,-78.984,53.275,-78.905,53.263,-78.951,53.235,-78.898,53.241,-78.893,53.221,-78.974,53.17,-78.921,53.165,-78.991,53.065,-78.921,53.05,-78.992,53.017,-78.776,52.966,-78.912,52.916,-78.879,52.861,-78.689,52.865,-78.792,52.815,-78.722,52.826,-78.707,52.781,-78.793,52.755,-78.82,52.779,-78.873,52.74,-78.71,52.679,-78.743,52.655,-78.813,52.64,-78.713,52.642,-78.767,52.542,-78.745,52.559,-78.702,52.564,-78.494,52.475,-78.551,52.255,-77.997,52.244,-78.035,52.231,-77.955,52.228,-77.885,52.22,-77.815,52.185,-77.707,52.203,-77.66,52.23,-77.636,52.234,-77.686,52.204,-77.773,52.185,-77.817,52.182,-78.556,52.234,-78.576,52.107,-78.641,52.052,-78.71,52.04,-78.654,51.988,-78.897,51.907,-78.773,51.863,-79.042,51.761,-78.841,51.599,-78.731,51.602,-78.355,51.505,-78.826,51.576,-78.826,51.508,-78.776,51.492,-78.709,51.501,-78.687,51.494,-78.533,51.436,-78.494,51.383,-78.398,51.363,-78.459,51.361,-78.712,51.488,-78.963,51.398,-79.094,51.532,-79.254,51.534,-79.226,51.644,-79.317,51.664,-79.54,51.551,-79.508,51.463,-79.68,51.404,-79.674,51.34,-79.737,51.35,-79.688,51.264,-79.794,51.159,-80.384,51.334,-80.682,51.164,-80.722,51.146,-80.804,51.144,-80.812,51.151,-80.709,51.177,-80.665,51.25,-80.632,51.276,-80.536,51.315,-80.464,51.328,-80.437,51.341,-80.412,51.36,-80.48,51.348,-80.431,51.448,-80.565,51.703,-80.991,52.015,-81.355,52.102,-81.432,52.142,-81.482,52.221,-81.661,52.204,-81.769,52.228,-81.779,52.222,-81.78,52.212,-81.784,52.203,-81.79,52.2,-82.097,52.109,-82.32,52.089,-82.418,51.997,-82.474,51.968,-82.589,51.926,-82.625,51.918,-82.732,51.917,-82.803,51.886,-82.847,51.883,-82.869,51.894,-82.791,51.929,-82.59,51.932,-82.307,52.1,-82.101,52.114,-81.872,52.24,-81.508,52.325,-81.531,52.442,-81.949,52.769,-82.266,52.734,-81.953,52.774,-82.037,52.837,-82.144,52.895,-82.192,52.844,-82.263,52.822,-82.37,52.806,-82.403,52.81,-82.409,52.8,-82.415,52.797,-82.438,52.794,-82.457,52.795,-82.477,52.787,-82.493,52.786,-82.827,52.822,-83.007,52.881,-83.077,52.911,-83.091,52.927,-83.163,52.933,-83.197,52.946,-83.484,52.91,-83.47,52.92,-83.434,52.929,-83.401,52.934,-83.364,52.933,-83.346,52.949,-83.304,52.962,-83.142,52.966,-82.978,52.927,-82.885,52.93,-82.842,52.927,-82.814,52.919,-82.796,52.92,-82.781,52.93,-82.766,52.932,-82.445,52.898,-82.439,52.919,-82.361,52.965,-82.265,52.972,-82.291,53.078,-82.282,53.063,-82.233,53.188,-82.346,53.262,-82.296,53.229,-82.288,53.228,-82.28,53.23,-82.26,53.23,-82.21,53.205,-82.187,53.227,-82.102,53.27,-82.216,53.59,-82.115,53.775,-82.247,54.059,-82.405,54.103,-82.351,54.131,-82.472,54.198,-82.454,54.36,-82.192,54.8,-82.257,55.115,-82.322,55.147,-82.304,55.053,-82.406,55.06,-82.449,55.121,-82.466,55.108,-82.455,55.08,-82.46,55.072,-82.471,55.067,-82.509,55.15,-82.517,55.147,-82.501,55.122,-82.506,55.1,-82.933,55.131,-82.88,55.158,-82.957,55.219,-83.674,55.27,-83.697,55.256,-83.573,55.219,-83.574,55.199,-83.537,55.174,-83.528,55.155,-83.682,55.227,-83.687,55.205,-83.756,55.183,-83.8,55.186,-83.806,55.129,-83.837,55.1,-83.927,55.056,-83.806,55.191,-83.693,55.228,-83.888,55.294,-84.546,55.221,-85.046,55.273,-85.14,55.248,-85.176,55.245,-85.232,55.215,-85.24,55.213,-85.244,55.213,-85.245,55.214,-85.241,55.217,-85.242,55.218,-85.262,55.212,-85.257,55.183,-85.261,55.165,-85.272,55.154,-85.275,55.135,-85.31,55.114,-85.332,55.105,-85.383,55.017,-85.389,55.014,-85.408,55.014,-85.413,55.01,-85.411,54.996,-85.413,54.99,-85.443,54.971,-85.483,54.953,-85.481,54.917,-85.512,54.861,-85.516,54.865,-85.515,54.857,-85.523,54.85,-85.56,54.826,-85.593,54.815,-85.626,54.813,-85.661,54.816,-85.717,54.782,-85.971,54.724,-86.113,54.713,-86.161,54.691,-86.175,54.687,-86.183,54.686,-86.189,54.684,-86.225,54.685,-86.298,54.697,-86.193,54.687,-86.107,54.721,-86.053,54.732,-85.997,54.739,-85.965,54.734,-85.926,54.743,-85.891,54.745,-85.816,54.779,-85.698,54.803,-85.678,54.822,-85.671,54.824,-85.557,54.835,-85.493,54.952,-85.421,54.99,-85.414,55.027,-85.39,55.056,-85.366,55.077,-85.375,55.082,-85.375,55.086,-85.37,55.107,-85.311,55.173,-85.266,55.216,-85.234,55.235,-85.217,55.239,-85.208,55.255,-85.186,55.266,-85.13,55.274,-85.112,55.309,-85.388,55.373,-85.892,55.66,-85.917,55.658,-85.941,55.665,-85.939,55.672,-86.018,55.676,-86.05,55.664,-86.265,55.71,-86.379,55.766,-86.92,55.92,-87.33,55.972,-87.358,55.949,-87.659,56.107,-87.978,56.472,-88.142,56.462,-88.651,56.675,-88.855,56.862,-90.054,57.047,-90.196,56.963,-90.161,57.009,-90.135,57.025,-90.11,57.035,-90.072,57.044,-90.071,57.049,-90.065,57.051,-90.636,57.238,-91.127,57.252,-92.171,57.04,-92.324,56.965,-92.448,56.926,-92.58,56.905,-92.651,56.783,-92.697,56.757,-92.682,56.74,-92.699,56.725,-92.664,56.716,-92.639,56.646,-92.663,56.569,-92.723,56.488,-92.812,56.415,-92.668,56.573,-92.646,56.644,-92.703,56.721,-92.703,56.756,-92.647,56.859,-92.585,56.907,-92.53,56.922,-92.506,56.925,-92.483,56.925,-92.439,56.953,-92.332,56.982,-92.194,57.068,-92.55,57.075,-92.435,57.262,-92.452,57.442,-93.11,58.479,-93.166,58.724,-94.209,58.81,-94.407,58.705,-94.512,58.823],"holes":[[-79.182,56.54,-79.273,56.626,-79.395,56.224,-79.642,56.085,-79.412,56.442,-79.453,56.56,-79.537,56.267,-80.02,55.894,-79.904,55.855,-79.881,55.884,-79.821,55.924,-79.825,55.91,-79.868,55.891,-79.895,55.854,-79.91,55.852,-79.86,55.828,-79.517,56.132,-79.466,56.095,-79.782,55.782,-79.614,55.909,-79.49,55.86,-79.13,56.237,-79.285,55.85,-79.055,56.182,-79.056,56.344,-78.938,56.39,-79.209,55.857,-78.883,56.307,-78.955,56.291,-78.925,56.429,-79.051,56.427,-79.182,56.54],[-81.261,53.209,-81.82,53.176,-82.05,53.007,-80.692,52.7,-80.826,53.011,-81.115,53.198,-81.261,53.209],[-82.171,52.886,-82.275,52.957,-82.447,52.896,-83.058,52.91,-82.487,52.787,-82.171,52.886],[-80.031,61.736,-79.852,61.576,-79.68,61.606,-79.454,61.95,-79.302,62.022,-79.352,62.048,-79.259,62.238,-79.605,62.416,-79.96,62.358,-80.202,62.152,-80.273,61.976,-80.211,61.79,-80.031,61.736],[-83.733,62.202,-83.718,62.137,-83.336,62.255,-83.163,62.164,-82.853,62.259,-81.98,62.694,-81.866,62.915,-82.245,62.99,-83.01,62.846,-83.396,62.904,-83.548,62.829,-83.692,62.601,-83.979,62.45,-83.746,62.296,-83.733,62.202]]}],"names":{"ar":"خليج هدسن","de":"Hudson Bay","es":"Bahía de Hudson","fr":"baie d'Hudson","he":"מפרץ הדסון","hu":"Hudson-öböl","it":"baia di Hudson","nl":"Hudsonbaai","pt":"Baía de Hudson","zh":"哈德森湾"}},{"name":"Great Australian Bight","bbox":[117.614,-43.566,146.231,-31.464],"area":133.2823,"polygons":[{"outer":[143.533,-38.855,143.87,-39.705,143.875,-40.118,143.929,-40.162,144.097,-40.048,144.694,-40.685,144.713,-40.856,144.607,-41.009,144.745,-41.422,144.917,-41.667,144.966,-41.636,144.922,-41.716,145.206,-41.964,145.223,-42.219,145.334,-42.153,145.481,-42.365,145.557,-42.358,145.513,-42.436,145.533,-42.448,145.544,-42.439,145.558,-42.436,145.615,-42.449,145.53,-42.449,145.491,-42.422,145.457,-42.435,145.475,-42.526,145.448,-42.439,145.452,-42.404,145.254,-42.288,145.214,-42.211,145.167,-42.196,145.267,-42.63,145.367,-42.658,145.495,-42.992,145.685,-43.054,145.663,-43.097,145.84,-43.315,145.927,-43.304,145.865,-43.275,145.927,-43.248,145.915,-43.211,145.921,-43.205,145.911,-43.191,145.929,-43.187,146.004,-43.335,146.231,-43.325,146.222,-43.386,146.157,-43.392,146.124,-43.352,146.08,-43.37,146.068,-43.353,146.004,-43.345,145.987,-43.328,145.975,-43.351,145.989,-43.348,145.998,-43.368,145.993,-43.394,145.977,-43.356,145.919,-43.382,145.997,-43.408,146.031,-43.566,117.614,-35.132,117.686,-35.043,118.019,-35.11,117.849,-35.049,117.975,-34.93,117.962,-35.014,118.189,-35.025,118.177,-34.939,118.404,-34.917,118.44,-34.761,118.725,-34.661,118.755,-34.545,118.912,-34.463,119.24,-34.535,119.309,-34.445,119.385,-34.484,119.42,-34.458,119.391,-34.42,119.409,-34.396,119.365,-34.387,119.373,-34.37,119.577,-34.387,119.457,-34.289,119.623,-34.105,119.816,-33.984,120.048,-33.921,120.437,-33.972,121.195,-33.799,121.176,-33.878,121.339,-33.8,121.791,-33.91,122.003,-33.83,122.113,-34.018,122.363,-33.912,122.577,-33.961,122.581,-33.916,122.622,-33.892,123.019,-33.856,123.14,-33.909,123.163,-34.017,123.374,-33.895,123.534,-33.941,123.974,-33.561,124.108,-33.163,124.241,-33.017,124.809,-32.876,125.962,-32.287,126.204,-32.232,126.683,-32.309,127.222,-32.275,128.319,-31.979,129.041,-31.677,130.156,-31.577,130.792,-31.608,131.151,-31.464,131.758,-31.719,132.202,-32.028,132.748,-31.949,133.125,-32.209,133.487,-32.206,133.401,-32.14,133.472,-32.101,133.554,-32.17,133.611,-32.086,133.952,-32.392,133.867,-32.398,133.848,-32.544,134.184,-32.483,134.291,-32.694,134.203,-32.797,134.216,-32.731,134.065,-32.715,134.139,-32.833,134.059,-32.907,134.183,-32.936,134.139,-33.02,134.327,-33.2,134.277,-33.025,134.369,-33.176,134.631,-33.246,134.559,-33.161,134.708,-33.182,134.719,-33.229,134.658,-33.234,134.847,-33.441,134.829,-33.626,135.183,-33.879,135.271,-34.019,135.24,-34.15,135.349,-34.277,135.395,-34.494,135.377,-34.516,135.388,-34.594,135.405,-34.507,135.432,-34.604,135.516,-34.613,135.452,-34.614,135.447,-34.627,135.387,-34.644,135.377,-34.645,135.366,-34.642,135.298,-34.552,135.354,-34.522,135.223,-34.499,135.207,-34.429,135.109,-34.588,135.251,-34.551,135.619,-34.945,135.792,-34.861,135.855,-34.927,135.929,-34.949,135.948,-35.007,136.004,-34.986,135.96,-34.854,136.007,-34.736,135.787,-34.81,135.892,-34.752,135.854,-34.666,135.938,-34.649,135.933,-34.528,136.058,-34.479,136.093,-34.543,136.113,-34.359,136.591,-33.901,136.958,-33.751,136.874,-33.762,136.933,-33.673,136.978,-33.681,136.99,-33.727,137.21,-33.665,137.453,-33.136,137.641,-32.956,137.786,-33.001,137.754,-32.428,137.839,-32.698,137.934,-32.758,137.949,-33.006,138.041,-33.083,138.024,-33.181,137.939,-33.149,137.81,-33.271,137.972,-33.556,137.601,-33.876,137.522,-34.129,137.45,-34.136,137.5,-34.311,137.414,-34.454,137.481,-34.439,137.509,-34.62,137.45,-34.908,137.01,-34.895,136.848,-35.282,137.173,-35.238,137.425,-35.098,137.637,-35.169,137.759,-35.115,137.71,-35.061,137.865,-34.779,137.888,-34.508,138.093,-34.137,138.265,-34.48,138.531,-34.74,138.562,-34.804,138.498,-34.852,138.514,-34.767,138.476,-34.784,138.518,-35.03,138.441,-35.35,138.095,-35.625,138.523,-35.643,138.631,-35.544,138.711,-35.514,138.877,-35.555,138.794,-35.524,138.785,-35.512,138.799,-35.466,138.888,-35.486,138.864,-35.464,138.847,-35.429,139.034,-35.514,138.975,-35.408,139.177,-35.313,139.305,-35.42,139.381,-35.345,139.372,-35.47,139.188,-35.513,139.23,-35.519,139.28,-35.542,139.366,-35.595,139.339,-35.685,139.261,-35.7,139.275,-35.726,139.26,-35.734,139.206,-35.655,139.264,-35.547,139.233,-35.525,139.208,-35.531,139.182,-35.523,139.177,-35.502,139.023,-35.596,139.382,-35.843,139.398,-35.882,139.396,-35.902,139.412,-35.874,139.517,-35.961,139.637,-36.118,139.709,-36.265,139.547,-36.043,139.395,-35.903,139.367,-35.857,139.336,-35.847,139.012,-35.594,138.881,-35.558,139.308,-35.831,139.656,-36.223,139.863,-36.702,139.837,-36.849,139.671,-36.953,139.795,-37.125,139.74,-37.182,139.994,-37.485,140.12,-37.531,140.396,-37.92,140.661,-38.062,140.839,-38.041,141.007,-38.053,141.332,-38.251,141.402,-38.396,141.626,-38.407,141.602,-38.314,141.746,-38.253,142.145,-38.393,142.368,-38.348,143.533,-38.855],"holes":[[137.481,-36.064,137.653,-35.888,138.04,-35.905,138.127,-35.803,137.906,-35.717,137.756,-35.842,137.78,-35.724,137.579,-35.727,137.622,-35.561,136.589,-35.748,136.532,-35.876,136.704,-36.064,137.2,-35.976,137.481,-36.064]]}],"names":{"ar":"الخليج الأسترالي العظيم","de":"Große Australische Bucht","es":"Gran Bahía Australiana","fr":"Grande baie australienne","he":"המפרצון האוסטרלי הגדול","hu":"Nagy-Ausztráliai-öböl","it":"Grande Baia Australiana","nl":"Grote Australische Bocht","pt":"Grande Baía Australiana","zh":"大澳洲灣"}},{"name":"Gulf of Mexico","bbox":[-98.054,17.407,-80.433,31.465],"area":139.388,"polygons":[{"outer":[-80.795,24.811,-81.111,24.69,-81.333,24.639,-81.349,24.703,-81.396,24.746,-81.347,24.637,-81.51,24.626,-81.562,24.694,-81.564,24.671,-81.584,24.65,-81.551,24.632,-81.571,24.607,-81.602,24.615,-81.581,24.655,-81.61,24.653,-81.617,24.598,-81.808,24.557,-83.0,24.583,-83.0,23.012,-83.164,22.922,-83.243,22.987,-83.385,22.865,-83.442,22.901,-83.44,22.897,-83.45,22.885,-83.464,22.89,-83.49,22.851,-83.546,22.856,-83.561,22.819,-83.63,22.824,-83.645,22.786,-83.846,22.76,-83.982,22.669,-84.022,22.716,-84.049,22.704,-84.037,22.644,-84.22,22.569,-84.202,22.546,-84.206,22.51,-84.396,22.332,-84.447,22.19,-84.317,22.026,-84.267,22.071,-84.322,21.991,-84.492,22.045,-84.952,21.863,-87.105,21.605,-87.336,21.563,-87.395,21.515,-87.099,21.601,-87.168,21.421,-88.139,21.624,-88.609,21.524,-88.694,21.454,-88.634,21.497,-88.64,21.469,-88.727,21.423,-89.703,21.285,-89.675,21.266,-90.096,21.155,-90.327,21.017,-90.401,20.86,-90.416,20.811,-90.414,20.776,-90.347,20.95,-90.334,20.958,-90.503,20.496,-90.453,19.952,-90.707,19.675,-90.715,19.364,-90.901,19.182,-91.387,18.896,-91.498,18.791,-91.357,18.908,-91.1,19.031,-91.363,18.878,-91.391,18.804,-91.222,18.751,-91.309,18.558,-91.587,18.442,-91.962,18.581,-91.947,18.529,-91.962,18.514,-92.045,18.523,-92.076,18.589,-92.298,18.496,-92.197,18.608,-92.061,18.619,-92.01,18.553,-92.002,18.598,-91.935,18.616,-91.883,18.562,-91.964,18.698,-92.466,18.651,-92.435,18.571,-92.47,18.65,-92.692,18.613,-92.652,18.522,-92.646,18.398,-92.579,18.395,-92.499,18.35,-92.487,18.336,-92.495,18.314,-92.417,18.305,-92.395,18.293,-92.369,18.295,-92.334,18.283,-92.318,18.267,-92.307,18.243,-92.281,18.263,-92.225,18.25,-92.204,18.239,-92.193,18.22,-92.213,18.178,-92.185,18.187,-92.177,18.183,-92.17,18.171,-92.181,18.105,-92.216,18.176,-92.205,18.235,-92.305,18.239,-92.497,18.312,-92.58,18.394,-92.647,18.396,-92.614,18.298,-92.649,18.392,-92.669,18.369,-92.652,18.346,-92.669,18.315,-92.654,18.297,-92.652,18.287,-92.654,18.281,-92.657,18.281,-92.67,18.319,-92.653,18.345,-92.672,18.363,-92.649,18.441,-92.686,18.572,-92.867,18.451,-93.088,18.441,-92.844,18.375,-92.852,18.355,-93.071,18.423,-93.094,18.443,-93.148,18.425,-93.117,18.421,-93.064,18.397,-93.123,18.341,-93.172,18.371,-93.173,18.409,-93.149,18.426,-93.164,18.434,-93.158,18.441,-93.405,18.432,-93.435,18.427,-93.353,18.429,-93.303,18.414,-94.126,18.217,-94.128,18.17,-94.122,18.166,-94.107,18.175,-94.1,18.174,-94.091,18.146,-94.129,18.169,-94.133,18.213,-94.413,18.167,-94.389,18.128,-94.486,17.899,-94.432,17.891,-94.411,17.881,-94.403,17.867,-94.412,17.854,-94.406,17.849,-94.403,17.845,-94.413,17.828,-94.402,17.825,-94.402,17.813,-94.391,17.829,-94.379,17.827,-94.381,17.837,-94.361,17.859,-94.209,17.859,-94.201,17.855,-94.198,17.845,-94.218,17.807,-94.208,17.857,-94.402,17.812,-94.488,17.9,-94.474,17.968,-94.512,17.962,-94.545,17.977,-94.55,17.943,-94.561,17.922,-94.554,17.91,-94.563,17.89,-94.656,17.865,-94.656,17.86,-94.646,17.864,-94.627,17.853,-94.626,17.819,-94.678,17.7,-94.683,17.696,-94.691,17.698,-94.705,17.725,-94.699,17.704,-94.725,17.669,-94.713,17.659,-94.749,17.638,-94.729,17.616,-94.726,17.605,-94.758,17.578,-94.719,17.552,-94.709,17.536,-94.709,17.529,-94.725,17.502,-94.755,17.483,-94.765,17.498,-94.779,17.481,-94.793,17.478,-94.811,17.483,-94.827,17.502,-94.84,17.499,-94.851,17.469,-94.832,17.449,-94.84,17.43,-94.922,17.407,-94.838,17.502,-94.724,17.507,-94.762,17.587,-94.728,17.606,-94.753,17.634,-94.701,17.709,-94.716,17.719,-94.708,17.735,-94.715,17.735,-94.717,17.74,-94.73,17.738,-94.737,17.739,-94.755,17.752,-94.76,17.767,-94.757,17.774,-94.779,17.78,-94.774,17.817,-94.718,17.874,-94.682,17.882,-94.665,17.873,-94.655,17.883,-94.647,17.871,-94.564,17.892,-94.563,17.923,-94.552,17.944,-94.556,17.962,-94.547,17.978,-94.476,17.973,-94.418,18.061,-94.416,18.167,-94.646,18.169,-94.623,18.288,-94.817,18.538,-95.023,18.507,-94.989,18.556,-95.182,18.707,-95.743,18.791,-95.584,18.691,-95.635,18.697,-95.633,18.621,-95.646,18.61,-95.668,18.602,-95.653,18.564,-95.655,18.563,-95.667,18.566,-95.655,18.558,-95.65,18.563,-95.639,18.56,-95.633,18.556,-95.63,18.547,-95.625,18.546,-95.621,18.543,-95.613,18.548,-95.61,18.547,-95.606,18.541,-95.608,18.535,-95.598,18.531,-95.596,18.527,-95.601,18.521,-95.597,18.519,-95.598,18.515,-95.595,18.516,-95.588,18.513,-95.579,18.492,-95.577,18.475,-95.584,18.466,-95.572,18.457,-95.57,18.45,-95.588,18.426,-95.595,18.422,-95.587,18.425,-95.576,18.419,-95.572,18.424,-95.568,18.424,-95.527,18.369,-95.532,18.361,-95.538,18.359,-95.522,18.359,-95.524,18.337,-95.534,18.334,-95.541,18.336,-95.542,18.333,-95.504,18.317,-95.494,18.273,-95.513,18.251,-95.505,18.247,-95.501,18.239,-95.497,18.244,-95.48,18.249,-95.473,18.25,-95.472,18.261,-95.464,18.268,-95.451,18.264,-95.433,18.247,-95.444,18.222,-95.431,18.221,-95.425,18.206,-95.419,18.211,-95.407,18.206,-95.412,18.196,-95.402,18.195,-95.404,18.183,-95.399,18.184,-95.393,18.191,-95.388,18.193,-95.386,18.189,-95.39,18.18,-95.384,18.174,-95.381,18.166,-95.379,18.175,-95.375,18.178,-95.358,18.172,-95.344,18.154,-95.336,18.16,-95.333,18.159,-95.332,18.157,-95.338,18.149,-95.337,18.147,-95.33,18.149,-95.324,18.145,-95.323,18.131,-95.312,18.139,-95.309,18.134,-95.308,18.125,-95.281,18.128,-95.273,18.127,-95.273,18.125,-95.308,18.124,-95.312,18.137,-95.322,18.13,-95.34,18.147,-95.333,18.159,-95.345,18.153,-95.375,18.176,-95.381,18.166,-95.391,18.18,-95.389,18.192,-95.399,18.183,-95.404,18.183,-95.445,18.222,-95.453,18.264,-95.465,18.267,-95.472,18.249,-95.502,18.237,-95.514,18.25,-95.596,18.422,-95.589,18.513,-95.599,18.515,-95.602,18.521,-95.598,18.529,-95.609,18.535,-95.61,18.546,-95.62,18.542,-95.63,18.546,-95.639,18.56,-95.654,18.557,-95.668,18.564,-95.674,18.613,-95.713,18.361,-95.862,18.281,-95.716,18.362,-95.728,18.552,-95.701,18.61,-95.637,18.62,-95.64,18.711,-95.857,18.715,-95.858,18.752,-95.852,18.566,-95.884,18.626,-95.896,18.574,-95.984,18.477,-96.029,18.496,-95.899,18.578,-95.867,18.752,-95.86,18.756,-95.852,18.755,-95.821,18.734,-95.878,18.818,-95.758,18.765,-95.75,18.795,-95.932,18.892,-95.973,19.057,-96.115,19.094,-96.126,19.217,-96.314,19.315,-96.447,19.846,-97.249,20.736,-97.193,20.739,-97.306,20.967,-97.438,20.912,-97.472,20.91,-97.479,20.937,-97.492,20.932,-97.492,20.918,-97.496,20.909,-97.501,20.906,-97.507,20.909,-97.509,20.915,-97.5,20.931,-97.521,20.934,-97.523,20.951,-97.564,20.95,-97.522,20.953,-97.498,20.933,-97.502,20.907,-97.494,20.931,-97.481,20.939,-97.359,20.945,-97.387,21.041,-97.307,20.971,-97.489,21.343,-97.49,21.452,-97.575,21.468,-97.68,21.663,-97.775,22.264,-97.845,22.205,-97.897,22.221,-97.947,22.204,-97.988,22.21,-98.014,22.185,-97.998,22.156,-97.999,22.151,-98.014,22.148,-98.016,22.187,-97.99,22.212,-97.773,22.267,-97.887,22.476,-97.848,22.493,-97.842,22.669,-97.865,22.552,-97.9,22.738,-97.803,22.779,-97.789,22.846,-97.769,22.89,-97.777,22.91,-97.775,22.935,-97.767,22.945,-97.767,22.893,-97.787,22.802,-97.828,22.702,-97.846,22.685,-97.838,22.674,-97.789,22.789,-97.768,22.879,-97.761,23.001,-97.762,23.054,-97.8,23.052,-97.804,23.072,-97.814,23.058,-97.817,23.057,-97.823,23.068,-97.81,23.081,-97.83,23.088,-97.809,23.082,-97.817,23.059,-97.804,23.073,-97.762,23.058,-97.732,23.767,-98.054,23.73,-97.803,23.791,-97.868,23.793,-97.803,23.873,-97.793,23.779,-97.731,23.771,-97.738,23.893,-97.738,23.789,-97.772,23.788,-97.775,24.068,-97.793,24.024,-97.826,24.103,-97.792,24.066,-97.772,24.086,-97.746,24.449,-97.834,24.418,-97.868,24.523,-97.862,24.559,-97.845,24.531,-97.834,24.539,-97.852,24.589,-97.846,24.587,-97.846,24.594,-97.834,24.606,-97.825,24.606,-97.742,24.488,-97.751,24.851,-97.732,24.905,-97.651,24.919,-97.772,24.958,-97.705,25.24,-97.833,25.255,-97.722,25.255,-97.693,25.378,-97.617,25.369,-97.534,25.286,-97.522,25.316,-97.526,25.319,-97.528,25.308,-97.537,25.305,-97.556,25.315,-97.552,25.37,-97.547,25.354,-97.482,25.36,-97.52,25.378,-97.537,25.439,-97.563,25.43,-97.576,25.444,-97.562,25.441,-97.557,25.462,-97.528,25.475,-97.537,25.461,-97.467,25.44,-97.478,25.434,-97.474,25.425,-97.464,25.44,-97.468,25.445,-97.466,25.455,-97.473,25.46,-97.491,25.493,-97.499,25.528,-97.392,25.402,-97.451,25.362,-97.396,25.374,-97.416,25.36,-97.424,25.33,-97.428,25.352,-97.446,25.3,-97.416,25.311,-97.42,25.337,-97.415,25.356,-97.401,25.369,-97.374,25.362,-97.387,25.363,-97.377,25.352,-97.392,25.349,-97.386,25.343,-97.387,25.338,-97.398,25.337,-97.398,25.326,-97.413,25.32,-97.409,25.311,-97.424,25.282,-97.44,25.254,-97.456,25.243,-97.421,25.236,-97.144,25.885,-97.153,26.063,-97.151,25.956,-97.223,25.973,-97.186,26.048,-97.297,25.966,-97.403,25.951,-97.197,26.052,-97.3,26.115,-97.367,26.341,-97.344,26.402,-97.381,26.48,-97.356,26.419,-97.361,26.398,-97.45,26.457,-97.478,26.805,-97.514,26.811,-97.559,26.842,-97.549,26.897,-97.47,26.815,-97.456,26.942,-97.542,26.909,-97.552,26.973,-97.452,26.984,-97.494,27.09,-97.422,27.261,-97.728,27.258,-97.655,27.304,-97.766,27.432,-97.611,27.284,-97.577,27.313,-97.598,27.299,-97.617,27.314,-97.633,27.335,-97.627,27.355,-97.568,27.318,-97.51,27.386,-97.474,27.399,-97.554,27.409,-97.54,27.543,-97.53,27.43,-97.518,27.463,-97.472,27.449,-97.457,27.324,-97.497,27.311,-97.411,27.322,-97.311,27.594,-97.269,27.678,-97.243,27.69,-97.307,27.709,-97.298,27.682,-97.342,27.63,-97.352,27.63,-97.379,27.636,-97.314,27.681,-97.393,27.81,-97.477,27.818,-97.523,27.844,-97.466,27.817,-97.392,27.812,-97.378,27.836,-97.479,27.825,-97.515,27.871,-97.263,27.88,-97.247,27.873,-97.209,27.818,-97.143,27.893,-97.112,27.888,-97.111,27.891,-97.133,27.896,-97.139,27.901,-97.021,28.105,-97.181,27.971,-97.157,28.047,-97.241,28.061,-97.257,28.071,-97.262,28.079,-97.026,28.206,-97.027,28.15,-96.98,28.125,-96.985,28.216,-96.917,28.269,-96.96,28.123,-96.786,28.235,-96.859,28.413,-96.765,28.412,-96.809,28.478,-96.675,28.322,-96.64,28.346,-96.619,28.345,-96.531,28.389,-96.432,28.433,-96.401,28.442,-96.644,28.579,-96.664,28.723,-96.577,28.697,-96.59,28.736,-96.576,28.765,-96.591,28.789,-96.577,28.832,-96.563,28.635,-96.484,28.608,-96.47,28.674,-96.436,28.608,-96.489,28.57,-96.381,28.62,-96.417,28.631,-96.405,28.727,-96.435,28.71,-96.451,28.74,-96.453,28.756,-96.448,28.763,-96.389,28.728,-96.361,28.627,-96.207,28.697,-96.205,28.729,-96.179,28.761,-96.158,28.763,-96.238,28.644,-96.101,28.627,-96.043,28.66,-96.017,28.664,-95.917,28.709,-95.819,28.74,-95.787,28.748,-95.763,28.75,-95.751,28.75,-95.725,28.741,-95.668,28.765,-95.626,28.766,-95.53,28.862,-95.509,28.847,-95.424,28.89,-95.398,28.9,-95.387,28.896,-95.388,28.98,-95.384,28.897,-95.346,28.92,-95.322,28.931,-95.342,28.938,-95.338,28.951,-95.261,28.988,-95.234,29.024,-95.206,29.084,-95.203,29.098,-95.21,29.082,-95.239,29.068,-95.252,29.081,-95.249,29.091,-95.268,29.073,-95.273,29.072,-95.274,29.075,-95.154,29.153,-95.219,29.216,-95.113,29.196,-95.103,29.175,-94.945,29.321,-94.929,29.297,-94.88,29.279,-94.832,29.286,-94.926,29.253,-95.117,29.086,-94.689,29.327,-94.766,29.338,-94.794,29.31,-94.898,29.304,-94.886,29.389,-94.812,29.363,-94.81,29.365,-95.014,29.465,-94.909,29.496,-95.014,29.538,-94.982,29.681,-95.054,29.707,-95.08,29.762,-95.106,29.739,-95.124,29.732,-95.153,29.734,-95.176,29.746,-95.195,29.742,-95.212,29.725,-95.239,29.718,-95.249,29.72,-95.255,29.725,-95.27,29.724,-95.276,29.727,-95.173,29.748,-95.129,29.736,-95.096,29.754,-95.09,29.763,-95.101,29.768,-95.1,29.784,-95.062,29.793,-95.091,29.799,-95.096,29.825,-95.062,29.829,-95.065,29.832,-95.079,29.829,-95.091,29.83,-95.104,29.839,-95.106,29.844,-95.106,29.846,-95.1,29.844,-95.094,29.847,-95.1,29.854,-95.091,29.853,-95.098,29.868,-95.095,29.878,-95.115,29.892,-95.087,29.879,-95.088,29.849,-95.104,29.84,-95.084,29.83,-95.067,29.834,-95.061,29.83,-95.053,29.745,-94.931,29.674,-94.946,29.717,-94.942,29.725,-94.929,29.74,-94.926,29.74,-94.91,29.655,-94.736,29.777,-94.801,29.826,-94.791,29.87,-94.755,29.86,-94.689,29.697,-94.764,29.525,-94.499,29.544,-94.475,29.558,-94.466,29.555,-94.461,29.56,-94.455,29.563,-94.421,29.568,-94.416,29.591,-94.412,29.595,-94.219,29.673,-94.497,29.512,-94.681,29.454,-94.781,29.365,-94.75,29.37,-94.74,29.362,-94.73,29.359,-94.698,29.348,-94.678,29.345,-94.75,29.37,-94.069,29.668,-93.342,29.744,-93.351,29.844,-93.386,29.828,-93.443,29.865,-93.347,29.89,-93.35,30.127,-93.333,30.128,-93.334,30.154,-93.316,30.18,-93.289,30.198,-93.306,30.2,-93.315,30.213,-93.259,30.233,-93.243,30.22,-93.248,30.239,-93.246,30.251,-93.241,30.254,-93.213,30.255,-93.208,30.268,-93.197,30.267,-93.198,30.28,-93.185,30.288,-93.191,30.295,-93.19,30.297,-93.166,30.304,-93.226,30.22,-93.258,30.219,-93.29,30.164,-93.331,30.152,-93.325,30.097,-93.301,30.11,-93.285,30.109,-93.233,29.895,-93.248,29.849,-93.346,29.819,-93.323,29.791,-93.328,29.785,-93.338,29.781,-93.342,29.773,-93.344,29.765,-93.34,29.745,-93.011,29.722,-93.111,29.759,-93.01,29.775,-92.736,29.618,-92.317,29.532,-92.298,29.592,-92.269,29.631,-92.245,29.727,-92.234,29.738,-92.177,29.773,-92.226,29.794,-91.937,29.882,-91.931,29.846,-91.85,29.846,-91.841,29.843,-91.826,29.832,-91.814,29.788,-91.771,29.765,-91.72,29.768,-91.596,29.76,-91.394,29.65,-91.357,29.742,-91.403,29.78,-91.404,29.84,-91.48,29.905,-91.444,29.89,-91.465,29.924,-91.465,29.982,-91.492,30.021,-91.486,30.059,-91.355,29.783,-91.243,29.761,-91.211,29.724,-91.209,29.777,-91.178,29.736,-91.3,29.965,-91.259,30.035,-91.308,30.075,-91.322,30.128,-91.258,30.037,-91.264,29.95,-91.177,29.766,-91.176,29.782,-91.164,29.804,-91.157,29.814,-91.151,29.814,-91.157,29.819,-91.15,29.833,-91.189,29.848,-91.201,29.862,-91.208,29.885,-91.21,29.902,-91.232,29.917,-91.234,29.927,-91.241,29.927,-91.246,29.931,-91.252,29.937,-91.251,29.94,-91.23,29.927,-91.208,29.901,-91.199,29.86,-91.148,29.837,-91.15,29.814,-91.175,29.781,-91.173,29.768,-91.177,29.763,-91.177,29.74,-91.179,29.731,-91.22,29.71,-91.209,29.689,-91.149,29.685,-91.135,29.671,-91.118,29.641,-91.109,29.64,-91.101,29.657,-91.1,29.687,-91.095,29.695,-91.188,29.714,-91.12,29.753,-91.094,29.697,-91.098,29.688,-91.098,29.664,-91.104,29.642,-91.028,29.662,-90.867,29.535,-90.7,29.614,-90.398,29.551,-90.393,29.555,-90.384,29.572,-90.359,29.61,-90.335,29.642,-90.331,29.646,-90.275,29.647,-90.22,29.659,-90.201,29.674,-90.315,29.657,-90.498,29.794,-90.531,29.879,-90.372,29.763,-90.35,29.697,-90.234,29.792,-90.3,29.827,-90.296,29.851,-90.169,29.828,-90.179,29.849,-90.171,29.854,-90.157,29.815,-90.17,29.8,-90.141,29.741,-90.136,29.752,-90.132,29.757,-90.067,29.78,-90.073,29.807,-90.069,29.826,-90.024,29.85,-89.992,29.901,-89.967,29.92,-90.114,29.905,-90.14,29.916,-90.148,29.953,-90.208,29.92,-90.256,29.967,-90.319,29.934,-90.353,29.933,-90.473,30.0,-90.495,30.05,-90.822,29.978,-90.844,30.049,-90.9,30.064,-90.929,30.131,-90.957,30.103,-91.03,30.114,-91.037,30.202,-91.149,30.175,-91.167,30.19,-91.165,30.211,-91.033,30.208,-91.027,30.12,-90.925,30.136,-90.84,30.053,-90.819,29.983,-90.491,30.056,-90.366,29.942,-90.257,29.973,-90.21,29.926,-90.152,29.96,-90.089,29.915,-90.023,29.994,-89.79,30.091,-89.852,30.105,-89.856,30.156,-90.036,30.033,-90.399,30.072,-90.424,30.191,-90.21,30.385,-89.652,30.161,-89.651,30.175,-89.646,30.186,-89.582,30.185,-89.617,30.222,-89.628,30.254,-89.444,30.188,-89.322,30.318,-89.34,30.343,-89.375,30.331,-89.395,30.336,-89.402,30.34,-89.406,30.373,-89.402,30.384,-89.273,30.363,-89.287,30.303,-88.857,30.39,-89.024,30.405,-89.022,30.444,-88.966,30.453,-88.793,30.422,-88.832,30.419,-88.854,30.424,-88.738,30.345,-88.612,30.373,-88.629,30.451,-88.618,30.516,-88.61,30.519,-88.604,30.516,-88.592,30.522,-88.594,30.546,-88.587,30.547,-88.584,30.552,-88.578,30.554,-88.586,30.561,-88.587,30.578,-88.571,30.583,-88.575,30.585,-88.583,30.597,-88.585,30.59,-88.621,30.585,-88.641,30.599,-88.653,30.634,-88.646,30.652,-88.633,30.655,-88.628,30.651,-88.638,30.663,-88.636,30.667,-88.629,30.668,-88.638,30.67,-88.632,30.681,-88.637,30.693,-88.634,30.698,-88.641,30.7,-88.648,30.707,-88.643,30.715,-88.654,30.717,-88.654,30.728,-88.661,30.727,-88.666,30.73,-88.66,30.751,-88.742,30.804,-88.769,30.856,-88.775,30.891,-88.745,30.921,-88.73,30.913,-88.74,30.938,-88.729,30.913,-88.768,30.855,-88.658,30.752,-88.664,30.73,-88.653,30.729,-88.641,30.714,-88.632,30.698,-88.631,30.68,-88.636,30.671,-88.627,30.668,-88.628,30.649,-88.651,30.632,-88.639,30.598,-88.583,30.598,-88.57,30.584,-88.576,30.555,-88.586,30.546,-88.593,30.544,-88.591,30.522,-88.603,30.514,-88.617,30.516,-88.617,30.424,-88.6,30.425,-88.604,30.4,-88.558,30.437,-88.518,30.421,-88.519,30.415,-88.528,30.409,-88.583,30.412,-88.544,30.387,-88.566,30.345,-88.337,30.404,-88.138,30.309,-88.125,30.594,-88.032,30.661,-88.067,30.779,-88.044,30.74,-88.014,30.803,-87.945,30.829,-87.987,30.867,-87.983,30.887,-87.959,30.906,-87.996,30.974,-87.996,30.988,-87.987,31.002,-88.015,31.01,-88.02,31.014,-88.022,31.02,-88.017,31.027,-87.998,31.022,-88.014,31.032,-88.014,31.037,-88.002,31.045,-88.0,31.055,-87.977,31.066,-87.965,31.064,-87.978,31.089,-87.94,31.144,-87.968,31.148,-87.976,31.158,-87.972,31.164,-87.943,31.163,-87.948,31.172,-87.952,31.204,-87.94,31.221,-87.932,31.225,-87.988,31.258,-87.966,31.312,-87.891,31.36,-87.986,31.257,-87.929,31.226,-87.94,31.162,-87.972,31.154,-87.928,31.164,-87.92,31.161,-87.917,31.154,-87.909,31.153,-87.9,31.147,-87.901,31.166,-87.873,31.192,-87.877,31.216,-87.869,31.217,-87.853,31.204,-87.849,31.209,-87.859,31.215,-87.865,31.225,-87.865,31.231,-87.859,31.237,-87.852,31.237,-87.836,31.229,-87.846,31.238,-87.839,31.243,-87.843,31.254,-87.828,31.279,-87.818,31.28,-87.795,31.272,-87.796,31.296,-87.784,31.3,-87.766,31.295,-87.781,31.306,-87.787,31.325,-87.765,31.363,-87.76,31.366,-87.753,31.363,-87.749,31.335,-87.73,31.337,-87.727,31.373,-87.706,31.402,-87.667,31.423,-87.649,31.427,-87.603,31.41,-87.565,31.465,-87.602,31.409,-87.704,31.401,-87.729,31.335,-87.751,31.333,-87.763,31.363,-87.764,31.294,-87.798,31.267,-87.827,31.276,-87.835,31.228,-87.863,31.23,-87.859,31.219,-87.849,31.211,-87.848,31.205,-87.899,31.146,-87.934,31.161,-87.946,31.118,-87.975,31.088,-87.941,31.063,-87.939,31.048,-87.956,31.028,-87.93,31.017,-87.964,30.967,-87.945,30.965,-87.926,30.945,-87.914,30.924,-87.903,30.947,-87.876,30.949,-87.891,30.967,-87.872,30.985,-87.878,30.99,-87.887,30.99,-87.893,30.986,-87.896,30.987,-87.903,31.009,-87.895,31.024,-87.913,31.04,-87.91,31.054,-87.87,30.984,-87.937,30.482,-87.855,30.378,-87.837,30.376,-87.847,30.395,-87.825,30.416,-87.83,30.428,-87.821,30.433,-87.722,30.271,-88.029,30.224,-87.559,30.271,-87.58,30.274,-87.515,30.309,-87.631,30.296,-87.613,30.358,-87.517,30.32,-87.498,30.372,-87.4,30.423,-87.39,30.446,-87.399,30.447,-87.416,30.453,-87.434,30.475,-87.436,30.483,-87.341,30.43,-87.499,30.313,-87.264,30.344,-87.336,30.365,-87.15,30.491,-87.179,30.544,-87.21,30.554,-87.221,30.568,-87.108,30.548,-87.091,30.439,-87.034,30.63,-87.017,30.502,-86.876,30.439,-86.936,30.454,-87.203,30.36,-87.184,30.344,-86.95,30.395,-86.779,30.41,-86.753,30.403,-86.723,30.411,-86.591,30.404,-86.638,30.432,-86.492,30.462,-86.498,30.522,-86.421,30.452,-86.201,30.497,-86.112,30.373,-86.515,30.396,-85.963,30.281,-85.734,30.122,-85.864,30.301,-85.763,30.313,-85.72,30.234,-85.589,30.342,-85.553,30.271,-85.734,30.177,-85.393,30.052,-85.392,30.027,-85.473,30.006,-85.687,30.123,-85.317,29.838,-85.363,29.679,-85.417,29.842,-85.399,29.746,-85.36,29.67,-85.102,29.719,-84.991,29.715,-85.182,29.814,-85.02,29.809,-85.034,29.83,-85.043,29.879,-85.015,29.944,-85.025,29.964,-85.031,29.987,-85.05,29.989,-85.115,30.023,-85.136,30.042,-85.154,30.092,-85.141,30.17,-84.966,30.58,-84.859,30.697,-84.948,30.754,-84.931,30.75,-84.94,30.822,-84.939,30.841,-84.927,30.844,-84.937,30.857,-84.94,30.875,-84.935,30.883,-84.943,30.888,-84.967,30.917,-84.972,30.928,-84.983,30.933,-84.988,30.969,-84.934,30.883,-84.925,30.839,-84.934,30.836,-84.937,30.822,-84.926,30.796,-84.885,30.744,-84.883,30.805,-84.767,30.859,-84.782,30.796,-84.814,30.769,-84.725,30.793,-84.662,30.793,-84.668,30.827,-84.661,30.844,-84.654,30.851,-84.643,30.852,-84.634,30.865,-84.629,30.86,-84.627,30.871,-84.618,30.872,-84.612,30.892,-84.607,30.896,-84.605,30.896,-84.619,30.867,-84.626,30.87,-84.628,30.86,-84.66,30.842,-84.654,30.806,-84.664,30.79,-84.861,30.741,-84.857,30.696,-84.99,30.523,-85.058,30.261,-85.135,30.179,-85.134,30.041,-85.03,29.988,-85.013,29.942,-85.019,29.808,-85.04,29.769,-85.013,29.757,-85.007,29.749,-84.988,29.738,-84.974,29.725,-84.955,29.728,-84.856,29.813,-84.889,29.722,-84.694,29.872,-84.345,29.898,-84.339,29.947,-84.523,29.98,-84.528,29.985,-84.52,29.996,-84.526,30.006,-84.342,29.97,-84.39,29.994,-84.385,30.037,-84.271,30.112,-84.212,30.092,-84.213,30.154,-84.179,30.073,-83.98,30.115,-83.681,29.922,-83.584,29.759,-83.39,29.671,-83.401,29.518,-83.312,29.473,-83.279,29.427,-83.219,29.42,-83.171,29.366,-83.185,29.354,-83.173,29.33,-83.154,29.322,-83.166,29.318,-83.163,29.314,-83.163,29.307,-83.17,29.301,-83.166,29.294,-83.161,29.307,-83.143,29.323,-83.062,29.35,-83.063,29.353,-83.068,29.351,-83.073,29.355,-83.067,29.373,-83.034,29.398,-83.025,29.398,-83.028,29.42,-83.017,29.42,-83.023,29.428,-83.022,29.432,-83.0,29.464,-82.986,29.466,-83.023,29.399,-83.065,29.373,-83.061,29.349,-83.141,29.323,-83.162,29.291,-83.081,29.287,-83.028,29.165,-83.037,29.142,-83.046,29.157,-83.065,29.151,-83.066,29.143,-83.03,29.133,-82.986,29.177,-82.796,29.175,-82.817,29.075,-82.786,29.078,-82.782,29.063,-82.756,29.052,-82.775,29.003,-82.727,28.996,-82.724,28.962,-82.783,28.942,-82.718,28.952,-82.598,28.898,-82.593,28.882,-82.655,28.902,-82.632,28.888,-82.648,28.848,-82.733,28.854,-82.684,28.83,-82.69,28.757,-82.67,28.768,-82.64,28.762,-82.689,28.71,-82.663,28.715,-82.633,28.697,-82.61,28.715,-82.798,28.187,-82.78,28.057,-82.837,28.089,-82.786,28.048,-82.792,27.988,-82.83,27.899,-82.849,27.876,-82.84,27.849,-82.793,27.807,-82.784,27.81,-82.77,27.799,-82.765,27.816,-82.784,27.842,-82.78,27.864,-82.742,27.769,-82.75,27.749,-82.638,27.704,-82.582,27.879,-82.611,27.874,-82.728,27.937,-82.695,28.037,-82.528,27.936,-82.534,27.833,-82.473,27.822,-82.442,27.954,-82.436,27.904,-82.43,27.946,-82.415,27.95,-82.411,27.943,-82.387,27.95,-82.376,27.947,-82.403,27.854,-82.382,27.863,-82.348,27.858,-82.342,27.863,-82.327,27.869,-82.312,27.866,-82.348,27.856,-82.398,27.853,-82.482,27.743,-82.454,27.704,-82.487,27.71,-82.495,27.719,-82.553,27.598,-82.604,27.582,-82.573,27.549,-82.647,27.533,-82.563,27.506,-82.474,27.534,-82.474,27.519,-82.691,27.471,-82.58,27.416,-82.442,27.127,-82.445,27.058,-82.338,26.93,-82.337,26.887,-82.282,26.816,-82.302,26.847,-82.148,26.79,-82.183,26.936,-82.236,26.966,-82.271,27.005,-82.259,27.003,-82.268,27.009,-82.274,27.02,-82.275,27.026,-82.271,27.029,-82.279,27.032,-82.293,27.044,-82.295,27.051,-82.142,26.924,-82.005,26.975,-81.991,27.056,-81.974,26.979,-82.005,26.954,-82.097,26.914,-82.04,26.525,-81.948,26.54,-81.894,26.656,-81.839,26.693,-81.754,26.724,-81.737,26.719,-81.872,26.647,-81.925,26.538,-82.013,26.487,-81.854,26.444,-81.801,26.09,-81.762,26.005,-81.752,25.992,-81.776,26.041,-81.77,26.051,-81.713,26.042,-81.744,26.014,-81.716,25.993,-81.739,25.975,-81.711,25.986,-81.668,25.973,-81.7,25.961,-81.68,25.963,-81.686,25.953,-81.68,25.956,-81.667,25.951,-81.654,25.944,-81.65,25.933,-81.634,25.938,-81.632,25.896,-81.615,25.932,-81.587,25.941,-81.611,25.896,-81.588,25.88,-81.583,25.891,-81.558,25.899,-81.531,25.894,-81.508,25.833,-81.51,25.856,-81.479,25.849,-81.471,25.85,-81.471,25.853,-81.456,25.861,-81.51,25.877,-81.479,25.906,-81.337,25.834,-81.312,25.786,-81.376,25.767,-81.337,25.759,-81.303,25.693,-81.287,25.768,-81.231,25.76,-81.226,25.723,-81.193,25.724,-81.18,25.695,-81.201,25.688,-81.223,25.721,-81.286,25.665,-81.269,25.668,-81.247,25.632,-81.222,25.554,-81.122,25.616,-81.027,25.565,-81.135,25.584,-81.137,25.565,-81.201,25.538,-81.21,25.506,-81.115,25.352,-80.987,25.347,-80.904,25.24,-80.93,25.211,-80.999,25.212,-81.047,25.236,-81.07,25.255,-81.071,25.259,-81.067,25.26,-81.069,25.27,-81.068,25.279,-81.092,25.276,-81.08,25.284,-81.089,25.311,-81.145,25.326,-81.172,25.225,-81.082,25.143,-81.14,25.183,-81.141,25.157,-81.088,25.116,-80.806,25.184,-80.673,25.139,-80.519,25.222,-80.442,25.194,-80.433,25.107,-80.516,25.014,-80.795,24.811],"holes":[[-97.332,26.563,-97.269,26.565,-97.371,26.9,-97.36,27.232,-97.058,27.842,-97.136,27.825,-97.384,27.266,-97.418,26.895,-97.332,26.563],[-91.058,29.185,-91.036,29.267,-91.052,29.193,-90.938,29.251,-91.054,29.297,-90.99,29.332,-90.77,29.26,-90.704,29.563,-90.678,29.248,-90.605,29.223,-90.564,29.426,-90.539,29.323,-90.458,29.365,-90.498,29.297,-90.428,29.358,-90.378,29.26,-90.363,29.357,-90.255,29.253,-90.323,29.336,-90.347,29.39,-90.326,29.418,-90.207,29.249,-90.24,29.171,-90.226,29.079,-90.048,29.214,-90.209,29.233,-90.108,29.266,-90.162,29.37,-90.035,29.293,-90.03,29.38,-90.245,29.48,-90.181,29.681,-90.399,29.55,-90.697,29.613,-90.764,29.535,-90.952,29.572,-90.966,29.471,-91.188,29.596,-91.258,29.445,-91.12,29.292,-91.021,29.341,-91.133,29.285,-91.058,29.185],[-90.116,29.717,-89.889,29.554,-89.898,29.47,-89.764,29.48,-89.815,29.396,-89.733,29.49,-89.595,29.355,-89.488,29.32,-89.448,29.182,-89.359,29.236,-89.357,29.158,-89.342,29.167,-89.352,29.275,-89.408,29.341,-89.579,29.376,-89.614,29.451,-89.957,29.646,-90.028,29.771,-89.922,29.915,-90.116,29.717],[-89.901,29.891,-90.019,29.77,-89.952,29.649,-89.567,29.379,-89.326,29.306,-89.31,29.384,-89.352,29.325,-89.383,29.389,-89.532,29.374,-89.522,29.456,-89.652,29.487,-89.764,29.634,-89.622,29.603,-89.67,29.694,-89.49,29.629,-89.684,29.723,-89.677,29.817,-89.406,29.688,-90.013,29.995,-89.901,29.891]]}],"names":{"ar":"خليج المكسيك","de":"Golf von Mexiko","es":"golfo de México","fr":"golfe du Mexique","he":"מפרץ מקסיקו","hu":"Mexikói-öböl","it":"golfo del Messico","nl":"Golf van Mexico","pt":"Golfo do México","zh":"墨西哥湾"}},{"name":"Davis Strait","bbox":[-70.143,59.894,-44.462,70.069],"area":144.296,"polygons":[{"outer":[-47.448,60.813,-47.323,60.864,-47.635,60.872,-47.342,60.98,-47.259,60.882,-46.966,60.998,-47.18,60.871,-46.947,60.954,-46.959,60.943,-46.906,60.917,-47.056,60.821,-46.834,60.876,-46.998,60.813,-46.867,60.787,-46.705,60.841,-46.693,60.875,-46.92,60.926,-46.565,60.897,-46.703,61.032,-46.534,60.933,-46.589,61.005,-46.556,61.026,-46.412,60.988,-46.467,61.049,-46.369,61.033,-46.405,61.08,-46.192,60.975,-46.118,61.003,-46.299,61.066,-46.072,61.03,-45.934,61.123,-45.815,61.122,-46.111,61.241,-45.93,61.215,-45.787,61.323,-45.706,61.306,-45.865,61.203,-45.609,61.139,-46.116,60.963,-46.062,60.909,-45.609,61.007,-45.465,61.106,-45.513,61.147,-45.51,61.245,-45.388,61.083,-45.24,61.189,-45.197,61.185,-45.493,60.979,-45.9,60.916,-46.206,60.81,-45.839,60.887,-46.229,60.764,-46.015,60.709,-45.995,60.817,-45.929,60.838,-45.689,60.839,-45.885,60.742,-45.687,60.768,-45.389,60.943,-45.407,61.002,-45.335,60.968,-45.368,60.908,-45.254,60.916,-45.858,60.688,-45.565,60.697,-45.99,60.567,-45.898,60.558,-45.904,60.513,-45.79,60.621,-45.62,60.639,-45.757,60.595,-45.626,60.594,-45.591,60.557,-45.684,60.52,-45.578,60.524,-45.627,60.506,-45.568,60.458,-45.46,60.624,-45.358,60.637,-45.37,60.708,-45.217,60.762,-45.309,60.649,-45.128,60.686,-45.391,60.596,-45.508,60.472,-45.079,60.647,-45.281,60.491,-45.197,60.423,-45.02,60.463,-44.806,60.669,-44.495,60.785,-44.961,60.471,-44.834,60.461,-44.921,60.32,-45.19,60.216,-45.221,60.158,-45.178,60.12,-45.077,60.203,-44.872,60.206,-44.462,60.544,-44.824,60.237,-44.767,60.185,-45.076,60.16,-45.058,60.101,-45.086,60.121,-45.17,60.082,-45.033,60.102,-44.948,60.08,-44.835,60.014,-44.935,60.08,-44.913,60.134,-44.879,60.151,-44.859,60.139,-44.899,60.109,-44.916,60.109,-44.872,60.105,-44.816,60.0,-64.16,60.0,-64.318,60.057,-64.217,60.013,-64.337,59.993,-64.372,59.924,-64.519,59.894,-64.398,59.982,-64.577,60.003,-64.391,60.012,-64.364,60.13,-64.438,60.066,-64.55,60.06,-64.572,60.071,-64.526,60.087,-64.451,60.091,-64.384,60.225,-64.549,60.169,-64.439,60.233,-64.753,60.239,-64.622,60.295,-64.388,60.236,-64.367,60.245,-64.576,60.328,-64.43,60.289,-64.403,60.285,-64.394,60.294,-64.492,60.34,-64.415,60.419,-64.786,61.319,-64.738,61.328,-64.7,61.382,-64.794,61.322,-64.827,61.336,-64.805,61.352,-64.767,61.364,-64.755,61.401,-64.792,61.366,-64.795,61.381,-64.837,61.356,-64.865,61.361,-64.687,61.429,-64.669,61.46,-64.739,61.447,-64.742,61.456,-64.65,61.51,-64.753,61.495,-64.652,61.532,-64.74,61.531,-64.627,61.612,-64.775,61.613,-64.698,61.655,-64.839,61.673,-64.96,61.648,-65.012,61.695,-65.469,61.616,-65.844,61.755,-65.761,61.754,-65.852,61.833,-65.939,61.784,-65.943,61.921,-66.122,62.003,-66.035,62.01,-66.139,62.087,-66.143,62.117,-66.152,62.098,-66.205,62.091,-66.145,62.129,-66.009,62.047,-66.067,62.124,-66.018,62.122,-66.034,62.16,-65.961,62.162,-65.961,62.189,-65.912,62.185,-65.892,62.206,-66.228,62.248,-66.101,62.235,-66.204,62.349,-66.348,62.345,-66.311,62.255,-66.505,62.34,-66.307,62.386,-66.501,62.415,-66.438,62.491,-66.764,62.625,-66.713,62.661,-66.989,62.646,-66.878,62.69,-67.048,62.68,-67.012,62.767,-67.105,62.737,-67.127,62.738,-67.053,62.786,-67.348,62.857,-67.209,62.878,-67.336,62.942,-67.41,62.929,-67.362,62.958,-67.653,62.93,-67.498,63.031,-67.648,63.033,-67.69,62.957,-67.759,62.953,-67.777,63.012,-67.609,63.093,-67.756,63.08,-67.864,63.113,-67.868,63.072,-67.934,63.047,-67.969,63.046,-67.859,63.133,-68.2,63.201,-68.434,63.39,-68.463,63.426,-68.397,63.43,-68.419,63.437,-68.427,63.457,-68.464,63.439,-68.522,63.443,-68.859,63.599,-68.851,63.616,-68.789,63.612,-68.975,63.763,-68.408,63.729,-68.374,63.656,-68.27,63.626,-68.283,63.7,-68.218,63.684,-68.179,63.57,-68.16,63.615,-68.109,63.577,-68.105,63.627,-68.046,63.545,-67.93,63.534,-67.814,63.466,-67.78,63.408,-67.735,63.414,-67.664,63.36,-67.671,63.38,-67.622,63.42,-67.76,63.534,-67.815,63.714,-67.86,63.724,-67.86,63.748,-67.9,63.76,-67.898,63.775,-67.744,63.695,-67.738,63.636,-67.674,63.658,-67.604,63.672,-67.665,63.643,-67.613,63.616,-67.562,63.565,-67.606,63.536,-67.513,63.551,-67.365,63.404,-67.376,63.467,-67.35,63.473,-67.142,63.275,-66.955,63.283,-66.992,63.334,-66.964,63.414,-66.917,63.395,-66.97,63.338,-66.903,63.283,-67.008,63.247,-66.801,63.149,-66.821,63.249,-66.786,63.292,-66.76,63.102,-66.691,63.051,-66.636,63.062,-66.53,63.001,-66.651,63.094,-66.544,63.053,-66.555,63.204,-66.689,63.303,-66.59,63.397,-66.512,63.41,-66.497,63.41,-66.666,63.299,-66.482,63.199,-66.548,63.163,-66.396,63.015,-66.056,62.943,-66.254,63.129,-66.258,63.203,-66.241,63.222,-66.122,63.042,-65.922,62.978,-65.921,63.056,-65.876,63.018,-65.819,63.041,-65.785,63.026,-65.911,62.925,-65.858,62.94,-65.751,62.882,-65.778,62.94,-65.703,62.969,-65.733,63.033,-65.69,63.051,-65.657,62.984,-65.732,62.933,-65.604,62.924,-65.541,62.801,-65.415,62.855,-65.366,62.834,-65.349,62.916,-65.297,62.942,-65.29,62.897,-65.33,62.849,-65.215,62.835,-65.337,62.788,-65.195,62.788,-65.353,62.69,-65.263,62.656,-65.196,62.567,-64.961,62.527,-64.929,62.545,-64.795,62.579,-64.938,62.606,-65.099,62.563,-64.961,62.652,-64.891,62.651,-64.938,62.724,-65.051,62.742,-65.021,62.78,-65.136,62.841,-65.195,62.908,-65.148,62.926,-65.253,62.965,-65.274,63.029,-65.099,62.927,-65.136,62.968,-65.115,63.012,-65.071,62.97,-65.078,62.947,-64.917,62.91,-64.836,62.873,-64.806,62.831,-64.722,62.906,-64.593,62.917,-64.832,62.962,-64.748,62.987,-64.851,63.047,-64.743,63.074,-64.951,63.092,-64.957,63.099,-64.76,63.133,-65.071,63.166,-64.927,63.213,-65.069,63.231,-65.123,63.288,-64.976,63.249,-65.002,63.31,-64.911,63.235,-64.877,63.233,-64.953,63.39,-65.144,63.4,-65.005,63.42,-65.13,63.429,-65.004,63.455,-65.122,63.583,-64.956,63.597,-65.295,63.796,-65.129,63.777,-65.19,63.782,-64.844,63.547,-64.755,63.322,-64.511,63.242,-64.475,63.281,-64.575,63.292,-64.62,63.324,-64.576,63.32,-64.519,63.291,-64.484,63.375,-64.705,63.387,-64.458,63.431,-64.678,63.691,-64.525,63.595,-64.5,63.678,-64.664,63.712,-64.66,63.761,-64.744,63.74,-64.942,63.762,-64.973,63.81,-64.833,63.944,-64.606,63.973,-65.218,64.019,-65.008,64.047,-65.167,64.062,-65.091,64.094,-65.244,64.179,-65.315,64.145,-65.412,64.142,-65.216,64.191,-65.36,64.218,-65.298,64.242,-65.484,64.24,-65.543,64.244,-65.557,64.249,-65.4,64.29,-65.671,64.303,-65.196,64.294,-65.131,64.343,-65.257,64.375,-65.222,64.411,-65.035,64.431,-65.172,64.471,-65.116,64.511,-65.232,64.475,-65.184,64.497,-65.197,64.54,-65.335,64.476,-65.364,64.45,-65.348,64.427,-65.364,64.418,-65.383,64.45,-65.31,64.537,-65.486,64.464,-65.695,64.484,-65.685,64.577,-65.752,64.587,-65.555,64.641,-65.529,64.722,-65.65,64.779,-65.687,64.756,-65.671,64.737,-65.692,64.679,-65.716,64.759,-65.638,64.8,-65.689,64.849,-65.764,64.795,-65.769,64.87,-65.944,64.873,-65.794,64.627,-65.927,64.768,-66.027,64.714,-66.015,64.856,-66.152,64.694,-66.216,64.729,-66.154,64.762,-66.402,64.722,-66.322,64.65,-66.301,64.622,-66.311,64.612,-66.434,64.729,-66.174,64.78,-66.119,64.869,-66.304,64.861,-66.476,64.925,-66.492,64.954,-66.456,64.986,-66.49,64.985,-66.508,64.944,-66.544,64.935,-66.501,65.009,-66.673,65.041,-66.606,64.901,-66.67,64.836,-66.614,64.793,-66.731,64.721,-66.668,64.788,-66.78,64.87,-66.712,64.924,-66.848,64.931,-66.733,64.964,-66.808,65.029,-66.773,65.038,-66.765,65.065,-66.86,65.033,-66.866,64.974,-66.934,65.046,-66.812,65.054,-66.748,65.177,-66.914,65.062,-67.116,65.056,-66.951,65.11,-66.918,65.24,-66.96,65.191,-67.253,65.173,-67.073,65.239,-67.1,65.267,-67.078,65.286,-67.176,65.246,-67.107,65.318,-67.238,65.333,-67.267,65.359,-67.294,65.312,-67.568,65.335,-67.069,65.391,-67.142,65.439,-67.053,65.466,-67.345,65.434,-67.461,65.495,-67.257,65.55,-67.351,65.573,-67.257,65.606,-67.303,65.664,-67.369,65.662,-67.436,65.681,-67.376,65.643,-67.525,65.676,-67.563,65.654,-67.743,65.638,-67.798,65.588,-68.034,65.463,-68.113,65.431,-68.233,65.427,-68.301,65.401,-68.31,65.4,-68.239,65.429,-68.121,65.434,-68.104,65.454,-68.018,65.485,-67.931,65.57,-68.031,65.56,-68.061,65.573,-67.846,65.638,-67.988,65.704,-67.943,65.75,-67.862,65.745,-67.837,65.768,-67.807,65.762,-67.832,65.802,-67.799,65.816,-67.985,65.776,-68.16,65.806,-68.24,65.765,-68.165,65.813,-68.049,65.828,-68.17,65.854,-68.131,65.867,-68.132,65.939,-68.158,65.899,-68.327,65.927,-68.303,65.957,-68.17,65.95,-68.31,66.004,-68.365,66.072,-68.197,65.979,-68.108,65.965,-68.1,65.988,-68.11,65.983,-68.146,66.001,-68.169,65.992,-68.204,66.006,-68.17,66.062,-68.14,66.079,-68.163,66.035,-68.081,66.023,-68.102,66.006,-68.087,65.985,-68.06,65.981,-68.037,66.025,-68.097,66.037,-68.011,66.082,-68.217,66.137,-68.207,66.077,-68.262,66.085,-68.359,66.119,-68.458,66.199,-68.574,66.172,-68.648,66.187,-68.688,66.174,-68.85,66.175,-68.9,66.195,-68.954,66.18,-69.016,66.193,-68.456,66.216,-68.509,66.302,-68.385,66.237,-68.378,66.179,-68.269,66.228,-68.181,66.134,-67.934,66.137,-67.991,66.121,-67.997,66.093,-67.934,66.087,-68.003,66.081,-67.992,66.012,-67.997,65.994,-68.023,65.994,-67.923,65.941,-67.933,65.908,-67.803,65.876,-67.741,65.919,-67.688,65.892,-67.711,65.908,-67.703,65.922,-67.722,65.932,-67.835,65.938,-67.739,65.948,-67.8,65.978,-67.755,65.996,-67.741,66.077,-67.709,66.1,-67.742,65.973,-67.633,65.911,-67.66,65.903,-67.53,65.913,-67.414,65.892,-67.471,65.93,-67.411,65.951,-67.172,65.911,-67.248,65.986,-67.234,66.017,-67.135,66.017,-67.249,66.052,-67.269,66.107,-67.823,66.217,-67.692,66.287,-67.99,66.51,-67.68,66.444,-67.796,66.509,-67.745,66.508,-67.492,66.406,-67.41,66.287,-67.381,66.32,-67.342,66.314,-67.288,66.267,-67.253,66.281,-67.243,66.269,-67.197,66.278,-67.106,66.268,-67.213,66.282,-67.233,66.273,-67.229,66.282,-67.257,66.282,-67.111,66.31,-67.361,66.388,-67.435,66.442,-67.111,66.379,-67.287,66.529,-67.718,66.558,-67.953,66.633,-67.516,66.562,-67.505,66.59,-67.575,66.626,-67.579,66.634,-67.45,66.732,-67.528,66.661,-67.469,66.584,-67.327,66.604,-67.313,66.567,-67.159,66.486,-67.061,66.495,-67.172,66.521,-67.149,66.546,-66.854,66.568,-67.175,66.669,-66.733,66.596,-66.858,66.536,-66.766,66.503,-66.822,66.459,-66.668,66.455,-66.712,66.386,-66.766,66.375,-66.634,66.356,-66.79,66.373,-66.929,66.418,-66.974,66.409,-66.652,66.294,-66.499,66.35,-66.458,66.282,-66.541,66.24,-66.403,66.204,-66.398,66.222,-66.348,66.225,-66.443,66.279,-66.296,66.267,-66.32,66.25,-66.224,66.234,-66.28,66.218,-66.183,66.187,-66.142,66.131,-65.969,66.11,-65.565,66.233,-65.565,66.321,-65.479,66.402,-65.527,66.232,-65.964,66.043,-65.871,65.942,-64.95,66.069,-64.833,66.101,-64.846,66.19,-64.704,66.278,-64.378,66.336,-64.351,66.356,-64.344,66.333,-64.766,66.181,-64.854,66.023,-64.67,65.958,-64.941,66.005,-65.357,65.913,-65.497,65.745,-65.389,65.79,-65.463,65.684,-65.359,65.663,-65.137,65.663,-64.998,65.719,-64.782,65.725,-65.296,65.635,-65.342,65.566,-64.997,65.548,-64.857,65.656,-64.711,65.652,-64.859,65.639,-64.732,65.58,-65.147,65.498,-65.16,65.435,-64.987,65.372,-64.867,65.427,-64.402,65.479,-64.908,65.343,-64.685,65.342,-64.912,65.3,-64.801,65.231,-64.713,65.273,-64.603,65.251,-64.723,65.222,-64.709,65.159,-64.618,65.193,-64.647,65.141,-64.556,65.083,-64.448,65.191,-64.464,65.301,-64.212,65.45,-64.257,65.381,-64.401,65.301,-64.343,65.296,-64.407,65.25,-64.371,65.178,-64.119,65.257,-64.144,65.191,-64.069,65.204,-64.263,65.105,-64.108,65.043,-64.071,65.138,-63.948,65.089,-63.859,65.188,-63.802,65.189,-63.879,65.034,-63.664,65.04,-63.847,64.977,-63.606,65.004,-63.67,64.906,-63.538,64.885,-63.502,64.965,-63.586,65.05,-63.455,64.986,-63.512,65.039,-63.352,65.055,-63.479,65.09,-63.383,65.106,-63.328,65.144,-63.521,65.192,-63.318,65.214,-63.474,65.235,-63.338,65.242,-63.485,65.259,-63.353,65.29,-63.538,65.377,-63.388,65.384,-63.746,65.479,-63.29,65.422,-63.695,65.564,-63.655,65.559,-63.578,65.538,-63.577,65.596,-63.465,65.518,-63.329,65.584,-63.736,65.669,-63.723,65.677,-63.619,65.658,-63.449,65.672,-63.345,65.658,-63.519,65.927,-63.46,65.945,-63.238,65.634,-63.041,65.625,-62.952,65.573,-62.912,65.746,-62.871,65.627,-62.734,65.582,-62.664,65.637,-62.663,65.575,-62.611,65.675,-62.843,65.755,-62.563,65.745,-62.804,65.827,-62.848,65.91,-62.683,65.805,-62.284,65.8,-62.483,65.904,-62.388,65.918,-62.413,65.965,-62.803,66.012,-62.913,66.139,-62.754,66.02,-61.935,66.011,-62.14,66.032,-62.078,66.073,-62.033,66.083,-62.194,66.086,-62.15,66.124,-62.125,66.12,-62.109,66.125,-62.099,66.135,-62.699,66.186,-62.865,66.306,-62.694,66.205,-62.315,66.297,-62.478,66.399,-62.617,66.386,-62.7,66.405,-62.57,66.396,-62.595,66.445,-62.197,66.405,-62.165,66.305,-61.534,66.326,-61.467,66.367,-61.563,66.415,-61.987,66.4,-61.557,66.457,-61.802,66.615,-61.957,66.639,-62.063,66.601,-62.18,66.621,-61.983,66.689,-61.43,66.53,-61.283,66.593,-61.28,66.668,-61.524,66.806,-61.767,66.815,-61.615,66.87,-61.999,66.854,-61.716,66.93,-61.846,66.92,-61.834,66.97,-62.088,66.894,-61.965,67.023,-62.017,67.054,-62.268,67.049,-62.242,66.928,-62.423,66.928,-62.312,66.739,-62.565,66.953,-62.764,66.932,-62.729,66.774,-62.914,66.631,-62.81,66.805,-62.82,66.809,-62.827,66.864,-62.852,66.886,-62.829,66.963,-63.139,66.946,-63.33,66.897,-63.404,66.704,-63.423,66.909,-63.567,66.812,-63.785,66.803,-63.84,66.817,-63.583,66.837,-63.51,66.918,-63.193,66.971,-63.285,67.109,-62.954,67.218,-63.118,67.334,-63.245,67.242,-63.181,67.293,-63.317,67.286,-63.514,67.076,-63.766,67.007,-63.777,66.914,-63.798,66.9,-63.826,66.997,-63.5,67.115,-63.446,67.227,-64.546,67.127,-64.714,67.007,-64.621,67.137,-63.953,67.268,-64.795,67.194,-64.233,67.3,-64.685,67.328,-64.79,67.368,-63.944,67.347,-64.032,67.46,-64.301,67.444,-64.469,67.473,-64.232,67.455,-64.065,67.565,-64.3,67.614,-64.202,67.669,-64.291,67.723,-64.65,67.651,-64.355,67.746,-64.545,67.737,-64.441,67.778,-64.52,67.812,-64.75,67.82,-64.789,67.682,-64.894,67.675,-64.826,67.768,-64.938,67.785,-65.226,67.643,-65.013,67.798,-65.09,67.818,-65.198,67.753,-65.257,67.756,-64.922,67.919,-65.095,67.92,-64.725,67.982,-65.052,68.046,-65.23,67.938,-65.422,67.919,-65.574,67.781,-65.39,67.701,-65.328,67.598,-65.362,67.587,-65.626,67.79,-65.459,67.989,-65.797,67.973,-65.74,67.901,-65.94,67.819,-66.029,67.595,-65.924,67.871,-66.013,67.951,-66.143,67.927,-66.024,67.963,-65.934,68.032,-66.261,67.962,-66.373,67.768,-66.394,67.831,-66.6,67.801,-66.53,67.833,-66.737,67.879,-66.775,67.867,-66.785,67.867,-66.786,67.869,-66.751,67.88,-66.669,67.877,-66.48,67.846,-66.388,67.866,-66.342,67.97,-66.183,68.014,-66.267,68.087,-66.485,68.055,-66.535,68.078,-66.313,68.126,-66.616,68.139,-66.745,67.933,-66.678,68.138,-66.956,67.997,-67.049,67.991,-66.762,68.239,-67.121,68.286,-67.338,68.197,-67.253,68.304,-67.013,68.331,-67.384,68.366,-67.583,68.254,-67.706,68.246,-67.522,68.341,-67.88,68.268,-67.414,68.403,-67.023,68.36,-66.937,68.42,-66.668,68.448,-66.981,68.443,-67.126,68.489,-67.104,68.424,-67.356,68.415,-67.206,68.438,-67.445,68.502,-67.575,68.491,-67.619,68.384,-67.63,68.507,-67.508,68.541,-67.683,68.571,-67.879,68.539,-67.903,68.429,-67.954,68.404,-67.939,68.56,-68.121,68.532,-68.346,68.6,-68.753,68.575,-68.479,68.611,-68.621,68.639,-68.848,68.595,-68.848,68.594,-68.843,68.594,-68.84,68.593,-68.849,68.577,-68.838,68.566,-68.943,68.537,-68.847,68.568,-68.906,68.61,-68.747,68.653,-68.069,68.685,-68.875,68.773,-69.055,68.72,-69.144,68.718,-69.246,68.731,-68.944,68.787,-69.411,68.815,-69.174,68.837,-69.408,68.872,-69.034,68.877,-68.623,68.788,-68.496,68.798,-68.606,68.855,-67.77,68.786,-68.124,68.834,-68.287,68.879,-68.519,68.899,-67.967,68.863,-68.116,68.905,-68.064,68.948,-68.57,68.968,-67.716,69.022,-68.205,69.152,-68.824,69.11,-68.942,68.99,-69.012,68.979,-68.923,69.106,-69.092,69.106,-68.506,69.209,-69.008,69.22,-68.98,69.233,-68.084,69.224,-68.928,69.34,-69.024,69.336,-69.229,69.262,-69.29,69.259,-69.048,69.356,-68.198,69.318,-67.647,69.175,-66.681,69.189,-66.677,69.269,-66.857,69.368,-67.23,69.468,-67.998,69.467,-68.733,69.597,-69.234,69.509,-70.143,69.518,-69.231,69.539,-68.708,69.662,-68.315,69.632,-68.04,69.774,-67.123,69.735,-67.22,69.916,-67.231,69.92,-67.248,69.918,-67.291,69.922,-67.31,69.926,-67.292,69.922,-67.234,69.921,-67.221,69.918,-67.259,70.0,-54.631,69.999,-54.264,69.917,-54.767,69.957,-54.925,69.828,-54.386,69.675,-54.684,69.746,-54.959,69.687,-54.657,69.567,-54.032,69.552,-53.96,69.616,-53.894,69.558,-54.002,69.495,-53.949,69.481,-53.821,69.52,-53.726,69.515,-53.873,69.472,-53.782,69.454,-53.556,69.57,-53.343,69.593,-53.585,69.47,-53.447,69.446,-54.28,69.414,-53.839,69.259,-53.819,69.267,-53.728,69.258,-53.736,69.251,-53.571,69.26,-53.553,69.232,-52.251,69.454,-51.849,69.642,-51.972,69.672,-51.927,69.735,-51.997,69.803,-52.515,69.865,-52.894,70.016,-51.54,70.041,-51.579,69.986,-51.463,69.967,-51.24,70.069,-50.89,70.027,-51.269,69.965,-50.995,69.963,-50.255,70.05,-50.265,69.987,-50.621,69.928,-50.317,69.866,-50.357,69.824,-50.226,69.761,-50.626,69.781,-50.845,69.699,-50.477,69.655,-50.421,69.592,-50.841,69.646,-50.768,69.545,-50.91,69.494,-50.218,69.526,-50.328,69.487,-50.541,69.486,-50.462,69.433,-50.918,69.457,-50.881,69.446,-50.861,69.409,-50.924,69.376,-50.866,69.341,-51.017,69.324,-50.911,69.282,-51.149,69.206,-50.926,69.167,-50.708,69.234,-50.649,69.234,-50.76,69.196,-50.695,69.197,-50.591,69.218,-50.569,69.274,-50.603,69.263,-50.623,69.265,-50.52,69.326,-50.35,69.343,-50.484,69.295,-50.431,69.219,-50.491,69.193,-50.166,69.261,-49.957,69.165,-50.487,69.095,-50.586,69.13,-50.711,69.108,-50.431,69.048,-50.231,69.081,-50.138,69.022,-50.247,69.028,-50.193,68.962,-50.312,68.974,-50.281,69.056,-50.502,69.006,-50.277,68.92,-50.378,68.9,-50.665,69.072,-51.005,69.037,-50.745,69.135,-51.092,69.138,-51.088,69.047,-51.152,69.044,-51.094,68.959,-51.213,68.884,-50.972,68.928,-51.304,68.741,-50.667,68.821,-50.995,68.722,-50.818,68.663,-50.88,68.614,-51.055,68.698,-50.999,68.65,-51.032,68.574,-51.845,68.501,-52.008,68.554,-51.821,68.634,-52.08,68.597,-52.247,68.653,-52.447,68.6,-51.989,68.582,-52.653,68.522,-52.382,68.514,-52.497,68.496,-52.457,68.46,-52.838,68.408,-52.706,68.402,-52.724,68.359,-53.123,68.304,-53.301,68.342,-53.247,68.362,-53.401,68.336,-53.413,68.319,-53.467,68.309,-53.42,68.312,-53.349,68.336,-53.396,68.278,-53.204,68.315,-53.111,68.285,-52.578,68.353,-52.933,68.28,-52.83,68.252,-52.936,68.23,-52.495,68.308,-52.578,68.262,-52.394,68.235,-52.498,68.22,-52.464,68.169,-52.06,68.232,-52.0,68.226,-51.918,68.235,-51.802,68.228,-51.698,68.264,-51.624,68.271,-51.234,68.275,-51.333,68.366,-51.639,68.407,-50.792,68.502,-51.189,68.398,-51.094,68.365,-51.204,68.325,-51.158,68.278,-51.411,68.243,-51.206,68.241,-51.147,68.202,-51.047,68.196,-51.031,68.179,-51.434,68.187,-51.059,68.146,-51.141,68.094,-50.937,68.123,-51.07,68.077,-50.353,67.925,-50.624,67.894,-51.17,68.057,-51.988,68.04,-52.58,68.194,-52.692,68.179,-52.796,68.199,-52.851,68.174,-52.699,68.174,-52.277,68.057,-52.637,68.058,-52.579,68.088,-53.142,68.202,-53.498,68.145,-53.258,68.166,-53.28,68.121,-53.121,68.147,-53.168,68.149,-53.153,68.165,-53.073,68.167,-53.059,68.159,-53.124,68.127,-53.203,68.12,-53.02,68.093,-52.793,67.982,-52.084,67.981,-52.062,67.952,-52.571,67.928,-52.773,67.965,-53.102,68.058,-53.259,68.007,-53.269,67.977,-52.937,67.984,-52.91,67.967,-53.253,67.96,-53.195,67.921,-53.292,67.921,-53.354,67.899,-53.134,67.9,-53.062,67.879,-53.066,67.866,-53.463,67.889,-53.371,67.853,-53.735,67.793,-53.667,67.783,-53.591,67.742,-53.609,67.734,-53.576,67.722,-53.589,67.718,-53.569,67.705,-53.58,67.696,-53.618,67.704,-53.609,67.726,-53.633,67.727,-53.628,67.744,-53.688,67.772,-53.684,67.774,-53.795,67.743,-53.683,67.697,-53.715,67.652,-53.556,67.677,-53.778,67.579,-53.676,67.479,-53.001,67.754,-52.607,67.784,-52.231,67.895,-51.756,67.952,-51.552,67.963,-52.369,67.814,-51.746,67.683,-51.476,67.722,-51.307,67.826,-51.593,67.925,-51.029,67.98,-50.98,67.87,-51.409,67.868,-51.173,67.8,-51.193,67.75,-50.996,67.766,-50.876,67.811,-50.62,67.84,-50.947,67.841,-50.776,67.897,-50.27,67.848,-51.255,67.708,-50.895,67.67,-51.02,67.63,-50.838,67.627,-50.598,67.493,-50.434,67.52,-50.083,67.506,-50.583,67.481,-51.342,67.68,-51.806,67.623,-52.472,67.774,-52.874,67.736,-52.543,67.73,-52.72,67.689,-52.984,67.73,-53.128,67.636,-52.908,67.686,-52.887,67.683,-53.706,67.454,-53.785,67.397,-53.814,67.401,-53.822,67.386,-53.793,67.386,-53.785,67.379,-53.863,67.34,-53.822,67.311,-53.879,67.21,-53.391,67.248,-53.154,67.344,-53.227,67.285,-52.191,67.394,-51.472,67.346,-51.396,67.314,-51.175,67.134,-51.025,67.144,-51.003,67.141,-50.914,67.169,-50.883,67.168,-50.852,67.183,-50.774,67.186,-50.694,67.204,-50.521,67.217,-50.575,67.211,-50.599,67.198,-50.88,67.165,-50.914,67.167,-51.002,67.139,-51.18,67.13,-51.46,67.327,-52.226,67.388,-53.877,67.155,-53.89,67.111,-53.95,67.102,-53.889,67.097,-53.934,67.074,-53.334,67.115,-53.522,67.063,-53.689,67.076,-53.731,67.069,-53.755,67.072,-53.803,67.07,-53.83,67.06,-53.783,67.016,-53.609,66.992,-53.454,67.018,-53.204,66.996,-53.804,66.983,-53.661,66.92,-53.066,66.93,-52.799,66.889,-52.446,66.982,-52.535,66.948,-52.269,66.93,-52.488,66.88,-52.229,66.84,-53.057,66.851,-53.115,66.757,-52.891,66.821,-52.655,66.802,-52.835,66.748,-53.21,66.74,-52.598,66.711,-53.28,66.704,-53.495,66.645,-53.466,66.621,-53.484,66.592,-53.326,66.623,-53.432,66.566,-53.341,66.561,-53.276,66.563,-53.202,66.536,-52.805,66.583,-52.412,66.546,-53.632,66.507,-53.64,66.493,-53.608,66.489,-53.628,66.484,-53.603,66.47,-53.606,66.464,-53.656,66.459,-53.682,66.483,-53.696,66.449,-53.649,66.458,-53.61,66.403,-53.693,66.34,-53.638,66.303,-53.613,66.234,-53.085,66.289,-53.56,66.233,-53.57,66.185,-53.669,66.146,-53.657,66.137,-53.668,66.133,-53.681,66.123,-53.53,66.142,-53.68,66.108,-53.089,66.163,-52.716,66.342,-52.141,66.51,-51.977,66.655,-50.967,66.966,-50.771,66.979,-50.985,66.905,-50.906,66.869,-50.4,66.856,-51.27,66.842,-51.526,66.746,-51.439,66.704,-51.922,66.622,-52.052,66.511,-51.975,66.493,-52.406,66.42,-52.928,66.186,-53.439,66.05,-53.356,66.017,-53.412,65.985,-53.473,65.992,-53.489,65.954,-53.423,65.923,-53.437,65.921,-53.421,65.918,-53.291,65.944,-53.249,65.939,-53.249,65.919,-52.95,66.017,-52.872,66.012,-53.272,65.905,-53.146,65.878,-53.235,65.882,-53.177,65.838,-52.768,65.937,-52.788,65.988,-52.543,66.023,-52.494,65.99,-52.601,65.955,-52.529,65.906,-52.295,65.889,-52.166,65.998,-51.711,66.098,-51.839,66.027,-51.713,65.921,-51.947,66.011,-52.186,65.957,-52.289,65.828,-52.675,65.938,-53.203,65.75,-52.911,65.81,-52.848,65.792,-52.853,65.789,-52.905,65.785,-52.989,65.744,-53.164,65.715,-53.232,65.67,-52.811,65.662,-52.685,65.816,-52.636,65.703,-52.789,65.631,-52.541,65.674,-52.799,65.54,-52.676,65.486,-52.451,65.666,-52.59,65.459,-52.673,65.451,-52.695,65.456,-52.7,65.452,-52.547,65.441,-52.439,65.538,-52.372,65.564,-52.471,65.459,-52.537,65.425,-52.484,65.379,-52.159,65.495,-51.896,65.542,-51.937,65.622,-51.684,65.712,-51.207,65.789,-51.094,65.754,-50.974,65.775,-50.761,65.742,-51.692,65.694,-51.892,65.632,-51.736,65.579,-51.979,65.474,-52.556,65.329,-52.414,65.259,-52.515,65.182,-52.341,65.261,-52.356,65.263,-52.335,65.335,-52.305,65.333,-52.315,65.268,-52.298,65.283,-52.231,65.302,-52.219,65.316,-52.186,65.309,-52.185,65.319,-52.153,65.278,-52.307,65.159,-52.378,65.154,-52.35,65.132,-52.377,65.134,-52.469,65.092,-52.472,65.068,-51.982,65.31,-52.12,65.197,-52.214,65.159,-52.034,65.193,-51.982,65.174,-52.272,65.079,-52.004,64.979,-52.009,64.954,-52.231,64.906,-52.145,64.856,-52.209,64.797,-52.042,64.856,-52.05,64.919,-51.929,64.876,-52.11,64.788,-51.701,64.866,-51.83,64.904,-51.705,64.95,-51.716,65.011,-51.559,64.98,-51.665,64.959,-51.69,64.871,-51.44,64.946,-51.544,64.964,-51.495,64.999,-51.423,64.944,-51.357,64.972,-51.438,64.994,-51.245,65.015,-51.543,64.846,-51.622,64.882,-52.134,64.717,-52.129,64.713,-52.132,64.707,-52.169,64.68,-52.153,64.675,-52.158,64.649,-52.051,64.632,-52.084,64.597,-52.11,64.608,-52.079,64.581,-52.043,64.587,-52.156,64.528,-52.112,64.53,-52.125,64.496,-52.029,64.493,-52.097,64.487,-52.093,64.463,-52.172,64.447,-52.104,64.426,-52.12,64.358,-52.171,64.33,-52.127,64.325,-52.114,64.292,-52.134,64.29,-52.14,64.263,-52.171,64.25,-52.086,64.248,-52.039,64.186,-52.074,64.164,-51.993,64.177,-52.026,64.194,-52.019,64.216,-51.974,64.184,-52.039,64.124,-52.093,64.126,-51.961,64.119,-51.933,64.136,-51.928,64.151,-51.882,64.157,-51.963,64.183,-51.945,64.215,-51.693,64.287,-51.73,64.349,-51.485,64.467,-51.43,64.61,-51.202,64.765,-51.031,64.764,-51.229,64.616,-51.045,64.608,-50.655,64.735,-50.733,64.768,-50.642,64.821,-50.686,64.887,-50.578,64.741,-50.341,64.717,-50.173,64.817,-49.997,64.827,-50.255,64.701,-50.038,64.678,-50.168,64.623,-50.105,64.519,-49.643,64.326,-50.178,64.466,-50.304,64.646,-50.538,64.695,-50.699,64.671,-50.48,64.645,-50.551,64.648,-50.635,64.612,-50.794,64.641,-50.927,64.588,-50.654,64.568,-50.707,64.537,-50.659,64.502,-50.452,64.539,-50.677,64.475,-50.606,64.439,-50.204,64.441,-50.398,64.378,-50.716,64.446,-50.607,64.375,-50.91,64.406,-51.054,64.275,-50.859,64.248,-50.956,64.217,-51.25,64.169,-51.402,64.219,-51.747,64.18,-51.414,64.166,-51.639,64.119,-51.452,64.074,-50.807,64.172,-50.428,64.349,-50.45,64.281,-50.572,64.194,-50.178,64.242,-50.101,64.204,-50.093,64.121,-50.206,64.203,-50.485,64.142,-50.677,64.186,-50.868,64.096,-51.394,64.051,-51.296,64.012,-51.629,64.023,-51.34,63.944,-51.432,63.952,-51.355,63.885,-51.468,63.85,-51.447,63.796,-51.309,63.803,-51.31,63.842,-50.908,63.923,-51.215,63.8,-51.558,63.74,-51.556,63.708,-51.568,63.692,-51.561,63.689,-51.478,63.71,-51.338,63.716,-51.552,63.672,-51.404,63.63,-51.363,63.592,-51.327,63.613,-51.32,63.61,-51.244,63.641,-51.231,63.641,-51.338,63.565,-51.392,63.612,-51.522,63.648,-51.448,63.615,-51.5,63.569,-51.444,63.568,-51.427,63.561,-51.435,63.521,-51.392,63.563,-51.396,63.523,-51.277,63.537,-51.167,63.491,-50.753,63.543,-51.238,63.436,-51.133,63.353,-51.041,63.464,-51.001,63.383,-51.086,63.362,-50.371,63.411,-50.207,63.378,-51.104,63.33,-51.006,63.27,-51.017,63.258,-51.051,63.247,-51.071,63.257,-51.091,63.233,-51.001,63.245,-51.059,63.207,-51.02,63.137,-50.78,63.251,-50.916,63.125,-50.788,63.198,-50.671,63.205,-50.74,63.166,-50.671,63.105,-50.623,63.094,-50.518,63.112,-50.624,63.082,-50.581,63.048,-50.619,63.034,-50.573,63.009,-50.591,62.999,-50.477,63.011,-50.607,62.972,-50.587,62.954,-50.158,63.002,-50.382,62.869,-50.366,62.773,-50.165,62.952,-49.691,63.05,-50.145,62.934,-50.32,62.734,-50.268,62.686,-50.16,62.748,-50.19,62.801,-49.878,62.837,-50.053,62.787,-50.068,62.733,-50.143,62.689,-50.143,62.651,-49.993,62.711,-50.075,62.63,-50.118,62.624,-50.196,62.639,-50.316,62.485,-49.975,62.335,-49.746,62.46,-49.61,62.449,-49.748,62.412,-49.588,62.375,-49.895,62.372,-49.886,62.283,-49.777,62.302,-49.847,62.245,-49.522,62.309,-49.568,62.254,-49.282,62.26,-49.704,62.146,-49.277,62.17,-49.425,62.102,-49.328,62.07,-49.711,62.109,-49.45,62.07,-49.691,62.015,-49.633,61.971,-49.4,62.061,-49.35,61.998,-49.434,61.973,-49.16,62.002,-49.078,62.174,-49.005,62.195,-49.085,62.063,-48.954,62.138,-49.005,62.072,-48.837,62.061,-49.393,61.94,-49.312,61.926,-49.427,61.815,-49.322,61.809,-49.282,61.872,-49.165,61.896,-49.355,61.762,-49.212,61.707,-49.161,61.849,-48.919,61.975,-48.745,61.981,-49.114,61.818,-49.154,61.712,-48.86,61.784,-49.064,61.701,-49.07,61.612,-48.574,61.633,-49.293,61.552,-48.927,61.533,-49.126,61.505,-49.011,61.446,-48.329,61.603,-48.491,61.523,-48.237,61.529,-49.062,61.396,-48.924,61.395,-48.975,61.35,-48.63,61.442,-48.668,61.386,-48.316,61.373,-48.522,61.298,-48.444,61.307,-48.357,61.295,-48.57,61.283,-48.629,61.227,-48.454,61.265,-48.568,61.214,-48.489,61.238,-48.542,61.189,-48.491,61.165,-48.276,61.22,-48.437,61.152,-48.315,61.114,-48.215,61.137,-48.258,61.225,-48.142,61.313,-48.108,61.312,-48.118,61.29,-47.987,61.326,-47.866,61.312,-48.214,61.19,-48.145,61.118,-48.002,61.209,-48.094,61.075,-47.85,61.141,-47.931,61.076,-47.839,61.016,-47.907,60.996,-47.679,60.998,-47.918,60.934,-47.966,60.835,-47.694,60.804,-47.522,60.832,-47.448,60.813],"holes":[[-64.693,62.376,-64.47,62.383,-64.502,62.448,-64.35,62.467,-64.5,62.477,-64.336,62.487,-64.776,62.562,-64.838,62.518,-64.733,62.488,-64.939,62.469,-64.693,62.376],[-51.263,64.207,-51.148,64.255,-50.824,64.51,-51.05,64.539,-51.192,64.39,-51.165,64.293,-51.345,64.241,-51.263,64.207],[-51.106,69.516,-50.961,69.561,-50.978,69.742,-50.686,69.865,-51.061,69.927,-51.46,69.919,-51.235,69.922,-51.38,69.853,-51.238,69.835,-51.017,69.888,-51.39,69.714,-51.246,69.529,-51.106,69.516]]}],"names":{"ar":"مضيق دافيس","de":"Davisstraße","es":"Estrecho de Davis","fr":"détroit de Davis","he":"מצר דייוויס","hu":"Davis-szoros","it":"Stretto di Davis","nl":"Straat Davis","pt":"Estreito de Davis","zh":"戴维斯海峡"}},{"name":"Baffin Bay","bbox":[-82.204,69.618,-50.523,82.45],"area":162.4941,"polygons":[{"outer":[-71.549,70.029,-71.568,70.034,-71.485,70.096,-71.389,70.134,-71.455,70.157,-71.263,70.273,-71.324,70.322,-71.171,70.548,-71.512,70.583,-71.657,70.478,-71.645,70.449,-71.753,70.317,-71.735,70.313,-71.745,70.301,-71.735,70.293,-71.738,70.285,-71.744,70.282,-71.864,70.296,-71.735,70.418,-71.961,70.397,-71.985,70.414,-71.758,70.452,-71.585,70.557,-71.609,70.615,-71.182,70.591,-70.768,70.757,-70.523,70.923,-70.614,71.057,-70.805,71.12,-71.213,71.008,-71.289,70.905,-71.186,70.856,-72.179,70.779,-72.334,70.669,-72.566,70.616,-72.632,70.639,-72.387,70.685,-72.186,70.84,-72.791,70.822,-72.337,70.886,-72.086,71.084,-71.483,71.065,-71.145,71.262,-71.56,71.499,-72.557,71.66,-72.666,71.522,-72.85,71.507,-72.759,71.426,-72.985,71.409,-72.938,71.332,-72.979,71.256,-73.195,71.216,-73.164,71.168,-73.257,71.079,-73.416,71.038,-73.369,71.002,-73.363,70.971,-73.451,71.034,-73.225,71.146,-73.251,71.23,-73.048,71.289,-73.423,71.345,-73.606,71.23,-73.643,71.106,-73.867,71.054,-73.713,71.099,-73.678,71.253,-73.387,71.385,-73.534,71.475,-73.573,71.427,-73.5,71.384,-73.631,71.364,-73.568,71.391,-73.65,71.445,-73.588,71.574,-73.851,71.537,-73.974,71.325,-74.204,71.2,-74.053,71.298,-74.162,71.312,-74.033,71.339,-74.023,71.442,-74.11,71.444,-74.275,71.406,-74.342,71.401,-74.129,71.463,-73.973,71.462,-73.965,71.542,-74.055,71.536,-73.616,71.72,-73.598,71.775,-73.677,71.716,-73.696,71.772,-73.976,71.757,-74.104,71.649,-74.219,71.618,-74.106,71.528,-74.257,71.614,-74.099,71.741,-74.509,71.662,-74.601,71.551,-74.741,71.531,-74.613,71.376,-75.04,71.18,-75.107,71.206,-74.689,71.395,-74.915,71.453,-74.831,71.523,-75.132,71.477,-74.614,71.658,-74.765,71.683,-74.866,71.626,-75.4,71.52,-74.913,71.666,-75.4,71.667,-75.353,71.696,-74.906,71.699,-74.532,71.828,-74.223,71.832,-74.118,71.963,-74.303,72.083,-75.06,72.117,-75.189,72.027,-75.536,71.978,-75.62,71.834,-75.776,71.73,-75.878,71.704,-76.067,71.7,-75.807,71.741,-75.587,71.993,-75.213,72.074,-75.759,72.108,-76.023,72.049,-76.072,71.984,-76.43,71.844,-76.44,71.774,-76.527,71.739,-76.452,71.849,-76.052,72.072,-75.633,72.146,-75.237,72.115,-74.921,72.251,-75.152,72.492,-76.107,72.861,-76.07,72.938,-76.295,72.957,-76.248,73.002,-76.323,73.069,-76.251,73.093,-76.617,73.17,-76.564,73.223,-76.73,73.284,-76.703,73.327,-77.025,73.36,-77.214,73.516,-78.164,73.67,-80.231,74.602,-80.264,74.692,-80.164,74.716,-80.323,74.79,-80.196,74.823,-80.494,74.897,-80.204,74.98,-80.372,75.033,-79.441,75.277,-79.626,75.282,-79.479,75.376,-79.676,75.431,-79.574,75.459,-80.419,75.475,-79.933,75.535,-80.134,75.595,-79.319,75.874,-78.872,75.845,-79.175,75.951,-78.801,76.076,-78.865,76.534,-78.569,76.57,-78.611,76.491,-78.389,76.456,-77.824,76.677,-77.718,76.824,-77.895,76.858,-77.816,76.887,-78.083,77.02,-78.412,76.99,-78.731,76.808,-78.965,76.852,-78.893,76.929,-79.409,76.93,-79.041,77.148,-79.28,77.223,-79.703,77.244,-80.333,77.073,-80.394,77.08,-80.055,77.2,-81.088,77.287,-81.856,77.174,-81.878,77.241,-82.204,77.297,-81.14,77.33,-81.789,77.439,-81.649,77.536,-81.93,77.617,-81.945,77.685,-81.839,77.715,-81.903,77.689,-81.85,77.624,-81.591,77.59,-81.679,77.57,-81.307,77.428,-80.463,77.3,-79.159,77.29,-78.821,77.39,-78.705,77.386,-78.852,77.309,-78.32,77.372,-77.972,77.494,-78.015,77.549,-77.723,77.605,-78.071,77.678,-77.941,77.78,-78.413,77.897,-78.32,77.937,-76.87,77.895,-76.22,78.021,-75.879,77.957,-75.63,78.036,-75.678,78.063,-75.572,78.126,-76.147,78.099,-76.939,78.213,-75.631,78.199,-74.998,78.33,-76.606,78.525,-75.458,78.508,-74.571,78.587,-74.804,78.638,-74.855,78.685,-74.675,78.704,-74.766,78.74,-74.683,78.767,-74.896,78.854,-76.437,78.847,-75.704,78.974,-76.69,79.03,-77.587,78.98,-78.165,78.764,-78.266,78.782,-77.658,79.016,-77.832,79.055,-78.87,79.051,-78.855,79.066,-77.825,79.073,-77.462,79.018,-76.038,79.102,-78.237,79.168,-77.817,79.208,-76.103,79.2,-75.737,79.077,-74.386,79.049,-74.794,79.165,-74.43,79.221,-74.503,79.231,-77.582,79.239,-77.303,79.267,-78.045,79.35,-77.162,79.321,-77.379,79.44,-77.109,79.464,-76.85,79.347,-75.85,79.346,-76.212,79.457,-77.197,79.516,-77.123,79.564,-75.093,79.369,-74.887,79.377,-75.031,79.485,-74.93,79.51,-74.181,79.431,-73.902,79.464,-73.963,79.548,-73.651,79.489,-73.095,79.548,-73.175,79.594,-73.132,79.636,-73.375,79.746,-73.306,79.769,-74.855,79.835,-74.232,79.888,-72.884,79.816,-73.057,79.803,-72.938,79.708,-72.678,79.671,-71.464,79.722,-70.853,79.883,-71.396,79.927,-70.654,79.989,-70.451,80.086,-72.275,80.06,-71.831,80.107,-72.387,80.218,-71.617,80.103,-70.108,80.188,-69.903,80.255,-70.436,80.339,-70.327,80.408,-70.802,80.547,-70.19,80.432,-70.209,80.339,-69.507,80.369,-68.868,80.595,-68.974,80.61,-67.5,80.894,-67.635,80.925,-66.772,81.007,-66.057,81.219,-65.338,81.272,-64.423,81.486,-64.541,81.547,-65.512,81.499,-69.271,81.205,-70.012,81.083,-69.545,81.183,-69.898,81.156,-70.192,81.174,-66.596,81.516,-68.686,81.518,-68.871,81.547,-68.36,81.544,-69.949,81.768,-68.236,81.558,-65.812,81.627,-65.297,81.692,-66.072,81.687,-65.295,81.758,-64.371,81.714,-64.083,81.758,-64.332,81.813,-63.901,81.787,-62.788,81.931,-61.105,82.274,-61.123,82.372,-61.436,82.45,-55.225,82.337,-56.391,82.258,-56.078,82.257,-55.927,82.15,-56.145,82.177,-56.046,82.221,-56.697,82.249,-57.102,82.143,-57.32,82.208,-60.29,81.983,-59.335,81.88,-58.854,81.654,-58.432,81.634,-57.251,81.339,-57.905,81.414,-58.357,81.574,-59.38,81.67,-59.459,81.699,-59.387,81.748,-59.666,81.85,-60.801,81.911,-61.866,81.774,-61.954,81.731,-61.32,81.496,-61.869,81.342,-61.011,81.05,-61.583,81.165,-62.27,81.134,-61.91,81.053,-61.96,81.039,-62.768,81.178,-63.506,81.199,-63.797,81.129,-63.589,80.963,-63.073,80.761,-64.067,81.119,-64.972,80.965,-64.859,80.92,-65.195,80.881,-65.121,80.822,-65.308,80.76,-65.836,80.697,-65.551,80.657,-66.163,80.655,-66.79,80.536,-66.62,80.515,-66.795,80.451,-67.483,80.341,-67.388,80.238,-67.46,80.187,-67.094,80.136,-67.018,80.055,-66.426,80.106,-65.824,80.009,-65.277,80.111,-64.479,80.093,-64.285,80.153,-64.232,80.247,-64.155,80.161,-63.801,80.145,-65.059,80.014,-64.22,79.929,-64.29,79.919,-64.241,79.894,-65.019,79.816,-64.936,79.766,-64.995,79.723,-64.641,79.699,-64.554,79.598,-65.904,79.142,-65.784,79.117,-66.246,79.085,-66.707,79.138,-66.849,79.082,-66.846,79.14,-67.076,79.149,-67.185,79.114,-67.276,79.11,-67.352,79.113,-67.436,79.134,-67.459,79.098,-67.725,79.072,-67.666,79.055,-67.682,79.038,-68.561,79.039,-69.146,78.92,-68.658,78.811,-69.098,78.881,-69.287,78.784,-69.828,78.8,-70.603,78.698,-70.715,78.668,-70.7,78.586,-71.171,78.636,-72.422,78.523,-72.363,78.519,-72.56,78.455,-72.476,78.422,-72.846,78.32,-72.606,78.314,-72.365,78.293,-72.696,78.291,-72.645,78.288,-72.62,78.248,-72.792,78.226,-72.637,78.185,-73.026,78.169,-72.251,78.05,-72.193,78.035,-72.308,78.006,-72.221,77.939,-71.934,77.894,-71.151,77.873,-71.424,77.803,-71.226,77.763,-70.623,77.785,-70.333,77.87,-69.883,77.835,-70.682,77.707,-70.631,77.684,-70.036,77.672,-69.425,77.765,-69.458,77.693,-70.276,77.572,-69.311,77.469,-68.575,77.519,-68.803,77.651,-68.702,77.677,-68.4,77.592,-68.352,77.508,-67.717,77.513,-66.646,77.709,-66.557,77.665,-66.913,77.601,-66.241,77.624,-66.169,77.599,-66.267,77.576,-66.01,77.499,-66.103,77.434,-66.623,77.418,-66.154,77.253,-67.488,77.39,-69.094,77.275,-68.513,77.211,-67.706,77.218,-66.835,77.144,-66.117,77.192,-66.436,77.144,-66.341,77.109,-70.163,77.243,-70.866,77.195,-70.662,77.143,-71.152,77.134,-71.361,77.012,-71.053,76.933,-71.101,76.95,-70.628,76.943,-70.952,76.892,-70.453,76.792,-69.669,77.019,-69.612,76.987,-69.843,76.87,-70.042,76.808,-69.257,76.681,-67.919,76.687,-68.024,76.652,-68.05,76.652,-67.952,76.627,-68.0,76.58,-68.71,76.589,-69.624,76.387,-68.71,76.182,-68.793,76.16,-68.412,76.144,-68.592,76.116,-68.483,76.085,-66.455,75.906,-67.051,76.075,-66.995,76.134,-67.285,76.173,-66.871,76.27,-66.412,76.085,-66.168,76.28,-65.725,76.276,-65.517,76.235,-65.731,76.207,-65.657,76.187,-65.864,76.099,-65.411,76.021,-65.234,76.077,-65.298,76.173,-64.579,76.126,-64.658,76.183,-64.523,76.18,-64.665,76.26,-64.41,76.221,-64.344,76.271,-64.439,76.29,-64.33,76.357,-64.123,76.314,-64.183,76.248,-64.106,76.147,-63.735,76.151,-63.396,76.381,-62.809,76.307,-62.712,76.189,-62.712,76.255,-62.567,76.189,-62.579,76.25,-62.236,76.268,-61.478,76.169,-61.45,76.115,-61.354,76.178,-60.907,76.164,-60.789,76.143,-60.876,76.074,-60.724,76.0,-60.462,76.068,-60.088,76.03,-59.982,75.925,-59.743,75.97,-59.566,75.911,-59.763,75.8,-59.355,75.881,-59.147,75.872,-59.094,75.848,-59.267,75.764,-58.377,75.716,-58.581,75.675,-58.266,75.661,-58.277,75.637,-58.396,75.573,-58.122,75.52,-58.204,75.437,-58.671,75.347,-58.217,75.404,-58.341,75.362,-58.267,75.318,-58.406,75.304,-57.897,75.168,-58.115,75.048,-56.952,74.901,-56.976,74.848,-56.783,74.815,-57.17,74.773,-57.01,74.731,-57.009,74.666,-56.815,74.71,-56.472,74.648,-56.602,74.604,-56.148,74.558,-56.248,74.505,-56.133,74.49,-56.141,74.47,-56.548,74.446,-56.805,74.452,-56.081,74.38,-56.701,74.353,-56.51,74.325,-56.569,74.289,-56.332,74.331,-56.239,74.277,-57.297,74.101,-56.515,74.161,-56.077,74.281,-56.289,74.107,-56.102,74.08,-56.409,74.072,-55.92,74.042,-56.09,73.969,-55.577,73.862,-55.712,73.838,-55.943,73.863,-55.776,73.763,-55.821,73.757,-55.487,73.729,-55.62,73.695,-55.818,73.73,-55.928,73.772,-55.896,73.717,-56.003,73.68,-55.807,73.681,-56.044,73.654,-55.749,73.63,-55.769,73.593,-55.613,73.585,-55.478,73.464,-55.138,73.415,-55.037,73.365,-55.108,73.355,-55.23,73.391,-55.338,73.329,-55.214,73.337,-55.186,73.331,-55.439,73.262,-55.17,73.241,-55.091,73.189,-55.411,73.192,-55.292,73.174,-55.694,73.059,-55.174,73.055,-55.259,73.039,-55.139,73.007,-54.974,73.06,-55.023,73.012,-54.813,73.024,-54.72,72.95,-54.777,72.924,-54.536,72.959,-54.526,72.894,-54.381,72.862,-54.628,72.851,-54.564,72.829,-54.814,72.817,-54.913,72.779,-54.49,72.788,-54.444,72.76,-54.769,72.739,-54.743,72.727,-54.703,72.727,-54.677,72.713,-54.627,72.71,-54.565,72.698,-54.546,72.688,-54.865,72.661,-54.601,72.623,-54.777,72.609,-54.751,72.578,-54.769,72.519,-54.701,72.497,-54.394,72.515,-54.31,72.5,-54.293,72.49,-54.293,72.485,-54.892,72.475,-55.443,72.527,-55.629,72.453,-55.332,72.389,-55.291,72.476,-55.191,72.382,-55.284,72.368,-55.225,72.35,-55.078,72.428,-54.978,72.398,-54.996,72.461,-54.716,72.365,-54.907,72.366,-54.939,72.276,-54.856,72.249,-55.593,71.99,-54.885,71.944,-54.619,72.026,-54.605,72.107,-54.481,72.192,-54.555,72.039,-54.823,71.922,-55.366,71.841,-55.217,71.774,-55.222,71.761,-55.405,71.812,-55.863,71.687,-55.556,71.647,-55.667,71.641,-55.513,71.473,-55.268,71.396,-55.11,71.407,-55.244,71.5,-54.796,71.358,-53.898,71.446,-53.874,71.576,-54.074,71.653,-53.74,71.647,-53.75,71.724,-53.928,71.749,-53.633,71.75,-53.394,71.855,-53.515,72.044,-53.802,72.138,-53.805,72.295,-53.705,72.139,-53.387,72.042,-53.396,71.939,-53.297,71.865,-53.369,71.778,-53.018,71.845,-53.001,71.931,-52.7,72.015,-52.899,71.906,-52.893,71.834,-53.27,71.715,-52.463,71.701,-52.592,71.636,-52.151,71.619,-51.67,71.742,-51.824,71.597,-52.515,71.554,-52.994,71.421,-52.493,71.378,-51.775,71.498,-51.352,71.481,-52.278,71.356,-52.246,71.277,-52.532,71.187,-52.224,71.182,-51.654,71.364,-51.704,71.335,-51.492,71.27,-51.865,71.252,-52.232,71.121,-51.227,71.146,-51.198,71.101,-51.5,71.061,-50.929,71.016,-51.164,70.963,-51.82,71.074,-51.959,71.025,-51.17,70.857,-50.882,70.888,-50.952,70.829,-50.661,70.747,-51.283,70.809,-51.264,70.754,-51.454,70.744,-51.202,70.696,-51.066,70.771,-51.114,70.649,-50.929,70.688,-50.648,70.641,-51.024,70.61,-50.776,70.543,-50.784,70.532,-50.812,70.534,-50.934,70.572,-50.912,70.502,-51.191,70.624,-51.356,70.565,-50.974,70.476,-51.123,70.461,-51.093,70.442,-50.824,70.417,-50.523,70.508,-50.715,70.4,-50.562,70.332,-50.734,70.325,-51.811,70.48,-52.653,70.739,-54.023,70.834,-54.579,70.719,-54.579,70.672,-54.609,70.654,-54.048,70.416,-53.025,70.317,-52.353,70.05,-52.037,70.024,-52.894,70.016,-53.301,70.206,-54.37,70.33,-54.839,70.198,-54.787,70.05,-54.631,69.999,-67.259,70.0,-68.064,70.329,-68.359,70.195,-68.232,70.103,-68.361,70.065,-68.575,70.056,-68.697,69.939,-69.089,69.932,-69.404,69.783,-69.747,69.808,-70.011,69.618,-69.796,69.831,-69.446,69.82,-68.642,70.163,-69.102,70.225,-69.009,70.187,-69.641,70.148,-69.803,70.064,-69.726,69.997,-69.786,69.963,-70.068,69.961,-70.332,69.847,-70.513,69.833,-70.144,69.973,-69.822,69.984,-69.906,70.051,-70.186,70.029,-69.904,70.087,-69.828,70.129,-69.876,70.174,-68.71,70.327,-68.581,70.473,-68.581,70.41,-68.441,70.378,-68.288,70.52,-69.46,70.793,-70.012,70.647,-70.038,70.572,-70.059,70.562,-70.054,70.635,-70.414,70.612,-70.288,70.493,-70.632,70.467,-70.404,70.517,-70.466,70.629,-70.181,70.659,-69.769,70.86,-69.881,70.882,-71.009,70.633,-71.064,70.554,-70.992,70.526,-71.076,70.522,-71.334,70.137,-71.526,70.027,-71.54,70.024,-71.549,70.026,-71.549,70.029],"holes":[[-53.635,71.029,-53.4,71.103,-53.638,71.311,-54.002,71.13,-53.635,71.029],[-52.973,71.153,-52.63,71.163,-52.339,71.296,-52.954,71.383,-53.176,71.332,-53.126,71.211,-52.973,71.153],[-53.222,71.538,-52.837,71.622,-52.747,71.672,-53.457,71.665,-53.456,71.576,-53.222,71.538],[-73.131,71.565,-73.389,71.519,-73.268,71.365,-73.056,71.3,-72.974,71.315,-73.035,71.409,-72.828,71.437,-72.993,71.525,-73.175,71.421,-73.067,71.53,-73.131,71.565],[-55.527,72.268,-55.396,72.279,-55.686,72.196,-55.411,72.165,-55.007,72.29,-54.972,72.376,-55.605,72.287,-55.527,72.268],[-55.545,72.605,-55.445,72.613,-55.571,72.59,-55.532,72.553,-55.336,72.588,-55.347,72.657,-54.914,72.812,-55.239,72.842,-55.841,72.625,-55.545,72.605],[-56.593,73.808,-56.284,73.841,-56.334,73.8,-56.279,73.781,-55.918,73.844,-56.6,73.928,-56.739,73.879,-56.618,73.877,-56.675,73.84,-56.593,73.808],[-57.179,74.466,-56.611,74.474,-56.404,74.504,-56.668,74.486,-56.463,74.517,-56.721,74.559,-57.516,74.491,-57.179,74.466],[-79.862,74.812,-79.663,74.866,-79.345,74.888,-79.521,75.006,-79.744,75.032,-80.336,74.938,-80.334,74.882,-79.862,74.812],[-71.885,77.308,-71.303,77.381,-72.515,77.413,-72.115,77.318,-71.885,77.308],[-71.231,77.455,-71.001,77.373,-70.023,77.409,-70.713,77.467,-71.231,77.455],[-71.857,71.069,-72.017,71.046,-72.066,70.95,-72.247,70.909,-72.137,70.819,-71.967,70.822,-71.428,70.918,-71.361,70.994,-71.946,71.036,-71.857,71.069]]}],"names":{"ar":"خليج بافن","de":"Baffin Bay","es":"bahía de Baffin","fr":"baie de Baffin","he":"מפרץ באפין","hu":"Baffin-öböl","it":"baia di Baffin","nl":"Baffinbaai","pt":"Baía de Baffin","zh":"巴芬灣"}},{"name":"Laptev Sea","bbox":[93.657,63.688,141.225,81.269],"area":171.7706,"polygons":[{"outer":[134.818,69.516,134.876,69.554,134.954,69.574,134.88,69.614,134.937,69.657,134.989,69.669,134.937,69.676,134.95,69.707,135.188,69.769,135.154,69.828,135.242,69.896,135.324,69.908,135.511,69.889,135.489,69.937,135.551,69.954,135.561,69.978,135.581,69.981,135.463,70.011,135.344,69.99,135.314,69.991,135.265,70.005,135.267,70.008,135.26,70.011,135.256,70.026,135.261,70.032,135.383,70.098,135.095,70.154,135.045,70.169,135.02,70.188,135.023,70.173,135.016,70.194,135.036,70.218,135.08,70.235,135.14,70.226,135.14,70.234,135.097,70.244,135.095,70.248,135.208,70.285,135.186,70.297,135.211,70.345,135.055,70.305,134.957,70.319,134.788,70.43,134.795,70.446,134.91,70.466,134.913,70.488,134.769,70.533,134.766,70.564,134.787,70.574,134.817,70.574,134.941,70.558,135.056,70.576,135.12,70.639,135.403,70.722,135.598,70.732,135.892,70.7,135.836,70.675,135.926,70.69,135.935,70.687,136.073,70.78,136.123,70.781,136.172,70.761,136.203,70.765,136.189,70.82,136.246,70.88,136.434,70.935,136.264,70.965,136.23,70.989,136.24,71.035,136.201,71.051,135.839,71.118,135.99,71.161,135.985,71.173,135.922,71.163,135.913,71.164,135.897,71.173,135.896,71.245,135.898,71.248,135.907,71.252,135.956,71.266,135.712,71.265,135.641,71.289,135.644,71.298,135.695,71.319,135.696,71.35,135.489,71.34,135.433,71.349,135.44,71.371,135.384,71.372,135.317,71.356,135.285,71.359,135.298,71.35,135.293,71.346,135.281,71.346,135.246,71.354,135.23,71.363,135.227,71.385,135.169,71.364,135.148,71.362,135.13,71.369,135.154,71.384,135.059,71.335,135.073,71.326,135.097,71.34,135.129,71.342,135.174,71.328,135.178,71.323,135.188,71.323,135.176,71.323,135.13,71.341,135.097,71.34,135.079,71.325,135.071,71.325,135.06,71.33,135.056,71.337,135.088,71.376,134.893,71.393,134.77,71.415,134.88,71.38,134.818,71.323,134.858,71.325,134.922,71.317,134.926,71.313,134.919,71.306,134.941,71.309,134.956,71.309,134.994,71.292,134.977,71.285,135.015,71.289,135.028,71.28,135.21,71.284,135.266,71.274,135.266,71.271,135.253,71.267,135.284,71.266,135.311,71.262,135.311,71.259,135.301,71.255,135.326,71.256,135.361,71.251,135.385,71.229,135.396,71.23,135.408,71.238,135.419,71.241,135.424,71.24,135.431,71.234,135.457,71.229,135.467,71.24,135.485,71.242,135.489,71.24,135.489,71.237,135.48,71.232,135.489,71.232,135.512,71.237,135.517,71.237,135.531,71.218,135.384,71.228,135.359,71.251,135.3,71.255,135.283,71.265,135.294,71.258,135.285,71.242,135.293,71.243,135.296,71.25,135.301,71.252,135.35,71.24,135.308,71.225,135.341,71.216,135.348,71.224,135.357,71.224,135.374,71.222,135.362,71.21,135.371,71.222,135.342,71.215,135.303,71.226,135.347,71.24,135.284,71.241,135.292,71.259,135.247,71.267,135.259,71.275,135.175,71.27,135.024,71.279,135.011,71.288,134.978,71.284,134.86,71.324,134.767,71.306,134.762,71.271,134.842,71.272,134.855,71.269,134.869,71.261,134.85,71.255,134.869,71.257,134.886,71.256,134.944,71.224,134.946,71.221,134.941,71.22,134.908,71.215,134.915,71.196,134.904,71.215,134.944,71.223,134.846,71.255,134.853,71.269,134.736,71.253,134.75,71.316,134.822,71.367,134.713,71.401,133.561,71.422,133.655,71.444,132.754,71.803,132.822,71.835,132.788,71.921,132.902,71.899,132.877,71.911,132.855,71.936,133.155,71.924,133.338,71.838,133.319,71.888,133.108,71.968,132.68,71.93,132.299,71.681,131.984,71.262,131.998,71.255,132.001,71.261,132.021,71.272,132.04,71.277,132.06,71.278,131.998,71.254,131.978,71.258,131.96,71.236,132.091,71.243,132.248,71.215,132.181,71.2,132.667,71.104,132.789,71.031,132.781,71.022,132.726,71.005,132.786,70.994,132.807,70.983,132.765,70.955,132.798,70.949,132.874,70.956,132.9,70.954,132.912,70.944,132.892,70.928,132.909,70.925,132.974,70.945,133.0,70.944,133.015,70.936,132.984,70.901,133.096,70.908,133.15,70.901,133.162,70.895,133.162,70.888,133.092,70.869,133.112,70.86,133.174,70.874,133.208,70.876,133.222,70.874,133.293,70.812,133.356,70.812,133.354,70.818,133.317,70.828,133.305,70.839,133.318,70.85,133.351,70.853,133.343,70.854,133.398,70.858,133.424,70.859,133.492,70.846,133.507,70.835,133.502,70.804,133.459,70.776,133.496,70.738,133.51,70.706,133.417,70.712,133.482,70.652,133.46,70.643,133.406,70.635,133.347,70.635,133.446,70.62,133.457,70.612,133.451,70.607,133.348,70.597,133.257,70.6,133.235,70.571,133.147,70.572,133.235,70.549,133.24,70.546,133.239,70.54,133.21,70.512,133.2,70.512,133.183,70.519,133.186,70.521,133.166,70.521,133.176,70.51,133.177,70.49,133.142,70.483,133.109,70.487,133.098,70.481,133.101,70.474,133.121,70.466,133.122,70.461,132.958,70.413,133.026,70.408,133.043,70.405,133.053,70.397,132.963,70.4,133.051,70.382,133.019,70.315,133.014,70.312,132.991,70.309,132.972,70.309,132.965,70.305,132.974,70.299,132.989,70.302,133.007,70.299,133.009,70.296,132.968,70.24,132.897,70.219,132.929,70.213,132.946,70.177,132.91,70.137,132.859,70.121,132.858,70.116,132.877,70.109,132.878,70.105,132.847,70.094,132.812,70.097,132.822,70.088,132.815,70.085,132.786,70.081,132.76,70.08,132.728,70.082,132.713,70.072,132.712,70.065,132.706,70.062,132.685,70.073,132.7,70.061,132.676,70.06,132.668,70.049,132.61,70.034,132.626,70.024,132.627,70.008,132.617,70.004,132.597,70.008,132.591,69.995,132.563,69.991,132.572,69.982,132.587,69.98,132.589,69.975,132.566,69.971,132.551,69.97,132.56,69.961,132.563,69.95,132.531,69.951,132.545,69.947,132.547,69.937,132.541,69.933,132.506,69.929,132.532,69.919,132.534,69.913,132.498,69.911,132.502,69.895,132.466,69.881,132.469,69.877,132.505,69.866,132.536,69.85,132.522,69.825,132.511,69.821,132.482,69.818,132.476,69.807,132.439,69.799,132.439,69.795,132.467,69.793,132.471,69.788,132.457,69.786,132.436,69.787,132.453,69.771,132.448,69.766,132.423,69.763,132.429,69.756,132.419,69.763,132.452,69.772,132.434,69.787,132.467,69.788,132.437,69.795,132.435,69.798,132.486,69.822,132.52,69.825,132.53,69.853,132.465,69.876,132.464,69.883,132.561,69.991,132.593,70.008,132.622,70.008,132.606,70.02,132.616,70.027,132.601,70.029,132.606,70.037,132.635,70.048,132.663,70.049,132.682,70.073,132.718,70.082,132.74,70.084,132.784,70.081,132.819,70.088,132.789,70.098,132.81,70.108,132.847,70.095,132.855,70.122,132.944,70.177,132.895,70.22,133.006,70.296,132.974,70.298,132.962,70.304,132.969,70.31,133.016,70.316,132.982,70.325,132.964,70.354,133.003,70.39,132.972,70.393,132.96,70.401,133.015,70.404,132.954,70.412,132.978,70.44,132.943,70.428,132.936,70.439,133.233,70.546,133.141,70.572,133.247,70.602,133.45,70.612,133.338,70.63,133.353,70.64,133.477,70.653,133.406,70.673,133.395,70.683,133.41,70.714,133.493,70.722,133.449,70.779,133.497,70.84,133.327,70.803,133.288,70.812,133.234,70.837,133.215,70.873,133.107,70.859,133.092,70.865,133.089,70.872,133.158,70.895,132.978,70.901,132.996,70.943,132.914,70.922,132.774,70.947,132.759,70.954,132.759,70.961,132.801,70.983,132.72,71.002,132.77,71.037,132.706,71.047,132.636,71.071,132.616,71.087,132.551,71.087,132.446,71.12,132.509,71.13,132.025,71.228,131.851,71.157,132.212,71.162,132.2,71.144,132.124,71.124,132.138,71.122,132.17,71.129,132.191,71.129,132.222,71.122,132.251,71.106,132.275,71.113,132.306,71.117,132.344,71.116,132.476,71.053,132.339,71.116,132.149,71.075,132.107,71.084,132.073,71.078,132.049,71.082,132.038,71.113,131.995,71.106,131.888,71.114,131.874,71.117,131.866,71.122,131.863,71.129,131.779,71.12,131.865,71.067,131.778,70.958,131.728,70.954,131.517,70.814,131.612,70.81,131.628,70.802,131.645,70.813,131.663,70.812,131.616,70.797,131.522,70.805,131.368,70.747,131.491,70.711,131.461,70.71,131.47,70.699,131.384,70.682,131.285,70.681,131.448,70.701,130.959,70.746,130.851,70.967,130.809,70.868,130.596,70.849,130.265,70.939,130.144,71.074,130.002,71.012,129.95,70.998,129.953,71.004,129.927,70.996,129.916,70.999,129.922,70.987,129.904,70.971,129.826,70.925,129.767,70.91,129.776,70.905,129.778,70.901,129.76,70.884,129.714,70.854,129.696,70.854,129.688,70.847,129.658,70.844,129.662,70.84,129.658,70.833,129.648,70.833,129.655,70.824,129.653,70.818,129.638,70.821,129.65,70.842,129.687,70.849,129.686,70.863,129.72,70.873,129.751,70.908,129.776,70.915,129.747,70.911,129.793,70.93,129.834,70.935,129.829,70.952,129.885,70.973,129.901,70.971,129.902,70.997,129.954,71.012,130.002,71.017,129.942,71.047,129.981,71.06,129.983,71.069,129.994,71.069,129.998,71.078,129.993,71.071,129.97,71.072,129.965,71.076,129.961,71.073,129.969,71.072,129.975,71.069,129.969,71.069,129.973,71.067,129.965,71.065,129.893,71.074,129.922,71.065,129.932,71.054,129.929,71.051,129.911,71.049,129.915,71.037,129.897,71.027,129.924,71.028,129.93,71.024,129.925,71.024,129.924,71.028,129.903,71.025,129.894,71.027,129.883,71.068,129.804,71.056,129.743,71.063,129.717,71.071,129.831,71.09,129.73,71.128,129.82,71.113,129.763,71.206,129.48,71.268,129.24,71.6,128.906,71.576,128.847,71.648,128.983,71.706,129.405,71.757,129.604,71.706,129.125,72.003,129.271,71.814,128.968,71.715,128.729,71.774,128.507,71.955,128.506,72.039,127.788,72.242,127.9,72.254,127.798,72.324,126.729,72.396,126.932,72.298,126.976,72.043,127.409,71.905,127.375,71.874,127.411,71.888,127.436,71.889,127.451,71.871,127.541,71.85,127.548,71.841,127.554,71.828,127.519,71.803,127.54,71.796,127.529,71.785,127.508,71.803,127.533,71.849,127.319,71.88,127.206,71.763,127.272,71.718,127.335,71.76,127.407,71.787,127.278,71.684,127.376,71.456,127.319,71.245,127.606,71.033,127.513,70.901,127.672,70.79,127.115,70.519,126.875,70.492,126.27,70.496,126.161,70.513,125.869,70.456,125.903,70.353,126.033,70.404,126.081,70.415,126.112,70.419,126.143,70.421,126.203,70.412,126.236,70.418,126.255,70.419,126.362,70.407,125.912,70.347,126.107,70.323,126.265,70.28,126.068,70.086,125.522,69.927,125.201,69.887,125.239,69.876,125.257,69.882,125.261,69.879,125.283,69.877,125.288,69.874,125.264,69.877,125.279,69.872,125.272,69.856,125.235,69.829,125.34,69.838,125.321,69.824,125.274,69.819,125.229,69.831,125.272,69.874,125.172,69.879,125.089,69.672,124.879,69.554,124.862,69.431,124.111,69.053,124.006,68.741,124.057,68.73,124.057,68.765,124.121,68.784,124.089,68.803,124.091,68.815,124.172,68.823,124.124,68.841,124.122,68.847,124.128,68.851,124.357,68.921,124.387,68.921,124.402,68.915,124.415,68.901,124.426,68.899,124.449,68.906,124.462,68.924,124.475,68.928,124.49,68.929,124.503,68.926,124.526,68.91,124.535,68.912,124.535,68.929,124.547,68.938,124.602,68.935,124.61,68.951,124.623,68.961,124.669,68.976,124.659,68.983,124.621,68.983,124.583,68.991,124.591,68.997,124.604,68.999,124.661,68.992,124.615,69.07,124.622,69.076,124.636,69.078,124.686,69.077,124.656,69.099,124.689,69.11,124.698,69.11,124.718,69.107,124.742,69.087,124.765,69.094,124.782,69.093,124.804,69.09,124.879,69.073,125.065,68.995,125.081,68.983,125.07,68.962,125.124,68.962,125.232,68.948,125.298,68.908,125.379,68.915,125.492,68.875,125.468,68.849,125.533,68.863,125.556,68.862,125.568,68.855,125.531,68.847,125.559,68.839,125.552,68.822,125.577,68.827,125.627,68.824,125.754,68.801,125.848,68.769,125.878,68.742,125.909,68.751,125.941,68.753,125.983,68.768,126.012,68.772,126.024,68.768,126.026,68.755,126.048,68.751,126.067,68.743,126.101,68.746,126.094,68.75,126.055,68.751,126.033,68.758,126.04,68.764,126.072,68.769,126.047,68.779,126.061,68.786,126.238,68.777,126.345,68.747,126.363,68.706,126.522,68.726,126.534,68.722,126.53,68.712,126.556,68.708,126.55,68.694,126.554,68.706,126.528,68.712,126.528,68.724,126.386,68.701,126.357,68.707,126.342,68.716,126.329,68.73,126.341,68.746,126.163,68.746,126.073,68.785,126.078,68.771,126.041,68.756,126.098,68.751,126.105,68.747,126.102,68.741,125.865,68.739,125.547,68.822,125.528,68.847,125.557,68.86,125.463,68.848,125.373,68.913,125.333,68.9,125.07,68.958,124.845,69.079,124.738,69.087,124.708,69.108,124.663,69.1,124.691,69.077,124.618,69.069,124.665,68.993,124.657,68.99,124.639,68.989,124.606,68.998,124.589,68.992,124.664,68.984,124.672,68.976,124.605,68.934,124.523,68.906,124.497,68.925,124.47,68.925,124.438,68.896,124.413,68.899,124.366,68.92,124.128,68.848,124.178,68.823,124.094,68.804,124.104,68.798,124.126,68.792,124.13,68.789,124.137,68.791,124.146,68.792,124.17,68.783,124.072,68.767,124.062,68.752,124.084,68.74,124.078,68.726,124.041,68.714,123.999,68.716,123.986,68.708,123.904,68.424,124.0,68.399,124.026,68.358,123.921,68.395,123.706,68.157,123.335,68.069,123.319,68.01,123.643,68.05,123.699,68.051,123.704,68.05,123.639,68.048,123.589,68.035,123.724,68.047,123.754,68.018,123.695,67.991,123.772,67.972,123.788,67.944,123.785,67.931,123.751,67.917,123.692,67.908,123.754,67.859,123.723,67.839,123.783,67.828,123.97,67.708,123.97,67.697,123.94,67.692,123.949,67.676,124.062,67.688,124.119,67.666,124.123,67.658,123.943,67.676,123.713,67.831,123.675,67.909,123.692,67.916,123.732,67.924,123.764,67.927,123.776,67.944,123.684,67.986,123.725,68.038,123.323,67.992,123.234,67.997,123.14,67.687,123.26,67.527,123.248,67.493,123.206,67.451,123.188,67.406,123.16,67.374,123.355,67.321,123.533,67.191,123.531,67.183,123.505,67.166,123.462,67.154,123.468,67.138,123.53,67.127,123.554,67.114,123.605,67.11,123.617,67.106,123.615,67.099,123.589,67.096,123.615,67.084,123.625,67.067,123.602,67.052,123.624,67.058,123.637,67.055,123.669,67.039,123.657,67.019,123.667,67.03,123.69,67.029,123.693,67.026,123.685,67.014,123.727,67.012,123.766,66.998,123.799,66.971,123.652,67.02,123.662,67.042,123.627,67.035,123.592,67.043,123.668,66.964,123.661,66.957,123.617,66.946,123.609,66.935,123.623,66.924,123.675,66.916,123.696,66.908,123.701,66.901,123.693,66.895,123.684,66.892,123.582,66.884,123.584,66.858,123.646,66.854,123.744,66.812,123.717,66.794,123.738,66.798,123.745,66.798,123.754,66.793,123.771,66.804,123.791,66.804,123.807,66.802,123.81,66.791,123.825,66.791,123.832,66.788,123.823,66.773,123.865,66.781,123.882,66.78,123.888,66.779,124.0,66.705,123.859,66.742,123.855,66.772,123.891,66.777,123.866,66.78,123.849,66.772,123.82,66.772,123.79,66.803,123.753,66.792,123.741,66.797,123.728,66.792,123.707,66.795,123.687,66.773,123.633,66.758,123.666,66.74,123.689,66.718,123.671,66.691,123.698,66.685,123.706,66.679,123.672,66.658,123.725,66.634,123.66,66.603,123.714,66.595,123.729,66.587,123.704,66.554,123.861,66.423,123.868,66.371,123.853,66.369,124.004,66.328,123.917,66.275,123.942,66.275,123.959,66.26,124.009,66.272,124.033,66.274,124.043,66.272,124.048,66.268,124.045,66.264,124.012,66.258,124.06,66.232,124.097,66.265,124.111,66.27,124.13,66.27,124.219,66.232,124.221,66.277,124.232,66.286,124.252,66.287,124.386,66.23,124.324,66.196,124.342,66.197,124.371,66.213,124.396,66.216,124.45,66.2,124.465,66.187,124.482,66.191,124.528,66.189,124.537,66.187,124.546,66.178,124.605,66.18,124.624,66.176,124.618,66.169,124.595,66.166,124.599,66.16,124.651,66.174,124.787,66.142,124.862,66.099,124.851,66.091,124.826,66.083,124.792,66.085,124.816,66.079,124.824,66.062,124.805,66.052,124.822,66.053,124.835,66.044,124.776,66.014,124.787,66.009,124.815,66.016,124.831,66.012,124.831,66.006,124.822,66.0,124.79,65.999,124.801,65.986,124.834,65.999,124.854,65.995,124.916,65.928,124.914,65.924,124.906,65.923,124.878,65.925,124.883,65.91,125.034,65.916,125.049,65.912,125.05,65.898,125.064,65.909,125.079,65.911,125.091,65.909,125.102,65.9,125.131,65.893,125.141,65.893,125.17,65.902,125.185,65.903,125.205,65.9,125.228,65.888,125.282,65.899,125.399,65.91,125.528,65.9,125.535,65.897,125.534,65.894,125.493,65.882,125.525,65.878,125.549,65.87,125.557,65.873,125.555,65.88,125.568,65.89,125.598,65.899,125.606,65.899,125.609,65.896,125.607,65.892,125.596,65.886,125.597,65.884,125.618,65.885,125.674,65.88,125.677,65.892,125.684,65.895,125.693,65.894,125.722,65.884,125.716,65.902,125.751,65.911,125.788,65.912,125.809,65.908,125.823,65.888,125.861,65.911,125.934,65.877,125.94,65.881,125.9,65.901,125.896,65.906,125.898,65.909,125.906,65.911,125.922,65.91,125.941,65.898,126.009,65.911,126.065,65.907,126.086,65.932,126.118,65.938,126.118,65.956,126.133,65.966,126.138,65.968,126.155,65.968,126.149,65.978,126.157,65.988,126.164,65.99,126.198,65.985,126.211,65.993,126.242,65.994,126.246,66.003,126.252,66.006,126.32,66.013,126.299,66.032,126.425,66.058,126.449,66.06,126.482,66.055,126.559,66.098,126.581,66.104,126.621,66.112,126.485,66.055,126.419,66.056,126.323,66.013,126.199,65.983,126.159,65.988,126.157,65.968,126.121,65.952,126.141,65.94,126.087,65.931,126.085,65.918,126.071,65.908,126.045,65.9,125.941,65.896,125.9,65.908,125.944,65.883,125.936,65.877,125.823,65.886,125.785,65.911,125.722,65.883,125.682,65.893,125.676,65.88,125.595,65.884,125.592,65.887,125.596,65.891,125.607,65.896,125.598,65.897,125.558,65.881,125.559,65.873,125.548,65.869,124.986,65.887,124.928,65.907,124.888,65.909,124.874,65.912,124.871,65.915,124.878,65.928,124.912,65.925,124.902,65.932,124.833,65.94,124.849,65.995,124.805,65.984,124.795,65.987,124.786,65.994,124.786,66.0,124.819,66.001,124.827,66.01,124.783,66.009,124.774,66.012,124.774,66.018,124.831,66.044,124.801,66.053,124.787,66.086,124.855,66.1,124.786,66.141,124.6,66.158,124.589,66.161,124.589,66.166,124.62,66.177,124.554,66.175,124.54,66.178,124.531,66.188,124.509,66.185,124.484,66.189,124.461,66.186,124.402,66.214,124.41,66.21,124.383,66.214,124.343,66.194,124.319,66.195,124.38,66.23,124.244,66.286,124.241,66.237,124.223,66.229,124.128,66.268,124.068,66.229,123.969,66.247,123.993,66.239,124.066,66.151,124.044,66.134,124.071,66.125,124.11,66.1,124.237,65.966,124.385,65.747,124.414,65.656,124.379,65.588,124.406,65.584,124.432,65.557,124.417,65.492,124.462,65.477,124.453,65.453,124.514,65.44,124.538,65.401,124.483,65.353,124.559,65.304,124.549,65.281,124.511,65.258,124.576,65.221,124.585,65.194,125.044,64.991,125.058,65.002,125.022,65.014,125.022,65.022,125.033,65.036,125.281,65.068,125.298,65.066,125.333,65.055,125.334,65.047,125.317,65.035,125.37,65.046,125.428,65.038,125.441,65.048,125.41,65.052,125.407,65.058,125.495,65.096,125.586,65.109,125.55,65.131,125.552,65.139,125.559,65.144,125.608,65.152,125.642,65.163,125.709,65.175,125.757,65.172,125.778,65.177,125.795,65.188,125.825,65.198,125.978,65.236,126.004,65.239,126.031,65.241,126.025,65.267,126.032,65.272,126.068,65.268,126.088,65.276,126.1,65.273,126.115,65.265,126.033,65.27,126.037,65.24,125.777,65.175,125.559,65.142,125.588,65.109,125.431,65.037,125.37,65.044,125.344,65.034,125.326,65.031,125.319,65.032,125.285,65.067,125.283,65.038,125.048,65.035,125.068,64.992,125.035,64.99,125.004,65.004,124.968,64.993,125.05,64.972,125.084,64.944,125.082,64.929,125.112,64.913,125.331,64.875,125.589,64.765,125.639,64.711,125.645,64.609,125.559,64.682,125.558,64.61,125.606,64.614,125.916,64.575,126.256,64.476,127.23,64.083,127.076,64.131,127.343,64.014,127.51,63.891,127.746,63.887,127.973,63.767,127.985,63.688,127.51,63.802,127.418,63.84,127.355,63.907,127.291,63.918,127.168,63.987,127.111,63.981,127.012,64.016,127.114,63.976,127.029,63.982,127.121,63.865,127.089,63.795,127.018,64.005,126.972,64.038,126.956,64.033,126.945,64.036,126.901,64.062,126.879,64.1,126.709,64.172,126.709,64.156,126.745,64.121,126.746,64.112,126.585,64.267,126.526,64.278,126.569,64.252,126.574,64.246,126.573,64.242,126.571,64.238,126.542,64.241,126.522,64.234,126.513,64.234,126.505,64.235,126.474,64.255,126.573,64.245,126.521,64.273,126.421,64.362,126.386,64.341,126.416,64.306,126.406,64.293,126.441,64.263,126.439,64.242,126.429,64.257,126.399,64.199,126.383,64.183,126.361,64.17,126.359,64.162,126.203,64.07,126.097,64.048,126.081,64.051,126.077,64.011,125.949,63.946,125.82,63.918,125.839,63.928,125.761,63.923,125.715,63.911,125.702,63.911,125.626,63.927,125.557,63.895,125.555,63.901,125.482,63.894,125.436,63.895,125.385,63.901,125.322,63.865,125.28,63.863,125.131,63.869,124.621,63.954,124.606,63.972,124.637,64.023,124.489,64.005,124.472,63.98,124.453,63.97,124.44,63.967,124.477,64.003,124.496,64.007,124.477,64.012,124.452,63.994,124.429,63.991,124.163,64.041,124.076,64.031,123.989,64.039,123.889,64.024,123.702,64.088,123.632,64.073,123.603,64.044,123.574,64.031,123.544,64.027,123.533,64.027,123.517,64.011,123.506,64.004,123.422,64.006,123.376,63.962,123.312,63.958,123.248,63.966,123.233,63.918,123.198,63.9,123.15,63.906,123.058,63.886,122.967,63.886,122.881,63.943,122.789,63.908,122.736,63.897,122.448,63.895,122.344,63.843,122.098,63.871,122.345,63.867,122.399,63.898,122.441,63.903,122.768,63.909,122.854,63.949,123.202,63.913,123.23,63.968,123.683,64.093,123.856,64.085,123.908,64.031,123.972,64.043,124.085,64.037,124.112,64.045,124.211,64.046,124.264,64.042,124.3,64.055,124.628,64.032,124.658,64.008,124.671,64.014,124.679,64.016,124.713,64.016,124.743,64.01,124.779,63.998,124.798,63.997,124.822,63.992,124.837,63.98,124.68,64.015,124.662,63.957,125.29,63.865,125.387,63.905,125.976,63.963,126.07,64.051,126.191,64.072,126.221,64.111,126.374,64.198,126.389,64.195,126.425,64.264,126.374,64.327,126.379,64.391,126.239,64.421,126.147,64.389,126.004,64.408,125.826,64.465,125.772,64.499,125.769,64.53,125.688,64.528,125.583,64.581,125.319,64.548,125.375,64.52,125.411,64.516,125.432,64.506,125.438,64.481,125.451,64.484,125.461,64.483,125.462,64.48,125.453,64.476,125.467,64.478,125.475,64.482,125.481,64.483,125.485,64.47,125.477,64.464,125.501,64.461,125.51,64.455,125.516,64.457,125.521,64.457,125.538,64.452,125.539,64.449,125.52,64.456,125.514,64.453,125.478,64.463,125.476,64.467,125.483,64.471,125.479,64.482,125.459,64.474,125.45,64.476,125.459,64.483,125.442,64.48,125.435,64.482,125.411,64.514,125.374,64.509,125.292,64.56,125.269,64.535,125.258,64.533,125.254,64.532,125.233,64.536,125.229,64.538,125.227,64.55,125.242,64.555,125.325,64.566,125.243,64.556,125.254,64.569,125.215,64.572,125.207,64.592,125.166,64.586,125.144,64.588,125.062,64.625,125.056,64.635,125.078,64.651,125.003,64.666,124.966,64.688,124.896,64.804,124.888,64.794,124.858,64.782,124.81,64.78,124.882,64.707,124.84,64.716,124.794,64.785,124.863,64.834,124.797,64.853,124.8,64.842,124.765,64.838,124.72,64.841,124.668,64.862,124.644,64.882,124.648,64.873,124.637,64.867,124.622,64.871,124.647,64.875,124.616,64.932,124.63,64.963,124.489,65.006,124.501,65.049,124.368,65.082,124.397,65.084,124.304,65.137,124.318,65.172,124.282,65.185,124.239,65.234,124.234,65.269,124.114,65.268,124.104,65.538,124.191,65.629,124.156,65.724,124.175,65.802,124.101,65.842,124.084,65.904,123.918,65.95,123.997,65.909,123.963,65.848,124.16,65.776,123.963,65.844,123.788,66.107,123.766,66.073,123.737,66.055,123.718,66.05,123.742,66.055,123.753,66.051,123.759,66.046,123.73,66.008,123.757,66.046,123.715,66.05,123.728,66.06,123.663,66.016,123.688,66.002,123.681,65.988,123.667,65.983,123.681,65.983,123.688,65.982,123.707,65.968,123.665,65.983,123.662,66.02,123.781,66.11,123.756,66.148,123.756,66.139,123.731,66.124,123.682,66.114,123.656,66.093,123.647,66.094,123.654,66.093,123.678,66.114,123.729,66.125,123.754,66.143,123.636,66.341,123.647,66.418,123.626,66.433,123.578,66.442,123.527,66.469,123.537,66.483,123.553,66.493,123.565,66.512,123.472,66.528,123.498,66.513,123.507,66.497,123.459,66.439,123.461,66.428,123.479,66.42,123.484,66.415,123.507,66.38,123.502,66.368,123.533,66.367,123.543,66.365,123.55,66.36,123.546,66.355,123.5,66.369,123.459,66.427,123.455,66.439,123.461,66.466,123.504,66.497,123.466,66.525,123.514,66.601,123.342,66.874,123.39,66.968,123.267,67.067,123.253,67.199,123.227,67.215,123.187,67.226,123.179,67.232,123.166,67.222,123.132,67.213,123.117,67.211,123.11,67.211,123.1,67.221,123.176,67.234,123.006,67.355,123.001,67.392,123.04,67.435,122.986,67.465,122.948,67.459,122.937,67.461,122.935,67.467,122.923,67.464,122.877,67.47,122.869,67.461,122.844,67.468,122.869,67.463,122.868,67.468,122.879,67.47,122.935,67.469,122.899,67.481,122.896,67.489,122.906,67.493,122.933,67.494,122.935,67.497,122.899,67.506,122.898,67.511,122.915,67.518,122.892,67.518,122.887,67.52,122.932,67.563,122.896,67.562,122.886,67.566,122.881,67.573,122.894,67.585,122.948,67.583,122.937,67.638,122.967,67.668,122.943,67.699,122.945,67.731,122.961,67.749,122.937,67.772,122.95,67.78,123.049,67.802,123.063,67.881,123.023,67.871,122.942,67.889,122.924,67.92,122.853,67.896,122.82,67.895,122.631,67.966,122.655,67.993,122.636,67.976,122.528,67.989,122.35,68.018,122.236,68.052,122.655,67.994,122.824,67.898,122.924,67.925,122.977,67.89,122.969,67.902,122.976,67.925,122.916,67.936,122.876,67.95,122.872,67.956,122.878,67.961,122.901,67.968,122.941,67.969,123.015,67.945,122.93,67.995,122.934,68.003,122.947,68.009,123.007,68.011,123.012,68.023,122.926,68.03,122.923,68.039,122.949,68.054,122.934,68.065,122.916,68.06,122.882,68.07,122.855,68.06,122.836,68.061,122.85,68.084,122.835,68.077,122.812,68.074,122.794,68.074,122.776,68.077,122.777,68.082,122.811,68.081,122.816,68.088,122.739,68.104,122.729,68.13,123.104,68.027,123.087,68.048,123.118,68.071,123.336,68.187,123.617,68.224,123.528,68.246,123.815,68.383,123.94,68.79,123.993,68.835,123.98,68.888,123.887,68.85,124.038,69.03,123.983,69.018,124.179,69.171,124.622,69.328,124.747,69.506,124.729,69.514,124.739,69.569,124.685,69.576,124.591,69.531,124.405,69.489,124.389,69.487,124.379,69.49,124.371,69.496,124.372,69.502,124.382,69.506,124.416,69.51,124.375,69.519,124.357,69.519,124.367,69.504,124.355,69.499,124.334,69.5,124.302,69.509,124.284,69.509,124.304,69.492,124.306,69.478,124.297,69.475,124.287,69.474,124.243,69.485,124.233,69.474,124.271,69.469,124.279,69.465,124.277,69.459,124.257,69.455,124.218,69.456,124.205,69.45,124.241,69.453,124.262,69.449,124.268,69.445,124.267,69.432,124.264,69.43,124.236,69.429,124.254,69.422,124.257,69.418,124.243,69.397,124.237,69.393,124.227,69.393,124.192,69.404,124.216,69.392,124.218,69.387,124.213,69.385,124.129,69.381,124.175,69.373,124.185,69.368,124.185,69.365,124.181,69.363,124.151,69.357,124.164,69.347,124.162,69.343,124.1,69.319,124.15,69.358,124.184,69.367,124.128,69.382,124.192,69.405,124.241,69.398,124.204,69.451,124.221,69.457,124.271,69.458,124.277,69.464,124.27,69.468,124.237,69.471,124.229,69.475,124.239,69.484,124.284,69.51,124.299,69.51,124.354,69.5,124.352,69.518,124.361,69.521,124.422,69.512,124.44,69.523,124.404,69.529,124.377,69.524,124.37,69.524,124.366,69.526,124.371,69.554,124.38,69.56,124.389,69.562,124.449,69.557,124.278,69.587,124.274,69.592,124.284,69.596,124.353,69.598,124.305,69.602,124.272,69.617,124.276,69.631,124.194,69.659,124.188,69.665,124.186,69.681,124.146,69.675,124.111,69.679,124.0,69.715,124.186,69.682,124.358,69.599,124.346,69.592,124.279,69.592,124.286,69.586,124.505,69.571,124.538,69.56,124.577,69.576,124.747,69.58,124.737,69.571,125.001,69.701,125.065,69.885,124.993,69.831,124.853,69.854,124.832,69.845,124.866,69.818,124.868,69.806,124.856,69.792,124.65,69.765,124.656,69.748,124.617,69.735,124.62,69.711,124.576,69.685,124.601,69.678,124.649,69.693,124.667,69.692,124.686,69.687,124.695,69.681,124.699,69.669,124.686,69.647,124.672,69.64,124.574,69.683,124.573,69.692,124.589,69.703,124.616,69.712,124.61,69.731,124.62,69.739,124.651,69.749,124.645,69.765,124.857,69.797,124.814,69.844,124.867,69.858,124.994,69.841,124.993,69.861,125.1,69.903,125.151,69.961,125.196,69.98,125.259,69.989,125.362,69.975,125.492,69.989,125.97,70.143,126.019,70.257,125.752,70.374,125.897,70.499,126.375,70.55,127.02,70.548,127.621,70.803,127.405,70.873,127.333,70.809,127.305,70.794,127.291,70.792,127.262,70.79,127.561,71.017,127.241,71.235,127.219,71.392,127.31,71.454,127.089,71.885,126.111,72.188,126.078,72.293,126.004,72.293,125.775,72.33,125.86,72.289,125.861,72.285,125.855,72.281,125.861,72.279,125.856,72.279,125.332,72.421,125.084,72.55,124.395,72.64,124.239,72.685,124.224,72.717,124.096,72.711,123.875,72.753,123.783,72.734,123.475,72.725,123.772,72.727,123.546,72.693,123.465,72.726,122.831,72.792,122.669,72.84,122.619,72.842,122.44,72.863,122.444,72.865,122.42,72.86,122.181,72.887,121.713,72.968,121.068,72.924,119.803,73.0,120.829,72.88,120.987,72.843,121.084,72.789,121.526,72.8,121.572,72.797,121.623,72.788,121.947,72.65,121.997,72.581,122.385,72.487,122.821,72.316,123.151,72.274,123.388,72.157,123.565,72.12,123.664,72.09,123.699,72.063,123.771,72.056,123.796,72.04,123.727,72.021,123.717,71.955,123.674,71.924,123.584,71.898,123.686,71.872,123.706,71.792,123.814,71.782,123.837,71.772,123.827,71.75,123.763,71.725,123.636,71.702,123.609,71.643,123.407,71.57,123.108,71.534,123.062,71.498,122.915,71.444,122.795,71.364,122.589,71.293,122.596,71.275,122.683,71.241,122.682,71.229,122.651,71.213,122.497,71.181,122.494,71.164,122.526,71.12,122.491,71.092,122.525,71.059,122.485,71.025,122.327,70.981,122.435,70.894,122.306,70.822,122.328,70.803,122.325,70.701,122.452,70.687,122.604,70.655,122.532,70.578,122.006,70.495,121.469,70.529,121.218,70.49,120.701,70.47,120.576,70.473,120.48,70.484,120.393,70.47,120.0,70.525,120.186,70.51,120.398,70.474,120.506,70.505,121.211,70.494,121.49,70.539,122.002,70.499,122.493,70.58,122.59,70.654,122.32,70.697,122.297,70.819,122.425,70.897,122.32,70.981,122.513,71.06,122.482,71.098,122.486,71.184,122.673,71.234,122.603,71.264,122.578,71.29,123.096,71.534,123.376,71.567,123.633,71.705,123.817,71.754,123.451,71.77,123.357,71.804,123.323,71.794,123.326,71.77,123.314,71.75,123.303,71.743,123.294,71.739,123.099,71.742,123.35,71.805,123.701,71.789,123.572,71.901,123.672,71.935,123.667,71.97,123.713,72.023,123.777,72.045,123.713,72.044,123.351,72.154,123.222,72.216,123.145,72.263,122.832,72.301,121.96,72.573,121.612,72.778,121.164,72.761,120.238,72.915,119.928,72.924,119.97,72.916,119.889,72.898,119.918,72.833,119.916,72.821,119.899,72.799,119.563,72.774,119.563,72.729,119.535,72.717,119.449,72.7,119.531,72.683,119.612,72.63,119.56,72.614,119.498,72.607,119.544,72.581,119.529,72.564,119.642,72.544,119.659,72.538,119.671,72.527,119.672,72.517,119.66,72.507,119.578,72.482,119.649,72.446,119.63,72.428,119.609,72.418,119.647,72.395,119.658,72.384,119.656,72.371,119.631,72.365,119.576,72.483,119.667,72.528,119.526,72.563,119.494,72.607,119.607,72.633,119.447,72.7,119.566,72.776,119.895,72.803,119.879,72.888,119.887,72.907,119.92,72.931,119.637,73.004,119.58,73.065,119.527,73.051,119.599,73.025,118.793,73.13,118.408,73.225,118.416,73.306,118.374,73.303,118.357,73.305,118.475,73.353,118.462,73.357,118.435,73.35,118.431,73.355,118.421,73.349,118.36,73.347,118.339,73.349,118.455,73.358,118.4,73.368,118.384,73.378,118.456,73.432,118.56,73.47,118.844,73.47,118.84,73.457,118.869,73.448,118.918,73.449,118.849,73.457,118.852,73.485,118.908,73.456,118.974,73.459,118.958,73.458,118.865,73.491,118.792,73.494,118.797,73.498,118.853,73.497,118.945,73.469,118.983,73.467,119.013,73.473,118.668,73.561,117.7,73.55,117.762,73.566,115.593,73.714,115.187,73.666,115.22,73.647,115.263,73.65,115.298,73.648,115.303,73.642,115.219,73.634,115.177,73.648,115.154,73.638,115.146,73.64,115.185,73.669,115.257,73.684,115.234,73.687,115.105,73.665,115.06,73.651,115.084,73.657,115.144,73.64,115.082,73.653,115.059,73.631,115.04,73.629,115.012,73.632,115.052,73.619,115.078,73.601,115.107,73.627,115.174,73.629,115.206,73.602,113.514,73.506,113.845,73.35,114.007,73.367,114.078,73.363,114.154,73.351,114.164,73.349,114.17,73.343,114.192,73.346,114.182,73.343,114.168,73.343,114.136,73.352,114.114,73.348,114.328,73.302,114.393,73.213,114.57,73.179,114.764,73.18,114.816,73.162,114.884,73.113,114.872,73.093,114.928,73.062,114.932,73.052,114.797,73.058,114.846,73.034,114.863,72.981,115.356,72.991,115.79,72.901,115.551,72.898,115.355,72.989,114.991,72.945,114.846,72.984,114.786,73.054,114.876,73.11,114.831,73.126,114.771,73.171,114.405,73.17,114.225,73.299,114.179,73.308,114.208,73.293,114.202,73.284,114.195,73.282,114.174,73.28,114.168,73.272,114.152,73.267,114.139,73.267,114.13,73.268,114.117,73.268,114.204,73.293,114.088,73.327,113.925,73.312,113.791,73.327,113.581,73.223,113.668,73.162,113.647,73.153,113.665,73.144,113.661,73.135,113.672,73.133,113.69,73.135,113.701,73.134,113.713,73.114,113.725,73.119,113.736,73.12,113.789,73.115,113.791,73.112,113.784,73.107,113.81,73.108,113.823,73.106,113.86,73.052,113.658,73.018,113.82,73.105,113.785,73.106,113.782,73.109,113.789,73.114,113.785,73.115,113.745,73.112,113.733,73.119,113.722,73.113,113.71,73.114,113.569,73.171,113.663,73.115,113.626,73.116,113.565,73.008,113.538,73.014,113.596,72.926,113.195,72.831,113.331,72.775,113.28,72.724,113.451,72.685,113.435,72.663,114.031,72.595,114.079,72.566,114.178,72.555,114.178,72.557,114.086,72.583,114.069,72.59,114.07,72.592,114.133,72.577,114.236,72.538,113.647,72.614,113.438,72.653,113.283,72.703,113.157,72.83,113.252,72.874,113.469,72.917,113.482,73.023,113.608,73.139,113.333,73.249,113.207,73.23,113.095,73.267,113.124,73.248,113.011,73.203,112.99,73.232,112.989,73.24,113.054,73.25,113.081,73.269,113.555,73.256,113.556,73.34,113.226,73.437,113.099,73.44,113.048,73.45,113.02,73.47,113.197,73.442,113.463,73.63,113.18,73.866,112.865,73.988,112.97,73.765,112.29,73.701,111.53,73.783,111.213,73.955,111.601,74.013,111.546,74.05,111.158,74.024,111.107,73.934,111.036,73.902,110.881,73.915,110.678,73.976,110.618,73.981,110.507,74.001,110.464,74.0,110.469,74.004,110.381,74.015,109.99,74.001,109.557,73.765,109.77,73.652,110.296,73.68,110.693,73.793,110.892,73.771,110.901,73.761,110.845,73.717,111.009,73.694,111.05,73.677,110.109,73.56,110.183,73.534,109.905,73.431,109.576,73.434,109.258,73.55,109.218,73.552,109.207,73.491,109.456,73.416,109.25,73.472,109.209,73.455,109.313,73.389,108.939,73.332,108.276,73.293,108.2,73.258,108.414,73.242,108.362,73.207,106.851,73.128,106.353,73.187,106.216,73.126,106.224,72.971,107.02,72.887,106.953,72.885,107.025,72.873,107.109,72.841,107.042,72.844,107.117,72.821,107.262,72.875,107.293,72.879,107.321,72.881,107.345,72.879,107.694,72.811,107.771,72.753,107.934,72.771,108.001,72.765,108.043,72.749,108.035,72.73,108.06,72.724,108.135,72.728,108.167,72.724,108.37,72.643,108.55,72.665,108.66,72.659,108.762,72.638,108.709,72.585,108.736,72.579,108.789,72.582,108.828,72.575,108.971,72.508,109.025,72.56,109.048,72.567,109.083,72.57,109.261,72.539,109.528,72.555,109.837,72.528,109.857,72.516,109.835,72.501,109.758,72.494,109.767,72.484,109.745,72.481,110.09,72.389,110.115,72.372,110.1,72.344,110.111,72.347,110.266,72.331,110.48,72.277,110.625,72.273,110.653,72.251,110.642,72.238,110.091,72.343,110.062,72.395,109.804,72.444,109.767,72.455,109.763,72.462,109.745,72.465,109.736,72.475,109.741,72.491,109.847,72.516,109.823,72.527,109.527,72.552,109.432,72.534,109.365,72.53,109.239,72.536,109.066,72.566,108.972,72.506,108.7,72.582,108.747,72.641,108.363,72.639,108.14,72.684,108.166,72.717,108.031,72.725,108.0,72.761,107.763,72.75,107.668,72.809,107.364,72.859,107.135,72.814,106.674,72.847,106.578,72.863,106.549,72.877,106.445,72.874,106.289,72.893,106.251,72.908,106.17,72.904,106.066,72.945,105.923,72.863,106.137,72.844,106.15,72.832,106.122,72.821,106.148,72.802,106.136,72.785,106.219,72.773,106.193,72.743,106.216,72.772,106.134,72.782,106.116,72.821,106.136,72.842,106.011,72.829,105.923,72.856,105.909,72.869,105.521,72.743,105.137,72.756,105.249,72.746,105.189,72.725,105.171,72.677,105.107,72.717,105.118,72.701,104.735,72.593,104.744,72.584,104.808,72.561,104.81,72.556,104.756,72.522,104.56,72.484,104.272,72.497,104.186,72.465,103.875,72.394,103.994,72.378,104.022,72.361,103.997,72.347,104.014,72.339,104.013,72.326,104.034,72.316,104.074,72.334,104.092,72.336,104.12,72.333,104.149,72.312,104.167,72.322,104.174,72.325,104.185,72.325,104.212,72.32,104.204,72.311,104.229,72.314,104.259,72.311,104.296,72.298,104.294,72.311,104.385,72.326,104.296,72.297,104.034,72.312,104.009,72.324,103.992,72.348,104.015,72.364,103.935,72.361,103.921,72.379,103.874,72.387,103.838,72.414,103.388,72.412,103.321,72.373,103.172,72.333,103.17,72.246,102.988,72.198,102.87,72.185,102.607,72.009,102.371,71.958,102.224,71.956,102.119,71.923,102.235,71.824,102.239,71.791,102.532,71.784,102.615,71.724,102.605,71.711,102.559,71.695,102.548,71.68,102.604,71.624,103.003,71.545,103.101,71.494,103.208,71.513,103.282,71.49,103.201,71.396,103.146,71.386,102.99,71.392,103.132,71.336,102.966,71.252,102.839,71.22,102.625,71.197,102.695,71.155,102.644,71.125,102.451,71.122,102.309,71.065,102.723,70.993,102.906,70.915,102.919,70.903,102.911,70.86,102.984,70.848,103.018,70.838,103.084,70.781,103.094,70.731,103.083,70.725,103.034,70.718,103.022,70.71,103.071,70.664,103.436,70.642,103.453,70.635,103.456,70.619,103.514,70.618,103.534,70.616,103.547,70.611,103.589,70.562,103.625,70.562,103.685,70.581,103.729,70.587,103.774,70.59,103.801,70.588,103.821,70.585,103.832,70.581,103.84,70.573,103.594,70.56,103.44,70.614,103.472,70.581,103.313,70.489,103.376,70.458,103.461,70.456,103.48,70.45,103.496,70.438,103.47,70.428,103.321,70.421,103.314,70.387,103.421,70.383,103.448,70.407,103.471,70.411,103.498,70.405,103.511,70.396,103.542,70.33,103.544,70.296,103.568,70.292,103.574,70.288,103.56,70.274,103.729,70.249,103.746,70.241,103.757,70.227,103.662,70.181,103.552,70.206,103.513,70.193,103.524,70.171,103.498,70.156,103.446,70.148,103.377,70.16,103.394,70.127,103.199,70.039,103.136,70.038,103.072,70.051,103.096,69.988,102.983,69.961,103.075,69.918,103.078,69.905,103.065,69.897,103.016,69.88,103.023,69.841,103.007,69.833,102.977,69.827,102.849,69.831,103.099,69.712,103.094,69.706,103.075,69.702,102.989,69.709,102.993,69.703,103.042,69.694,103.05,69.688,103.017,69.667,103.052,69.639,103.024,69.62,103.04,69.618,103.084,69.626,103.109,69.625,103.224,69.592,103.269,69.556,103.346,69.573,103.372,69.575,103.398,69.573,103.437,69.557,103.421,69.504,103.549,69.503,103.564,69.498,103.563,69.488,103.542,69.481,103.46,69.485,103.426,69.457,103.494,69.451,103.567,69.373,103.537,69.348,103.652,69.319,103.741,69.285,103.703,69.268,103.733,69.284,103.528,69.348,103.419,69.457,103.457,69.488,103.559,69.497,103.402,69.504,103.393,69.523,103.43,69.558,103.393,69.571,103.264,69.554,103.021,69.619,102.982,69.708,103.022,69.716,103.071,69.704,103.093,69.712,102.843,69.832,103.018,69.842,103.007,69.88,103.069,69.917,102.977,69.959,102.992,69.975,103.089,69.989,103.067,70.051,103.194,70.041,103.371,70.161,103.55,70.208,103.662,70.184,103.752,70.226,103.556,70.273,103.569,70.289,103.54,70.296,103.493,70.404,103.461,70.408,103.431,70.382,103.377,70.376,103.309,70.386,103.298,70.405,103.326,70.425,103.49,70.437,103.373,70.456,103.313,70.482,103.301,70.498,103.45,70.627,103.066,70.663,103.014,70.708,103.078,70.782,102.902,70.861,102.898,70.915,102.712,70.992,102.298,71.063,102.441,71.124,102.652,71.132,102.621,71.2,102.946,71.252,103.106,71.337,102.978,71.395,103.19,71.398,103.261,71.492,103.096,71.488,102.568,71.612,102.473,71.641,102.341,71.772,102.178,71.781,102.147,71.795,102.145,71.78,102.116,71.768,102.146,71.76,102.149,71.758,102.145,71.755,102.129,71.754,102.108,71.76,102.052,71.757,102.106,71.761,102.143,71.755,102.113,71.768,102.133,71.793,102.14,71.795,102.153,71.795,102.191,71.787,102.031,71.839,101.987,71.865,101.989,71.878,101.891,71.853,101.679,71.836,101.712,71.817,101.714,71.806,101.704,71.798,101.669,71.781,101.629,71.774,101.535,71.769,101.488,71.758,101.396,71.761,101.317,71.751,101.327,71.753,100.803,71.736,100.745,71.739,100.642,71.719,100.482,71.696,100.495,71.7,100.218,71.672,100.218,71.676,100.16,71.679,100.069,71.675,100.002,71.645,100.033,71.655,100.109,71.662,100.13,71.671,100.217,71.674,100.136,71.671,100.109,71.661,100.094,71.658,100.033,71.653,100.0,71.642,99.891,71.633,99.86,71.62,99.752,71.595,99.718,71.575,99.484,71.536,99.602,71.464,99.511,71.311,99.765,71.28,99.732,71.279,99.77,71.278,99.914,71.233,100.0,71.167,99.918,71.178,99.911,71.197,99.838,71.199,99.814,71.204,99.809,71.21,99.831,71.219,99.794,71.218,99.791,71.229,99.766,71.235,99.763,71.242,99.762,71.239,99.748,71.237,99.723,71.241,99.707,71.277,99.483,71.311,99.209,71.183,98.863,71.126,98.089,71.186,98.111,71.113,98.204,71.114,98.267,71.105,98.431,71.061,98.433,71.057,98.426,71.054,98.36,71.055,98.381,71.041,98.431,71.039,98.44,71.036,98.438,71.032,98.433,71.031,98.391,71.028,98.362,71.003,98.385,71.03,98.438,71.036,98.377,71.041,98.357,71.057,98.43,71.061,98.081,71.109,98.084,71.101,98.043,71.086,98.153,71.078,98.188,71.062,98.172,71.022,98.109,70.988,98.139,70.946,98.133,70.946,98.135,70.935,98.125,70.92,98.13,70.932,98.047,70.904,97.918,70.896,97.84,70.799,97.785,70.791,97.687,70.789,97.629,70.796,97.594,70.791,97.682,70.765,97.665,70.757,97.598,70.749,97.482,70.784,97.585,70.734,97.572,70.7,97.553,70.697,97.535,70.706,97.511,70.704,97.579,70.674,97.5,70.616,97.344,70.615,97.366,70.598,97.369,70.585,97.421,70.586,97.425,70.585,97.424,70.582,97.419,70.577,97.344,70.587,97.342,70.617,97.574,70.675,97.512,70.682,97.503,70.704,97.573,70.731,97.478,70.786,97.662,70.767,97.591,70.778,97.577,70.795,97.501,70.794,97.447,70.798,97.328,70.83,97.204,70.843,97.059,70.843,96.914,70.854,96.886,70.858,96.353,70.809,95.696,70.831,95.688,70.832,95.666,70.837,95.575,70.873,95.527,70.849,95.187,70.879,95.156,70.888,95.131,70.925,94.86,70.917,94.842,70.919,94.813,70.928,94.75,70.956,94.628,70.943,94.548,70.966,94.348,70.873,94.241,70.86,94.285,70.858,94.08,70.763,94.339,70.729,94.504,70.678,94.608,70.625,94.678,70.65,95.233,70.65,95.408,70.64,95.452,70.627,95.457,70.62,95.451,70.616,95.31,70.578,95.452,70.533,95.521,70.554,95.682,70.528,95.773,70.555,95.868,70.56,95.905,70.557,95.923,70.541,95.915,70.505,95.892,70.488,95.841,70.472,95.858,70.429,95.812,70.401,95.953,70.331,95.969,70.315,95.931,70.304,95.946,70.296,95.92,70.289,95.916,70.273,95.895,70.262,95.783,70.227,95.839,70.147,95.824,70.129,95.891,70.126,96.005,70.103,96.147,70.034,96.223,69.975,96.226,69.967,96.211,69.961,96.252,69.955,96.278,69.927,96.396,69.908,96.39,69.906,96.408,69.9,96.398,69.907,96.526,69.908,96.631,69.899,97.107,69.741,97.185,69.689,97.19,69.636,97.104,69.741,96.638,69.896,96.551,69.898,96.516,69.906,96.409,69.899,96.343,69.906,96.267,69.921,96.245,69.953,96.051,69.858,96.039,69.855,96.015,69.856,96.0,69.852,95.967,69.852,95.957,69.847,95.979,69.818,95.975,69.813,95.963,69.811,95.964,69.808,96.011,69.781,96.003,69.777,95.957,69.809,95.978,69.818,95.952,69.829,95.953,69.847,96.224,69.954,96.184,69.961,96.097,70.046,96.121,70.038,96.118,70.042,96.043,70.065,95.996,70.102,95.846,70.117,95.825,70.124,95.813,70.132,95.822,70.149,95.83,70.149,95.818,70.15,95.813,70.159,95.763,70.169,95.767,70.141,95.595,70.101,95.485,70.094,95.46,70.096,95.44,70.108,95.409,70.112,95.327,70.1,95.281,70.064,95.251,70.049,95.209,70.038,95.208,70.024,95.178,70.008,95.027,69.955,94.957,69.938,94.863,69.933,94.876,69.93,94.774,69.905,94.758,69.899,94.769,69.895,94.766,69.882,94.747,69.866,94.601,69.822,94.139,69.746,94.224,69.794,94.248,69.781,94.277,69.804,94.291,69.798,94.331,69.804,94.331,69.809,93.657,69.779,94.742,69.868,94.759,69.877,94.745,69.897,94.75,69.903,94.763,69.907,94.859,69.934,94.968,69.945,95.075,69.997,95.182,70.015,95.192,70.033,95.28,70.065,95.277,70.081,95.285,70.09,95.319,70.098,95.297,70.102,95.313,70.103,95.326,70.102,95.407,70.113,95.594,70.102,95.633,70.123,95.724,70.141,95.762,70.142,95.732,70.166,95.773,70.226,95.95,70.317,95.851,70.345,95.835,70.36,95.857,70.365,95.83,70.371,95.802,70.397,95.836,70.472,95.911,70.506,95.894,70.557,95.686,70.526,95.448,70.531,95.326,70.549,95.303,70.56,95.318,70.567,95.305,70.576,95.316,70.587,95.451,70.623,95.408,70.638,94.577,70.623,94.353,70.709,94.155,70.716,94.062,70.724,94.052,70.728,94.043,70.735,94.052,70.772,94.122,70.821,94.215,70.866,94.356,70.895,94.352,70.898,94.392,70.913,94.387,70.925,94.426,70.95,94.378,70.946,94.384,70.929,94.379,70.926,94.367,70.924,94.272,70.929,94.292,70.942,94.372,70.925,94.366,70.94,94.379,70.948,94.508,70.967,94.5,70.969,94.493,70.965,94.483,70.965,94.485,70.97,94.725,70.966,95.163,70.926,95.23,70.884,95.3,70.901,95.337,70.9,95.378,70.971,95.433,70.993,95.449,70.994,95.511,70.988,95.591,71.016,95.673,71.013,95.668,71.017,95.76,71.044,95.819,71.034,95.792,71.046,95.793,71.053,95.8,71.056,95.961,71.068,96.148,71.074,96.219,71.051,96.214,71.072,96.268,71.072,96.252,71.085,96.253,71.089,96.26,71.093,96.283,71.097,96.291,71.098,96.3,71.097,96.326,71.088,96.347,71.089,96.356,71.097,96.354,71.109,96.363,71.119,96.526,71.141,96.571,71.138,96.53,71.167,96.528,71.192,96.542,71.2,96.611,71.205,96.591,71.213,96.582,71.223,96.595,71.233,96.612,71.233,96.636,71.229,96.65,71.23,96.639,71.24,96.643,71.244,96.653,71.23,96.585,71.222,96.614,71.207,96.612,71.204,96.53,71.191,96.571,71.138,96.224,71.05,95.961,71.067,95.799,71.053,95.823,71.035,95.807,71.031,95.756,71.041,95.673,71.012,95.59,71.014,95.531,70.988,95.512,70.986,95.434,70.991,95.345,70.902,95.403,70.867,95.571,70.877,95.747,70.838,96.911,70.87,97.296,70.849,97.542,70.803,97.82,70.803,97.855,70.825,97.827,70.868,97.839,70.883,97.936,70.911,98.072,70.92,98.108,70.94,98.069,70.992,98.158,71.058,98.012,71.069,97.982,71.086,97.998,71.097,98.086,71.12,98.017,71.168,98.029,71.177,98.082,71.192,99.182,71.183,99.436,71.336,99.491,71.351,99.478,71.392,99.552,71.475,99.417,71.499,99.408,71.526,99.448,71.562,99.561,71.589,99.687,71.598,99.737,71.594,100.077,71.694,100.087,71.695,100.102,71.694,100.125,71.687,100.209,71.682,100.313,71.69,100.663,71.786,100.707,71.791,100.692,71.786,100.761,71.779,100.787,71.766,100.81,71.777,100.692,71.795,100.724,71.805,100.782,71.782,100.89,71.822,101.912,71.863,101.971,71.894,101.955,71.899,101.91,71.899,101.879,71.906,101.893,71.913,101.936,71.92,101.941,71.965,102.002,71.99,102.049,71.999,102.113,72.005,102.156,72.002,102.227,71.99,102.278,72.008,102.346,72.002,102.343,72.009,102.397,72.009,102.385,72.018,102.403,72.027,102.4,72.009,102.345,72.008,102.498,72.006,102.65,72.082,102.558,72.12,102.621,72.156,103.09,72.291,103.059,72.328,103.07,72.31,103.064,72.304,102.982,72.3,102.951,72.279,102.858,72.27,102.839,72.26,102.864,72.256,102.869,72.253,102.867,72.24,102.779,72.223,102.866,72.241,102.836,72.261,102.861,72.273,102.95,72.281,102.959,72.298,103.063,72.312,103.043,72.339,102.959,72.337,102.912,72.353,102.906,72.372,102.872,72.357,102.847,72.354,102.808,72.377,102.719,72.36,102.688,72.362,102.625,72.375,102.621,72.382,102.659,72.405,102.62,72.408,102.582,72.428,102.57,72.445,102.586,72.473,102.574,72.479,102.375,72.469,102.361,72.473,102.356,72.486,102.333,72.492,102.24,72.475,102.216,72.476,102.156,72.486,102.141,72.484,102.116,72.468,102.096,72.463,101.992,72.455,101.905,72.458,101.833,72.473,101.768,72.471,101.725,72.474,101.664,72.488,101.611,72.506,101.642,72.522,101.584,72.518,101.49,72.554,101.414,72.591,101.368,72.622,101.064,72.632,101.189,72.655,101.137,72.663,101.227,72.65,101.253,72.627,101.312,72.63,101.377,72.621,101.556,72.543,101.572,72.522,101.638,72.524,101.649,72.52,101.648,72.516,101.614,72.507,101.724,72.476,101.832,72.474,102.002,72.458,102.159,72.488,102.331,72.494,102.36,72.487,102.377,72.47,102.57,72.482,102.591,72.473,102.592,72.428,102.665,72.406,102.678,72.367,102.808,72.379,102.856,72.356,102.905,72.374,102.971,72.338,103.208,72.414,103.265,72.425,103.398,72.435,103.396,72.45,103.462,72.466,104.258,72.604,104.44,72.61,104.344,72.613,104.779,72.75,105.126,72.781,105.147,72.794,105.204,72.804,105.16,72.811,105.151,72.815,105.15,72.82,105.184,72.826,105.215,72.82,105.234,72.833,105.144,72.848,105.126,72.862,105.237,72.837,105.234,72.829,105.222,72.82,105.153,72.819,105.159,72.812,105.199,72.809,105.206,72.803,105.142,72.781,105.24,72.777,105.722,72.882,105.772,72.95,105.923,73.007,105.906,73.106,106.064,73.142,106.124,73.271,106.805,73.321,106.887,73.426,107.21,73.537,107.189,73.547,107.184,73.552,107.195,73.561,107.153,73.554,106.939,73.573,106.768,73.599,106.684,73.639,106.621,73.628,106.555,73.625,106.523,73.614,106.286,73.612,106.087,73.638,106.053,73.657,105.871,73.651,105.787,73.659,105.719,73.644,105.711,73.644,105.688,73.65,105.723,73.657,105.696,73.652,105.648,73.653,105.631,73.646,105.572,73.642,105.586,73.638,105.596,73.632,105.59,73.627,105.467,73.622,105.401,73.605,105.288,73.597,105.257,73.587,105.264,73.584,105.272,73.57,105.248,73.545,105.055,73.499,104.834,73.489,104.58,73.507,104.543,73.515,104.528,73.512,104.573,73.504,104.573,73.489,104.557,73.482,104.5,73.477,104.462,73.489,104.443,73.502,104.453,73.493,104.378,73.482,104.287,73.482,104.289,73.475,104.32,73.478,104.35,73.471,104.377,73.441,104.372,73.435,104.359,73.43,104.337,73.429,104.289,73.439,104.304,73.433,104.218,73.464,104.209,73.471,104.214,73.476,104.226,73.478,104.252,73.477,104.263,73.479,104.218,73.498,104.181,73.466,104.159,73.457,104.14,73.455,104.131,73.457,104.129,73.46,104.147,73.47,104.107,73.48,104.123,73.487,104.071,73.486,103.967,73.492,103.961,73.497,103.978,73.501,103.968,73.513,103.933,73.504,103.912,73.503,103.876,73.516,103.865,73.527,103.778,73.518,103.669,73.541,103.639,73.538,103.584,73.54,103.514,73.535,103.515,73.524,103.446,73.49,103.444,73.441,103.44,73.438,103.42,73.433,103.403,73.44,103.401,73.454,103.318,73.451,103.171,73.486,103.157,73.495,103.163,73.515,103.142,73.521,103.132,73.529,103.14,73.531,103.101,73.542,103.082,73.553,103.058,73.56,103.053,73.564,102.971,73.569,102.836,73.595,102.823,73.603,102.861,73.621,102.807,73.613,102.732,73.611,102.644,73.615,102.614,73.622,102.576,73.622,102.526,73.608,102.49,73.611,102.381,73.581,102.362,73.58,102.349,73.582,102.342,73.589,102.393,73.6,102.358,73.623,102.391,73.643,102.32,73.653,102.394,73.644,102.395,73.6,102.345,73.588,102.364,73.581,102.491,73.614,102.575,73.623,102.862,73.621,102.827,73.603,103.059,73.564,103.134,73.537,103.167,73.516,103.173,73.487,103.4,73.455,103.421,73.435,103.438,73.493,103.513,73.536,103.587,73.542,103.641,73.539,103.659,73.543,103.777,73.536,103.781,73.519,103.866,73.528,103.915,73.504,103.967,73.515,103.984,73.507,103.972,73.493,104.125,73.489,104.131,73.486,104.11,73.48,104.152,73.469,104.141,73.456,104.218,73.499,104.269,73.481,104.221,73.465,104.347,73.431,104.371,73.445,104.335,73.472,104.289,73.473,104.281,73.482,104.541,73.516,104.835,73.492,104.919,73.51,104.978,73.51,105.047,73.501,105.289,73.601,105.656,73.655,105.809,73.663,106.048,73.66,106.096,73.639,106.291,73.614,106.369,73.621,106.528,73.62,106.627,73.644,106.694,73.644,106.724,73.638,106.788,73.6,106.92,73.631,107.003,73.631,107.012,73.63,107.027,73.628,107.009,73.635,106.981,73.635,106.966,73.638,106.943,73.66,106.98,73.692,106.912,73.703,106.919,73.721,106.883,73.722,106.804,73.732,106.801,73.733,106.794,73.738,106.798,73.748,106.168,73.767,106.154,73.769,106.153,73.772,106.168,73.779,106.191,73.784,106.175,73.794,106.208,73.803,105.913,73.801,105.844,73.809,105.838,73.811,105.845,73.819,105.812,73.817,105.818,73.822,105.849,73.819,105.913,73.802,106.032,73.814,106.211,73.803,106.179,73.794,106.194,73.784,106.165,73.768,106.362,73.805,106.276,73.809,106.299,73.828,106.277,73.828,106.268,73.829,106.259,73.842,106.234,73.835,106.209,73.834,106.203,73.835,106.203,73.84,106.234,73.845,106.185,73.855,106.235,73.847,106.235,73.844,106.205,73.839,106.208,73.835,106.26,73.843,106.302,73.828,106.279,73.809,106.364,73.807,106.347,73.776,106.602,73.773,106.799,73.749,106.797,73.736,106.808,73.733,106.928,73.722,107.167,73.609,107.231,73.611,107.29,73.621,107.339,73.624,107.365,73.622,107.393,73.612,107.406,73.613,107.376,73.623,107.39,73.625,107.41,73.61,107.623,73.629,107.789,73.615,108.046,73.669,108.171,73.665,108.163,73.663,108.174,73.659,108.169,73.655,108.302,73.714,108.485,73.773,108.485,73.781,108.447,73.78,108.416,73.796,108.411,73.803,108.431,73.809,108.42,73.814,108.426,73.823,108.415,73.82,108.419,73.825,108.429,73.823,108.434,73.811,108.424,73.793,108.508,73.782,108.485,73.766,108.762,73.932,108.947,73.996,108.914,73.999,108.891,73.995,108.887,73.997,109.545,74.081,109.953,74.26,109.922,74.318,109.764,74.287,108.99,74.255,108.564,74.364,108.557,74.384,108.614,74.397,108.598,74.401,108.595,74.404,108.606,74.414,108.574,74.414,108.607,74.415,108.617,74.396,108.569,74.374,108.644,74.376,108.987,74.258,109.579,74.343,109.788,74.299,110.014,74.376,110.426,74.44,110.395,74.488,110.298,74.485,110.219,74.489,110.187,74.499,110.175,74.494,110.078,74.506,110.069,74.51,110.03,74.508,109.972,74.506,109.848,74.515,109.774,74.527,109.756,74.531,109.724,74.543,109.72,74.549,109.714,74.549,109.688,74.569,109.642,74.571,109.607,74.581,109.584,74.598,109.58,74.612,109.503,74.61,109.442,74.645,109.384,74.712,109.393,74.716,109.427,74.722,109.407,74.73,109.395,74.741,109.392,74.74,109.39,74.745,109.408,74.747,109.409,74.75,109.385,74.757,109.371,74.768,109.376,74.772,109.367,74.776,109.261,74.794,109.266,74.799,109.19,74.814,109.171,74.825,109.313,74.792,109.311,74.797,109.338,74.822,109.363,74.832,109.398,74.835,109.395,74.855,109.417,74.857,109.233,74.97,109.235,75.033,109.303,75.066,109.295,75.082,109.301,75.093,109.337,75.11,109.426,75.143,109.51,75.155,109.526,75.167,109.541,75.169,109.553,75.167,109.566,75.17,109.631,75.177,109.665,75.178,109.677,75.184,109.734,75.197,109.775,75.2,109.786,75.217,109.825,75.227,109.777,75.2,109.67,75.178,109.557,75.166,109.532,75.167,109.523,75.159,109.503,75.152,109.488,75.15,109.479,75.146,109.43,75.143,109.412,75.123,109.345,75.108,109.32,75.097,109.312,75.065,109.248,75.033,109.437,74.888,109.412,74.84,109.386,74.827,109.353,74.826,109.352,74.812,109.325,74.796,109.317,74.795,109.381,74.78,109.414,74.751,109.434,74.723,109.395,74.71,109.446,74.646,109.499,74.614,109.581,74.614,109.592,74.596,109.63,74.584,109.637,74.574,109.69,74.575,109.715,74.569,109.75,74.544,109.884,74.528,109.933,74.514,109.935,74.52,110.124,74.521,110.574,74.486,110.56,74.479,111.005,74.53,111.149,74.581,111.027,74.595,111.003,74.65,110.963,74.653,110.948,74.66,110.911,74.662,110.9,74.671,110.874,74.67,110.854,74.667,110.825,74.666,110.697,74.684,110.664,74.697,110.667,74.707,110.727,74.682,110.899,74.671,111.008,74.65,111.049,74.599,111.471,74.686,111.804,74.65,111.993,74.735,111.981,74.746,111.946,74.74,111.912,74.75,112.133,74.863,112.754,74.938,112.96,75.033,113.057,75.047,112.969,75.059,112.947,75.058,112.921,75.059,112.898,75.063,113.5,75.205,113.709,75.377,113.589,75.388,113.571,75.375,113.503,75.369,113.371,75.375,113.349,75.36,113.338,75.36,113.315,75.367,113.26,75.37,113.239,75.364,113.215,75.364,113.157,75.356,113.129,75.355,113.088,75.346,113.074,75.346,113.048,75.349,113.039,75.338,113.032,75.336,112.956,75.336,112.9,75.341,112.904,75.328,112.891,75.326,112.881,75.328,112.897,75.341,113.755,75.427,113.704,75.505,113.363,75.525,113.283,75.661,112.798,75.548,112.884,75.635,112.685,75.704,112.88,75.691,112.849,75.737,112.5,75.771,112.483,75.822,112.271,75.844,112.099,75.923,113.121,75.678,113.612,75.64,113.423,75.536,113.725,75.61,113.925,75.844,113.779,75.932,113.758,75.902,113.575,75.916,113.664,75.866,113.527,75.834,113.471,75.836,113.629,75.862,113.611,75.894,113.549,75.912,113.532,75.912,113.562,75.921,113.615,75.924,113.491,75.925,113.431,75.941,113.593,75.951,113.442,76.06,113.481,76.149,113.304,76.198,113.378,76.208,113.285,76.255,113.121,76.224,112.951,76.235,113.336,76.149,113.361,76.109,113.071,76.146,112.992,76.117,113.053,76.089,112.913,76.045,112.667,76.051,112.881,76.125,112.485,76.23,112.562,76.236,112.728,76.203,112.81,76.319,112.483,76.387,112.359,76.427,112.352,76.444,112.283,76.394,111.92,76.318,112.007,76.244,111.94,76.223,111.946,76.237,111.998,76.248,111.907,76.319,112.244,76.444,111.56,76.598,111.63,76.616,111.433,76.58,111.477,76.543,111.433,76.528,111.43,76.58,111.607,76.649,111.5,76.644,111.613,76.672,111.266,76.687,111.192,76.648,111.13,76.656,111.109,76.643,111.06,76.63,110.953,76.625,110.931,76.611,110.807,76.587,110.794,76.592,110.8,76.588,110.712,76.575,110.689,76.553,110.627,76.534,110.6,76.531,110.561,76.518,110.569,76.51,110.561,76.506,110.538,76.501,110.534,76.477,110.511,76.466,110.545,76.445,110.429,76.413,110.409,76.412,110.386,76.414,110.377,76.41,110.352,76.409,110.539,76.446,110.509,76.464,110.508,76.469,110.533,76.499,110.563,76.51,110.556,76.518,110.715,76.578,110.946,76.628,110.976,76.632,111.023,76.632,111.056,76.63,111.094,76.642,111.113,76.654,111.124,76.656,111.18,76.66,111.108,76.702,111.237,76.741,111.096,76.758,110.943,76.728,111.04,76.724,111.007,76.709,110.966,76.72,110.869,76.707,110.685,76.722,110.675,76.723,110.669,76.726,110.66,76.737,110.704,76.746,110.689,76.749,110.425,76.739,110.434,76.729,110.252,76.708,110.286,76.695,110.083,76.685,110.108,76.652,109.992,76.652,110.036,76.691,109.428,76.752,108.277,76.69,108.372,76.681,108.46,76.658,108.535,76.587,108.496,76.541,108.444,76.529,108.513,76.588,108.398,76.622,108.398,76.644,108.428,76.664,108.291,76.679,108.249,76.69,108.301,76.7,108.3,76.723,108.362,76.736,108.031,76.735,107.951,76.723,107.975,76.65,108.027,76.641,108.177,76.675,108.194,76.676,108.198,76.67,107.724,76.501,106.874,76.456,106.51,76.491,106.407,76.578,106.564,76.597,106.608,76.543,106.67,76.546,106.845,76.661,106.765,76.725,107.399,76.866,107.239,76.871,107.212,76.896,107.535,76.914,106.876,77.049,106.585,76.975,106.509,76.981,106.652,76.995,106.241,77.058,105.523,76.981,105.579,76.952,105.364,77.0,105.815,77.024,105.764,77.04,105.936,77.134,104.071,77.033,104.486,76.911,104.614,76.828,104.538,76.802,104.487,76.796,104.545,76.796,104.55,76.772,104.538,76.761,104.475,76.794,104.593,76.829,104.563,76.837,104.463,76.91,104.04,77.001,104.077,77.063,104.237,77.097,104.174,77.103,104.156,77.111,105.368,77.193,105.395,77.207,105.432,77.215,105.452,77.216,105.467,77.227,105.619,77.255,105.516,77.267,105.839,77.343,105.838,77.353,105.794,77.341,105.643,77.341,105.597,77.346,105.591,77.328,105.559,77.323,105.225,77.314,105.24,77.318,105.842,77.368,106.193,77.335,106.185,77.337,106.277,77.344,106.308,77.365,106.002,77.423,105.932,77.551,104.831,78.295,105.422,78.494,105.454,78.566,105.307,78.785,105.145,78.826,105.016,78.838,104.952,78.855,104.923,78.855,104.777,78.778,104.655,78.766,104.74,78.789,104.407,78.845,104.66,78.861,104.524,78.89,104.582,78.92,104.533,78.949,104.123,78.996,104.038,79.052,104.085,79.132,103.879,79.172,103.692,79.111,103.65,79.181,103.518,79.097,102.97,79.047,102.805,78.895,102.464,78.81,102.774,78.96,102.754,79.064,102.877,79.121,102.936,79.212,102.886,79.216,103.222,79.302,100.149,79.669,99.937,79.736,100.093,79.81,99.817,79.914,99.134,80.013,99.342,80.039,98.803,80.024,98.593,80.065,98.48,79.999,98.544,79.935,98.364,79.849,97.536,79.832,97.867,79.895,97.97,79.943,97.883,79.953,97.93,80.003,98.11,80.058,97.311,80.228,97.513,80.304,97.249,80.335,97.304,80.401,97.242,80.426,97.27,80.44,97.082,80.522,97.277,80.62,97.147,80.639,97.748,80.632,97.812,80.664,97.974,80.666,97.775,80.711,97.947,80.732,96.782,80.921,96.724,80.978,96.803,80.991,95.803,81.269,138.713,76.181,138.494,76.146,138.507,76.136,138.457,76.082,138.469,76.063,138.453,76.05,138.506,76.045,138.53,76.017,138.503,75.997,138.535,75.982,138.502,75.993,138.493,76.042,138.426,76.048,138.343,76.127,138.429,76.137,138.503,76.151,138.233,76.111,138.012,76.017,138.168,76.074,138.2,76.071,138.106,76.0,137.471,75.929,137.483,75.829,137.536,75.839,137.762,75.729,137.636,75.759,137.501,75.738,137.271,75.765,137.15,75.719,137.214,75.643,136.991,75.586,137.327,75.453,137.172,75.431,137.203,75.377,137.482,75.357,137.005,75.36,136.931,75.343,136.954,75.263,137.271,75.255,137.002,75.234,137.358,75.055,137.757,75.03,137.68,74.993,137.972,74.936,137.889,74.926,138.176,74.757,139.076,74.635,139.836,73.43,139.779,73.402,139.974,73.342,140.954,72.683,141.057,72.686,141.067,72.685,141.086,72.681,141.101,72.658,141.148,72.657,141.17,72.64,141.147,72.624,141.116,72.622,141.124,72.617,141.157,72.619,141.18,72.613,141.179,72.61,141.15,72.605,141.146,72.6,141.225,72.599,141.172,72.596,141.134,72.6,141.142,72.601,141.149,72.606,141.179,72.612,141.157,72.619,141.131,72.615,141.121,72.617,141.114,72.62,141.066,72.684,140.965,72.673,140.928,72.637,140.851,72.587,140.933,72.6,141.06,72.566,140.91,72.554,140.795,72.572,140.656,72.497,140.568,72.475,140.462,72.468,140.224,72.469,140.339,72.482,140.269,72.527,140.377,72.548,140.289,72.546,140.351,72.555,140.286,72.564,140.147,72.598,140.211,72.597,140.267,72.578,140.23,72.598,139.966,72.601,139.395,72.404,139.512,72.382,139.542,72.364,139.685,72.376,139.83,72.373,139.894,72.361,139.995,72.353,140.029,72.35,140.042,72.346,139.533,72.363,139.385,72.395,139.398,72.414,139.173,72.29,139.2,72.168,139.375,72.117,139.281,72.174,139.581,72.147,139.459,72.167,140.068,72.274,140.354,72.185,140.114,72.133,140.065,72.139,140.005,72.16,140.163,72.184,140.013,72.186,139.678,71.966,139.466,71.964,139.496,72.048,139.367,71.966,139.698,71.848,139.766,71.863,139.925,71.863,139.986,71.858,140.0,71.856,139.704,71.841,139.746,71.814,139.841,71.564,139.917,71.523,139.959,71.52,139.868,71.522,140.171,71.485,140.193,71.478,140.192,71.462,140.239,71.459,140.244,71.457,140.191,71.461,140.177,71.483,140.096,71.476,140.114,71.438,140.11,71.428,140.041,71.411,140.081,71.402,140.0,71.406,140.031,71.381,140.021,71.375,139.926,71.367,139.966,71.363,139.976,71.35,140.025,71.343,140.034,71.339,140.033,71.337,140.0,71.336,139.924,71.367,140.09,71.478,139.892,71.454,139.952,71.494,139.846,71.518,139.441,71.489,139.376,71.421,139.265,71.402,139.233,71.404,139.106,71.473,139.174,71.48,139.162,71.583,138.989,71.688,138.646,71.614,138.206,71.625,138.103,71.58,138.125,71.543,138.083,71.494,138.101,71.488,138.081,71.464,137.935,71.384,137.863,71.388,137.886,71.385,137.902,71.375,137.916,71.379,137.946,71.37,137.94,71.353,138.026,71.388,137.97,71.354,138.061,71.326,138.105,71.344,138.213,71.324,138.345,71.329,138.364,71.328,138.386,71.325,138.419,71.324,138.462,71.319,138.489,71.304,138.033,71.3,138.355,71.279,138.398,71.248,138.164,71.241,138.227,71.211,138.216,71.195,138.181,71.193,138.197,71.184,138.348,71.219,138.364,71.221,138.382,71.22,138.388,71.218,138.34,71.185,138.147,71.164,138.171,71.169,138.159,71.18,138.186,71.197,138.215,71.196,138.218,71.214,137.891,71.265,137.812,71.218,137.903,71.174,138.053,71.198,138.169,71.137,138.082,71.106,138.3,71.071,138.304,71.069,138.304,71.065,138.297,71.057,138.31,71.055,138.297,71.056,138.295,71.057,138.301,71.07,138.279,71.071,138.274,71.029,138.139,70.986,138.277,71.069,138.051,71.092,138.01,71.153,137.848,71.108,137.783,71.196,137.722,71.195,137.67,71.201,137.663,71.197,137.67,71.192,137.666,71.189,137.619,71.164,137.608,71.162,137.574,71.159,137.58,71.153,137.569,71.148,137.55,71.147,137.529,71.151,137.524,71.158,137.51,71.153,137.503,71.156,137.525,71.158,137.568,71.148,137.573,71.16,137.665,71.19,137.66,71.196,137.665,71.201,137.743,71.2,137.712,71.235,137.8,71.287,137.726,71.284,137.678,71.248,137.667,71.253,137.666,71.246,137.603,71.235,137.584,71.235,137.653,71.25,137.626,71.263,137.625,71.251,137.551,71.228,137.496,71.224,137.507,71.215,137.48,71.204,137.496,71.224,137.539,71.228,137.558,71.234,137.504,71.229,137.563,71.235,137.595,71.252,137.622,71.254,137.525,71.297,137.588,71.361,137.411,71.33,137.491,71.372,137.327,71.353,137.39,71.392,137.121,71.419,136.794,71.523,136.879,71.53,136.701,71.534,136.619,71.49,136.652,71.47,136.661,71.457,136.622,71.424,136.7,71.424,136.757,71.401,136.769,71.384,136.745,71.395,136.736,71.378,136.682,71.378,136.704,71.376,136.709,71.374,136.686,71.362,136.725,71.358,136.692,71.332,136.729,71.328,136.739,71.32,136.678,71.302,136.7,71.302,136.706,71.301,136.706,71.288,136.725,71.291,136.765,71.285,136.764,71.281,136.757,71.28,136.725,71.282,136.709,71.273,136.721,71.273,136.726,71.271,136.743,71.261,136.741,71.257,136.731,71.253,136.755,71.248,136.737,71.233,136.707,71.273,136.726,71.283,136.764,71.282,136.704,71.287,136.675,71.302,136.736,71.32,136.693,71.326,136.679,71.378,136.754,71.399,136.606,71.37,136.627,71.462,136.571,71.415,136.545,71.414,136.549,71.406,136.497,71.398,136.523,71.387,136.521,71.382,136.488,71.375,136.449,71.373,136.47,71.366,136.431,71.332,136.36,71.33,136.406,71.319,136.416,71.308,136.409,71.304,136.365,71.305,136.354,71.298,136.401,71.295,136.408,71.293,136.409,71.289,136.406,71.288,136.398,71.288,136.378,71.291,136.383,71.281,136.378,71.276,136.368,71.276,136.351,71.299,136.363,71.306,136.414,71.308,136.404,71.318,136.364,71.324,136.359,71.331,136.428,71.332,136.45,71.378,136.52,71.383,136.494,71.398,136.541,71.415,136.568,71.417,136.575,71.488,136.626,71.517,136.365,71.478,136.277,71.475,136.27,71.469,136.29,71.467,136.297,71.46,136.258,71.44,136.141,71.437,136.195,71.419,136.129,71.389,136.21,71.381,136.172,71.344,136.083,71.319,136.026,71.32,135.996,71.301,136.073,71.299,136.198,71.252,136.187,71.237,136.12,71.236,136.159,71.231,136.18,71.214,136.112,71.187,136.124,71.163,136.191,71.152,136.208,71.145,136.212,71.136,135.994,71.105,136.421,71.086,136.222,71.051,136.441,70.94,136.495,70.964,136.539,70.967,136.534,70.976,136.538,70.978,136.581,70.98,136.548,70.985,136.548,70.987,136.62,70.991,136.639,70.986,136.642,70.993,136.624,71.004,136.627,71.007,136.657,71.009,136.68,71.004,136.658,71.014,136.639,71.013,136.634,71.014,136.646,71.023,136.741,71.064,136.81,71.058,136.785,71.067,136.825,71.094,136.838,71.096,136.87,71.095,136.875,71.085,136.884,71.098,136.892,71.101,136.909,71.098,136.921,71.128,136.932,71.13,136.967,71.13,137.009,71.118,137.022,71.13,137.046,71.133,137.044,71.138,137.048,71.141,137.063,71.141,137.079,71.135,137.088,71.136,137.089,71.15,137.095,71.152,137.127,71.149,137.109,71.157,137.119,71.159,137.138,71.157,137.084,71.164,137.083,71.167,137.099,71.173,137.092,71.178,137.095,71.185,137.14,71.157,137.129,71.148,137.091,71.149,137.108,71.139,137.08,71.135,137.05,71.141,137.047,71.132,137.022,71.128,137.022,71.119,137.003,71.118,136.959,71.129,136.922,71.127,136.912,71.098,136.887,71.098,136.878,71.085,136.871,71.084,136.827,71.094,136.788,71.067,136.812,71.058,136.743,71.063,136.661,71.024,136.713,71.011,136.651,71.022,136.682,71.004,136.629,71.006,136.645,70.994,136.642,70.985,136.458,70.948,136.422,70.898,136.276,70.882,136.233,70.841,136.215,70.805,136.229,70.821,136.381,70.784,136.365,70.808,136.377,70.811,136.466,70.805,136.489,70.796,136.515,70.809,136.555,70.807,136.53,70.819,136.533,70.823,136.547,70.826,136.576,70.821,136.586,70.816,136.577,70.802,136.605,70.808,136.616,70.807,136.618,70.803,136.591,70.794,136.602,70.791,136.66,70.817,136.672,70.817,136.692,70.81,136.697,70.821,136.703,70.822,136.71,70.82,136.716,70.812,136.702,70.801,136.741,70.818,136.747,70.818,136.709,70.801,136.603,70.79,136.588,70.793,136.587,70.796,136.614,70.806,136.575,70.802,136.55,70.824,136.556,70.806,136.488,70.795,136.466,70.804,136.367,70.806,136.384,70.783,136.233,70.798,136.238,70.764,136.224,70.753,136.207,70.747,136.177,70.748,136.095,70.776,136.047,70.712,135.938,70.677,135.896,70.673,135.894,70.669,135.867,70.666,135.69,70.674,135.734,70.659,135.718,70.649,135.464,70.656,135.403,70.718,135.301,70.644,135.053,70.565,134.945,70.553,134.776,70.562,134.921,70.492,134.915,70.462,134.797,70.435,134.964,70.321,135.176,70.367,135.203,70.365,135.228,70.352,135.214,70.275,135.148,70.222,135.043,70.216,135.065,70.187,135.048,70.173,135.094,70.169,135.147,70.152,135.188,70.149,135.287,70.134,135.395,70.099,135.366,70.05,135.343,70.035,135.296,70.019,135.31,70.004,135.448,70.018,135.609,69.99,135.618,69.971,135.513,69.885,135.337,69.876,135.256,69.895,135.257,69.865,135.169,69.812,135.222,69.767,135.114,69.726,135.043,69.714,135.097,69.681,135.089,69.664,134.946,69.598,134.938,69.607,134.945,69.595,134.968,69.582,134.955,69.567,134.792,69.478,134.677,69.441,134.691,69.427,134.685,69.403,134.507,69.259,134.467,69.268,134.493,69.252,134.538,69.205,134.475,69.124,134.521,69.107,134.539,69.092,134.506,69.073,134.463,69.063,134.508,68.996,134.48,68.917,134.416,68.876,134.464,68.858,134.469,68.837,134.376,68.799,134.596,68.747,134.655,68.684,134.65,68.653,134.612,68.644,134.691,68.631,134.706,68.598,134.706,68.595,134.701,68.593,134.643,68.582,134.69,68.577,134.709,68.565,134.73,68.559,134.733,68.556,134.696,68.543,134.713,68.522,134.745,68.516,134.754,68.508,134.758,68.492,134.746,68.485,134.725,68.479,134.725,68.476,134.763,68.476,134.774,68.471,134.742,68.45,134.816,68.45,134.841,68.458,134.862,68.459,134.946,68.399,134.944,68.384,134.901,68.37,134.874,68.366,134.845,68.368,134.863,68.338,134.86,68.325,134.788,68.291,134.854,68.26,134.775,68.219,134.864,68.196,134.874,68.186,134.869,68.177,134.909,68.168,134.912,68.157,134.886,68.149,134.838,68.153,134.906,68.16,134.851,68.178,134.851,68.197,134.814,68.196,134.767,68.216,134.767,68.205,134.711,68.178,134.745,68.167,134.726,68.138,134.699,68.126,134.663,68.126,134.686,68.115,134.675,68.106,134.687,68.096,134.674,68.058,134.701,68.055,134.737,68.017,134.641,67.992,134.686,67.988,134.734,67.966,134.731,67.959,134.711,67.955,134.718,67.945,134.695,67.932,134.745,67.931,134.784,67.921,134.769,67.9,134.712,67.888,134.687,67.857,134.703,67.855,134.713,67.85,134.691,67.835,134.728,67.829,134.734,67.824,134.733,67.82,134.726,67.818,134.718,67.817,134.68,67.821,134.663,67.817,134.683,67.812,134.686,67.809,134.669,67.8,134.694,67.791,134.709,67.772,134.705,67.754,134.698,67.751,134.685,67.751,134.67,67.759,134.662,67.747,134.644,67.744,134.615,67.759,134.634,67.74,134.623,67.723,134.599,67.727,134.613,67.76,134.628,67.758,134.648,67.745,134.668,67.76,134.704,67.755,134.667,67.798,134.683,67.811,134.661,67.816,134.676,67.821,134.73,67.822,134.687,67.834,134.685,67.859,134.711,67.89,134.782,67.919,134.694,67.93,134.709,67.954,134.731,67.966,134.638,67.991,134.732,68.018,134.671,68.057,134.659,68.126,134.723,68.139,134.688,68.162,134.781,68.248,134.847,68.259,134.838,68.265,134.809,68.268,134.789,68.274,134.781,68.295,134.812,68.326,134.789,68.355,134.836,68.392,134.86,68.394,134.919,68.383,134.94,68.389,134.886,68.424,134.752,68.441,134.672,68.482,134.666,68.537,134.692,68.555,134.683,68.562,134.648,68.565,134.621,68.577,134.58,68.665,134.617,68.663,134.62,68.676,134.521,68.681,134.572,68.705,134.501,68.722,134.527,68.744,134.476,68.744,134.441,68.765,134.453,68.752,134.398,68.743,134.343,68.748,134.384,68.732,134.391,68.712,134.383,68.708,134.355,68.712,134.343,68.702,134.302,68.694,134.348,68.692,134.353,68.689,134.35,68.686,134.224,68.639,134.197,68.644,134.171,68.66,134.159,68.641,134.124,68.626,134.081,68.633,134.089,68.622,134.027,68.59,133.989,68.58,133.977,68.58,133.965,68.583,133.955,68.582,133.923,68.588,133.937,68.582,133.93,68.577,133.906,68.572,133.878,68.577,133.85,68.56,133.7,68.568,133.516,68.657,133.513,68.675,133.548,68.681,133.54,68.687,133.539,68.693,133.318,68.663,133.31,68.665,133.314,68.671,133.292,68.675,133.291,68.666,133.282,68.661,133.081,68.648,133.046,68.649,133.02,68.658,133.045,68.649,132.952,68.633,132.894,68.642,132.875,68.604,132.813,68.594,132.849,68.58,132.804,68.542,132.795,68.54,132.767,68.539,132.766,68.537,132.773,68.532,132.774,68.531,132.773,68.53,132.758,68.526,132.766,68.541,132.731,68.537,132.747,68.532,132.704,68.515,132.699,68.518,132.681,68.516,132.688,68.5,132.67,68.491,132.648,68.491,132.646,68.486,132.633,68.482,132.633,68.492,132.68,68.497,132.679,68.518,132.731,68.542,132.801,68.543,132.766,68.546,132.801,68.546,132.813,68.602,132.888,68.642,133.009,68.66,133.539,68.694,133.849,68.561,133.878,68.579,133.864,68.581,133.862,68.581,133.875,68.587,133.935,68.589,133.997,68.583,133.983,68.59,133.985,68.595,134.119,68.654,134.345,68.686,134.298,68.694,134.351,68.713,134.385,68.71,134.338,68.748,134.352,68.751,134.39,68.747,134.448,68.753,134.393,68.771,134.351,68.801,134.356,68.81,134.342,68.82,134.344,68.825,134.363,68.83,134.386,68.83,134.394,68.834,134.37,68.894,134.49,68.982,134.412,69.047,134.422,69.066,134.441,69.082,134.411,69.102,134.417,69.113,134.383,69.112,134.362,69.115,134.351,69.112,134.36,69.116,134.391,69.119,134.433,69.119,134.464,69.129,134.406,69.131,134.446,69.136,134.413,69.154,134.494,69.197,134.481,69.207,134.502,69.226,134.461,69.245,134.455,69.259,134.495,69.292,134.481,69.289,134.47,69.304,134.471,69.31,134.477,69.313,134.477,69.318,134.489,69.326,134.513,69.327,134.479,69.331,134.564,69.329,134.691,69.473,134.776,69.48,134.769,69.508,134.818,69.516],"holes":[[135.989,71.133,136.199,71.143,136.096,71.188,136.188,71.249,135.991,71.3,136.169,71.346,136.131,71.436,136.776,71.575,136.615,71.596,136.614,71.542,136.558,71.59,136.638,71.61,136.557,71.633,136.625,71.634,136.329,71.632,136.586,71.607,136.328,71.523,136.421,71.584,135.784,71.648,135.387,71.576,135.728,71.479,135.689,71.449,135.631,71.432,135.637,71.422,135.666,71.427,135.674,71.427,135.692,71.423,135.709,71.416,135.703,71.407,135.689,71.405,135.648,71.418,135.614,71.415,135.672,71.397,135.628,71.385,135.61,71.414,135.63,71.418,135.638,71.419,135.657,71.418,135.694,71.406,135.706,71.413,135.671,71.427,135.648,71.421,135.637,71.421,135.631,71.423,135.632,71.44,135.723,71.475,135.38,71.573,134.821,71.461,135.279,71.347,135.725,71.353,135.654,71.298,135.957,71.269,135.904,71.179,135.989,71.133],[138.335,71.654,138.674,71.632,139.008,71.75,138.891,71.791,139.082,71.763,138.955,71.807,138.999,71.833,138.736,71.833,139.002,71.835,138.925,71.955,138.701,71.966,138.399,71.871,138.253,71.719,138.296,71.696,138.378,71.779,138.652,71.839,138.856,71.79,138.388,71.778,138.335,71.654],[137.696,71.616,137.234,71.587,137.156,71.55,137.766,71.408,138.034,71.532,137.696,71.616],[126.771,72.156,126.379,72.193,126.329,72.252,126.644,72.2,126.501,72.317,126.117,72.222,126.838,72.041,126.771,72.156],[126.223,72.284,126.445,72.397,126.041,72.5,126.133,72.529,126.06,72.548,125.306,72.49,126.223,72.284],[125.212,72.537,125.7,72.552,125.576,72.595,125.609,72.658,125.573,72.663,125.538,72.643,125.497,72.644,125.402,72.706,125.54,72.703,125.498,72.646,125.534,72.645,125.561,72.662,125.574,72.666,125.595,72.665,125.814,72.61,125.726,72.732,125.825,72.661,125.922,72.712,125.831,72.621,126.045,72.599,125.941,72.559,126.272,72.491,126.374,72.552,126.286,72.75,126.247,72.748,126.225,72.766,126.25,72.738,126.177,72.721,126.264,72.685,126.295,72.598,126.033,72.601,126.274,72.619,126.121,72.664,126.266,72.635,126.17,72.718,126.181,72.728,126.248,72.739,126.197,72.753,126.236,72.774,126.092,72.789,126.139,72.811,126.108,72.794,126.238,72.775,126.248,72.749,126.284,72.751,126.298,72.745,126.319,72.749,126.343,72.904,126.165,72.887,126.192,72.846,126.144,72.809,126.189,72.846,126.135,72.859,126.167,72.889,126.085,72.889,126.811,73.063,126.699,73.209,126.645,73.161,126.621,73.16,126.571,73.168,126.554,73.165,126.524,73.11,126.547,73.163,126.59,73.184,126.491,73.202,126.685,73.201,126.584,73.242,126.674,73.284,126.573,73.36,126.681,73.28,126.608,73.239,126.72,73.206,126.937,73.317,126.839,73.333,126.828,73.446,126.701,73.4,126.713,73.389,126.763,73.383,126.792,73.367,126.706,73.314,126.798,73.286,126.701,73.312,126.765,73.377,126.544,73.371,126.566,73.312,126.469,73.365,126.592,73.276,126.565,73.237,126.586,73.229,126.565,73.234,126.562,73.239,126.583,73.276,126.491,73.335,126.42,73.332,126.381,73.29,126.311,73.255,126.42,73.237,126.4,73.185,126.208,73.216,126.273,73.179,126.167,73.113,125.963,73.132,125.894,73.093,125.815,73.091,125.754,73.045,125.808,73.05,125.841,73.016,125.77,72.971,125.807,73.048,125.782,73.047,125.758,73.034,125.707,73.026,125.757,73.036,125.749,73.05,125.79,73.082,125.81,73.091,125.843,73.099,125.78,73.116,125.888,73.093,125.951,73.139,126.151,73.126,126.26,73.181,126.194,73.215,126.248,73.22,126.333,73.213,126.374,73.222,126.289,73.261,126.403,73.314,126.341,73.322,126.442,73.342,126.482,73.338,126.423,73.358,126.445,73.351,126.467,73.375,126.538,73.377,126.407,73.378,126.343,73.337,126.214,73.357,126.367,73.387,126.197,73.396,126.22,73.449,126.351,73.395,126.541,73.455,126.293,73.55,126.185,73.525,126.207,73.47,126.048,73.499,126.042,73.435,125.981,73.425,126.04,73.416,125.974,73.411,125.962,73.427,126.042,73.484,125.934,73.526,125.803,73.46,125.89,73.416,125.709,73.464,125.576,73.371,125.548,73.445,125.672,73.442,125.68,73.527,125.5,73.485,125.455,73.584,125.402,73.501,125.38,73.586,125.332,73.5,125.286,73.572,125.175,73.485,125.267,73.606,125.133,73.551,125.247,73.626,125.133,73.624,125.166,73.705,125.036,73.593,125.037,73.697,124.948,73.647,124.847,73.677,124.89,73.725,124.452,73.717,124.502,73.763,124.427,73.799,123.988,73.584,123.752,73.564,123.607,73.621,123.733,73.69,123.624,73.698,123.527,73.605,123.426,73.65,123.506,73.552,123.309,73.535,123.512,73.39,123.361,73.454,123.377,73.398,123.264,73.394,123.527,73.376,123.37,73.349,123.462,73.293,123.415,73.277,123.571,73.273,123.484,73.238,123.574,73.196,123.687,73.24,123.731,73.224,123.61,73.209,123.739,73.205,123.63,73.168,123.725,73.156,123.427,73.151,123.706,73.105,123.61,73.013,124.507,72.941,125.284,72.994,124.792,73.017,125.124,73.022,125.283,73.002,125.373,72.889,125.313,72.854,125.436,72.828,125.308,72.852,125.365,72.889,125.25,72.966,125.285,72.917,125.2,72.877,125.284,72.837,125.226,72.779,125.261,72.841,125.188,72.88,125.234,72.916,125.089,72.946,124.259,72.935,124.279,72.879,124.319,72.864,124.341,72.865,124.373,72.861,124.375,72.859,124.365,72.853,124.388,72.855,124.378,72.851,124.364,72.852,124.359,72.854,124.372,72.861,124.32,72.863,124.276,72.88,124.197,72.963,124.093,72.974,123.337,72.856,124.151,72.761,124.059,72.765,124.443,72.65,125.104,72.66,124.852,72.612,125.258,72.513,125.118,72.554,125.27,72.602,125.212,72.537],[123.483,73.14,123.512,73.046,123.428,73.025,123.511,73.048,123.485,73.112,123.378,73.019,123.433,72.965,123.228,72.897,122.662,73.053,122.531,72.999,123.41,72.856,123.832,72.958,123.565,73.019,123.695,73.073,123.483,73.14],[128.602,73.247,128.319,73.173,128.592,73.189,128.503,73.169,127.917,73.115,127.892,73.077,127.866,73.064,127.816,73.055,127.796,73.016,127.81,73.058,127.868,73.068,127.149,72.969,128.284,73.249,128.116,73.209,127.942,73.201,127.899,73.181,127.935,73.201,127.968,73.208,128.111,73.213,128.244,73.255,127.8,73.253,128.011,73.317,127.961,73.323,127.876,73.292,127.563,73.25,127.399,73.173,127.559,73.254,127.869,73.294,128.106,73.41,127.333,73.233,127.742,73.393,127.361,73.341,127.143,73.12,127.201,73.084,127.05,73.053,126.998,72.974,126.942,72.953,127.008,72.903,126.832,72.94,126.934,72.944,126.984,73.009,126.722,72.931,126.649,72.844,126.751,72.822,126.587,72.752,126.565,72.663,126.71,72.694,126.564,72.65,126.704,72.597,126.831,72.704,126.95,72.722,126.828,72.695,127.007,72.623,127.036,72.698,127.178,72.709,126.951,72.732,127.083,72.723,127.219,72.83,126.986,72.748,127.123,72.832,128.009,73.003,127.824,72.96,128.526,72.979,128.72,73.04,128.284,73.024,128.751,73.084,128.199,73.048,128.938,73.187,128.602,73.247],[129.047,72.465,128.876,72.424,128.175,72.554,127.627,72.464,127.904,72.395,128.238,72.424,128.956,72.381,128.778,72.387,128.891,72.343,129.342,72.444,129.047,72.465],[129.466,72.34,129.068,72.354,128.727,72.295,128.656,72.251,128.845,72.215,128.901,72.237,128.773,72.251,128.904,72.239,128.809,72.297,128.881,72.295,129.017,72.202,129.257,72.269,129.173,72.312,129.539,72.316,129.466,72.34],[126.548,72.642,126.409,72.598,126.63,72.78,126.571,72.824,126.653,72.984,126.385,72.865,126.514,72.711,126.38,72.688,126.483,72.743,126.421,72.755,126.335,72.66,126.435,72.572,126.51,72.544,126.548,72.642],[126.943,73.104,127.097,73.116,127.202,73.189,127.069,73.266,126.996,73.251,127.063,73.232,126.982,73.203,127.047,73.231,126.99,73.249,126.996,73.253,127.054,73.264,127.005,73.315,126.761,73.22,126.862,73.156,126.738,73.129,126.792,73.082,126.943,73.104],[126.998,73.32,127.127,73.297,127.099,73.312,127.139,73.361,127.025,73.404,127.074,73.396,127.147,73.362,127.166,73.293,127.109,73.317,127.134,73.298,127.094,73.242,127.226,73.223,127.371,73.302,127.334,73.371,127.418,73.408,127.352,73.433,127.434,73.475,127.382,73.485,127.307,73.389,127.366,73.503,127.221,73.517,127.229,73.456,127.201,73.515,127.235,73.524,127.064,73.517,127.096,73.509,127.112,73.495,127.105,73.453,127.099,73.505,127.008,73.519,126.96,73.422,127.034,73.41,126.949,73.413,126.903,73.432,126.952,73.37,126.851,73.348,126.998,73.32],[135.969,73.979,136.18,73.878,136.299,73.916,136.05,74.078,135.443,74.241,135.472,74.144,135.591,74.164,135.969,73.979],[113.412,74.363,113.366,74.386,113.485,74.387,113.204,74.495,112.037,74.555,112.004,74.45,112.104,74.438,112.029,74.362,111.507,74.312,112.206,74.135,112.833,74.079,112.985,74.184,113.195,74.197,113.31,74.284,113.223,74.3,113.412,74.363],[135.772,75.367,136.055,75.442,135.965,75.493,136.236,75.623,135.905,75.708,135.723,75.871,135.608,75.674,135.619,75.518,135.497,75.443,135.532,75.371,135.772,75.367]]}],"names":{"ar":"بحر لابتيف","de":"Laptewsee","es":"mar de Láptev","fr":"mer des Laptev","he":"ים לאפטב","hu":"Laptyev-tenger","it":"Mare di Laptev","nl":"Laptevzee","pt":"Mar de Laptev","zh":"拉普捷夫海"}},{"name":"East Siberian Sea","bbox":[139.032,64.316,180.0,77.084],"area":177.8296,"polygons":[{"outer":[158.737,68.393,158.728,68.392,158.736,68.377,158.726,68.364,158.751,68.357,158.757,68.352,158.746,68.348,158.724,68.347,158.752,68.354,158.719,68.365,158.711,68.363,158.723,68.349,158.719,68.323,158.696,68.299,158.658,68.309,158.69,68.279,158.671,68.269,158.684,68.266,158.704,68.256,158.705,68.253,158.649,68.267,158.661,68.241,158.651,68.233,158.623,68.228,158.633,68.187,158.582,68.183,158.602,68.167,158.59,68.132,158.497,68.101,158.487,68.114,158.494,68.12,158.501,68.12,158.486,68.122,158.485,68.124,158.551,68.142,158.512,68.15,158.54,68.165,158.573,68.168,158.562,68.173,158.56,68.178,158.603,68.219,158.596,68.222,158.59,68.235,158.619,68.249,158.594,68.291,158.628,68.273,158.623,68.298,158.655,68.331,158.681,68.334,158.671,68.348,158.663,68.348,158.661,68.352,158.727,68.378,158.695,68.378,158.625,68.436,158.622,68.454,158.705,68.498,158.68,68.509,158.699,68.526,158.655,68.552,158.648,68.599,158.672,68.616,158.658,68.615,158.641,68.629,158.624,68.627,158.621,68.631,158.629,68.634,158.624,68.637,158.569,68.643,158.546,68.695,158.647,68.718,158.678,68.711,158.513,68.783,158.24,68.626,157.97,68.603,158.073,68.536,158.057,68.45,157.929,68.363,157.698,68.283,157.75,68.187,157.685,68.151,157.753,68.125,157.475,68.005,157.483,68.036,157.463,68.03,157.445,68.027,157.358,68.021,157.198,68.033,157.112,68.0,156.874,68.001,157.041,68.066,157.076,68.072,157.058,68.082,157.058,68.086,157.063,68.087,157.077,68.087,157.089,68.084,157.092,68.087,157.051,68.108,157.061,68.125,157.109,68.1,157.113,68.103,157.103,68.116,157.105,68.121,157.087,68.118,157.08,68.119,157.079,68.122,157.111,68.144,157.12,68.145,157.138,68.143,157.171,68.134,157.171,68.141,157.153,68.147,157.154,68.151,157.164,68.153,157.201,68.149,157.21,68.148,157.186,68.158,157.192,68.16,157.21,68.161,157.211,68.163,157.182,68.172,157.192,68.174,157.213,68.174,157.217,68.174,157.224,68.169,157.224,68.175,157.173,68.175,157.168,68.18,157.168,68.185,157.173,68.189,157.199,68.189,157.213,68.197,157.219,68.197,157.226,68.193,157.169,68.182,157.225,68.176,157.229,68.17,157.222,68.168,157.184,68.172,157.214,68.164,157.213,68.148,157.207,68.147,157.163,68.152,157.154,68.15,157.196,68.132,157.169,68.134,157.142,68.142,157.112,68.144,157.114,68.101,157.108,68.099,157.095,68.098,157.083,68.099,157.082,68.112,157.065,68.117,157.094,68.089,157.092,68.083,157.059,68.083,157.444,68.044,157.668,68.114,157.713,68.219,157.575,68.236,157.672,68.245,157.653,68.302,157.634,68.294,157.628,68.294,157.609,68.301,157.603,68.291,157.589,68.29,157.573,68.29,157.448,68.308,157.557,68.314,157.573,68.292,157.609,68.302,157.634,68.295,157.654,68.315,157.74,68.327,157.82,68.377,157.948,68.406,158.019,68.453,158.025,68.535,157.998,68.557,158.017,68.535,158.015,68.529,158.008,68.526,158.0,68.524,157.992,68.525,157.974,68.535,157.961,68.527,157.943,68.522,157.937,68.522,157.922,68.528,157.91,68.527,157.974,68.535,158.001,68.525,158.015,68.535,157.915,68.609,157.899,68.608,157.897,68.609,157.895,68.611,157.896,68.65,157.961,68.659,158.008,68.653,157.967,68.669,157.981,68.677,158.004,68.682,157.94,68.678,157.92,68.688,157.902,68.675,157.896,68.674,157.886,68.675,157.875,68.681,157.864,68.707,157.855,68.708,157.847,68.71,157.839,68.721,157.835,68.719,157.843,68.726,157.861,68.728,157.863,68.726,157.878,68.722,157.891,68.716,157.841,68.723,157.868,68.707,157.89,68.675,157.92,68.689,158.002,68.684,158.131,68.638,158.446,68.776,158.446,68.781,158.431,68.776,158.415,68.777,158.386,68.789,158.387,68.794,158.431,68.796,158.434,68.792,158.431,68.787,158.443,68.791,158.393,68.819,158.413,68.825,158.401,68.833,158.425,68.845,158.37,68.836,158.319,68.836,158.307,68.839,158.302,68.842,158.293,68.861,158.252,68.864,158.215,68.872,158.16,68.85,158.185,68.871,158.198,68.872,158.226,68.872,158.294,68.862,158.318,68.837,158.547,68.876,158.63,68.867,158.64,68.861,158.649,68.851,158.656,68.849,158.685,68.854,158.691,68.848,158.692,68.854,158.712,68.853,158.693,68.848,158.648,68.849,158.534,68.873,158.403,68.833,158.451,68.814,158.446,68.793,158.434,68.786,158.425,68.788,158.43,68.795,158.39,68.789,158.424,68.779,158.464,68.79,158.499,68.797,158.588,68.794,158.714,68.724,158.817,68.728,158.917,68.748,158.915,68.758,158.924,68.763,159.002,68.781,159.074,68.79,159.037,68.79,158.943,68.821,158.907,68.881,158.855,68.878,158.851,68.848,158.847,68.843,158.831,68.838,158.812,68.835,158.78,68.834,158.76,68.836,158.742,68.84,158.837,68.84,158.851,68.877,158.861,68.882,158.873,68.884,158.917,68.88,158.9,68.89,158.98,68.857,158.947,68.821,159.074,68.792,159.09,68.776,159.133,68.775,159.093,68.806,159.165,68.826,159.18,68.824,159.207,68.815,159.254,68.793,159.306,68.809,159.412,68.795,159.444,68.784,159.481,68.756,159.518,68.796,159.594,68.804,159.623,68.802,159.639,68.796,159.651,68.784,159.658,68.749,159.705,68.747,159.727,68.755,159.741,68.755,159.754,68.745,159.752,68.734,159.764,68.729,159.759,68.72,159.793,68.743,159.805,68.742,159.817,68.738,159.823,68.729,159.817,68.718,159.77,68.705,159.845,68.717,159.86,68.715,159.868,68.712,159.87,68.707,159.866,68.703,159.842,68.688,159.844,68.674,159.83,68.673,159.814,68.68,159.807,68.678,159.822,68.652,159.85,68.652,159.864,68.67,159.873,68.672,159.884,68.67,159.954,68.653,159.956,68.649,159.942,68.631,159.948,68.628,159.992,68.634,159.995,68.648,160.011,68.65,160.13,68.615,160.121,68.66,160.24,68.686,160.411,68.665,160.425,68.657,160.415,68.629,160.564,68.662,160.602,68.654,160.632,68.624,160.685,68.622,160.763,68.607,160.801,68.631,160.959,68.652,160.992,68.649,161.051,68.631,161.029,68.663,161.049,68.672,161.233,68.656,161.246,68.793,161.419,68.918,161.368,68.93,161.359,68.978,161.292,68.988,161.147,69.044,161.027,69.056,160.974,69.078,161.091,69.158,161.03,69.212,161.011,69.428,160.956,69.438,160.952,69.442,160.967,69.488,161.029,69.534,160.948,69.584,160.929,69.612,160.879,69.577,160.768,69.567,160.884,69.511,160.679,69.55,160.872,69.607,160.841,69.619,160.817,69.639,160.635,69.639,160.654,69.659,160.542,69.675,160.61,69.651,160.411,69.658,160.624,69.625,160.549,69.595,160.628,69.578,160.649,69.563,160.649,69.56,160.622,69.578,160.546,69.585,160.652,69.554,160.636,69.531,160.744,69.502,160.802,69.48,160.627,69.532,160.433,69.651,160.374,69.637,160.556,69.521,160.563,69.494,160.516,69.472,160.601,69.418,160.607,69.386,160.683,69.361,160.717,69.33,160.771,69.316,160.798,69.297,160.837,69.289,160.855,69.283,160.905,69.248,160.942,69.244,160.964,69.236,160.909,69.245,160.851,69.284,160.796,69.296,160.723,69.327,160.732,69.317,160.601,69.384,160.495,69.476,160.531,69.522,160.417,69.556,160.39,69.573,160.353,69.608,160.342,69.627,160.317,69.633,160.313,69.637,160.366,69.678,160.126,69.71,160.128,69.706,160.227,69.681,160.198,69.679,160.039,69.729,160.109,69.674,160.089,69.673,159.87,69.772,159.756,69.736,159.75,69.681,159.705,69.672,159.713,69.665,159.71,69.658,159.688,69.649,159.675,69.648,159.657,69.642,159.648,69.642,159.606,69.656,159.583,69.656,159.543,69.665,159.526,69.671,159.506,69.672,159.476,69.665,159.467,69.664,159.476,69.619,159.473,69.602,159.451,69.602,159.459,69.594,159.456,69.588,159.446,69.588,159.438,69.595,159.395,69.582,159.327,69.589,159.351,69.583,159.273,69.571,159.268,69.572,159.265,69.577,159.26,69.577,159.271,69.563,159.198,69.54,159.178,69.537,159.136,69.543,159.146,69.539,159.137,69.536,159.131,69.543,159.14,69.544,159.148,69.544,159.189,69.539,159.233,69.566,159.268,69.563,159.258,69.578,159.329,69.583,159.317,69.598,159.39,69.583,159.424,69.596,159.44,69.596,159.454,69.588,159.449,69.603,159.473,69.621,159.457,69.654,159.462,69.663,159.499,69.673,159.746,69.682,159.718,69.711,159.75,69.747,159.783,69.754,159.785,69.777,159.818,69.79,159.683,69.802,159.692,69.809,159.652,69.819,159.6,69.859,159.687,69.816,159.724,69.807,159.79,69.804,159.677,69.873,159.687,69.868,159.684,69.884,159.703,69.897,159.862,69.978,159.846,70.005,159.949,70.078,159.75,70.106,159.587,70.085,159.567,70.087,159.54,70.098,159.548,70.082,159.398,70.032,159.336,70.033,159.373,70.016,159.325,69.995,159.257,69.984,159.23,69.989,159.231,69.986,159.25,69.982,159.251,69.98,159.249,69.974,159.224,69.961,159.179,69.958,159.114,69.986,159.131,69.969,159.096,69.961,159.101,69.984,159.535,70.1,159.837,70.131,159.855,70.13,159.858,70.127,159.843,70.117,159.925,70.106,160.092,70.265,159.909,70.498,159.582,70.692,159.102,70.831,158.721,70.903,158.624,70.928,158.41,70.847,158.156,70.829,158.139,70.793,158.204,70.767,158.231,70.708,158.227,70.701,158.181,70.689,158.225,70.675,158.225,70.655,158.12,70.644,158.174,70.628,158.164,70.622,158.137,70.627,158.122,70.615,158.066,70.607,158.123,70.562,158.058,70.5,158.119,70.561,158.046,70.613,158.102,70.613,158.123,70.626,158.142,70.629,158.167,70.626,158.109,70.641,158.115,70.644,158.143,70.649,158.163,70.649,158.17,70.655,158.195,70.654,158.14,70.662,158.127,70.685,158.216,70.703,158.113,70.805,158.133,70.805,158.148,70.827,158.193,70.843,158.391,70.853,158.569,70.929,158.437,70.959,156.617,71.065,156.651,71.006,156.632,70.989,156.576,70.967,156.583,70.965,156.599,70.966,156.623,70.964,156.661,70.941,156.627,70.886,156.689,70.879,156.712,70.852,156.695,70.839,156.726,70.844,156.739,70.843,156.745,70.84,156.751,70.828,156.742,70.818,156.654,70.793,156.72,70.769,156.75,70.741,156.741,70.713,156.65,70.795,156.747,70.832,156.69,70.839,156.682,70.877,156.622,70.883,156.655,70.94,156.566,70.97,156.64,71.0,156.567,70.993,156.567,71.064,155.886,71.069,153.654,70.831,153.732,70.807,153.647,70.829,153.654,70.866,153.162,70.811,153.241,70.781,153.248,70.749,153.16,70.81,153.164,70.827,153.197,70.834,153.185,70.839,153.148,70.825,152.935,70.813,152.601,70.817,152.564,70.809,152.555,70.756,152.521,70.736,152.441,70.743,152.543,70.718,152.539,70.71,152.465,70.72,152.454,70.712,152.462,70.708,152.49,70.712,152.505,70.71,152.503,70.69,152.496,70.683,152.464,70.686,152.463,70.684,152.484,70.683,152.486,70.681,152.448,70.671,152.473,70.676,152.483,70.675,152.514,70.654,152.521,70.654,152.533,70.659,152.54,70.66,152.524,70.652,152.512,70.653,152.503,70.657,152.479,70.674,152.447,70.671,152.462,70.686,152.501,70.69,152.45,70.713,152.466,70.721,152.54,70.716,152.436,70.741,152.444,70.746,152.463,70.748,152.53,70.743,152.524,70.824,151.677,70.967,151.606,70.959,151.545,70.959,151.526,70.954,151.415,70.973,151.407,70.958,151.291,70.956,151.292,70.946,151.282,70.94,151.085,70.947,151.082,70.939,151.025,70.898,151.051,70.887,151.072,70.807,151.114,70.798,151.087,70.775,151.027,70.867,151.049,70.887,151.026,70.896,151.023,70.898,151.025,70.905,151.054,70.923,151.017,70.924,150.987,70.911,150.94,70.908,150.899,70.91,150.882,70.927,150.854,70.918,150.814,70.919,150.804,70.91,150.784,70.924,150.774,70.915,150.756,70.909,150.738,70.906,150.72,70.905,150.55,70.909,150.418,70.934,150.384,70.928,150.37,70.918,150.337,70.906,150.33,70.905,150.303,70.905,150.248,70.914,150.234,70.913,150.239,70.9,150.202,70.896,150.217,70.89,150.184,70.868,150.164,70.868,150.216,70.89,149.944,70.872,149.701,70.904,149.628,70.929,149.566,70.878,149.544,70.868,149.514,70.861,149.468,70.86,149.407,70.871,149.224,70.862,149.226,70.865,149.049,70.798,148.766,70.789,148.765,70.693,148.712,70.641,148.288,70.601,148.179,70.6,148.073,70.618,148.071,70.584,148.031,70.583,148.06,70.575,148.057,70.565,148.05,70.563,148.019,70.568,148.026,70.565,148.037,70.552,148.033,70.551,148.014,70.557,148.012,70.551,147.98,70.549,147.99,70.543,148.026,70.54,148.035,70.537,147.989,70.543,147.978,70.546,147.979,70.55,147.984,70.551,148.011,70.552,148.014,70.558,148.035,70.552,148.017,70.568,148.034,70.569,148.056,70.565,148.027,70.582,148.047,70.588,148.069,70.586,148.028,70.6,148.074,70.619,147.862,70.606,147.921,70.554,147.892,70.526,147.844,70.516,147.712,70.519,147.702,70.51,147.738,70.475,147.732,70.465,147.524,70.382,147.592,70.334,147.552,70.325,147.439,70.331,147.474,70.258,147.461,70.23,147.291,70.215,147.476,70.209,147.485,70.167,147.468,70.125,147.363,70.045,147.552,70.024,147.599,70.015,147.608,70.005,147.585,69.964,147.578,69.958,147.528,69.931,147.541,69.875,147.46,69.767,147.472,69.731,147.519,69.714,147.518,69.7,147.491,69.682,147.389,69.661,147.387,69.642,147.458,69.626,147.498,69.569,147.557,69.589,147.626,69.587,147.657,69.57,147.685,69.608,147.732,69.607,147.671,69.582,147.754,69.576,147.756,69.571,147.686,69.56,147.716,69.559,147.744,69.539,147.778,69.539,147.815,69.529,147.843,69.533,147.803,69.539,147.797,69.542,147.798,69.545,147.85,69.548,147.872,69.539,147.819,69.527,147.741,69.535,147.621,69.571,147.654,69.475,147.606,69.438,147.674,69.413,147.709,69.388,147.705,69.38,147.72,69.382,147.718,69.381,147.738,69.379,147.742,69.377,147.742,69.376,147.377,69.315,147.375,69.294,147.408,69.281,147.411,69.266,147.517,69.236,147.511,69.21,147.563,69.202,147.597,69.162,147.578,69.1,147.455,69.126,147.544,69.063,147.432,69.012,147.437,68.978,147.587,68.916,147.608,68.877,147.568,68.859,147.493,68.862,147.353,68.913,147.408,68.831,147.394,68.792,147.323,68.782,147.199,68.818,147.146,68.78,147.07,68.773,147.124,68.74,147.122,68.72,147.001,68.638,147.108,68.613,147.122,68.602,147.123,68.599,147.035,68.511,146.598,68.507,146.253,68.564,146.214,68.514,146.04,68.508,145.941,68.417,145.863,68.388,146.035,68.337,146.056,68.319,146.055,68.291,146.064,68.284,146.063,68.281,146.076,68.279,146.082,68.272,146.106,68.27,146.132,68.265,146.139,68.254,146.139,68.25,146.135,68.246,146.096,68.231,146.09,68.217,146.084,68.214,146.068,68.211,146.056,68.212,146.042,68.216,146.031,68.212,146.138,68.254,145.79,68.311,145.911,68.265,145.875,68.248,145.8,68.235,145.622,68.249,145.726,68.188,145.725,68.175,145.695,68.165,145.339,68.191,145.391,68.111,145.196,68.075,145.344,68.045,145.363,68.02,145.322,68.013,145.238,68.019,145.242,67.982,145.228,67.974,145.182,67.961,145.17,67.959,144.995,67.987,145.134,67.924,145.136,67.891,145.032,67.827,144.998,67.819,144.943,67.816,144.887,67.835,144.844,67.807,144.612,67.784,144.585,67.789,144.575,67.802,144.549,67.804,144.493,67.787,144.5,67.775,144.544,67.782,144.58,67.773,144.549,67.72,144.623,67.737,144.765,67.699,144.759,67.683,144.704,67.675,144.758,67.653,144.762,67.64,144.682,67.603,144.666,67.6,144.642,67.607,144.556,67.598,144.586,67.615,144.618,67.607,144.646,67.617,144.69,67.624,144.604,67.614,144.59,67.631,144.583,67.614,144.445,67.605,144.415,67.61,144.4,67.604,144.391,67.603,144.38,67.604,144.382,67.612,144.513,67.617,144.425,67.637,144.38,67.612,144.225,67.595,144.178,67.598,144.159,67.619,144.129,67.6,144.052,67.584,143.973,67.581,143.997,67.586,143.853,67.585,143.706,67.599,143.582,67.633,143.563,67.618,143.542,67.614,143.357,67.601,143.377,67.612,143.275,67.582,143.113,67.571,143.061,67.536,143.038,67.523,143.01,67.515,143.017,67.515,142.722,67.43,142.696,67.428,142.663,67.433,142.611,67.452,142.578,67.449,142.65,67.431,142.678,67.414,142.655,67.362,142.64,67.351,142.617,67.345,142.507,67.355,142.512,67.325,142.492,67.31,142.603,67.336,142.634,67.332,142.656,67.319,142.627,67.262,142.614,67.279,142.609,67.266,142.56,67.242,142.589,67.244,142.572,67.223,142.586,67.216,142.583,67.227,142.603,67.234,142.603,67.246,142.616,67.247,142.616,67.253,142.628,67.261,142.631,67.267,142.63,67.261,142.617,67.251,142.617,67.246,142.604,67.244,142.605,67.233,142.585,67.226,142.592,67.225,142.596,67.209,142.582,67.194,142.647,67.175,142.664,67.132,142.612,67.069,142.654,67.06,142.715,66.958,142.658,66.966,142.699,66.952,142.742,66.906,142.875,66.653,142.868,66.645,142.846,66.638,142.918,66.615,142.891,66.61,142.992,66.612,143.009,66.609,143.022,66.605,143.247,66.416,143.239,66.416,143.243,66.408,143.23,66.398,143.252,66.397,143.27,66.394,143.271,66.394,143.276,66.382,143.297,66.379,143.308,66.363,143.297,66.366,143.3,66.359,143.287,66.353,143.375,66.334,143.408,66.31,143.404,66.304,143.398,66.302,143.414,66.298,143.397,66.302,143.388,66.322,143.367,66.316,143.373,66.334,143.284,66.352,143.296,66.379,143.282,66.378,143.249,66.396,143.243,66.395,143.227,66.398,143.236,66.42,143.156,66.43,143.274,66.343,143.222,66.315,143.263,66.292,143.257,66.285,143.279,66.286,143.303,66.266,143.257,66.272,143.279,66.264,143.269,66.172,143.258,66.16,143.206,66.146,143.204,66.105,143.198,66.1,143.16,66.091,143.219,66.07,143.202,66.02,143.174,65.987,143.111,65.952,143.102,65.932,143.062,65.918,143.088,65.877,143.02,65.833,142.981,65.823,142.906,65.83,142.896,65.803,142.866,65.777,142.772,65.746,142.762,65.72,142.775,65.693,142.772,65.691,142.647,65.685,142.647,65.68,142.678,65.667,142.682,65.659,142.63,65.545,142.595,65.535,142.617,65.521,142.62,65.513,142.615,65.497,142.637,65.487,142.655,65.412,142.732,65.429,142.768,65.42,142.79,65.399,142.876,65.384,142.907,65.39,142.921,65.389,142.989,65.372,143.08,65.373,143.108,65.363,143.15,65.382,143.189,65.388,143.202,65.375,143.256,65.375,143.279,65.371,143.287,65.365,143.304,65.341,143.189,65.296,143.151,65.287,143.218,65.229,143.184,65.177,143.107,65.149,143.149,65.133,143.153,65.128,143.149,65.113,143.139,65.104,143.111,65.101,143.096,65.105,143.089,65.129,143.076,65.129,143.063,65.107,143.043,65.095,143.195,65.051,143.312,64.944,143.302,64.911,143.267,64.896,143.314,64.896,143.344,64.888,143.355,64.826,143.43,64.825,143.445,64.818,143.448,64.811,143.318,64.767,143.354,64.725,143.357,64.702,143.346,64.688,143.319,64.682,143.253,64.685,143.313,64.66,143.326,64.641,143.253,64.6,143.268,64.599,143.285,64.59,143.314,64.587,143.358,64.562,143.384,64.564,143.412,64.561,143.42,64.557,143.463,64.559,143.502,64.557,143.532,64.55,143.365,64.559,143.252,64.598,143.242,64.577,143.124,64.536,142.898,64.509,142.826,64.455,142.805,64.47,142.805,64.459,142.82,64.452,142.822,64.443,142.789,64.409,142.816,64.378,142.817,64.366,142.81,64.368,142.792,64.351,142.812,64.332,142.802,64.334,142.806,64.316,142.797,64.316,142.782,64.376,142.801,64.47,142.849,64.477,142.865,64.499,142.967,64.536,143.242,64.586,143.252,64.606,143.274,64.626,143.321,64.644,143.249,64.685,143.35,64.701,143.313,64.767,143.445,64.813,143.351,64.824,143.261,64.898,143.308,64.945,143.04,65.093,143.068,65.129,143.08,65.133,143.149,65.117,143.107,65.139,143.106,65.154,143.214,65.23,143.147,65.288,143.279,65.368,143.203,65.373,143.184,65.384,143.117,65.361,143.0,65.368,142.915,65.388,142.879,65.382,142.795,65.394,142.755,65.407,142.737,65.427,142.656,65.41,142.612,65.459,142.591,65.536,142.679,65.662,142.64,65.683,142.687,65.702,142.77,65.692,142.758,65.72,142.768,65.746,142.903,65.832,143.084,65.877,143.054,65.915,143.198,66.023,143.156,66.091,143.271,66.259,143.257,66.263,143.244,66.301,143.217,66.316,143.227,66.357,143.141,66.384,143.133,66.422,143.152,66.435,143.135,66.439,143.15,66.437,143.154,66.432,143.178,66.441,143.125,66.447,143.012,66.537,143.049,66.561,143.016,66.55,143.008,66.595,142.923,66.598,142.882,66.611,142.838,66.639,142.6,67.035,142.619,67.051,142.615,67.05,142.598,67.055,142.587,67.071,142.592,67.093,142.618,67.13,142.638,67.132,142.623,67.133,142.612,67.156,142.644,67.163,142.605,67.157,142.546,67.212,142.548,67.247,142.571,67.284,142.649,67.319,142.508,67.295,142.485,67.31,142.491,67.35,142.511,67.359,142.542,67.362,142.629,67.351,142.672,67.406,142.662,67.421,142.572,67.442,142.57,67.449,142.624,67.455,142.658,67.45,142.672,67.436,142.73,67.434,143.108,67.575,143.489,67.63,143.481,67.634,143.468,67.631,143.443,67.634,143.506,67.637,143.575,67.635,143.609,67.64,143.625,67.64,143.743,67.626,143.75,67.616,143.773,67.61,143.773,67.605,143.899,67.607,143.961,67.588,143.968,67.603,143.985,67.611,144.094,67.624,144.121,67.616,144.167,67.622,144.197,67.614,144.199,67.626,144.232,67.624,144.212,67.613,144.193,67.613,144.212,67.603,144.214,67.613,144.228,67.617,144.242,67.635,144.346,67.646,144.736,67.635,144.689,67.674,144.75,67.7,144.543,67.717,144.572,67.77,144.501,67.769,144.481,67.789,144.548,67.809,144.581,67.804,144.61,67.788,144.846,67.82,144.931,67.87,144.874,67.883,144.857,67.874,144.856,67.864,144.845,67.855,144.797,67.852,144.772,67.839,144.75,67.84,144.734,67.856,144.732,67.872,144.699,67.869,144.694,67.87,144.69,67.874,144.707,67.884,144.732,67.889,144.726,67.889,144.766,67.893,144.78,67.905,144.624,67.895,144.51,67.952,144.571,67.972,144.488,67.966,144.448,67.943,144.423,67.94,144.477,67.966,144.567,67.977,144.579,67.975,144.588,67.967,144.516,67.951,144.625,67.897,144.779,67.907,144.78,67.893,144.699,67.872,144.734,67.874,144.76,67.841,144.877,67.886,145.05,67.849,145.122,67.927,144.984,67.992,145.197,67.976,145.234,67.996,145.221,68.011,145.215,68.01,145.21,68.005,145.205,68.004,145.19,68.007,145.179,68.012,145.174,68.017,145.178,68.035,145.171,68.035,145.352,68.027,145.192,68.064,145.062,68.039,145.285,68.129,145.299,68.13,145.332,68.124,145.347,68.127,145.311,68.2,145.709,68.172,145.713,68.191,145.601,68.253,145.619,68.263,145.67,68.267,145.818,68.245,145.88,68.26,145.737,68.285,145.795,68.317,146.047,68.302,145.741,68.387,145.736,68.394,145.777,68.408,145.764,68.415,145.73,68.411,145.717,68.414,145.725,68.463,145.673,68.463,145.706,68.449,145.709,68.445,145.7,68.442,145.543,68.455,145.406,68.519,145.409,68.526,145.459,68.531,145.459,68.54,145.413,68.546,145.396,68.524,145.388,68.521,145.374,68.521,145.273,68.558,145.275,68.564,145.279,68.564,145.277,68.565,145.344,68.574,145.347,68.582,145.336,68.583,145.317,68.572,145.304,68.569,145.295,68.569,145.258,68.583,145.263,68.603,145.252,68.604,145.228,68.599,145.213,68.598,145.204,68.61,145.196,68.601,145.182,68.595,145.166,68.594,145.149,68.595,145.143,68.598,145.15,68.615,145.127,68.627,145.087,68.608,145.075,68.606,145.057,68.606,144.985,68.627,144.984,68.635,145.011,68.643,144.996,68.652,144.971,68.63,144.945,68.624,144.902,68.622,144.915,68.616,144.906,68.592,144.84,68.585,144.883,68.575,144.892,68.565,144.891,68.554,144.885,68.549,144.874,68.547,144.85,68.548,144.828,68.568,144.774,68.564,144.828,68.536,144.811,68.521,144.785,68.518,144.765,68.519,144.752,68.528,144.764,68.542,144.744,68.548,144.678,68.535,144.636,68.533,144.638,68.524,144.677,68.523,144.692,68.517,144.668,68.488,144.635,68.479,144.685,68.519,144.595,68.518,144.528,68.534,144.518,68.522,144.547,68.511,144.551,68.507,144.55,68.503,144.532,68.499,144.471,68.507,144.468,68.501,144.509,68.495,144.517,68.487,144.465,68.474,144.446,68.455,144.413,68.447,144.38,68.447,144.366,68.451,144.358,68.457,144.38,68.511,144.323,68.513,144.289,68.535,144.313,68.511,144.307,68.505,144.255,68.488,144.283,68.469,144.278,68.46,144.268,68.457,144.24,68.453,144.215,68.455,144.191,68.471,144.183,68.529,144.151,68.497,144.068,68.486,144.055,68.488,144.051,68.49,144.043,68.5,144.017,68.506,144.014,68.522,143.996,68.524,143.989,68.519,144.002,68.512,144.0,68.508,143.949,68.512,143.964,68.498,143.963,68.495,143.955,68.492,143.875,68.503,143.926,68.489,143.921,68.484,143.904,68.478,143.909,68.453,143.907,68.45,143.898,68.447,143.878,68.447,143.862,68.449,143.843,68.479,143.814,68.452,143.781,68.439,143.763,68.437,143.746,68.438,143.703,68.454,143.677,68.432,143.572,68.432,143.558,68.434,143.55,68.44,143.555,68.453,143.478,68.467,143.466,68.462,143.489,68.454,143.489,68.446,143.47,68.442,143.41,68.444,143.413,68.412,143.398,68.405,143.373,68.402,143.348,68.402,143.335,68.404,143.3,68.429,143.274,68.413,143.259,68.411,143.244,68.413,143.239,68.416,143.239,68.421,143.258,68.433,143.232,68.44,143.227,68.448,143.223,68.438,143.211,68.433,143.16,68.438,143.189,68.425,143.192,68.419,143.168,68.415,143.16,68.409,143.151,68.408,143.011,68.414,143.005,68.417,143.009,68.421,143.035,68.425,142.965,68.436,142.937,68.47,142.915,68.465,142.872,68.466,142.852,68.455,142.825,68.448,142.814,68.45,142.809,68.454,142.814,68.462,142.844,68.455,142.851,68.472,142.941,68.471,143.152,68.408,143.156,68.438,143.226,68.449,143.261,68.434,143.245,68.414,143.3,68.43,143.358,68.403,143.381,68.439,143.384,68.434,143.398,68.442,143.419,68.447,143.486,68.447,143.463,68.461,143.477,68.468,143.558,68.454,143.57,68.433,143.84,68.481,143.888,68.461,143.865,68.45,143.898,68.449,143.872,68.503,143.996,68.525,144.015,68.523,144.067,68.487,144.148,68.498,144.15,68.522,144.169,68.531,144.19,68.529,144.254,68.456,144.253,68.489,144.274,68.501,144.309,68.511,144.266,68.526,144.288,68.536,144.385,68.511,144.415,68.449,144.469,68.509,144.547,68.503,144.516,68.521,144.524,68.535,144.742,68.551,144.767,68.543,144.761,68.523,144.807,68.522,144.77,68.564,144.815,68.573,144.882,68.549,144.882,68.571,144.835,68.582,144.839,68.589,144.904,68.593,144.9,68.624,144.988,68.653,144.999,68.652,145.01,68.65,145.015,68.644,144.986,68.629,145.057,68.607,145.127,68.628,145.154,68.617,145.156,68.595,145.204,68.611,145.265,68.604,145.295,68.57,145.332,68.584,145.348,68.583,145.348,68.574,145.274,68.559,145.366,68.523,145.41,68.547,145.458,68.544,145.467,68.539,145.461,68.53,145.409,68.519,145.547,68.455,145.702,68.443,145.667,68.463,145.699,68.468,145.726,68.465,145.781,68.409,145.752,68.388,145.842,68.413,145.93,68.419,145.969,68.507,146.256,68.577,146.519,68.55,146.612,68.515,146.692,68.561,146.741,68.571,147.038,68.528,147.071,68.606,146.988,68.628,146.969,68.644,147.058,68.774,147.185,68.823,147.379,68.817,147.34,68.923,147.516,68.863,147.56,68.866,147.577,68.875,147.536,68.869,147.57,68.906,147.437,68.953,147.399,68.987,147.414,68.994,147.405,69.009,147.422,69.03,147.493,69.057,147.417,69.144,147.571,69.149,147.488,69.18,147.489,69.24,147.359,69.271,147.37,69.253,147.324,69.231,147.336,69.209,147.26,69.187,147.315,69.159,147.322,69.141,147.341,69.141,147.321,69.141,147.258,69.183,147.26,69.187,147.284,69.203,147.335,69.209,147.323,69.232,147.364,69.252,147.353,69.264,147.355,69.322,147.343,69.331,147.494,69.391,147.661,69.405,147.571,69.442,147.642,69.484,147.547,69.514,147.533,69.528,147.546,69.55,147.605,69.577,147.445,69.563,147.363,69.654,147.487,69.704,147.398,69.751,147.448,69.737,147.482,69.937,147.55,69.966,147.567,69.994,147.369,70.032,147.351,70.05,147.379,70.089,147.282,70.092,147.263,70.097,147.252,70.105,147.258,70.115,147.291,70.123,147.459,70.124,147.275,70.215,147.417,70.258,147.371,70.365,147.387,70.38,147.435,70.391,147.364,70.435,147.367,70.444,147.378,70.449,147.46,70.455,147.445,70.471,147.447,70.494,147.316,70.461,147.228,70.47,147.268,70.445,147.268,70.421,147.144,70.429,147.067,70.452,147.016,70.448,147.002,70.394,146.967,70.378,146.926,70.396,146.92,70.382,146.873,70.366,146.928,70.369,146.96,70.35,146.96,70.343,146.953,70.341,146.935,70.341,146.886,70.346,146.895,70.337,146.862,70.31,146.814,70.291,146.835,70.287,146.831,70.279,146.738,70.257,146.768,70.256,146.791,70.247,146.79,70.242,146.743,70.238,146.736,70.259,146.869,70.351,146.958,70.344,146.869,70.365,147.015,70.45,147.05,70.456,147.232,70.427,147.22,70.476,147.426,70.521,147.446,70.524,147.466,70.522,147.503,70.506,147.624,70.502,147.41,70.56,147.712,70.582,147.774,70.578,147.838,70.567,147.84,70.599,147.88,70.617,148.032,70.648,148.148,70.635,148.056,70.706,148.059,70.709,148.091,70.712,148.129,70.701,148.147,70.708,148.124,70.711,148.111,70.715,148.114,70.723,148.096,70.721,148.056,70.724,148.015,70.74,147.958,70.725,147.922,70.744,147.888,70.732,147.879,70.731,147.865,70.732,147.855,70.734,147.846,70.745,147.832,70.748,147.823,70.764,147.8,70.757,147.751,70.761,147.745,70.763,147.745,70.765,147.766,70.773,147.748,70.772,147.74,70.78,147.721,70.776,147.706,70.776,147.703,70.779,147.712,70.785,147.708,70.786,147.695,70.783,147.678,70.782,147.623,70.789,147.58,70.807,147.565,70.822,147.531,70.816,147.485,70.824,147.529,70.817,147.556,70.823,147.565,70.823,147.572,70.822,147.629,70.789,147.694,70.808,147.726,70.795,147.659,70.791,147.712,70.787,147.713,70.785,147.706,70.779,147.709,70.776,147.743,70.781,147.769,70.772,147.752,70.762,147.825,70.765,147.873,70.732,147.921,70.745,147.96,70.726,148.01,70.741,148.105,70.722,148.088,70.744,148.117,70.75,148.154,70.754,148.213,70.735,148.222,70.752,148.246,70.761,148.236,70.791,148.251,70.798,148.467,70.853,148.517,70.822,148.506,70.866,148.527,70.885,148.563,70.89,148.535,70.897,148.533,70.901,148.541,70.902,148.596,70.897,148.617,70.901,148.598,70.915,148.578,70.915,148.575,70.919,148.619,70.921,148.646,70.919,148.689,70.91,148.735,70.913,148.761,70.909,148.816,70.917,148.844,70.915,148.823,70.925,148.821,70.935,148.872,70.953,148.945,70.956,148.884,70.963,148.87,70.981,149.049,70.992,149.121,70.991,149.052,71.014,149.227,71.077,149.331,71.092,149.228,71.105,149.294,71.134,149.387,71.148,149.323,71.18,149.494,71.205,149.458,71.266,149.562,71.302,149.426,71.315,149.517,71.317,149.448,71.346,149.618,71.41,149.646,71.409,149.688,71.39,149.726,71.391,149.765,71.438,149.866,71.461,149.871,71.488,149.899,71.526,149.887,71.531,149.862,71.565,149.842,71.574,149.85,71.586,149.903,71.525,149.888,71.498,150.034,71.533,149.994,71.58,150.0,71.6,150.028,71.605,150.013,71.618,150.026,71.636,150.036,71.633,149.984,71.654,149.744,71.631,149.82,71.622,149.856,71.59,149.85,71.586,149.74,71.623,149.744,71.655,149.14,71.697,149.03,71.644,149.09,71.635,149.124,71.616,149.158,71.582,149.163,71.57,149.125,71.537,149.215,71.503,149.212,71.498,149.186,71.487,149.214,71.484,149.238,71.479,149.251,71.471,149.273,71.388,149.123,71.536,149.13,71.604,149.009,71.644,149.014,71.668,148.933,71.685,148.829,71.666,148.807,71.626,148.773,71.592,148.692,71.571,148.703,71.559,148.662,71.536,148.682,71.532,148.687,71.528,148.658,71.524,148.671,71.517,148.675,71.496,148.674,71.46,148.65,71.446,148.664,71.433,148.661,71.431,148.64,71.426,148.559,71.434,148.555,71.436,148.556,71.448,148.52,71.439,148.498,71.443,148.482,71.45,148.482,71.455,148.493,71.458,148.532,71.461,148.551,71.463,148.554,71.463,148.484,71.453,148.555,71.449,148.562,71.446,148.561,71.434,148.66,71.432,148.646,71.447,148.672,71.494,148.656,71.524,148.684,71.529,148.66,71.534,148.66,71.537,148.696,71.577,148.771,71.595,148.814,71.65,148.8,71.663,149.091,71.761,148.936,71.771,148.808,71.843,148.763,71.722,148.759,71.718,148.752,71.717,148.726,71.719,148.788,71.843,148.828,71.847,148.868,71.836,148.93,71.787,149.115,71.76,149.475,71.89,149.703,71.747,150.12,71.876,149.798,72.074,149.037,72.244,148.382,72.32,147.961,72.313,147.489,72.326,147.134,72.304,146.344,71.918,146.468,71.899,146.357,71.909,146.252,71.861,146.337,71.878,146.362,71.879,146.382,71.878,146.392,71.876,146.39,71.865,146.378,71.857,145.38,71.652,145.42,71.622,145.424,71.6,145.403,71.58,145.294,71.53,145.291,71.508,145.232,71.496,145.249,71.487,145.213,71.474,145.254,71.459,145.202,71.446,145.201,71.433,145.198,71.432,145.182,71.431,145.193,71.43,145.198,71.424,145.243,71.417,145.287,71.404,145.287,71.4,145.282,71.398,145.227,71.396,145.254,71.392,145.268,71.384,145.272,71.374,145.287,71.378,145.297,71.379,145.321,71.376,145.325,71.364,145.321,71.358,145.316,71.377,145.296,71.379,145.275,71.374,145.267,71.375,145.253,71.391,145.233,71.391,145.227,71.394,145.225,71.398,145.282,71.405,145.197,71.423,145.179,71.431,145.198,71.446,145.251,71.46,145.176,71.479,145.409,71.605,145.341,71.647,145.332,71.68,145.008,71.688,144.934,71.637,144.801,71.632,144.823,71.606,144.786,71.589,144.775,71.592,144.782,71.61,144.752,71.597,144.739,71.598,144.73,71.605,144.684,71.613,144.684,71.601,144.678,71.596,144.659,71.594,144.628,71.599,144.627,71.591,144.663,71.584,144.666,71.578,144.655,71.572,144.644,71.57,144.612,71.574,144.615,71.558,144.664,71.565,144.675,71.562,144.685,71.552,144.634,71.543,144.613,71.524,144.631,71.525,144.651,71.539,144.661,71.541,144.718,71.54,144.734,71.537,144.739,71.53,144.728,71.527,144.684,71.533,144.644,71.523,144.65,71.518,144.691,71.526,144.706,71.527,144.712,71.525,144.705,71.507,144.67,71.491,144.693,71.481,144.675,71.471,144.57,71.468,144.594,71.466,144.601,71.462,144.58,71.444,144.628,71.448,144.638,71.445,144.633,71.434,144.594,71.429,144.631,71.43,144.643,71.428,144.649,71.424,144.611,71.412,144.588,71.409,144.504,71.421,144.501,71.401,144.493,71.399,144.46,71.4,144.454,71.395,144.487,71.396,144.522,71.38,144.526,71.374,144.52,71.367,144.512,71.365,144.502,71.367,144.502,71.374,144.47,71.379,144.41,71.375,144.482,71.374,144.492,71.367,144.482,71.351,144.403,71.349,144.404,71.347,144.44,71.34,144.447,71.336,144.403,71.296,144.429,71.302,144.454,71.303,144.461,71.302,144.465,71.296,144.432,71.291,144.477,71.288,144.516,71.282,144.52,71.279,144.511,71.274,144.48,71.269,144.422,71.274,144.412,71.267,144.444,71.271,144.49,71.258,144.493,71.254,144.485,71.249,144.477,71.249,144.434,71.255,144.407,71.234,144.357,71.231,144.414,71.226,144.417,71.22,144.417,71.215,144.374,71.209,144.394,71.21,144.4,71.209,144.403,71.208,144.4,71.189,144.42,71.184,144.415,71.177,144.485,71.18,144.523,71.166,144.525,71.16,144.516,71.156,144.504,71.157,144.483,71.167,144.427,71.153,144.473,71.159,144.52,71.149,144.519,71.145,144.505,71.141,144.497,71.14,144.473,71.144,144.473,71.126,144.511,71.137,144.526,71.116,144.58,71.12,144.59,71.118,144.595,71.115,144.567,71.066,144.555,71.064,144.53,71.065,144.522,71.066,144.521,71.088,144.477,71.069,144.468,71.069,144.464,71.07,144.593,71.115,144.524,71.115,144.509,71.136,144.489,71.124,144.479,71.124,144.469,71.126,144.471,71.144,144.519,71.148,144.472,71.158,144.446,71.15,144.428,71.15,144.425,71.154,144.483,71.168,144.516,71.157,144.52,71.166,144.413,71.176,144.371,71.209,144.411,71.225,144.355,71.231,144.432,71.256,144.49,71.255,144.412,71.265,144.411,71.27,144.42,71.274,144.517,71.279,144.43,71.29,144.45,71.303,144.411,71.294,144.4,71.296,144.399,71.349,144.487,71.367,144.482,71.373,144.43,71.368,144.411,71.372,144.408,71.375,144.47,71.381,144.499,71.376,144.514,71.367,144.519,71.38,144.484,71.395,144.459,71.392,144.452,71.394,144.449,71.399,144.505,71.422,144.645,71.424,144.59,71.429,144.632,71.446,144.593,71.442,144.576,71.444,144.568,71.468,144.582,71.471,144.673,71.472,144.668,71.492,144.708,71.525,144.65,71.517,144.641,71.523,144.685,71.534,144.736,71.531,144.657,71.539,144.636,71.524,144.609,71.523,144.625,71.543,144.682,71.553,144.629,71.549,144.592,71.562,144.622,71.601,144.686,71.614,144.735,71.605,144.748,71.597,144.78,71.611,144.792,71.606,144.788,71.593,144.818,71.606,144.783,71.626,144.806,71.636,144.844,71.64,144.856,71.65,144.821,71.659,144.801,71.643,144.766,71.642,144.734,71.636,144.718,71.636,144.82,71.66,144.878,71.651,144.858,71.655,144.852,71.665,144.818,71.669,144.792,71.656,144.756,71.65,144.775,71.659,144.826,71.675,144.931,71.64,144.944,71.724,144.996,71.73,144.91,71.765,145.229,71.834,145.037,71.899,145.027,71.942,145.061,71.952,145.027,71.951,145.005,71.96,145.022,71.969,145.002,71.964,144.985,71.965,144.946,71.994,145.299,71.898,145.339,71.863,145.841,71.937,145.803,72.03,145.661,72.025,145.629,72.091,145.811,72.216,145.77,72.225,145.731,72.208,145.733,72.204,145.725,72.201,145.675,72.201,145.722,72.201,145.73,72.206,145.494,72.251,145.738,72.254,145.949,72.185,145.852,72.203,145.875,72.146,146.072,72.211,145.954,72.09,146.0,72.06,145.998,72.106,146.099,72.114,146.048,72.158,146.323,72.121,146.085,72.025,146.003,72.045,145.982,71.862,146.957,72.305,145.208,72.262,144.548,72.151,144.242,72.221,144.215,72.18,144.141,72.171,144.136,72.171,144.13,72.172,144.114,72.185,144.055,72.203,144.03,72.203,144.083,72.179,144.077,72.175,144.056,72.176,144.051,72.167,144.045,72.165,144.035,72.165,143.978,72.181,144.08,72.179,144.023,72.2,144.025,72.202,144.038,72.205,144.055,72.204,144.113,72.188,144.136,72.171,144.2,72.181,144.204,72.223,144.264,72.237,144.141,72.27,144.15,72.255,144.143,72.245,144.126,72.24,144.116,72.24,144.07,72.25,144.092,72.242,144.094,72.238,144.09,72.236,144.081,72.236,144.068,72.241,144.036,72.237,144.058,72.234,144.064,72.22,144.041,72.208,144.032,72.208,144.025,72.211,144.034,72.237,144.066,72.243,144.091,72.238,144.069,72.251,144.13,72.243,144.13,72.27,144.314,72.282,144.495,72.228,144.766,72.39,144.749,72.393,144.733,72.405,144.725,72.405,144.719,72.401,144.699,72.397,144.693,72.397,144.68,72.399,144.698,72.398,144.723,72.406,144.736,72.406,144.757,72.394,144.967,72.431,145.119,72.395,145.255,72.452,145.392,72.335,146.896,72.338,144.46,72.629,141.955,72.718,140.716,72.87,139.974,73.342,140.689,73.426,142.49,73.219,143.611,73.211,143.414,73.343,143.479,73.351,143.466,73.461,143.484,73.461,143.5,73.465,143.412,73.486,143.411,73.547,143.076,73.658,143.032,73.658,143.052,73.646,142.964,73.626,142.973,73.617,142.958,73.614,142.952,73.616,142.864,73.644,143.052,73.662,142.068,73.908,141.123,73.855,140.912,73.758,141.061,73.763,141.069,73.752,141.012,73.747,141.035,73.74,141.007,73.735,141.004,73.731,141.024,73.733,141.054,73.724,141.016,73.717,141.011,73.723,141.006,73.713,140.995,73.716,140.984,73.712,140.975,73.714,140.964,73.707,140.949,73.71,140.975,73.703,141.072,73.717,141.112,73.703,141.117,73.697,140.999,73.69,141.083,73.693,141.1,73.683,141.152,73.687,141.193,73.681,141.25,73.637,141.191,73.68,141.094,73.681,141.076,73.689,140.984,73.687,140.962,73.701,140.926,73.704,140.957,73.699,140.955,73.68,141.02,73.682,140.996,73.678,140.995,73.67,141.005,73.676,141.015,73.677,141.047,73.669,141.056,73.68,141.094,73.664,140.972,73.645,140.947,73.608,140.858,73.609,140.881,73.631,140.851,73.646,140.868,73.624,140.816,73.622,140.861,73.621,140.855,73.613,140.815,73.608,140.796,73.618,140.804,73.61,140.821,73.606,140.857,73.608,140.853,73.599,140.968,73.608,140.965,73.611,140.991,73.606,141.016,73.585,141.026,73.599,141.11,73.604,141.126,73.599,141.086,73.553,141.122,73.599,141.018,73.583,140.794,73.603,140.363,73.442,139.836,73.43,139.076,74.635,139.434,74.696,139.694,74.852,139.54,74.847,139.472,74.921,139.718,74.98,139.644,75.017,139.587,75.019,139.579,75.024,139.646,75.02,139.698,75.007,139.739,74.976,139.772,74.98,139.797,74.981,139.839,74.973,139.705,74.966,139.79,74.958,139.797,74.939,139.829,74.941,139.845,74.964,139.863,74.967,139.859,74.933,139.932,74.941,139.88,74.956,139.893,74.971,139.862,74.968,139.955,75.01,139.985,75.013,139.979,75.027,139.979,75.039,140.0,75.04,139.999,75.017,139.895,74.977,139.936,74.943,139.93,74.938,139.894,74.93,139.896,74.92,139.938,74.92,140.024,74.892,140.105,74.802,139.837,74.803,139.891,74.792,140.188,74.805,140.867,74.886,141.528,74.931,141.597,74.933,141.871,75.0,142.23,75.005,142.443,74.906,142.147,74.968,141.982,74.917,141.909,74.933,142.061,74.89,142.09,74.898,142.111,74.899,142.069,74.888,142.361,74.821,142.343,74.817,142.457,74.819,142.502,74.798,142.721,74.893,143.707,74.949,142.751,75.074,142.202,75.335,142.18,75.473,142.066,75.55,142.193,75.592,141.988,75.608,142.282,75.645,141.982,75.664,142.352,75.658,142.49,75.699,142.295,75.689,142.261,75.737,143.113,75.691,142.93,75.568,142.949,75.564,142.991,75.565,143.005,75.563,142.56,75.461,142.612,75.438,142.525,75.343,142.744,75.3,142.928,75.144,143.057,75.14,143.374,75.049,143.615,75.068,144.399,75.052,144.68,75.15,144.725,75.149,144.779,75.268,144.829,75.251,144.956,75.271,144.81,75.297,144.755,75.317,144.747,75.334,144.668,75.337,144.653,75.35,145.383,75.512,143.899,75.823,142.647,75.849,141.396,76.173,141.68,76.004,141.352,76.036,141.455,76.063,141.19,76.103,141.147,76.043,140.988,76.028,140.919,75.903,140.918,75.708,140.982,75.715,141.071,75.675,140.976,75.667,140.953,75.683,141.002,75.639,141.111,75.655,141.12,75.646,140.9,75.605,140.716,75.618,140.739,75.634,140.722,75.633,140.705,75.626,140.689,75.626,140.669,75.635,140.632,75.635,140.621,75.636,140.606,75.64,140.799,75.667,140.591,75.707,140.568,75.695,140.613,75.681,140.608,75.656,140.467,75.635,140.409,75.672,140.543,75.755,140.388,75.729,140.37,75.735,140.514,75.775,140.159,75.776,139.834,75.877,139.966,75.93,139.839,75.963,139.943,75.956,139.6,75.985,139.218,76.074,139.032,76.177,148.784,76.706,148.478,76.638,149.2,76.631,149.355,76.737,156.626,77.084,180.0,71.711,180.0,71.532,179.916,71.519,179.84,71.499,179.941,71.52,179.956,71.508,179.482,71.41,178.63,71.074,178.842,70.805,178.761,70.87,178.788,70.795,177.457,69.586,176.258,69.755,176.323,69.777,176.091,69.878,175.763,69.879,175.499,69.866,175.448,69.859,175.497,69.865,175.691,69.869,175.739,69.875,175.908,69.866,175.912,69.803,176.055,69.872,176.16,69.818,175.951,69.766,175.718,69.808,175.838,69.861,175.786,69.868,175.787,69.869,175.525,69.862,175.305,69.822,175.179,69.836,175.275,69.836,175.291,69.847,175.441,69.857,174.037,69.848,174.028,69.89,174.041,69.696,174.011,69.816,173.994,69.814,173.963,69.829,173.944,69.822,173.95,69.83,173.901,69.842,173.901,69.851,173.874,69.823,173.841,69.847,173.852,69.868,173.797,69.874,173.788,69.88,173.799,69.867,173.801,69.854,173.84,69.847,173.805,69.851,173.793,69.859,173.786,69.878,173.752,69.874,173.734,69.814,173.467,69.934,173.277,69.911,173.179,69.86,173.288,69.903,173.514,69.799,173.344,69.778,173.336,69.765,173.327,69.766,173.336,69.76,173.33,69.75,173.365,69.738,173.356,69.727,173.279,69.754,173.323,69.707,173.232,69.73,173.262,69.729,173.24,69.738,173.25,69.745,173.227,69.753,173.23,69.763,173.24,69.767,173.173,69.766,173.288,69.799,173.22,69.832,172.846,69.896,172.793,69.915,172.753,69.965,170.46,70.118,170.587,70.066,170.525,69.907,170.573,69.882,170.566,69.811,170.621,69.789,170.628,69.757,170.249,69.705,170.154,69.594,170.641,69.57,170.933,69.272,170.889,69.165,171.031,69.102,170.983,69.049,171.031,69.043,171.044,69.022,171.067,69.036,171.089,69.041,171.098,69.042,171.169,69.033,171.224,69.019,171.26,69.0,171.034,69.011,170.997,69.041,170.646,68.925,170.75,68.882,170.88,68.895,170.887,68.894,170.898,68.888,170.874,68.894,170.809,68.882,170.752,68.881,170.716,68.886,170.768,68.816,170.764,68.812,170.739,68.804,170.737,68.795,170.794,68.798,170.803,68.796,170.806,68.794,170.807,68.787,170.689,68.763,170.704,68.758,170.732,68.763,170.752,68.762,170.763,68.76,170.754,68.761,170.769,68.752,170.765,68.739,170.791,68.748,170.813,68.745,170.825,68.722,170.835,68.723,170.838,68.727,170.846,68.728,170.833,68.736,170.836,68.74,170.845,68.743,170.95,68.721,170.895,68.716,170.948,68.707,170.958,68.695,170.979,68.698,170.992,68.698,170.996,68.695,170.961,68.694,170.894,68.714,170.844,68.742,170.848,68.725,170.789,68.695,170.734,68.686,170.789,68.69,170.834,68.674,170.792,68.675,170.79,68.689,170.733,68.685,170.734,68.687,170.747,68.692,170.787,68.696,170.814,68.719,170.799,68.725,170.773,68.723,170.756,68.73,170.75,68.761,170.684,68.748,170.68,68.748,170.676,68.75,170.672,68.756,170.664,68.756,170.67,68.743,170.648,68.738,170.665,68.734,170.663,68.727,170.641,68.719,170.658,68.734,170.617,68.746,170.584,68.748,170.62,68.744,170.633,68.706,170.625,68.701,170.575,68.696,170.592,68.675,170.553,68.661,170.604,68.666,170.621,68.665,170.631,68.66,170.661,68.657,170.663,68.653,170.66,68.651,170.651,68.651,170.549,68.661,170.571,68.699,170.589,68.703,170.627,68.706,170.623,68.729,170.51,68.769,170.5,68.805,170.421,68.782,170.472,68.778,170.489,68.771,170.481,68.768,170.488,68.762,170.481,68.752,170.441,68.734,170.502,68.74,170.443,68.731,170.437,68.736,170.446,68.744,170.481,68.755,170.472,68.775,170.419,68.776,170.331,68.84,169.59,68.772,169.42,68.851,169.43,68.995,169.331,69.08,168.716,69.198,168.354,69.206,168.303,69.22,168.191,69.418,168.285,69.546,167.939,69.642,168.115,69.65,167.942,69.768,167.723,69.763,167.708,69.747,167.389,69.621,167.408,69.62,167.417,69.611,167.437,69.612,167.441,69.61,167.216,69.555,167.306,69.46,167.295,69.424,167.213,69.55,166.917,69.475,166.907,69.464,166.612,69.453,166.546,69.471,166.503,69.405,166.479,69.413,166.5,69.46,166.572,69.493,166.621,69.501,166.646,69.496,166.731,69.507,164.618,69.576,164.393,69.601,164.465,69.605,164.022,69.754,163.647,69.67,162.363,69.678,162.356,69.632,162.22,69.635,162.044,69.521,161.465,69.348,161.544,69.327,161.626,69.276,161.615,69.261,161.595,69.251,161.486,69.245,161.44,69.202,161.537,69.169,161.492,69.057,161.543,69.037,161.567,68.958,161.646,68.92,161.668,68.884,161.589,68.832,161.504,68.815,161.453,68.813,161.374,68.796,161.319,68.747,161.447,68.74,161.528,68.697,161.529,68.689,161.521,68.671,161.551,68.667,161.632,68.646,161.661,68.628,161.669,68.617,161.519,68.671,161.525,68.697,161.424,68.741,161.446,68.729,161.463,68.715,161.461,68.703,161.447,68.689,161.094,68.505,160.875,68.504,160.891,68.509,160.753,68.526,160.696,68.527,160.696,68.528,160.529,68.465,160.345,68.517,159.502,68.573,159.017,68.661,159.047,68.686,159.234,68.717,159.187,68.745,158.659,68.686,158.602,68.693,158.735,68.616,158.678,68.616,158.733,68.593,158.69,68.556,158.735,68.548,158.734,68.544,158.717,68.537,158.738,68.54,158.752,68.537,158.785,68.494,158.783,68.489,158.776,68.486,158.726,68.479,158.72,68.458,158.715,68.455,158.697,68.453,158.727,68.451,158.733,68.441,158.73,68.439,158.715,68.438,158.724,68.433,158.721,68.418,158.751,68.405,158.756,68.392,158.743,68.386,158.737,68.393],"holes":[[159.264,68.704,159.548,68.653,159.534,68.709,159.557,68.661,159.531,68.631,159.649,68.613,159.954,68.651,159.811,68.654,159.865,68.711,159.62,68.801,159.095,68.806,159.264,68.704],[159.987,68.627,159.374,68.648,160.536,68.477,160.675,68.542,161.106,68.536,161.215,68.646,160.759,68.603,160.237,68.684,159.987,68.627],[161.394,68.868,161.538,68.896,161.438,69.064,161.468,69.177,161.139,69.28,161.13,69.074,161.505,68.948,161.394,68.868],[161.191,69.303,161.378,69.206,161.432,69.281,161.336,69.414,161.437,69.45,161.431,69.518,161.097,69.449,161.191,69.303],[167.788,69.805,168.607,69.592,169.183,69.554,169.302,69.649,169.272,69.757,169.459,69.815,168.53,70.027,168.138,69.992,167.788,69.805],[151.986,71.076,151.691,71.232,151.8,71.182,151.899,71.157,151.785,71.198,151.705,71.248,151.821,71.215,151.697,71.274,151.743,71.251,151.049,71.387,150.584,71.235,150.631,71.137,150.496,71.106,150.569,71.03,150.141,70.928,152.128,70.971,151.982,71.062,151.852,71.055,151.784,71.062,151.686,71.049,151.414,71.077,151.986,71.076],[150.141,70.929,150.57,71.032,150.508,71.098,150.11,71.001,150.007,71.016,150.111,71.002,150.28,71.088,150.559,71.103,150.626,71.144,150.551,71.226,150.646,71.29,150.525,71.22,150.51,71.238,150.752,71.373,150.008,71.208,149.917,71.145,149.987,71.076,149.638,71.031,149.697,70.957,149.574,70.949,150.141,70.929],[149.555,70.946,149.688,70.96,149.597,71.033,149.956,71.087,149.894,71.141,150.0,71.219,149.657,71.163,149.665,71.128,149.617,71.117,149.643,71.104,149.574,71.067,149.641,71.105,149.302,71.133,149.324,71.081,149.072,71.015,149.144,70.992,149.069,70.978,149.154,70.942,149.062,70.922,149.11,70.881,148.926,70.815,149.603,70.929,149.555,70.946],[148.741,70.8,149.132,70.947,148.578,70.917,148.524,70.821,148.059,70.706,148.271,70.605,148.332,70.681,148.699,70.651,148.697,70.746,148.482,70.74,148.741,70.8],[149.4,71.145,149.625,71.118,150.355,71.273,150.447,71.386,150.289,71.379,150.505,71.508,150.126,71.498,149.999,71.379,149.796,71.289,149.864,71.321,149.536,71.323,149.5,71.239,149.789,71.288,149.357,71.178,149.4,71.145],[140.69,73.908,140.53,73.912,141.042,74.009,141.1,74.203,140.652,74.269,140.195,74.193,140.183,74.062,140.352,73.943,140.545,73.898,140.69,73.908],[149.087,74.727,150.644,74.864,150.673,74.993,150.967,75.128,150.513,75.098,150.15,75.203,149.394,75.262,148.769,75.181,148.396,75.242,148.407,75.226,148.316,75.225,148.372,75.206,148.344,75.184,148.384,75.176,148.385,75.165,148.361,75.157,148.413,75.146,148.382,75.141,148.421,75.138,148.465,75.127,148.372,75.067,148.462,75.127,148.371,75.141,148.402,75.148,148.356,75.156,148.38,75.167,148.331,75.187,148.351,75.207,148.244,75.214,148.389,75.243,148.525,75.232,148.582,75.367,147.607,75.441,146.917,75.327,146.641,75.433,146.622,75.492,146.748,75.518,146.515,75.59,146.229,75.385,146.346,75.316,146.111,75.236,147.69,74.953,148.164,74.785,149.087,74.727]]}],"names":{"ar":"بحر سيبيريا الشرقي","de":"Ostsibirische See","es":"mar de Siberia Oriental","fr":"mer de Sibérie orientale","he":"ים סיביר המזרחי","hu":"Kelet-szibériai-tenger","it":"Mar della Siberia Orientale","nl":"Oost-Siberische Zee","pt":"Mar Siberiano Oriental","zh":"东西伯利亚海"}},{"name":"Bay of Bengal","bbox":[78.898,5.75,95.049,24.378],"area":183.1081,"polygons":[{"outer":[79.696,9.079,79.44,9.158,79.314,9.328,79.206,9.28,79.001,9.345,78.997,9.341,79.002,9.333,79.002,9.329,78.999,9.328,78.987,9.327,78.967,9.336,78.898,9.489,79.267,10.038,79.229,10.144,79.295,10.26,79.546,10.348,79.526,10.306,79.674,10.281,79.739,10.278,79.683,10.29,79.674,10.301,79.663,10.289,79.556,10.308,79.551,10.312,79.63,10.363,79.636,10.318,79.71,10.325,79.731,10.311,79.757,10.314,79.778,10.302,79.743,10.275,79.883,10.302,79.838,11.356,79.79,11.368,79.772,11.384,79.758,11.361,79.755,11.358,79.744,11.358,79.743,11.348,79.739,11.342,79.73,11.338,79.713,11.341,79.766,11.39,79.827,11.363,79.78,11.5,79.727,11.447,79.692,11.446,79.773,11.498,79.79,11.878,80.007,12.254,79.945,12.22,80.156,12.466,80.347,13.312,80.326,13.444,80.326,13.43,80.32,13.424,80.331,13.391,80.332,13.376,80.327,13.37,80.329,13.366,80.051,13.589,80.121,13.678,80.139,13.613,80.13,13.723,80.147,13.724,80.264,13.561,80.315,13.434,80.141,14.07,80.129,14.064,80.126,14.064,80.115,14.076,80.115,14.064,80.099,14.051,80.106,14.043,80.098,14.021,80.063,14.0,80.044,13.997,80.017,14.011,79.911,13.943,79.896,13.947,79.895,13.921,79.874,13.889,79.789,13.831,79.765,13.833,79.892,13.949,80.013,14.011,80.061,14.002,80.113,14.077,80.139,14.073,80.135,14.242,80.113,14.252,80.109,14.241,80.098,14.231,80.063,14.228,80.052,14.21,80.037,14.21,80.044,14.198,80.035,14.207,80.036,14.211,80.051,14.212,80.062,14.23,80.09,14.234,80.1,14.256,80.137,14.252,80.199,14.587,80.194,14.599,80.19,14.6,80.197,14.583,80.187,14.573,80.167,14.568,80.14,14.57,80.136,14.566,80.136,14.569,80.144,14.573,80.17,14.571,80.189,14.599,80.059,14.82,80.046,15.209,80.193,15.551,80.34,15.747,80.538,15.855,80.774,15.866,80.791,15.857,80.81,15.837,80.829,15.745,80.901,15.941,80.892,15.955,80.875,16.026,80.889,16.058,80.838,16.147,80.819,16.262,80.801,16.269,80.792,16.278,80.792,16.285,80.769,16.292,80.761,16.315,80.746,16.318,80.752,16.331,80.728,16.345,80.711,16.379,80.714,16.398,80.684,16.424,80.671,16.462,80.631,16.474,80.607,16.501,80.567,16.509,80.548,16.526,80.534,16.551,80.525,16.555,80.528,16.559,80.513,16.567,80.485,16.561,80.413,16.577,80.402,16.561,80.322,16.593,80.248,16.586,80.205,16.602,80.138,16.652,80.115,16.819,80.072,16.813,80.056,16.743,80.033,16.714,79.998,16.699,80.071,16.817,80.099,16.825,80.123,16.818,80.209,16.603,80.505,16.587,80.677,16.459,80.826,16.256,80.883,16.117,80.899,16.059,80.892,16.007,80.9,16.008,80.91,15.985,80.913,15.96,80.914,15.924,80.894,15.857,80.899,15.835,81.011,15.769,81.124,15.991,81.066,16.002,81.016,16.089,81.0,16.084,81.006,16.058,81.003,16.051,80.995,16.045,80.927,16.032,80.916,16.035,80.909,16.041,80.991,16.045,80.999,16.089,81.017,16.091,81.146,15.974,81.265,16.281,81.476,16.395,81.548,16.38,81.553,16.376,81.561,16.345,81.716,16.32,81.697,16.411,81.71,16.444,81.857,16.544,81.747,16.792,81.727,16.917,81.742,17.009,81.73,17.024,81.721,17.057,81.694,17.089,81.704,17.103,81.677,17.11,81.64,17.207,81.638,17.228,81.658,17.273,81.657,17.3,81.611,17.333,81.602,17.37,81.613,17.405,81.584,17.406,81.563,17.438,81.541,17.436,81.526,17.442,81.499,17.48,81.475,17.466,81.44,17.46,81.407,17.465,81.39,17.472,81.367,17.498,81.318,17.487,81.305,17.49,81.291,17.5,81.261,17.564,81.261,17.58,81.283,17.596,81.283,17.635,81.294,17.658,81.327,17.703,81.37,17.71,81.388,17.754,81.372,17.708,81.294,17.653,81.286,17.596,81.261,17.574,81.295,17.498,81.371,17.501,81.414,17.466,81.5,17.483,81.614,17.41,81.68,17.301,81.672,17.135,81.706,17.141,81.736,17.102,81.737,17.082,81.73,17.073,81.749,17.054,81.753,17.042,81.76,17.039,81.753,17.068,81.835,16.806,82.062,16.681,82.081,16.719,82.095,16.726,82.19,16.729,82.294,16.708,82.312,16.733,82.343,16.738,82.366,16.902,82.358,16.963,82.355,16.851,82.249,16.93,82.369,17.118,82.608,17.296,83.213,17.591,83.573,18.018,84.125,18.309,84.783,19.11,84.751,19.082,84.723,19.09,84.704,19.121,84.778,19.11,85.066,19.366,85.055,19.366,85.048,19.367,85.044,19.371,85.011,19.418,85.067,19.372,85.664,19.739,86.364,19.954,86.317,19.95,86.173,20.065,86.149,20.119,86.159,20.17,86.124,20.168,86.092,20.18,86.039,20.205,86.026,20.226,86.013,20.257,86.033,20.299,86.021,20.34,86.026,20.357,85.957,20.363,85.907,20.396,85.901,20.421,86.028,20.359,86.149,20.217,86.167,20.16,86.151,20.119,86.179,20.062,86.29,20.061,86.325,20.033,86.349,19.981,86.408,19.995,86.559,20.2,86.489,20.121,86.488,20.167,86.469,20.146,86.464,20.147,86.462,20.149,86.459,20.158,86.46,20.162,86.469,20.148,86.498,20.19,86.635,20.244,86.606,20.248,86.581,20.267,86.577,20.275,86.636,20.245,86.719,20.29,86.486,20.33,86.558,20.325,86.571,20.337,86.598,20.342,86.61,20.342,86.593,20.362,86.591,20.383,86.594,20.394,86.499,20.389,86.421,20.452,86.411,20.447,86.399,20.45,86.393,20.443,86.387,20.442,86.361,20.461,86.35,20.455,86.344,20.462,86.339,20.454,86.329,20.453,86.291,20.47,86.231,20.453,86.203,20.449,86.122,20.455,86.093,20.467,86.051,20.448,86.094,20.432,86.117,20.41,85.983,20.446,85.939,20.465,85.911,20.494,86.03,20.442,86.06,20.466,86.092,20.47,86.172,20.452,86.289,20.472,86.341,20.472,86.348,20.47,86.353,20.464,86.369,20.468,86.416,20.456,86.45,20.44,86.471,20.413,86.499,20.407,86.517,20.418,86.549,20.416,86.57,20.411,86.575,20.406,86.59,20.403,86.699,20.298,86.789,20.358,86.626,20.433,86.727,20.399,86.708,20.566,86.712,20.595,86.73,20.605,86.725,20.613,86.726,20.622,86.733,20.643,86.745,20.649,86.744,20.66,86.667,20.604,86.612,20.607,86.623,20.586,86.617,20.574,86.603,20.566,86.554,20.624,86.543,20.603,86.532,20.599,86.45,20.601,86.419,20.624,86.407,20.626,86.379,20.598,86.371,20.595,86.329,20.607,86.233,20.689,86.194,20.739,86.189,20.761,86.193,20.777,86.189,20.783,86.136,20.783,86.158,20.799,86.192,20.783,86.372,20.598,86.403,20.63,86.555,20.625,86.605,20.568,86.609,20.608,86.74,20.67,86.584,20.69,86.577,20.697,86.577,20.701,86.581,20.706,86.563,20.719,86.542,20.693,86.488,20.678,86.425,20.685,86.421,20.691,86.427,20.705,86.42,20.714,86.397,20.72,86.393,20.725,86.391,20.734,86.379,20.732,86.34,20.748,86.33,20.756,86.329,20.772,86.328,20.774,86.318,20.775,86.313,20.777,86.304,20.785,86.303,20.793,86.299,20.794,86.298,20.772,86.27,20.807,86.265,20.828,86.22,20.824,86.194,20.857,86.194,20.865,86.179,20.853,86.173,20.852,86.161,20.854,86.146,20.869,86.14,20.881,86.13,20.879,86.103,20.891,86.083,20.882,86.061,20.885,86.053,20.877,86.008,20.888,85.956,20.846,85.914,20.837,85.894,20.84,85.855,20.861,85.81,20.861,85.786,20.878,85.767,20.871,85.76,20.874,85.753,20.866,85.737,20.868,85.699,20.825,85.626,20.806,85.611,20.755,85.58,20.741,85.52,20.746,85.316,20.824,85.284,20.852,85.289,20.883,85.249,20.893,85.24,20.905,85.237,20.964,85.253,20.991,85.108,21.07,85.102,21.093,85.152,21.147,85.063,21.193,85.059,21.198,85.06,21.224,85.049,21.228,85.03,21.259,85.031,21.267,85.063,21.224,85.063,21.196,85.156,21.151,85.152,21.131,85.114,21.094,85.111,21.075,85.249,21.002,85.26,20.984,85.245,20.902,85.289,20.887,85.365,20.806,85.578,20.745,85.608,20.756,85.614,20.802,85.642,20.824,85.757,20.878,86.102,20.892,86.194,20.867,86.223,20.825,86.265,20.831,86.427,20.711,86.429,20.701,86.426,20.686,86.561,20.72,86.568,20.718,86.581,20.708,86.594,20.718,86.601,20.716,86.606,20.71,86.607,20.697,86.621,20.689,86.678,20.703,86.741,20.677,86.756,20.616,86.827,20.771,86.97,20.823,86.819,21.178,87.064,21.464,87.029,21.465,86.991,21.486,86.984,21.497,86.99,21.508,86.983,21.504,86.981,21.488,86.979,21.486,86.953,21.485,86.924,21.523,86.925,21.53,86.927,21.534,86.939,21.54,86.919,21.538,86.908,21.563,86.94,21.542,86.954,21.486,86.99,21.509,87.035,21.466,87.391,21.55,87.336,21.576,87.311,21.57,87.303,21.574,87.299,21.583,87.299,21.589,87.302,21.597,87.343,21.633,87.3,21.667,87.307,21.691,87.285,21.678,87.241,21.685,87.195,21.719,87.209,21.747,87.174,21.75,87.164,21.763,87.159,21.775,87.163,21.788,87.177,21.797,87.205,21.796,87.211,21.846,87.25,21.877,87.147,22.076,87.255,21.873,87.217,21.802,87.165,21.765,87.213,21.753,87.241,21.693,87.311,21.694,87.347,21.628,87.31,21.586,87.404,21.556,87.804,21.702,87.894,21.783,87.876,21.8,87.869,21.82,87.87,21.848,87.88,21.858,87.876,21.87,87.882,21.86,87.872,21.818,87.899,21.789,87.976,21.851,88.052,22.014,87.982,22.099,87.954,22.098,87.932,22.118,87.879,22.14,87.85,22.163,87.83,22.162,87.827,22.166,87.853,22.165,87.932,22.121,87.957,22.1,87.991,22.103,88.062,22.013,88.186,22.095,87.94,22.26,87.843,22.558,87.962,22.277,88.048,22.22,88.115,22.468,88.241,22.564,88.318,22.552,88.359,22.612,88.33,22.781,88.407,22.997,88.465,23.08,88.511,23.115,88.502,23.124,88.471,23.125,88.454,23.129,88.448,23.135,88.447,23.174,88.469,23.219,88.41,23.207,88.363,23.228,88.333,23.26,88.329,23.275,88.347,23.298,88.326,23.344,88.329,23.354,88.338,23.356,88.378,23.332,88.356,23.351,88.355,23.362,88.383,23.422,88.368,23.43,88.348,23.481,88.353,23.49,88.372,23.488,88.374,23.509,88.353,23.55,88.334,23.541,88.306,23.545,88.272,23.529,88.261,23.54,88.266,23.553,88.352,23.553,88.38,23.516,88.375,23.488,88.349,23.483,88.387,23.424,88.381,23.332,88.368,23.328,88.331,23.351,88.357,23.303,88.333,23.266,88.473,23.222,88.453,23.132,88.503,23.132,88.514,23.112,88.334,22.781,88.368,22.609,88.324,22.55,88.123,22.466,88.07,22.216,88.207,22.158,88.224,22.078,88.164,21.879,88.274,21.755,88.265,21.73,88.283,21.757,88.27,21.766,88.255,21.783,88.252,21.802,88.282,21.761,88.263,21.788,88.258,21.801,88.259,21.809,88.264,21.814,88.277,21.822,88.306,21.83,88.314,21.846,88.26,21.858,88.255,21.863,88.255,21.871,88.264,21.882,88.27,21.885,88.354,21.857,88.35,21.927,88.391,21.799,88.383,21.9,88.386,21.907,88.406,21.92,88.393,21.936,88.398,21.952,88.374,21.963,88.373,21.966,88.375,21.969,88.394,21.971,88.408,21.919,88.385,21.899,88.409,21.885,88.42,21.906,88.459,21.919,88.451,21.942,88.469,21.957,88.445,21.964,88.442,21.991,88.45,22.009,88.461,22.011,88.446,21.966,88.47,21.959,88.474,21.896,88.477,21.904,88.483,21.91,88.521,21.917,88.52,21.942,88.495,21.955,88.491,21.961,88.491,21.966,88.5,21.979,88.521,21.989,88.542,21.982,88.554,22.035,88.543,21.981,88.495,21.969,88.494,21.959,88.519,21.945,88.525,21.929,88.522,21.914,88.494,21.878,88.528,21.853,88.541,21.904,88.545,21.906,88.561,21.899,88.57,21.903,88.549,21.92,88.541,21.96,88.542,21.97,88.548,21.974,88.607,21.913,88.573,22.189,88.619,22.109,88.672,22.199,88.638,22.209,88.631,22.222,88.666,22.339,88.635,22.217,88.7,22.21,88.734,22.196,88.79,22.349,88.819,22.364,88.85,22.364,88.87,22.359,88.876,22.355,88.875,22.358,88.861,22.364,88.846,22.388,88.848,22.431,88.819,22.43,88.803,22.443,88.78,22.492,88.779,22.505,88.733,22.51,88.708,22.528,88.692,22.522,88.672,22.55,88.767,22.561,88.853,22.433,88.843,22.508,88.869,22.546,88.897,22.563,88.938,22.57,88.937,22.594,88.958,22.617,88.933,22.618,88.909,22.676,88.883,22.666,88.863,22.664,88.808,22.71,88.806,22.735,88.809,22.753,88.809,22.711,88.911,22.677,88.962,22.617,89.003,22.472,89.003,22.429,88.975,22.408,88.996,22.391,88.997,22.387,88.986,22.325,89.001,22.324,89.026,22.298,89.022,22.292,89.0,22.295,88.996,22.286,89.1,22.222,89.075,22.2,89.092,22.194,89.142,22.13,89.147,22.088,89.192,22.119,89.174,22.121,89.168,22.126,89.162,22.15,89.147,22.151,89.141,22.155,89.138,22.164,89.146,22.184,89.158,22.207,89.17,22.211,89.156,22.218,89.154,22.222,89.155,22.227,89.2,22.272,89.216,22.267,89.22,22.26,89.219,22.258,89.205,22.253,89.206,22.25,89.228,22.255,89.242,22.248,89.246,22.271,89.261,22.283,89.224,22.294,89.187,22.379,89.188,22.399,89.21,22.41,89.19,22.408,89.206,22.414,89.195,22.426,89.199,22.44,89.207,22.45,89.195,22.464,89.19,22.475,89.179,22.528,89.186,22.553,89.184,22.557,89.176,22.548,89.147,22.547,89.144,22.551,89.146,22.557,89.192,22.567,89.214,22.411,89.194,22.37,89.27,22.286,89.247,22.224,89.314,22.281,89.279,22.287,89.249,22.338,89.263,22.373,89.24,22.368,89.235,22.375,89.236,22.38,89.256,22.396,89.24,22.424,89.243,22.446,89.258,22.468,89.285,22.475,89.262,22.487,89.251,22.501,89.234,22.549,89.235,22.554,89.245,22.569,89.286,22.476,89.261,22.453,89.268,22.451,89.297,22.462,89.318,22.464,89.344,22.465,89.353,22.453,89.361,22.459,89.369,22.457,89.422,22.324,89.399,22.389,89.415,22.497,89.341,22.514,89.363,22.562,89.328,22.559,89.308,22.565,89.268,22.628,89.303,22.65,89.261,22.668,89.259,22.717,89.304,22.65,89.297,22.587,89.321,22.584,89.324,22.597,89.334,22.603,89.339,22.603,89.371,22.596,89.387,22.585,89.361,22.758,89.429,22.646,89.428,22.668,89.432,22.676,89.457,22.708,89.468,22.709,89.46,22.739,89.471,22.77,89.579,22.77,89.58,22.81,89.561,22.821,89.556,22.828,89.55,22.868,89.529,22.869,89.527,22.871,89.519,22.887,89.512,22.928,89.528,22.871,89.551,22.87,89.559,22.849,89.568,22.866,89.565,22.906,89.574,22.943,89.555,22.962,89.558,22.984,89.626,23.081,89.672,23.056,89.671,23.083,89.681,23.097,89.671,23.119,89.675,23.137,89.748,23.157,89.668,23.168,89.666,23.231,89.699,23.241,89.689,23.255,89.664,23.235,89.646,23.238,89.63,23.251,89.627,23.261,89.635,23.274,89.664,23.275,89.656,23.297,89.668,23.272,89.632,23.253,89.692,23.256,89.703,23.236,89.673,23.169,89.751,23.161,89.744,23.143,89.718,23.118,89.676,23.134,89.689,23.095,89.673,23.053,89.557,22.963,89.573,22.955,89.576,22.94,89.571,22.867,89.555,22.836,89.582,22.825,89.587,22.812,89.595,22.829,89.599,22.829,89.611,22.825,89.525,22.625,89.548,22.664,89.594,22.472,89.65,22.592,89.772,22.537,89.769,22.552,89.79,22.583,89.788,22.608,89.803,22.621,89.797,22.631,89.8,22.642,89.805,22.65,89.761,22.694,89.763,22.714,89.779,22.719,89.762,22.694,89.805,22.654,89.805,22.622,89.769,22.526,89.786,22.517,89.802,22.49,89.839,22.49,89.858,22.472,89.882,22.437,89.885,22.395,89.979,22.475,89.92,22.509,89.962,22.586,89.916,22.604,89.926,22.644,89.963,22.589,89.922,22.509,90.017,22.504,90.014,22.609,90.03,22.652,89.996,22.689,89.994,22.77,89.969,22.786,89.908,22.883,89.878,22.876,89.8,22.935,89.782,22.968,89.796,22.993,89.8,22.938,89.878,22.878,89.901,22.886,89.909,22.884,89.915,22.879,89.996,22.77,90.032,22.654,90.025,22.602,90.095,22.758,90.118,22.776,90.152,22.786,90.159,22.841,90.227,22.829,90.25,22.81,90.277,22.854,90.287,22.863,90.295,22.864,90.301,22.853,90.309,22.858,90.312,22.882,90.329,22.885,90.339,22.875,90.348,22.881,90.355,22.916,90.372,22.923,90.317,22.949,90.307,23.0,90.318,23.018,90.334,23.031,90.313,23.046,90.295,23.039,90.285,23.048,90.292,23.056,90.327,23.062,90.327,23.068,90.308,23.075,90.306,23.081,90.332,23.104,90.237,23.119,90.237,23.123,90.249,23.136,90.237,23.164,90.238,23.177,90.246,23.194,90.227,23.177,90.214,23.175,90.132,23.245,90.068,23.375,90.071,23.524,90.098,23.531,89.892,23.619,89.83,23.684,89.804,23.765,89.555,23.795,89.047,23.976,89.304,23.939,89.543,23.821,89.727,23.807,89.692,23.837,89.656,23.925,89.653,24.054,89.704,24.27,89.795,24.378,89.802,24.373,89.81,24.326,89.793,24.246,89.809,24.221,89.811,24.204,89.759,24.232,89.78,24.189,89.783,24.141,89.767,24.03,89.74,23.97,89.774,23.936,89.787,23.809,90.02,23.722,90.029,23.617,90.23,23.481,90.567,23.423,90.556,23.595,90.566,23.604,90.586,23.608,90.592,23.616,90.617,23.607,90.64,23.693,90.715,23.745,90.722,23.839,90.75,23.832,90.775,23.784,90.723,23.721,90.752,23.721,90.771,23.643,90.762,23.629,90.735,23.629,90.732,23.624,90.74,23.607,90.738,23.596,90.697,23.576,90.712,23.559,90.712,23.509,90.707,23.5,90.688,23.488,90.693,23.483,90.71,23.452,90.711,23.433,90.717,23.432,90.729,23.425,90.734,23.415,90.747,23.412,90.749,23.407,90.747,23.399,90.754,23.397,90.754,23.391,90.751,23.38,90.747,23.375,90.726,23.377,90.729,23.369,90.727,23.361,90.723,23.36,90.713,23.37,90.703,23.371,90.712,23.36,90.709,23.352,90.699,23.348,90.693,23.348,90.686,23.355,90.67,23.344,90.683,23.326,90.68,23.321,90.672,23.319,90.665,23.322,90.65,23.337,90.639,23.334,90.683,23.224,90.636,23.027,90.651,22.98,90.662,22.968,90.676,22.979,90.684,22.98,90.688,22.978,90.774,22.87,90.819,22.703,91.073,22.499,91.121,22.563,91.174,22.521,91.263,22.567,91.244,22.611,91.262,22.633,91.281,22.638,91.187,22.727,91.386,22.753,91.33,22.788,91.321,22.843,91.331,22.873,91.421,22.749,91.41,22.811,91.427,22.839,91.435,22.839,91.449,22.829,91.474,22.916,91.479,22.922,91.494,22.916,91.48,22.845,91.418,22.81,91.694,22.509,91.799,22.226,91.797,22.301,91.911,22.42,92.148,22.51,92.213,22.5,92.23,22.47,92.246,22.479,92.229,22.468,92.148,22.507,91.92,22.417,91.798,22.292,91.843,22.115,91.853,22.117,91.843,22.144,91.851,22.154,91.902,22.124,91.902,22.131,91.878,22.143,91.87,22.153,91.869,22.159,91.917,22.187,91.978,22.176,92.03,22.142,91.918,22.185,91.905,22.122,91.848,22.147,91.916,21.86,91.855,21.686,91.91,21.738,91.897,21.541,91.856,21.515,91.976,21.51,91.936,21.748,91.949,21.76,91.945,21.775,91.952,21.788,91.957,21.79,91.974,21.79,91.962,21.671,92.009,21.601,92.009,21.628,92.034,21.659,92.04,21.654,92.013,21.5,91.951,21.454,92.05,21.179,92.184,21.021,92.229,20.908,92.313,20.76,92.337,20.741,92.32,20.841,92.272,20.915,92.262,20.95,92.251,21.035,92.264,21.063,92.227,21.105,92.219,21.119,92.223,21.132,92.31,21.073,92.275,20.955,92.367,20.862,92.386,20.702,92.727,20.266,92.733,20.557,92.701,20.566,92.637,20.7,92.608,20.683,92.601,20.686,92.589,20.699,92.579,20.723,92.596,20.734,92.584,20.733,92.58,20.735,92.548,20.815,92.555,20.85,92.592,20.794,92.57,20.783,92.597,20.736,92.597,20.695,92.642,20.729,92.75,20.602,92.752,20.48,92.768,20.478,92.769,20.493,92.797,20.512,92.773,20.29,92.866,20.318,92.83,20.387,92.841,20.406,92.863,20.408,92.84,20.417,92.871,20.412,92.86,20.404,92.843,20.405,92.84,20.399,92.917,20.383,92.903,20.424,92.931,20.433,92.908,20.456,92.889,20.494,92.901,20.484,92.933,20.435,92.928,20.428,92.906,20.423,92.944,20.387,92.948,20.344,92.929,20.313,92.81,20.266,92.829,20.262,92.841,20.253,92.793,20.262,92.761,20.202,92.864,20.123,93.012,20.302,93.01,20.463,93.014,20.469,93.018,20.471,93.043,20.471,93.033,20.497,93.043,20.515,93.061,20.529,93.019,20.537,93.0,20.569,93.063,20.528,93.043,20.469,93.012,20.465,93.024,20.433,93.052,20.477,93.08,20.556,93.081,20.58,93.032,20.602,93.004,20.645,93.013,20.705,92.963,20.722,92.958,20.727,92.967,20.871,93.007,20.905,92.986,20.981,92.999,20.998,92.975,21.016,92.984,21.042,92.965,21.061,92.975,21.078,92.956,21.098,92.959,21.111,92.978,21.078,92.967,21.062,92.987,21.044,92.978,21.014,93.002,20.996,93.012,20.902,92.961,20.727,93.014,20.709,93.045,20.598,93.101,20.618,93.061,20.597,93.085,20.578,93.087,20.533,93.102,20.531,93.094,20.493,93.17,20.509,93.102,20.488,93.115,20.422,93.09,20.384,93.14,20.447,93.17,20.404,93.037,20.333,92.994,20.138,93.129,20.224,93.119,20.252,93.128,20.272,93.116,20.294,93.119,20.307,93.125,20.313,93.163,20.333,93.186,20.332,93.203,20.324,93.228,20.411,93.257,20.448,93.266,20.453,93.258,20.464,93.258,20.469,93.272,20.487,93.282,20.486,93.289,20.483,93.255,20.534,93.245,20.566,93.252,20.589,93.259,20.596,93.241,20.616,93.248,20.656,93.22,20.68,93.209,20.695,93.21,20.704,93.215,20.707,93.252,20.706,93.246,20.73,93.27,20.741,93.248,20.731,93.255,20.709,93.248,20.701,93.211,20.699,93.216,20.687,93.245,20.67,93.251,20.662,93.325,20.41,93.333,20.437,93.326,20.394,93.29,20.341,93.325,20.334,93.341,20.307,93.338,20.285,93.361,20.28,93.39,20.224,93.47,20.044,93.48,20.007,93.471,19.989,93.478,19.975,93.482,19.965,93.522,20.011,93.525,19.924,93.58,19.978,93.589,19.981,93.562,19.888,93.638,19.962,93.702,19.912,93.697,19.966,93.714,19.971,93.795,19.871,93.788,19.889,93.798,19.895,93.787,19.906,93.789,19.947,93.804,19.983,93.795,19.993,93.817,20.013,93.824,20.009,93.799,19.87,93.678,19.827,93.688,19.803,93.635,19.752,93.665,19.745,93.632,19.721,93.697,19.716,93.696,19.807,93.704,19.812,93.721,19.813,93.722,19.819,93.775,19.763,93.785,19.849,93.789,19.766,93.707,19.697,93.811,19.745,93.785,19.674,93.871,19.645,93.874,19.624,93.721,19.62,93.855,19.615,93.774,19.589,93.911,19.487,93.947,19.605,93.966,19.622,93.971,19.619,93.975,19.611,93.98,19.619,93.992,19.612,93.903,19.467,94.059,19.471,93.988,19.399,94.023,19.376,94.028,19.4,94.063,19.424,94.077,19.427,94.083,19.426,94.049,19.386,94.068,19.392,94.095,19.386,94.071,19.378,94.087,19.361,94.116,19.359,94.124,19.35,94.134,19.349,94.145,19.341,94.149,19.349,94.17,19.33,94.046,19.329,94.097,19.236,94.05,19.231,94.036,19.121,94.07,19.163,94.055,19.201,94.055,19.207,94.058,19.211,94.061,19.211,94.065,19.202,94.07,19.214,94.076,19.208,94.069,19.201,94.064,19.202,94.059,19.21,94.056,19.207,94.072,19.167,94.061,19.126,94.09,19.207,94.094,19.209,94.101,19.206,94.097,19.208,94.095,19.212,94.106,19.222,94.12,19.223,94.123,19.23,94.136,19.165,94.027,19.08,94.085,19.085,94.093,19.103,94.112,19.112,94.115,19.124,94.148,19.127,94.09,19.084,94.122,19.085,94.139,19.059,94.139,19.05,94.119,19.083,94.048,19.045,94.102,19.054,94.156,18.984,94.11,19.01,94.122,18.989,94.107,18.941,94.123,18.924,94.118,18.913,94.107,18.908,94.13,18.909,94.135,18.898,94.133,18.88,94.114,18.854,94.163,18.9,94.139,18.822,94.231,18.793,94.209,18.733,94.272,18.762,94.291,18.714,94.161,18.729,94.183,18.718,94.208,18.701,94.239,18.673,94.242,18.667,94.24,18.661,94.237,18.659,94.242,18.657,94.242,18.643,94.235,18.642,94.246,18.609,94.269,18.626,94.278,18.617,94.3,18.62,94.31,18.615,94.248,18.544,94.269,18.567,94.292,18.572,94.308,18.572,94.267,18.564,94.34,18.527,94.357,18.497,94.243,18.53,94.397,18.349,94.317,18.249,94.461,18.213,94.513,17.929,94.429,18.0,94.586,17.661,94.567,17.592,94.593,17.577,94.606,17.548,94.609,17.511,94.605,17.501,94.537,17.556,94.562,17.331,94.567,17.316,94.611,17.315,94.628,17.3,94.648,17.248,94.535,17.326,94.518,17.135,94.442,17.184,94.435,16.954,94.478,16.977,94.489,16.95,94.383,16.917,94.347,16.573,94.251,16.536,94.238,15.966,92.844,13.406,92.847,13.139,92.719,12.767,92.694,12.797,92.514,11.848,92.382,10.539,92.715,9.142,93.663,7.007,93.807,6.763,93.909,6.831,95.049,5.75,80.592,5.919,81.325,6.199,81.693,6.487,81.879,7.022,81.862,7.343,81.783,7.608,81.707,7.742,81.81,7.49,81.811,7.453,81.791,7.437,81.797,7.421,81.75,7.459,81.743,7.62,81.579,7.725,81.707,7.726,81.557,7.943,81.547,7.81,81.529,7.82,81.515,7.805,81.51,7.803,81.505,7.807,81.553,7.989,81.44,8.136,81.447,8.113,81.437,8.088,81.409,8.107,81.382,8.153,81.44,8.101,81.354,8.496,81.183,8.429,81.191,8.506,81.125,8.497,81.247,8.546,81.225,8.655,80.967,8.985,80.913,8.939,80.892,9.049,80.964,8.991,80.815,9.289,80.742,9.282,80.81,9.293,80.629,9.454,80.573,9.443,80.404,9.515,80.287,9.45,80.266,9.512,80.178,9.54,80.061,9.611,80.186,9.485,80.054,9.39,80.122,9.3,80.121,9.225,80.04,9.016,79.905,8.949,79.859,9.074,79.696,9.079],"holes":[[79.907,9.767,79.997,9.64,80.201,9.58,80.17,9.657,80.592,9.473,80.248,9.828,79.907,9.767],[81.865,16.61,81.974,16.513,81.96,16.404,82.311,16.57,82.266,16.642,82.188,16.601,82.142,16.684,81.951,16.692,81.753,16.946,81.865,16.61],[93.835,18.93,93.94,18.849,93.947,19.241,93.677,19.301,93.634,19.193,93.662,19.32,93.499,19.425,93.599,19.153,93.661,19.07,93.717,19.118,93.667,19.015,93.835,18.93],[89.634,22.352,89.713,22.349,89.696,22.17,89.74,22.135,89.756,22.233,89.801,22.072,89.834,22.166,89.761,22.314,89.765,22.34,89.836,22.165,89.807,22.074,89.845,22.064,89.856,22.468,89.641,22.554,89.634,22.352],[89.913,22.38,89.869,22.264,89.96,21.943,90.077,22.354,90.21,22.51,90.175,22.628,90.433,22.736,90.34,22.826,90.162,22.838,90.032,22.598,90.061,22.49,89.913,22.38],[90.348,23.022,90.5,23.043,90.588,23.175,90.534,23.293,90.089,23.458,90.125,23.264,90.2,23.258,90.348,23.022],[90.702,22.655,90.655,22.789,90.501,22.8,90.68,22.813,90.642,22.868,90.525,22.887,90.495,22.805,90.441,22.887,90.668,22.537,90.632,21.993,90.812,22.122,90.865,22.415,90.702,22.655],[90.024,21.924,90.077,21.862,90.242,22.028,90.258,21.955,90.277,22.06,90.308,21.982,90.378,22.084,90.341,22.17,90.431,22.193,90.37,22.293,90.419,22.346,90.213,22.336,90.22,22.226,90.272,22.277,90.221,22.13,90.024,21.924],[90.137,22.101,90.23,22.191,90.222,22.373,90.352,22.475,90.343,22.613,90.409,22.578,90.505,22.701,90.463,22.752,90.178,22.625,90.213,22.509,90.025,22.195,90.073,22.161,89.999,21.996,90.137,22.101],[90.625,22.355,90.574,22.544,90.454,22.575,90.453,22.415,90.347,22.348,90.434,22.344,90.372,22.295,90.436,22.195,90.39,22.088,90.625,22.355]]}],"names":{"ar":"خليج البنغال","de":"Golf von Bengalen","es":"Bahía de Bengala","fr":"golfe du Bengale","he":"מפרץ בנגל","hu":"Bengáli-öböl","it":"golfo del Bengala","nl":"Golf van Bengalen","pt":"Golfo de Bengala","zh":"孟加拉灣"}},{"name":"Sea of Okhotsk","bbox":[135.172,43.217,167.167,62.914],"area":220.3288,"polygons":[{"outer":[157.148,57.768,157.158,57.764,157.181,57.742,157.186,57.735,157.184,57.725,157.183,57.738,157.164,57.756,157.112,57.783,157.12,57.784,157.041,57.806,157.004,57.857,156.774,57.724,156.997,57.694,156.861,57.713,156.97,57.651,157.0,57.432,156.718,57.084,156.762,57.091,156.814,57.062,156.834,57.038,156.831,57.032,156.775,57.023,156.826,57.022,156.848,57.014,156.841,56.973,156.879,56.956,156.886,56.947,156.886,56.935,156.903,56.938,156.923,56.931,156.923,56.926,156.951,56.903,156.959,56.89,156.92,56.928,156.881,56.934,156.835,56.976,156.846,57.011,156.773,57.019,156.769,57.029,156.779,57.036,156.83,57.037,156.771,57.071,156.758,57.085,156.68,57.029,156.666,57.028,156.65,57.038,156.676,57.012,156.663,56.983,156.638,56.98,156.674,57.012,156.638,57.027,156.668,57.058,156.553,57.102,156.493,56.972,156.223,56.856,156.2,56.795,156.057,56.727,156.076,56.66,156.025,56.618,156.056,56.609,156.06,56.605,156.039,56.589,156.074,56.581,156.09,56.574,156.091,56.571,156.06,56.553,156.014,56.555,156.065,56.548,156.069,56.542,156.045,56.513,156.069,56.505,156.077,56.492,156.096,56.492,156.096,56.501,156.104,56.502,156.123,56.491,156.12,56.487,156.08,56.489,156.044,56.511,156.063,56.548,156.008,56.556,156.087,56.572,156.032,56.591,156.023,56.619,156.043,56.65,156.064,56.659,156.035,56.694,156.051,56.729,156.185,56.831,155.991,56.685,155.736,56.112,155.834,56.087,155.862,56.072,155.739,56.096,155.651,55.967,155.554,55.339,156.144,52.852,156.205,52.72,156.329,52.489,156.199,52.734,156.187,52.765,156.202,52.789,156.215,52.734,156.242,52.755,156.286,52.777,156.227,52.695,156.333,52.585,156.315,52.546,156.48,52.515,156.377,52.444,156.316,52.533,156.437,52.249,156.489,51.944,156.475,52.139,156.492,52.145,156.52,52.149,156.53,52.147,156.572,52.172,156.621,52.186,156.641,52.186,156.551,52.15,156.482,52.139,156.503,51.763,156.484,51.522,156.553,51.278,156.718,51.19,156.733,50.945,156.46,50.696,156.483,50.869,156.199,50.768,156.192,50.677,156.384,50.632,154.825,49.306,154.814,49.474,154.918,49.609,154.816,49.634,154.611,49.36,154.627,49.277,154.804,49.288,154.604,49.118,154.451,49.158,154.566,49.085,153.049,47.724,153.008,47.795,152.965,47.679,153.02,47.701,152.152,47.002,152.291,47.152,152.193,47.16,151.859,46.852,151.716,46.852,151.787,46.77,150.021,45.826,150.572,46.228,150.339,46.227,150.219,46.188,149.789,45.86,149.664,45.846,149.397,45.577,149.661,45.621,150.02,45.825,146.917,43.832,146.823,43.89,146.613,43.82,146.585,43.744,146.65,43.713,145.817,43.385,145.65,43.391,145.505,43.217,145.239,43.299,145.241,43.359,145.367,43.312,145.189,43.62,145.253,43.613,145.059,43.785,145.368,44.258,145.344,44.34,144.751,43.921,144.387,43.926,144.243,44.113,144.124,44.012,144.113,44.089,144.185,44.105,143.789,44.179,144.008,44.131,143.732,44.104,143.67,44.191,143.683,44.199,143.784,44.179,143.78,44.184,143.5,44.248,142.985,44.563,141.932,45.52,142.091,45.973,142.18,46.027,142.256,46.288,142.415,46.6,142.52,46.714,142.669,46.763,142.732,46.739,142.77,46.608,143.141,46.608,143.303,46.51,143.26,46.554,143.262,46.566,143.361,46.559,143.302,46.505,143.421,46.217,143.414,46.02,143.617,46.372,143.533,46.391,143.476,46.826,143.163,46.858,143.177,46.83,143.162,46.798,143.383,46.715,143.155,46.718,143.067,46.816,143.162,46.854,143.09,46.874,143.019,47.251,142.847,47.409,142.703,47.424,142.754,47.445,142.524,47.785,142.578,48.168,142.979,48.898,142.973,49.118,143.165,49.322,143.124,49.227,143.213,49.279,143.442,49.32,143.296,49.318,143.238,49.367,143.316,49.428,144.051,49.252,144.559,48.895,144.672,48.642,144.749,48.644,144.674,48.725,144.692,48.861,144.382,49.026,144.223,49.307,144.238,49.506,143.759,50.381,143.523,51.232,143.494,51.29,143.475,51.285,143.523,51.226,143.513,51.218,143.465,51.214,143.47,51.292,143.373,51.332,143.482,51.32,143.415,51.545,143.437,51.441,143.357,51.633,143.342,51.504,143.204,51.508,143.257,51.689,143.288,51.718,143.329,51.692,143.182,51.966,143.228,51.86,143.183,51.861,143.203,51.838,143.202,51.828,143.086,51.766,143.202,51.832,143.105,51.93,143.151,51.975,143.088,52.049,143.076,52.17,143.144,52.26,143.125,52.158,143.145,52.089,143.182,52.312,143.115,52.285,143.108,52.346,143.285,52.643,143.301,52.587,143.23,52.479,143.272,52.479,143.274,52.451,143.183,52.331,143.286,52.459,143.336,52.833,143.314,52.713,143.294,52.95,143.265,52.86,143.233,52.848,143.099,53.037,143.031,53.262,143.157,53.365,143.136,53.21,143.257,53.142,143.328,52.847,143.266,53.156,143.198,53.335,143.148,53.423,143.153,53.39,143.081,53.392,143.147,53.425,143.063,53.558,143.003,53.532,142.961,53.569,143.06,53.562,142.94,53.619,143.012,53.635,142.914,53.805,142.929,53.766,142.874,53.769,142.893,53.869,142.834,53.861,142.808,53.882,142.893,53.872,142.988,54.115,142.726,54.42,142.602,54.235,142.478,54.287,142.591,54.292,142.602,54.324,142.259,54.306,142.695,53.943,142.586,53.697,142.67,53.724,142.698,53.848,142.79,53.708,142.675,53.615,142.64,53.689,142.478,53.658,142.514,53.547,142.564,53.6,142.671,53.507,142.59,53.523,142.408,53.371,142.24,53.386,142.257,53.539,141.764,53.376,141.921,53.033,141.828,52.554,141.63,52.322,141.695,52.158,141.627,51.884,141.816,51.796,141.81,51.733,141.813,51.725,141.834,51.727,141.85,51.719,141.84,51.703,141.107,51.683,141.169,51.828,141.422,51.953,141.349,52.099,141.547,52.152,141.528,52.236,141.385,52.222,141.117,52.41,141.111,52.484,141.267,52.591,141.164,52.79,141.239,52.867,141.06,52.884,140.759,53.104,140.347,53.11,140.416,53.126,140.27,53.232,140.062,53.203,139.906,53.215,139.817,53.042,139.716,53.004,139.731,52.945,139.767,52.926,139.947,52.922,140.098,52.889,139.987,52.9,140.094,52.781,140.138,52.657,140.135,52.617,140.18,52.639,140.345,52.572,140.446,52.442,140.454,52.335,140.339,52.095,140.476,52.091,140.354,52.026,140.457,51.977,140.472,51.951,140.412,51.978,140.416,51.963,140.399,51.933,140.407,51.907,140.391,51.884,140.649,51.862,140.165,51.727,140.358,51.626,140.658,51.683,140.761,51.622,140.615,51.666,140.513,51.573,140.266,51.617,140.117,51.537,140.092,51.593,140.195,51.635,140.174,51.637,140.16,51.651,140.139,51.646,140.157,51.653,140.198,51.641,140.209,51.695,140.203,51.682,140.164,51.667,140.075,51.666,140.073,51.6,140.008,51.557,139.991,51.555,139.952,51.564,139.9,51.563,139.825,51.578,139.659,51.511,139.608,51.519,139.57,51.508,139.478,51.511,139.509,51.477,139.517,51.449,139.512,51.442,139.208,51.331,138.934,51.388,138.867,51.25,138.164,50.896,138.176,50.957,138.396,51.092,138.462,51.117,138.577,51.128,138.603,51.163,138.611,51.209,138.613,51.214,138.627,51.226,138.638,51.231,138.656,51.234,138.668,51.226,138.786,51.254,138.903,51.393,139.019,51.404,139.041,51.416,139.056,51.417,139.064,51.415,139.07,51.411,139.087,51.379,139.119,51.37,139.358,51.408,139.482,51.555,139.873,51.61,139.924,51.673,140.047,51.753,140.021,51.745,140.007,51.73,139.989,51.718,139.974,51.711,139.983,51.721,140.023,51.748,140.053,51.757,140.139,51.812,140.209,51.844,140.222,51.879,140.14,51.953,140.134,52.017,140.422,52.355,140.302,52.57,140.22,52.599,140.128,52.597,140.092,52.603,140.079,52.609,140.071,52.618,140.071,52.625,140.089,52.642,140.05,52.699,140.049,52.793,139.848,52.868,139.702,52.941,139.685,52.94,139.687,52.931,139.681,52.922,139.656,52.913,139.433,52.911,139.372,52.924,139.348,52.939,139.348,52.95,139.337,52.946,139.312,52.947,139.306,52.957,139.291,52.962,139.283,52.971,139.231,52.939,139.117,52.952,139.1,52.917,139.086,52.91,139.104,52.949,139.228,52.997,139.323,53.017,139.331,53.015,139.341,53.002,139.356,53.0,139.457,53.041,139.496,53.013,139.506,52.935,139.582,52.953,139.66,52.919,139.689,53.003,139.636,52.988,139.796,53.075,139.862,53.246,139.894,53.251,139.907,53.23,140.025,53.225,140.078,53.237,140.012,53.238,140.084,53.24,140.151,53.227,140.193,53.231,140.222,53.24,139.957,53.272,139.896,53.292,139.856,53.316,139.84,53.339,139.762,53.338,139.658,53.38,139.612,53.46,139.675,53.518,139.949,53.561,139.773,53.399,139.904,53.292,140.29,53.238,140.494,53.121,140.484,53.134,140.581,53.111,140.682,53.127,140.633,53.129,140.627,53.145,140.634,53.148,140.818,53.136,141.137,52.991,141.22,53.07,141.443,53.152,141.342,53.231,141.417,53.296,140.905,53.426,140.944,53.497,141.131,53.448,140.546,53.647,140.272,53.847,140.328,53.956,139.795,54.2,139.739,54.296,139.303,54.176,138.659,54.286,138.737,54.249,138.662,54.06,138.684,54.056,138.701,54.068,138.759,54.084,138.764,54.092,138.772,54.092,138.769,53.981,138.661,53.709,138.466,53.526,138.232,53.501,138.572,53.806,138.582,53.994,138.372,53.904,138.3,53.73,137.951,53.581,137.313,53.533,137.163,53.572,137.498,53.669,137.607,53.832,137.856,53.969,137.581,53.956,137.288,54.051,137.315,54.116,137.445,54.147,137.464,54.135,137.446,54.086,137.464,54.088,137.736,54.32,137.395,54.305,137.19,54.21,137.084,54.125,137.16,54.036,137.305,54.034,137.197,53.849,136.941,53.853,136.852,53.828,136.858,53.799,136.776,53.751,136.758,53.749,136.67,53.917,136.816,54.133,136.748,54.286,136.818,54.321,136.742,54.413,136.827,54.423,136.802,54.514,136.881,54.564,136.828,54.645,136.679,54.605,136.666,54.683,136.514,54.587,135.781,54.553,135.7,54.644,135.278,54.73,135.172,54.825,135.468,55.058,136.136,55.29,136.42,55.567,136.759,55.643,137.294,55.962,137.395,55.947,137.555,56.12,137.724,56.137,137.917,56.365,138.228,56.426,138.113,56.578,138.273,56.62,138.436,56.844,138.562,56.86,138.544,56.8,138.578,56.784,138.61,56.955,138.904,57.031,138.941,57.145,139.791,57.484,140.006,57.704,140.49,57.842,140.796,58.314,141.563,58.612,142.275,59.128,143.069,59.33,142.885,59.518,142.877,59.572,142.861,59.575,142.837,59.585,142.836,59.59,142.88,59.58,142.885,59.523,142.958,59.481,142.959,59.458,142.961,59.468,142.998,59.443,143.084,59.335,143.274,59.412,143.166,59.346,144.408,59.369,144.29,59.378,144.271,59.382,144.301,59.39,144.409,59.373,144.429,59.487,144.458,59.449,144.43,59.371,144.412,59.369,144.711,59.388,144.901,59.368,144.884,59.468,144.911,59.535,144.873,59.555,144.817,59.604,144.811,59.615,144.826,59.617,144.813,59.626,144.824,59.637,144.812,59.649,144.816,59.661,144.709,59.714,144.698,59.73,144.666,59.74,144.631,59.793,144.641,59.806,144.631,59.816,144.634,59.825,144.705,59.913,144.77,59.932,144.76,59.922,144.717,59.908,144.678,59.859,144.662,59.855,144.66,59.843,144.65,59.831,144.637,59.825,144.646,59.803,144.635,59.796,144.812,59.667,144.937,59.511,144.91,59.52,144.936,59.51,144.932,59.496,144.894,59.475,144.908,59.471,144.914,59.446,144.915,59.432,144.9,59.421,144.965,59.368,144.876,59.367,144.836,59.369,144.82,59.368,144.904,59.365,145.007,59.369,145.261,59.416,145.695,59.426,145.804,59.365,145.932,59.421,145.948,59.348,145.827,59.246,146.047,59.141,146.353,59.207,146.304,59.391,146.512,59.466,147.542,59.236,147.611,59.303,147.846,59.293,147.762,59.356,148.204,59.409,148.4,59.376,148.417,59.253,148.928,59.233,148.986,59.375,148.741,59.36,148.72,59.461,148.875,59.55,148.933,59.452,149.232,59.503,148.987,59.695,148.911,59.704,148.821,59.769,148.795,59.763,148.736,59.766,148.797,59.792,148.807,59.785,148.817,59.786,148.821,59.791,148.828,59.791,148.876,59.78,148.883,59.776,148.891,59.766,148.902,59.745,148.897,59.729,148.903,59.731,148.914,59.728,148.922,59.721,148.918,59.709,148.944,59.71,149.013,59.693,149.027,59.685,149.029,59.675,149.053,59.665,149.062,59.649,149.096,59.646,149.106,59.632,149.451,59.771,149.395,59.729,149.723,59.748,150.314,59.656,150.577,59.543,150.772,59.563,150.5,59.485,150.751,59.488,150.757,59.433,150.939,59.454,150.915,59.565,151.407,59.605,151.679,59.443,151.77,59.357,151.736,59.3,152.137,59.305,152.324,59.226,152.087,59.146,151.102,59.106,151.347,58.837,152.069,58.886,152.162,58.983,152.397,59.009,152.353,59.062,152.549,58.951,152.906,58.906,153.016,59.067,153.338,59.088,153.268,59.134,153.35,59.15,153.373,59.255,153.64,59.19,153.775,59.252,153.686,59.184,154.043,59.047,154.355,59.109,154.332,59.209,154.806,59.127,154.831,59.196,155.182,59.174,155.137,59.223,155.211,59.346,154.971,59.496,154.414,59.551,154.534,59.419,154.422,59.369,154.39,59.536,154.303,59.439,154.131,59.455,154.094,59.414,154.075,59.405,154.064,59.409,154.08,59.409,154.079,59.426,154.127,59.464,154.051,59.526,154.147,59.535,154.229,59.664,154.373,59.58,154.231,59.733,154.212,59.88,154.48,59.885,154.412,60.066,154.583,60.104,154.501,60.019,154.478,59.946,154.485,59.931,154.539,60.058,154.872,60.316,155.811,60.728,155.906,60.709,155.987,60.908,155.93,60.929,156.653,61.2,156.679,61.523,157.341,61.678,157.435,61.774,158.079,61.731,158.27,61.826,158.804,61.813,158.89,61.905,159.159,61.921,159.397,61.876,159.352,61.817,159.472,61.763,159.52,61.816,159.576,61.823,159.513,61.708,159.575,61.651,159.695,61.729,159.898,61.712,160.031,61.846,160.247,61.921,160.347,61.923,160.355,61.938,160.377,61.938,160.411,61.761,160.255,61.691,160.323,61.666,160.28,61.556,159.881,61.321,160.007,61.289,159.818,61.29,159.768,61.239,159.939,61.245,160.005,61.107,159.791,60.931,160.444,61.034,160.336,60.93,160.387,60.906,160.176,60.826,160.257,60.77,160.145,60.655,160.167,60.575,160.427,60.758,160.408,60.702,160.686,60.771,160.794,60.719,160.913,60.899,161.45,61.122,161.456,61.136,161.4,61.153,161.398,61.155,161.397,61.165,161.581,61.188,161.551,61.256,161.784,61.3,161.819,61.378,161.898,61.339,161.915,61.359,161.891,61.373,161.883,61.416,161.889,61.432,162.007,61.383,162.388,61.662,162.754,61.591,162.622,61.659,162.851,61.71,162.942,61.644,162.867,61.624,162.853,61.602,163.019,61.513,163.116,61.616,163.325,61.659,163.337,61.71,163.043,61.768,162.966,61.809,163.067,61.918,163.022,61.916,163.013,61.926,163.193,62.059,163.105,62.081,163.082,62.229,163.371,62.339,163.219,62.421,163.142,62.409,163.119,62.419,163.1,62.41,163.043,62.414,163.032,62.418,163.04,62.427,162.916,62.436,162.875,62.456,162.839,62.457,162.803,62.467,162.795,62.473,162.776,62.471,162.77,62.472,162.771,62.474,162.796,62.474,162.844,62.458,162.873,62.457,162.921,62.437,163.038,62.428,163.046,62.424,163.049,62.414,163.122,62.419,163.147,62.412,163.16,62.425,163.198,62.431,163.181,62.437,163.262,62.46,163.219,62.507,163.401,62.562,164.443,62.694,164.609,62.687,164.72,62.574,165.092,62.487,165.355,62.481,165.588,62.451,165.605,62.439,165.611,62.441,165.619,62.441,165.678,62.436,165.754,62.445,166.013,62.436,166.029,62.418,166.084,62.434,166.203,62.427,166.218,62.468,166.429,62.572,166.864,62.588,166.844,62.606,166.839,62.625,166.921,62.683,166.942,62.686,166.978,62.672,167.001,62.676,167.013,62.687,167.051,62.698,167.04,62.721,167.069,62.788,167.161,62.836,167.143,62.846,167.146,62.849,167.116,62.849,167.105,62.854,167.113,62.866,167.147,62.878,167.144,62.914,167.16,62.912,167.153,62.876,167.108,62.855,167.116,62.85,167.153,62.849,167.167,62.841,167.167,62.832,167.057,62.695,166.996,62.673,166.97,62.67,166.974,62.668,166.926,62.682,166.869,62.587,166.428,62.568,166.213,62.426,166.194,62.422,166.164,62.426,166.174,62.421,166.078,62.431,166.029,62.416,165.796,62.441,165.632,62.428,165.573,62.447,165.485,62.445,165.413,62.457,165.397,62.463,165.373,62.474,165.348,62.476,165.375,62.47,165.083,62.441,165.266,62.384,165.392,62.288,165.358,62.236,165.301,62.206,165.376,62.288,165.313,62.308,165.276,62.366,164.735,62.456,164.157,62.274,164.077,62.095,164.111,62.022,164.062,61.685,163.872,61.651,163.853,61.581,163.751,61.462,163.968,61.422,164.026,61.339,163.94,61.223,163.488,61.006,163.67,60.948,163.679,60.856,163.77,60.844,163.788,60.835,163.789,60.831,163.407,60.815,163.299,60.728,163.304,60.724,163.335,60.725,163.352,60.719,163.336,60.686,163.291,60.723,163.331,60.799,162.964,60.768,162.704,60.608,161.944,60.42,161.919,60.205,161.803,60.136,161.734,60.116,161.56,60.048,161.578,60.017,161.552,60.041,161.553,60.048,161.566,60.05,161.568,60.054,161.559,60.049,161.491,60.042,160.88,59.619,160.495,59.539,160.535,59.556,160.495,59.555,160.418,59.398,159.865,59.132,159.724,58.987,159.722,58.857,159.182,58.52,159.273,58.454,159.173,58.513,158.219,58.03,158.301,58.036,158.555,57.832,158.343,57.917,158.268,58.023,157.678,58.017,157.522,57.82,157.148,57.768],"holes":[[137.605,55.187,137.236,54.762,137.487,54.873,137.712,54.616,138.0,54.785,137.998,54.921,138.22,55.024,138.043,55.053,137.878,54.978,137.978,55.081,138.051,55.055,137.924,55.143,137.605,55.187],[155.857,50.652,155.649,50.378,155.213,50.295,155.221,50.052,155.402,50.002,155.51,50.139,155.888,50.246,156.042,50.467,156.17,50.507,156.15,50.749,156.027,50.766,155.857,50.652],[148.785,45.555,148.577,45.489,148.427,45.307,148.243,45.267,148.055,45.258,147.9,45.428,147.871,45.224,147.734,45.193,147.657,45.078,147.506,45.118,147.507,44.989,147.24,44.807,147.109,44.823,147.186,44.696,147.009,44.661,146.994,44.575,146.947,44.629,146.861,44.445,147.208,44.568,147.596,44.852,147.652,44.994,147.905,44.961,148.314,45.22,148.841,45.337,148.893,45.512,148.785,45.555],[146.547,44.41,146.129,44.508,145.742,44.051,145.405,43.84,145.421,43.737,145.558,43.726,145.547,43.654,145.589,43.866,145.78,43.932,145.922,44.136,146.547,44.41]]}],"names":{"ar":"بحر أوخوتسك","de":"Ochotskisches Meer","es":"mar de Ojotsk","fr":"mer d'Okhotsk","he":"ים אוחוצק","hu":"Ohotszki-tenger","it":"mare di Ochotsk","nl":"Zee van Ochotsk","pt":"Mar de Okhotsk","zh":"鄂霍次克海"}},{"name":"Caribbean Sea","bbox":[-89.413,7.71,-59.422,22.703],"area":237.8422,"polygons":[{"outer":[-74.294,20.066,-73.364,19.623,-73.063,19.633,-72.698,19.457,-72.799,19.221,-72.701,19.115,-72.822,19.045,-72.318,18.671,-72.359,18.536,-72.605,18.56,-72.729,18.426,-73.659,18.499,-73.724,18.546,-73.591,18.552,-73.564,18.584,-73.79,18.537,-74.064,18.609,-74.195,18.676,-74.425,18.615,-74.447,18.349,-74.091,18.247,-73.877,18.022,-73.791,18.026,-73.841,18.126,-73.648,18.253,-73.378,18.282,-73.369,18.217,-72.816,18.141,-72.553,18.171,-72.546,18.233,-72.053,18.237,-71.684,17.985,-71.628,17.823,-71.689,17.769,-71.532,17.757,-71.422,17.604,-71.072,18.11,-71.067,18.323,-70.957,18.248,-70.601,18.419,-70.506,18.194,-70.154,18.23,-69.881,18.491,-69.636,18.4,-69.331,18.425,-69.329,18.478,-68.897,18.407,-68.754,18.202,-68.643,18.209,-68.612,18.373,-68.465,18.345,-68.323,18.609,-67.129,18.51,-67.272,18.363,-67.153,18.204,-67.21,18.036,-67.194,17.932,-66.924,17.926,-66.767,18.006,-66.176,17.949,-66.213,17.945,-66.215,17.921,-65.885,17.987,-65.728,18.186,-65.598,18.211,-65.65,18.834,-64.339,18.748,-64.417,18.741,-64.272,18.689,-64.325,18.747,-63.421,18.595,-61.863,17.716,-61.857,17.58,-61.733,17.543,-61.74,17.637,-61.007,16.35,-61.09,16.313,-61.097,16.29,-61.004,16.345,-59.422,13.154,-59.637,13.326,-59.633,13.101,-59.468,13.081,-60.595,11.325,-60.839,11.173,-60.681,11.19,-60.909,10.835,-61.364,10.812,-61.429,10.759,-61.671,10.711,-61.498,10.64,-61.456,10.299,-61.929,10.049,-60.992,10.136,-61.0,9.532,-61.208,9.6,-61.211,9.596,-61.202,9.589,-61.199,9.576,-61.202,9.54,-61.186,9.499,-61.186,9.484,-61.241,9.379,-61.295,9.316,-61.186,9.494,-61.335,9.644,-61.204,9.589,-61.437,9.817,-61.509,9.847,-61.452,9.807,-61.418,9.747,-61.582,9.874,-61.641,9.9,-61.672,9.899,-61.563,9.788,-61.525,9.728,-61.51,9.672,-61.51,9.658,-61.622,9.511,-61.585,9.412,-61.608,9.376,-61.645,9.361,-61.632,9.43,-61.689,9.345,-61.708,9.34,-61.726,9.344,-61.725,9.32,-61.753,9.308,-61.752,9.264,-61.777,9.263,-61.775,9.224,-61.862,9.108,-61.861,9.087,-61.824,9.075,-61.819,9.06,-61.831,8.99,-61.86,8.942,-61.86,8.883,-61.877,8.845,-61.958,8.8,-62.043,8.829,-62.059,8.756,-62.092,8.726,-62.106,8.721,-62.018,8.669,-61.989,8.672,-61.997,8.674,-62.002,8.682,-61.989,8.698,-61.978,8.699,-61.982,8.723,-61.976,8.728,-61.948,8.72,-61.918,8.738,-61.907,8.738,-61.886,8.733,-61.874,8.723,-61.845,8.718,-61.832,8.71,-61.823,8.711,-61.812,8.701,-62.0,8.68,-61.745,8.611,-61.731,8.639,-61.717,8.642,-61.689,8.635,-61.653,8.676,-61.644,8.678,-61.637,8.673,-61.61,8.701,-61.587,8.72,-61.563,8.727,-61.691,8.633,-61.73,8.639,-61.663,8.579,-61.926,8.521,-62.034,8.534,-62.05,8.584,-62.151,8.56,-62.249,8.584,-62.583,8.462,-62.537,8.522,-62.296,8.612,-62.25,8.597,-62.248,8.629,-62.188,8.695,-62.067,8.759,-62.062,8.819,-62.058,8.825,-62.033,8.839,-61.879,8.848,-61.865,9.109,-61.756,9.31,-61.728,9.348,-61.688,9.353,-61.631,9.436,-61.624,9.516,-61.593,9.56,-61.637,9.529,-61.516,9.668,-61.574,9.792,-61.828,9.827,-61.823,9.808,-61.811,9.804,-61.806,9.795,-61.803,9.782,-61.809,9.769,-61.784,9.77,-61.764,9.756,-61.739,9.704,-61.737,9.626,-61.738,9.616,-61.744,9.594,-61.765,9.755,-61.849,9.647,-61.832,9.798,-62.201,10.02,-62.213,9.93,-62.171,9.938,-62.149,9.938,-62.109,9.918,-62.081,9.886,-62.049,9.879,-62.034,9.864,-62.034,9.861,-62.04,9.863,-62.057,9.874,-62.085,9.877,-62.15,9.936,-62.209,9.924,-62.205,9.912,-62.18,9.881,-62.17,9.863,-62.175,9.8,-62.167,9.792,-62.165,9.787,-62.163,9.718,-62.189,9.643,-62.196,9.638,-62.216,9.636,-62.224,9.63,-62.221,9.594,-62.225,9.632,-62.166,9.712,-62.179,9.777,-62.177,9.85,-62.266,9.96,-62.254,9.928,-62.245,9.853,-62.291,9.732,-62.252,9.766,-62.442,9.496,-62.433,9.485,-62.414,9.489,-62.399,9.48,-62.408,9.411,-62.357,9.397,-62.324,9.367,-62.325,9.297,-62.3,9.298,-62.279,9.286,-62.278,9.3,-62.269,9.306,-62.24,9.303,-62.098,9.125,-62.05,9.048,-62.083,8.959,-62.058,8.933,-62.054,8.874,-62.016,8.898,-61.996,8.896,-61.992,8.881,-62.015,8.861,-61.997,8.893,-62.009,8.898,-62.035,8.875,-62.054,8.873,-62.056,8.874,-62.09,8.963,-62.072,9.023,-62.069,9.028,-62.058,9.031,-62.057,9.035,-62.059,9.054,-62.067,9.056,-62.1,9.077,-62.111,9.046,-62.132,9.053,-62.148,9.067,-62.144,9.097,-62.159,9.105,-62.162,9.087,-62.188,9.085,-62.186,9.07,-62.193,9.062,-62.225,9.058,-62.329,9.127,-62.331,9.132,-62.328,9.166,-62.321,9.179,-62.305,9.186,-62.314,9.216,-62.302,9.226,-62.308,9.239,-62.318,9.245,-62.328,9.26,-62.348,9.324,-62.325,9.362,-62.409,9.405,-62.446,9.494,-62.438,9.566,-62.479,9.586,-62.512,9.556,-62.52,9.557,-62.538,9.571,-62.552,9.556,-62.562,9.554,-62.594,9.563,-62.549,9.58,-62.514,9.558,-62.486,9.587,-62.458,9.588,-62.446,9.578,-62.37,9.742,-62.31,9.762,-62.343,9.808,-62.355,9.833,-62.365,9.881,-62.404,9.919,-62.455,9.887,-62.449,9.923,-62.416,9.968,-62.467,10.055,-62.51,10.19,-62.617,10.235,-62.613,10.101,-62.676,10.06,-62.728,10.056,-62.776,10.074,-62.778,10.022,-62.798,9.993,-62.833,9.982,-62.832,9.972,-62.815,9.973,-62.81,9.967,-62.81,9.962,-62.813,9.956,-62.832,9.949,-62.833,9.935,-62.841,9.926,-62.853,9.931,-62.855,9.947,-62.874,9.952,-62.894,9.977,-62.855,9.949,-62.842,9.928,-62.836,9.982,-62.781,10.03,-62.826,10.035,-62.847,10.076,-62.864,10.051,-62.896,10.054,-62.903,10.06,-62.902,10.093,-62.934,10.074,-62.96,10.095,-62.973,10.069,-62.98,10.067,-62.986,10.07,-63.046,10.122,-63.066,10.179,-62.98,10.069,-62.964,10.095,-62.902,10.096,-62.893,10.083,-62.896,10.057,-62.846,10.078,-62.794,10.035,-62.787,10.071,-62.765,10.087,-62.668,10.071,-62.616,10.12,-62.663,10.198,-62.687,10.303,-62.685,10.326,-62.71,10.328,-62.723,10.356,-62.739,10.344,-62.771,10.349,-62.776,10.337,-62.828,10.366,-62.73,10.383,-62.796,10.422,-62.819,10.384,-62.87,10.368,-62.946,10.401,-62.92,10.295,-62.929,10.279,-62.946,10.282,-62.947,10.263,-62.98,10.266,-63.005,10.308,-62.958,10.352,-62.942,10.352,-63.005,10.388,-63.009,10.399,-63.001,10.415,-63.029,10.417,-63.038,10.45,-63.038,10.454,-63.033,10.459,-63.025,10.462,-63.015,10.461,-62.998,10.446,-62.912,10.524,-62.728,10.582,-62.345,10.532,-62.249,10.635,-61.919,10.657,-61.861,10.749,-62.17,10.683,-62.712,10.766,-63.533,10.627,-63.811,10.708,-63.916,10.625,-64.263,10.665,-64.233,10.506,-64.05,10.596,-63.644,10.49,-64.185,10.479,-64.337,10.348,-64.363,10.398,-64.379,10.337,-64.462,10.33,-64.364,10.31,-64.456,10.255,-64.709,10.224,-64.751,10.104,-65.147,10.039,-65.208,10.044,-65.059,10.06,-65.873,10.304,-65.719,10.223,-65.854,10.247,-66.108,10.495,-66.048,10.578,-66.237,10.649,-67.016,10.615,-67.839,10.439,-68.127,10.484,-68.297,10.685,-68.29,10.809,-68.321,10.8,-68.308,10.828,-68.329,10.822,-68.331,10.86,-68.216,10.877,-68.363,10.918,-68.262,10.907,-68.404,11.191,-68.453,11.178,-68.83,11.444,-69.266,11.534,-69.621,11.466,-69.761,11.692,-69.824,12.004,-70.0,12.196,-70.215,12.093,-70.295,11.862,-70.205,11.758,-70.238,11.626,-70.185,11.605,-69.843,11.691,-69.823,11.677,-69.805,11.697,-69.787,11.686,-69.802,11.705,-69.821,11.853,-69.799,11.709,-69.776,11.706,-69.669,11.466,-69.844,11.422,-69.92,11.523,-70.049,11.512,-70.047,11.437,-70.098,11.424,-70.134,11.463,-70.148,11.468,-70.158,11.467,-70.217,11.348,-71.48,10.951,-71.408,10.913,-71.433,10.794,-71.591,10.791,-71.523,10.724,-71.48,10.392,-71.432,10.358,-71.358,10.21,-71.279,10.153,-71.031,9.728,-71.062,9.314,-71.26,9.134,-71.546,9.036,-71.768,9.109,-71.749,9.236,-71.768,9.21,-71.828,9.215,-71.735,9.273,-71.741,9.349,-71.756,9.329,-71.781,9.317,-71.846,9.325,-71.848,9.313,-71.857,9.307,-71.853,9.304,-71.853,9.3,-71.866,9.293,-71.876,9.296,-71.879,9.282,-71.864,9.268,-71.866,9.252,-71.873,9.251,-71.878,9.266,-71.885,9.256,-71.892,9.252,-71.903,9.258,-71.902,9.249,-71.905,9.244,-71.928,9.235,-71.939,9.239,-71.94,9.235,-71.928,9.229,-71.928,9.225,-71.934,9.221,-71.939,9.221,-71.941,9.223,-71.943,9.232,-71.948,9.222,-71.928,9.215,-71.926,9.211,-71.945,9.185,-72.047,9.115,-72.115,9.105,-72.141,9.124,-72.143,9.14,-72.164,9.114,-72.197,9.105,-72.207,9.109,-72.204,9.132,-72.224,9.088,-72.275,9.056,-72.489,9.044,-72.465,9.06,-72.47,9.067,-72.466,9.094,-72.456,9.107,-72.53,9.113,-72.534,9.118,-72.536,9.135,-72.547,9.125,-72.565,9.129,-72.575,9.152,-72.572,9.132,-72.577,9.127,-72.763,9.074,-72.575,9.153,-72.535,9.138,-72.528,9.114,-72.455,9.108,-72.459,9.061,-72.279,9.056,-72.204,9.133,-72.201,9.107,-72.143,9.142,-72.046,9.117,-71.939,9.24,-71.926,9.236,-71.903,9.259,-71.879,9.266,-71.877,9.266,-71.875,9.263,-71.872,9.252,-71.866,9.253,-71.865,9.266,-71.875,9.267,-71.88,9.286,-71.876,9.298,-71.854,9.301,-71.859,9.307,-71.845,9.328,-71.781,9.318,-71.743,9.349,-71.74,9.349,-71.723,9.341,-71.715,9.345,-71.829,9.423,-71.906,9.385,-71.889,9.43,-71.82,9.424,-71.938,9.5,-71.962,9.465,-72.002,9.462,-71.997,9.442,-72.002,9.445,-72.02,9.494,-72.014,9.512,-71.978,9.478,-71.959,9.478,-71.942,9.502,-72.007,9.558,-71.968,9.594,-72.127,9.813,-71.79,10.328,-71.631,10.437,-71.582,10.701,-71.727,10.963,-71.781,11.002,-71.813,10.992,-71.845,10.998,-71.854,11.009,-71.871,11.063,-71.851,11.01,-71.822,11.009,-71.814,10.998,-71.801,11.008,-71.788,11.009,-71.772,11.001,-71.74,11.019,-71.639,10.974,-71.622,10.993,-71.608,10.984,-71.603,10.999,-71.866,11.204,-71.965,11.557,-71.439,11.719,-71.338,11.794,-71.327,11.92,-71.113,12.05,-71.274,12.338,-71.668,12.458,-71.725,12.432,-71.735,12.409,-71.685,12.445,-71.649,12.434,-71.702,12.393,-71.67,12.389,-71.665,12.384,-71.65,12.383,-71.642,12.379,-71.8,12.317,-71.752,12.302,-71.865,12.294,-71.806,12.305,-71.823,12.372,-71.956,12.273,-71.897,12.288,-71.824,12.267,-71.929,12.151,-71.985,12.149,-72.0,12.187,-71.96,12.259,-72.179,12.216,-72.139,12.103,-72.23,11.887,-72.771,11.677,-73.278,11.285,-73.786,11.256,-74.145,11.336,-74.242,11.22,-74.214,11.074,-74.386,10.739,-74.457,10.726,-74.514,10.925,-74.491,10.967,-74.361,10.967,-74.297,10.979,-74.291,10.99,-74.85,11.108,-74.839,11.065,-74.827,11.05,-74.79,11.036,-74.784,11.03,-74.712,10.895,-74.715,10.577,-74.883,10.31,-74.861,10.39,-74.818,10.426,-74.812,10.503,-74.726,10.604,-74.748,10.647,-74.746,10.652,-74.742,10.657,-74.727,10.666,-74.743,10.846,-74.738,10.889,-74.72,10.907,-74.778,11.008,-74.839,11.056,-74.855,11.107,-75.05,10.895,-75.261,10.801,-75.274,10.715,-75.514,10.57,-75.564,10.397,-75.524,10.411,-75.537,10.395,-75.503,10.301,-75.704,10.141,-75.67,10.137,-75.638,10.186,-75.543,10.244,-75.523,10.236,-75.704,9.7,-75.581,9.643,-75.613,9.46,-75.784,9.393,-75.8,9.433,-75.948,9.444,-76.17,9.23,-76.333,8.924,-76.65,8.672,-76.886,8.623,-76.936,8.539,-76.761,8.401,-76.727,7.982,-76.764,7.911,-76.903,7.91,-76.925,8.023,-76.843,8.023,-76.927,8.035,-76.965,7.91,-77.035,7.873,-77.029,7.859,-77.028,7.838,-77.057,7.793,-77.016,7.768,-77.043,7.71,-77.065,7.772,-77.036,7.872,-77.06,7.849,-77.078,7.846,-77.096,7.822,-77.125,7.83,-77.112,7.78,-77.139,7.72,-77.114,7.78,-77.129,7.826,-77.123,7.834,-77.094,7.826,-77.082,7.846,-77.033,7.88,-77.023,7.948,-77.001,7.959,-77.007,7.971,-77.007,7.985,-76.997,8.022,-76.988,8.032,-76.96,8.038,-76.94,8.063,-76.943,8.091,-76.952,8.098,-76.956,8.117,-76.955,8.148,-76.949,8.175,-76.937,8.182,-76.925,8.205,-77.268,8.492,-77.358,8.675,-77.545,8.71,-77.546,8.775,-77.644,8.819,-77.762,8.952,-77.81,9.054,-77.891,9.08,-77.959,9.155,-77.943,9.152,-77.941,9.156,-78.003,9.195,-78.043,9.245,-78.213,9.292,-78.561,9.443,-78.564,9.44,-78.582,9.442,-78.598,9.42,-78.619,9.419,-79.061,9.441,-79.074,9.513,-78.979,9.547,-78.972,9.567,-79.636,9.608,-79.797,9.361,-79.882,9.397,-79.922,9.268,-79.897,9.263,-79.868,9.316,-79.81,9.334,-79.822,9.262,-79.78,9.264,-79.78,9.254,-79.775,9.252,-79.859,9.186,-79.813,9.207,-79.833,9.179,-79.816,9.189,-79.788,9.182,-79.775,9.115,-79.676,9.141,-79.663,9.06,-79.799,9.116,-79.792,9.083,-79.849,9.037,-79.809,9.12,-79.87,9.114,-79.92,9.177,-79.959,9.001,-79.987,9.092,-80.015,9.016,-80.087,9.02,-79.962,9.128,-79.96,9.198,-79.93,9.261,-79.941,9.264,-79.943,9.283,-79.966,9.28,-79.972,9.295,-79.984,9.295,-80.002,9.318,-81.076,8.803,-81.547,8.811,-81.893,9.186,-81.948,9.137,-81.78,8.95,-81.92,8.991,-82.079,8.923,-82.264,9.015,-82.27,9.15,-82.234,9.174,-82.204,9.17,-82.176,9.149,-82.185,9.177,-82.172,9.196,-82.214,9.231,-82.23,9.19,-82.32,9.189,-82.397,9.265,-82.343,9.432,-82.452,9.475,-82.445,9.462,-82.448,9.45,-82.459,9.437,-82.483,9.433,-82.487,9.428,-82.486,9.421,-82.488,9.417,-82.51,9.416,-82.525,9.373,-82.513,9.417,-82.496,9.423,-82.491,9.421,-82.486,9.431,-82.482,9.434,-82.45,9.45,-82.457,9.481,-82.519,9.504,-82.622,9.635,-82.782,9.665,-83.399,10.338,-83.586,10.795,-83.588,10.773,-83.595,10.759,-83.633,10.722,-83.655,10.712,-83.655,10.708,-83.648,10.705,-83.646,10.699,-83.672,10.677,-83.678,10.676,-83.685,10.678,-83.707,10.689,-83.741,10.723,-83.762,10.774,-83.703,10.749,-83.672,10.73,-83.67,10.729,-83.668,10.722,-83.659,10.725,-83.656,10.732,-83.651,10.734,-83.647,10.732,-83.639,10.721,-83.588,10.803,-83.641,10.911,-83.718,10.937,-83.753,10.966,-83.763,10.978,-83.778,11.001,-83.75,10.965,-83.73,10.948,-83.715,10.943,-83.875,11.292,-83.795,11.506,-83.643,11.605,-83.704,11.856,-83.783,11.8,-83.84,11.878,-83.752,12.12,-83.831,12.154,-83.956,12.07,-84.05,12.088,-84.061,12.1,-84.073,12.119,-84.092,12.115,-84.095,12.119,-84.093,12.126,-84.127,12.112,-84.166,12.165,-84.173,12.156,-84.179,12.153,-84.22,12.157,-84.165,12.166,-83.955,12.071,-83.932,12.115,-83.853,12.153,-83.827,12.159,-83.792,12.156,-83.756,12.135,-83.749,12.123,-83.748,12.087,-83.734,12.056,-83.74,12.084,-83.71,12.129,-83.742,12.166,-83.735,12.172,-83.697,12.113,-83.734,12.076,-83.684,12.002,-83.618,12.352,-83.679,12.271,-83.778,12.528,-83.596,12.624,-83.698,12.881,-83.66,12.79,-83.544,12.782,-83.641,12.373,-83.491,12.394,-83.507,12.906,-83.525,12.875,-83.594,12.912,-83.584,12.898,-83.593,12.89,-83.647,12.895,-83.653,12.897,-83.661,12.903,-83.685,12.903,-83.696,12.91,-83.698,12.916,-83.69,12.921,-83.694,12.928,-83.719,12.924,-83.733,12.94,-83.737,12.929,-83.743,12.93,-83.849,13.007,-83.977,13.234,-83.743,12.932,-83.734,12.942,-83.693,12.929,-83.65,12.897,-83.594,12.913,-83.54,12.894,-83.531,12.902,-83.542,12.903,-83.535,12.952,-83.515,12.911,-83.572,13.298,-83.525,13.543,-83.595,13.606,-83.567,13.656,-83.618,13.672,-83.524,13.722,-83.526,13.561,-83.455,13.873,-83.535,13.962,-83.472,13.944,-83.509,14.082,-83.442,13.986,-83.463,13.884,-83.413,14.0,-83.354,14.049,-83.297,14.127,-83.291,14.173,-83.268,14.194,-83.337,14.265,-83.344,14.352,-83.261,14.305,-83.28,14.308,-83.25,14.255,-83.277,14.21,-83.267,14.194,-83.281,14.159,-83.199,14.328,-83.299,14.768,-83.361,14.731,-83.427,14.819,-83.329,14.91,-83.304,14.782,-83.132,14.994,-83.234,14.981,-83.278,14.988,-83.28,14.993,-83.274,15.001,-83.278,15.005,-83.295,14.998,-83.32,15.002,-83.276,15.007,-83.236,14.983,-83.135,15.005,-83.292,15.074,-83.383,15.249,-83.388,15.234,-83.397,15.228,-83.434,15.225,-83.442,15.201,-83.433,15.227,-83.39,15.235,-83.39,15.256,-83.719,15.397,-83.55,15.286,-83.769,15.194,-83.832,15.318,-84.049,15.389,-83.835,15.265,-83.932,15.231,-84.04,15.368,-84.116,15.357,-84.137,15.506,-84.208,15.457,-84.238,15.53,-83.963,15.506,-83.923,15.443,-83.756,15.37,-83.728,15.393,-84.293,15.805,-84.282,15.793,-84.281,15.766,-84.306,15.723,-84.309,15.72,-84.315,15.72,-84.321,15.724,-84.321,15.713,-84.326,15.708,-84.331,15.709,-84.337,15.715,-84.339,15.714,-84.337,15.708,-84.337,15.703,-84.347,15.698,-84.35,15.699,-84.35,15.705,-84.353,15.705,-84.354,15.7,-84.344,15.691,-84.349,15.681,-84.352,15.682,-84.353,15.689,-84.358,15.681,-84.36,15.679,-84.368,15.678,-84.37,15.68,-84.308,15.818,-84.646,15.865,-84.529,15.839,-84.502,15.824,-84.646,15.774,-84.658,15.867,-85.03,15.986,-85.507,15.855,-85.725,15.969,-85.736,15.96,-85.73,15.956,-85.729,15.952,-85.739,15.942,-85.745,15.941,-85.749,15.953,-85.75,15.943,-85.762,15.927,-85.759,15.924,-85.759,15.922,-85.764,15.918,-85.772,15.915,-85.767,15.911,-85.768,15.89,-85.781,15.877,-85.773,15.914,-85.75,15.956,-85.737,15.957,-85.75,15.976,-85.738,15.964,-85.735,15.973,-85.996,16.028,-85.922,15.943,-85.877,15.999,-85.816,15.973,-86.358,15.781,-86.999,15.751,-87.374,15.849,-87.511,15.778,-87.601,15.927,-87.641,15.875,-87.727,15.921,-87.953,15.864,-87.894,15.856,-88.122,15.687,-88.269,15.74,-88.276,15.731,-88.288,15.73,-88.286,15.716,-88.291,15.693,-88.319,15.669,-88.444,15.608,-88.318,15.671,-88.288,15.73,-88.269,15.743,-88.62,15.966,-88.544,15.865,-88.511,15.87,-88.502,15.834,-88.472,15.834,-88.469,15.83,-88.552,15.837,-88.562,15.86,-88.6,15.703,-88.753,15.813,-88.777,15.775,-88.848,15.756,-88.89,15.685,-88.993,15.666,-88.984,15.51,-89.09,15.419,-89.308,15.356,-89.413,15.467,-89.066,15.589,-88.928,15.735,-88.874,15.748,-88.859,15.785,-88.832,15.792,-88.778,15.777,-88.748,15.83,-88.945,15.889,-88.931,15.987,-88.654,16.256,-88.652,16.285,-88.679,16.301,-88.682,16.308,-88.68,16.314,-88.545,16.272,-88.251,16.805,-88.289,16.888,-88.218,16.967,-88.304,17.245,-88.249,17.471,-88.181,17.493,-88.288,17.564,-88.289,17.662,-88.088,18.281,-88.104,18.287,-88.097,18.374,-88.398,18.377,-88.297,18.457,-88.203,18.711,-88.052,18.866,-87.998,18.668,-88.074,18.48,-87.932,18.434,-87.898,18.22,-87.859,18.238,-87.853,18.209,-88.04,17.879,-87.985,17.884,-87.829,18.12,-87.714,18.704,-87.446,19.312,-87.651,19.181,-87.707,19.227,-87.634,19.375,-87.44,19.469,-87.41,19.598,-87.454,19.648,-87.462,19.606,-87.432,19.618,-87.422,19.597,-87.427,19.583,-87.683,19.491,-87.74,19.671,-87.649,19.688,-87.667,19.616,-87.459,19.858,-87.468,19.945,-87.431,19.888,-87.476,19.83,-87.486,19.787,-87.469,19.781,-87.43,19.888,-87.478,20.078,-87.425,20.223,-86.877,20.84,-86.74,21.134,-86.807,21.181,-86.817,21.286,-86.791,21.345,-86.796,21.406,-86.826,21.245,-86.976,21.547,-86.975,21.513,-87.103,21.605,-84.952,21.863,-84.798,21.812,-84.556,21.933,-84.464,21.905,-84.517,21.759,-84.454,21.764,-84.262,21.895,-84.017,21.918,-83.918,22.167,-83.655,22.166,-83.568,22.249,-83.48,22.17,-83.362,22.205,-82.759,22.703,-81.87,22.679,-81.735,22.629,-81.649,22.574,-81.616,22.521,-82.158,22.395,-81.807,22.176,-81.735,22.203,-81.681,22.185,-81.682,22.168,-81.644,22.15,-81.621,22.154,-81.63,22.126,-81.62,22.075,-81.612,22.071,-81.604,22.163,-81.64,22.153,-81.683,22.195,-81.398,22.174,-81.355,22.087,-81.36,22.078,-81.356,22.072,-81.363,22.072,-81.375,22.065,-81.357,22.071,-81.346,22.055,-81.385,22.028,-81.425,22.066,-81.442,22.067,-81.457,22.075,-81.384,22.022,-81.312,22.027,-81.344,22.039,-81.341,22.054,-81.358,22.08,-81.255,22.14,-81.227,22.131,-81.202,22.103,-81.214,22.049,-81.198,22.085,-81.195,22.208,-81.21,22.281,-81.108,22.1,-80.97,22.044,-80.462,22.04,-80.549,22.16,-80.487,22.179,-80.29,21.898,-80.038,21.807,-80.023,21.743,-79.989,21.727,-79.874,21.755,-79.824,21.667,-79.822,21.699,-79.813,21.714,-79.764,21.717,-79.208,21.543,-79.058,21.591,-78.75,21.638,-78.7,21.602,-78.689,21.581,-78.742,21.594,-78.767,21.572,-78.781,21.533,-78.678,21.561,-78.493,21.292,-78.486,21.028,-78.263,20.9,-78.143,20.761,-77.612,20.671,-77.348,20.716,-77.202,20.637,-77.243,20.55,-77.22,20.556,-77.213,20.549,-77.212,20.56,-77.206,20.565,-77.213,20.573,-77.211,20.58,-77.205,20.581,-77.193,20.571,-77.177,20.598,-77.17,20.598,-77.212,20.548,-77.251,20.545,-77.116,20.511,-77.152,20.562,-77.029,20.563,-77.014,20.541,-77.022,20.524,-77.122,20.525,-77.078,20.504,-77.084,20.436,-77.182,20.286,-77.582,20.092,-77.57,20.065,-77.738,19.842,-76.225,19.995,-75.963,19.956,-75.871,20.027,-75.87,19.966,-75.607,19.896,-75.305,19.881,-75.152,19.938,-75.142,19.982,-75.176,20.016,-75.093,20.058,-75.158,19.89,-74.812,20.041,-74.294,20.066],"holes":[[-82.885,21.438,-82.542,21.594,-82.69,21.882,-82.978,21.943,-83.092,21.781,-82.956,21.599,-82.967,21.571,-83.091,21.548,-83.194,21.624,-83.077,21.469,-82.885,21.438],[-73.22,18.969,-73.302,18.926,-73.217,18.835,-72.806,18.705,-72.847,18.836,-73.22,18.969],[-77.836,18.525,-77.938,18.443,-78.213,18.456,-78.369,18.264,-78.047,18.201,-77.741,17.854,-77.405,17.863,-77.164,17.706,-77.133,17.889,-76.927,17.848,-76.846,18.009,-76.723,17.948,-76.846,17.933,-76.671,17.94,-76.53,17.85,-76.182,17.912,-76.338,18.156,-76.78,18.272,-76.896,18.413,-77.836,18.525],[-61.464,16.515,-61.555,16.285,-61.744,16.362,-61.801,16.315,-61.708,15.947,-61.564,16.034,-61.581,16.237,-61.171,16.246,-61.38,16.346,-61.464,16.515],[-61.423,15.64,-61.48,15.537,-61.355,15.206,-61.259,15.251,-61.241,15.467,-61.423,15.64],[-61.148,14.879,-61.229,14.822,-61.18,14.693,-60.992,14.552,-61.088,14.534,-61.082,14.47,-60.875,14.471,-60.856,14.397,-60.826,14.568,-60.946,14.726,-60.875,14.776,-60.957,14.736,-61.148,14.879],[-63.892,11.162,-64.043,10.986,-64.216,11.086,-64.407,10.973,-64.169,10.976,-64.064,10.861,-63.952,10.931,-63.896,10.883,-63.776,10.998,-63.892,11.162]]}],"names":{"ar":"البحر الكاريبي","de":"Karibisches Meer","es":"Mar Caribe","fr":"mer des Caraïbes","he":"הים הקריבי","hu":"Karib-tenger","it":"mare Caraibico","nl":"Caraïbische Zee","pt":"Mar das Caraíbas","zh":"加勒比海"}},{"name":"South China Sea","bbox":[102.238,-3.229,122.147,25.567],"area":278.348,"polygons":[{"outer":[119.283,25.488,119.297,25.416,119.347,25.541,119.456,25.499,119.485,25.366,119.582,25.457,119.598,25.433,119.597,25.339,119.65,25.4,119.938,25.432,121.538,25.3,121.405,25.183,121.446,25.167,121.509,25.07,121.508,25.057,121.498,25.04,121.455,25.032,121.437,24.994,121.448,25.029,121.505,25.071,121.416,25.169,121.035,25.021,120.855,24.664,120.723,24.607,120.472,24.241,120.461,24.215,120.508,24.195,120.546,24.116,120.499,24.195,120.43,24.159,120.379,24.094,120.459,24.158,120.435,24.09,120.251,23.857,120.377,23.828,120.171,23.806,120.14,23.546,120.212,23.504,120.119,23.501,120.19,23.329,120.181,23.349,120.144,23.353,120.141,23.373,120.131,23.372,120.101,23.115,120.032,23.076,120.207,23.023,120.142,22.994,120.252,22.643,120.412,22.476,120.417,22.477,120.42,22.535,120.424,22.54,120.441,22.544,120.432,22.618,120.441,22.646,120.433,22.619,120.443,22.545,120.424,22.479,120.643,22.303,120.723,21.921,120.821,21.935,121.956,21.112,122.147,19.957,122.111,18.548,122.13,18.382,121.902,18.261,121.629,18.362,121.651,18.328,121.658,18.3,121.682,18.264,121.679,18.115,121.614,17.962,121.676,18.117,121.624,18.351,121.448,18.454,121.433,18.348,121.414,18.31,121.393,18.311,121.407,18.3,121.407,18.289,121.399,18.282,121.39,18.309,121.432,18.353,121.418,18.376,121.436,18.401,121.438,18.457,121.354,18.464,121.199,18.608,120.933,18.558,120.844,18.651,120.772,18.543,120.562,18.492,120.596,18.325,120.47,18.087,120.436,17.733,120.352,17.681,120.354,17.545,120.409,17.514,120.438,17.56,120.445,17.563,120.455,17.564,120.475,17.554,120.399,17.492,120.479,17.393,120.403,17.211,120.451,16.999,120.278,16.62,120.425,16.168,120.201,16.042,120.247,15.994,120.252,15.894,120.272,15.886,120.283,15.866,120.302,15.858,120.304,15.851,120.293,15.839,120.315,15.832,120.328,15.819,120.329,15.813,120.32,15.806,120.329,15.805,120.345,15.79,120.352,15.795,120.359,15.793,120.384,15.772,120.406,15.781,120.468,15.761,120.462,15.767,120.456,15.784,120.457,15.815,120.485,15.85,120.652,15.938,120.484,15.847,120.471,15.759,120.404,15.778,120.391,15.771,120.384,15.77,120.376,15.773,120.319,15.804,120.252,15.893,120.241,16.0,120.1,16.064,120.088,16.17,120.013,16.171,119.914,16.242,119.924,16.254,119.912,16.295,119.925,16.383,119.779,16.309,119.749,15.965,119.773,15.914,119.847,15.97,119.913,15.841,119.869,15.735,119.968,15.508,119.89,15.429,120.015,15.268,120.083,14.786,120.178,14.74,120.206,14.872,120.268,14.846,120.248,14.692,120.368,14.626,120.382,14.472,120.477,14.409,120.609,14.479,120.547,14.725,120.585,14.821,120.568,14.839,120.571,14.849,120.574,14.852,120.614,14.804,120.624,14.85,120.635,14.866,120.65,14.767,120.663,14.815,120.679,14.829,120.69,14.849,120.705,14.862,120.655,14.768,120.886,14.71,120.897,14.75,120.915,14.748,120.943,14.595,121.007,14.598,121.015,14.594,120.99,14.589,120.981,14.596,120.954,14.591,120.973,14.478,120.894,14.492,120.762,14.32,120.621,14.221,120.588,14.23,120.608,14.143,120.076,13.849,120.299,13.445,119.41,11.312,119.416,11.037,119.372,11.043,119.324,11.105,119.374,11.005,119.32,11.034,119.309,10.944,119.453,10.843,119.462,10.724,119.283,10.847,119.284,10.936,119.22,10.954,119.21,10.848,119.343,10.731,119.256,10.484,119.14,10.382,119.114,10.485,119.063,10.388,118.986,10.407,119.027,10.341,118.932,10.29,118.939,10.209,118.806,10.193,118.808,10.03,118.742,10.075,118.774,10.07,118.762,10.133,118.127,9.343,117.63,9.049,117.424,8.758,117.255,8.609,117.174,8.341,117.002,8.181,116.914,7.952,116.643,6.707,116.205,6.241,116.284,6.276,116.291,6.238,116.184,6.22,116.141,6.14,116.203,6.166,116.206,6.122,116.085,6.108,116.145,6.06,116.036,5.832,115.903,5.751,115.866,5.575,115.605,5.52,115.594,5.631,115.365,5.388,115.341,5.296,115.41,5.369,115.444,5.388,115.465,5.369,115.492,5.387,115.494,5.387,115.498,5.383,115.508,5.391,115.523,5.39,115.524,5.391,115.52,5.398,115.521,5.407,115.537,5.429,115.552,5.432,115.561,5.43,115.522,5.388,115.474,5.369,115.417,5.369,115.396,5.341,115.401,5.332,115.379,5.294,115.413,5.271,115.414,5.281,115.426,5.299,115.446,5.31,115.444,5.324,115.456,5.331,115.471,5.327,115.429,5.252,115.549,5.183,115.581,5.248,115.615,5.229,115.546,5.072,115.418,4.974,115.475,4.944,115.438,4.886,115.414,4.969,115.323,4.89,115.197,4.966,115.124,4.8,115.059,4.825,115.06,4.795,115.089,4.787,115.095,4.783,115.105,4.766,115.114,4.77,115.118,4.763,115.032,4.769,115.024,4.853,114.997,4.772,115.02,4.921,114.969,4.84,114.947,4.865,114.942,4.884,114.916,4.855,114.889,4.844,114.884,4.829,114.881,4.828,114.874,4.832,114.937,4.884,115.004,4.905,115.054,5.045,114.216,4.552,113.977,4.598,113.924,4.249,113.434,3.756,113.033,3.185,113.166,3.111,113.252,3.129,113.272,3.108,113.314,3.148,113.33,3.139,113.353,3.152,113.371,3.15,113.271,3.106,113.169,3.11,113.03,3.18,112.586,3.004,111.886,2.874,111.882,2.865,111.861,2.853,111.882,2.858,111.896,2.845,111.901,2.836,111.889,2.821,111.9,2.823,111.908,2.812,111.904,2.799,111.86,2.851,111.86,2.854,111.881,2.869,111.873,2.876,111.677,2.836,111.779,2.805,111.804,2.737,111.809,2.735,111.816,2.743,111.824,2.743,111.862,2.682,111.862,2.671,111.857,2.665,111.82,2.664,111.824,2.626,111.806,2.601,111.824,2.61,111.838,2.594,111.834,2.58,111.813,2.572,111.841,2.558,111.857,2.5,111.836,2.472,111.86,2.445,111.862,2.437,111.783,2.361,111.819,2.356,111.835,2.327,111.817,2.302,111.843,2.27,111.834,2.244,111.884,2.235,111.945,2.191,111.964,2.148,112.028,2.161,112.156,2.139,112.403,2.027,112.422,2.035,112.444,2.035,112.588,2.009,112.925,2.021,112.963,2.022,113.015,2.018,113.057,2.191,113.084,2.202,113.151,2.193,113.157,2.187,113.149,2.231,113.158,2.266,113.14,2.254,113.13,2.252,113.124,2.254,113.129,2.271,113.098,2.257,113.084,2.26,113.089,2.27,113.105,2.28,113.132,2.285,113.086,2.261,113.13,2.273,113.129,2.253,113.159,2.268,113.158,2.187,113.06,2.192,113.034,2.023,113.017,2.016,112.589,2.007,112.398,2.024,111.967,2.141,111.833,2.239,111.83,2.279,111.812,2.275,111.82,2.242,111.81,2.229,111.801,2.227,111.688,2.287,111.676,2.254,111.655,2.247,111.65,2.224,111.637,2.215,111.639,2.202,111.637,2.172,111.623,2.162,111.597,2.161,111.583,2.191,111.548,2.184,111.555,2.17,111.548,2.15,111.526,2.131,111.463,2.127,111.357,2.155,111.359,2.123,111.381,2.133,111.403,2.114,111.401,2.12,111.403,2.125,111.417,2.115,111.414,2.093,111.412,2.109,111.361,2.12,111.355,2.135,111.349,2.095,111.378,2.111,111.381,2.108,111.349,2.048,111.29,2.053,111.319,2.021,111.211,2.097,111.246,2.034,111.189,2.036,111.103,1.795,111.131,1.865,111.16,1.844,111.131,1.833,111.158,1.817,111.139,1.767,111.167,1.774,111.162,1.729,111.171,1.757,111.215,1.72,111.241,1.751,111.246,1.748,111.248,1.733,111.259,1.754,111.269,1.749,111.272,1.736,111.29,1.75,111.294,1.748,111.293,1.739,111.215,1.718,111.172,1.754,111.179,1.73,111.16,1.727,111.135,1.768,111.149,1.82,111.086,1.756,111.155,1.69,111.167,1.641,111.206,1.653,111.31,1.619,111.278,1.486,111.313,1.528,111.372,1.514,111.382,1.504,111.375,1.474,111.407,1.484,111.461,1.474,111.471,1.457,111.459,1.44,111.478,1.436,111.487,1.426,111.499,1.433,111.502,1.433,111.504,1.429,111.487,1.425,111.454,1.435,111.453,1.459,111.459,1.47,111.375,1.468,111.317,1.521,111.295,1.479,111.27,1.481,111.276,1.613,111.161,1.631,111.138,1.679,111.025,1.665,110.994,1.524,111.171,1.388,111.258,1.416,111.391,1.359,111.413,1.32,111.4,1.276,111.456,1.272,111.501,1.243,111.493,1.225,111.391,1.271,111.382,1.352,111.317,1.325,111.221,1.392,111.164,1.35,111.183,1.342,111.182,1.337,111.173,1.33,111.199,1.317,111.198,1.312,111.19,1.311,110.802,1.577,110.753,1.473,110.651,1.426,110.663,1.393,110.646,1.433,110.74,1.484,110.731,1.547,110.607,1.601,110.528,1.549,110.515,1.549,110.49,1.564,110.474,1.534,110.484,1.527,110.492,1.544,110.505,1.547,110.519,1.518,110.516,1.51,110.498,1.508,110.51,1.495,110.511,1.488,110.505,1.483,110.496,1.484,110.536,1.439,110.537,1.421,110.53,1.41,110.518,1.407,110.524,1.393,110.517,1.407,110.535,1.438,110.432,1.523,110.426,1.535,110.424,1.56,110.427,1.565,110.43,1.565,110.439,1.558,110.446,1.568,110.435,1.577,110.396,1.548,110.404,1.544,110.42,1.525,110.415,1.508,110.43,1.499,110.386,1.48,110.417,1.522,110.393,1.547,110.393,1.563,110.404,1.569,110.401,1.575,110.376,1.554,110.337,1.559,110.33,1.569,110.326,1.554,110.308,1.544,110.304,1.544,110.296,1.55,110.298,1.543,110.287,1.539,110.288,1.533,110.287,1.529,110.275,1.518,110.293,1.553,110.329,1.571,110.406,1.578,110.383,1.586,110.384,1.6,110.375,1.599,110.364,1.604,110.336,1.67,110.341,1.706,110.33,1.713,110.318,1.593,110.239,1.612,110.195,1.707,109.935,1.637,109.976,1.694,109.889,1.698,109.907,1.68,109.898,1.651,109.894,1.647,109.891,1.647,109.859,1.672,109.864,1.647,109.849,1.629,109.853,1.625,109.855,1.634,109.862,1.633,109.865,1.628,109.865,1.62,109.843,1.606,109.854,1.67,109.86,1.674,109.866,1.672,109.894,1.648,109.883,1.694,109.888,1.699,109.906,1.705,109.925,1.701,109.682,1.861,109.646,2.081,109.587,1.99,109.323,1.927,109.293,1.781,109.335,1.799,109.38,1.868,109.41,1.883,109.371,1.842,109.391,1.814,109.408,1.85,109.42,1.853,109.436,1.846,109.438,1.865,109.445,1.861,109.437,1.773,109.367,1.83,109.059,1.525,108.963,1.197,109.14,1.248,109.222,1.474,109.246,1.488,109.346,1.484,109.35,1.524,109.353,1.495,109.35,1.486,109.344,1.482,109.227,1.471,109.26,1.383,109.267,1.385,109.271,1.4,109.282,1.414,109.317,1.438,109.336,1.44,109.143,1.242,108.904,1.166,108.978,1.0,108.84,0.815,108.929,0.614,108.914,0.334,109.097,0.241,109.176,0.074,109.2,-0.214,109.198,-0.22,109.173,-0.232,109.135,-0.237,109.122,-0.258,109.087,-0.283,109.105,-0.307,109.118,-0.351,109.114,-0.508,109.146,-0.566,109.254,-0.605,109.405,-0.529,109.386,-0.583,109.399,-0.578,109.418,-0.59,109.452,-0.549,109.467,-0.547,109.407,-0.628,109.468,-0.601,109.479,-0.624,109.495,-0.625,109.503,-0.637,109.527,-0.638,109.529,-0.644,109.543,-0.645,109.546,-0.652,109.553,-0.65,109.557,-0.654,109.563,-0.651,109.583,-0.651,109.587,-0.657,109.616,-0.621,109.661,-0.63,109.68,-0.581,109.695,-0.571,109.701,-0.57,109.688,-0.619,109.583,-0.677,109.596,-0.694,109.611,-0.684,109.617,-0.685,109.618,-0.69,109.631,-0.69,109.655,-0.672,109.657,-0.672,109.657,-0.676,109.632,-0.691,109.617,-0.691,109.614,-0.684,109.598,-0.695,109.571,-0.693,109.552,-0.708,109.616,-0.701,109.658,-0.723,109.635,-0.796,109.712,-0.826,109.721,-0.85,109.716,-0.866,109.725,-0.871,109.735,-0.861,109.746,-0.862,109.751,-0.874,109.739,-0.883,109.75,-0.894,109.8,-0.84,109.856,-0.822,109.892,-0.822,109.893,-0.802,109.938,-0.761,109.908,-0.73,109.941,-0.729,109.941,-0.762,109.934,-0.782,109.895,-0.823,109.728,-0.92,109.732,-0.986,109.764,-0.995,109.825,-1.095,109.964,-1.104,109.988,-1.124,109.99,-1.12,109.985,-1.109,109.987,-1.105,110.048,-1.074,110.077,-1.083,109.905,-1.205,110.074,-1.401,110.027,-1.682,109.939,-1.764,109.989,-1.775,110.015,-1.807,110.009,-1.833,109.996,-1.835,109.98,-1.862,109.974,-1.865,109.901,-1.828,110.133,-2.03,110.063,-2.246,110.196,-2.518,110.265,-2.548,110.296,-2.467,110.325,-2.45,110.342,-2.463,110.358,-2.446,110.346,-2.436,110.339,-2.418,110.34,-2.386,110.398,-2.306,110.36,-2.447,110.344,-2.464,110.298,-2.471,110.282,-2.511,110.362,-2.528,110.361,-2.539,110.243,-2.562,110.196,-2.525,110.149,-2.621,110.216,-2.653,110.224,-2.888,110.303,-2.993,108.266,-2.767,108.088,-2.599,108.024,-2.663,108.004,-2.58,107.904,-2.577,107.816,-2.526,107.627,-2.601,106.843,-2.572,106.375,-2.474,106.079,-2.022,106.159,-2.086,106.187,-1.895,106.025,-1.675,106.052,-1.605,105.888,-1.501,105.703,-1.53,105.755,-1.728,105.872,-1.798,105.775,-1.767,105.696,-1.782,105.611,-1.704,105.724,-1.645,105.642,-1.644,105.605,-1.53,105.469,-1.561,105.32,-1.699,105.395,-1.735,105.389,-1.748,105.412,-1.755,105.423,-1.778,105.422,-1.765,105.425,-1.763,105.434,-1.762,105.438,-1.77,105.126,-1.958,105.125,-2.076,105.447,-2.134,105.566,-2.071,105.541,-2.048,105.539,-2.019,105.566,-1.999,105.587,-2.0,105.593,-1.957,105.597,-1.955,105.606,-1.961,105.591,-2.0,105.541,-2.023,105.57,-2.066,105.56,-2.087,105.765,-2.135,105.802,-2.366,105.931,-2.47,105.887,-2.644,105.949,-2.82,106.421,-2.97,106.491,-3.092,106.097,-3.229,106.044,-2.987,105.846,-2.949,105.78,-2.683,105.633,-2.644,105.624,-2.393,105.576,-2.42,105.527,-2.423,105.519,-2.431,105.51,-2.433,105.503,-2.439,105.495,-2.441,105.496,-2.451,105.492,-2.452,105.485,-2.449,105.494,-2.451,105.494,-2.44,105.526,-2.422,105.569,-2.41,105.21,-2.326,105.111,-2.382,105.117,-2.397,105.114,-2.436,105.068,-2.451,105.084,-2.464,105.083,-2.483,105.078,-2.491,105.064,-2.491,105.055,-2.516,105.091,-2.531,105.094,-2.536,105.097,-2.561,105.076,-2.594,105.091,-2.614,105.096,-2.641,105.094,-2.647,105.052,-2.679,105.067,-2.7,105.064,-2.701,105.05,-2.68,105.07,-2.65,105.094,-2.642,105.075,-2.597,105.08,-2.575,105.096,-2.557,105.091,-2.533,105.064,-2.535,105.053,-2.514,105.067,-2.447,105.115,-2.404,105.026,-2.397,105.036,-2.427,104.968,-2.64,104.977,-2.683,104.975,-2.698,104.898,-2.933,104.86,-2.979,104.836,-2.99,104.84,-3.001,104.837,-3.029,104.842,-3.039,104.766,-2.988,104.862,-2.958,104.86,-2.914,104.908,-2.827,104.896,-2.796,104.898,-2.777,104.903,-2.766,104.876,-2.749,104.869,-2.74,104.856,-2.751,104.827,-2.734,104.83,-2.749,104.814,-2.795,104.826,-2.792,104.815,-2.797,104.813,-2.792,104.819,-2.762,104.829,-2.748,104.821,-2.725,104.777,-2.759,104.761,-2.737,104.758,-2.706,104.777,-2.571,104.824,-2.466,104.845,-2.447,104.872,-2.44,104.86,-2.393,104.872,-2.375,104.905,-2.363,104.916,-2.321,104.916,-2.282,104.757,-2.392,104.732,-2.59,104.531,-2.772,104.724,-2.582,104.706,-2.354,104.565,-2.448,104.474,-2.48,104.436,-2.535,104.368,-2.572,104.357,-2.625,104.33,-2.637,104.36,-2.564,104.476,-2.474,104.565,-2.44,104.639,-2.357,104.596,-2.34,104.579,-2.295,104.563,-2.323,104.555,-2.324,104.544,-2.318,104.539,-2.32,104.543,-2.338,104.538,-2.363,104.532,-2.373,104.523,-2.378,104.513,-2.375,104.486,-2.348,104.474,-2.36,104.464,-2.363,104.439,-2.363,104.43,-2.359,104.424,-2.349,104.417,-2.301,104.396,-2.312,104.365,-2.304,104.418,-2.299,104.433,-2.359,104.486,-2.346,104.519,-2.376,104.538,-2.317,104.579,-2.292,104.639,-2.342,104.644,-2.358,104.639,-2.372,104.697,-2.343,104.743,-2.359,104.906,-2.184,104.856,-2.023,104.72,-1.994,104.69,-2.022,104.706,-2.063,104.703,-2.08,104.693,-2.09,104.697,-2.095,104.644,-2.059,104.631,-2.12,104.612,-2.123,104.596,-2.112,104.585,-2.117,104.585,-2.13,104.569,-2.145,104.504,-2.162,104.501,-2.186,104.492,-2.203,104.459,-2.233,104.441,-2.242,104.42,-2.242,104.416,-2.238,104.506,-2.158,104.595,-2.107,104.627,-2.116,104.631,-2.062,104.646,-2.053,104.683,-2.06,104.688,-1.993,104.414,-2.028,104.501,-1.966,104.494,-1.923,104.471,-1.97,104.419,-1.974,104.464,-1.909,104.453,-1.92,104.397,-1.925,104.351,-1.914,104.455,-1.918,104.504,-1.805,104.483,-1.787,104.473,-1.797,104.447,-1.81,104.437,-1.811,104.43,-1.805,104.484,-1.785,104.511,-1.807,104.544,-1.773,104.369,-1.023,104.207,-1.071,104.21,-1.159,104.157,-1.2,104.129,-1.189,104.163,-1.22,104.142,-1.273,104.062,-1.315,104.07,-1.216,104.055,-1.229,103.957,-1.187,103.953,-1.18,103.954,-1.17,103.97,-1.155,103.937,-1.17,103.929,-1.165,103.925,-1.15,103.916,-1.145,103.883,-1.159,103.855,-1.147,103.809,-1.065,103.804,-0.994,103.62,-0.987,103.671,-0.94,103.59,-0.873,103.552,-0.857,103.541,-0.867,103.523,-0.87,103.517,-0.869,103.515,-0.854,103.505,-0.859,103.547,-0.81,103.329,-0.836,103.481,-0.793,103.364,-0.715,103.435,-0.711,103.008,-0.699,103.427,-0.624,103.429,-0.532,103.392,-0.519,103.393,-0.533,103.368,-0.557,103.353,-0.567,103.325,-0.56,103.391,-0.533,103.37,-0.438,103.332,-0.442,103.311,-0.433,103.293,-0.456,103.263,-0.461,103.223,-0.46,103.333,-0.412,103.306,-0.384,103.313,-0.365,103.325,-0.353,103.262,-0.307,103.137,-0.347,103.087,-0.403,103.008,-0.451,103.008,-0.445,103.125,-0.345,103.245,-0.289,103.14,-0.278,103.456,-0.184,103.453,-0.158,103.435,-0.147,103.406,-0.15,103.391,-0.142,103.386,-0.134,103.389,-0.118,103.498,-0.155,103.357,-0.026,103.485,-0.097,103.398,0.026,103.441,-0.026,103.478,-0.049,103.501,-0.069,103.505,-0.08,103.568,-0.066,103.525,-0.06,103.483,-0.008,103.478,0.001,103.477,0.009,103.526,-0.058,103.596,-0.038,103.57,-0.036,103.561,-0.012,103.492,0.043,103.487,0.053,103.563,-0.009,103.574,-0.033,103.566,0.0,103.548,0.019,103.515,0.042,103.513,0.046,103.553,0.018,103.587,-0.023,103.597,0.006,103.812,-0.011,103.717,0.294,103.615,0.346,103.621,0.259,103.391,0.3,103.368,0.291,103.358,0.292,103.341,0.253,103.311,0.239,103.3,0.238,103.359,0.296,103.4,0.302,103.435,0.293,103.47,0.307,103.558,0.283,103.608,0.306,103.536,0.437,103.326,0.545,103.14,0.392,102.854,0.248,102.662,0.237,102.583,0.154,102.527,0.168,102.485,0.24,102.467,0.229,102.433,0.244,102.424,0.222,102.413,0.211,102.378,0.205,102.332,0.217,102.311,0.257,102.305,0.287,102.316,0.279,102.329,0.224,102.346,0.221,102.378,0.209,102.43,0.261,102.495,0.25,102.505,0.245,102.511,0.237,102.521,0.203,102.574,0.17,102.613,0.231,102.888,0.33,103.108,0.455,102.894,0.728,102.51,0.749,102.372,0.857,102.578,0.766,102.862,0.793,102.924,0.772,103.024,0.687,103.049,0.821,102.879,0.952,102.759,1.009,102.696,0.937,102.781,0.927,102.782,0.905,102.698,0.927,102.684,0.958,102.626,0.903,102.646,0.95,102.749,1.007,102.638,0.98,102.625,1.01,102.812,1.08,102.855,1.085,102.88,1.043,102.805,0.993,102.886,0.966,103.098,0.827,103.158,0.888,102.966,1.098,103.346,1.189,103.888,1.092,104.034,0.981,104.03,1.006,104.1,0.982,104.083,1.009,104.08,1.024,104.106,1.044,104.136,1.039,104.129,1.18,104.241,1.124,104.255,1.002,104.498,1.037,104.409,0.964,104.557,0.813,104.66,0.941,104.585,1.228,104.283,1.37,104.248,1.654,104.11,1.875,104.116,1.929,104.105,1.933,104.1,1.946,104.092,1.926,104.089,1.926,104.074,1.936,104.073,1.926,104.067,1.916,104.063,1.915,104.059,1.919,104.074,1.938,104.091,1.927,104.099,1.948,104.13,1.927,103.823,2.583,103.755,2.651,103.595,2.637,103.443,2.887,103.477,3.505,103.305,3.779,103.44,3.963,103.399,4.039,103.384,4.12,103.493,4.324,103.427,4.779,103.394,4.776,103.393,4.765,103.388,4.762,103.383,4.763,103.312,4.808,103.311,4.819,103.44,4.782,103.144,5.338,103.097,5.286,103.061,5.265,103.04,5.263,103.047,5.138,103.008,5.112,103.038,5.264,103.093,5.287,103.086,5.304,103.099,5.33,103.11,5.327,103.126,5.344,103.138,5.345,103.096,5.408,102.592,5.813,102.513,5.771,102.559,5.833,102.501,5.86,102.479,5.878,102.479,5.89,102.47,5.878,102.436,5.845,102.425,5.845,102.483,5.898,102.374,6.131,102.238,6.216,104.719,8.605,104.848,8.564,105.107,8.634,105.187,8.794,105.144,8.758,105.001,8.76,105.143,8.761,105.181,8.796,105.262,8.837,105.328,8.831,105.393,9.068,105.421,9.018,106.189,9.379,105.973,9.426,105.964,9.477,105.967,9.505,105.986,9.425,106.143,9.403,106.209,9.465,106.06,9.759,105.593,10.208,105.457,10.362,105.449,10.383,105.434,10.394,105.412,10.417,105.402,10.439,105.361,10.452,105.349,10.459,105.218,10.606,105.201,10.685,105.122,10.709,105.056,10.8,105.123,10.712,105.078,10.846,105.081,10.91,105.117,10.96,105.097,10.954,105.087,10.954,105.06,10.97,105.003,11.375,105.005,11.394,105.008,11.402,105.048,11.426,104.958,11.468,104.931,11.535,104.948,11.56,105.079,11.531,105.231,11.436,105.257,11.393,105.281,11.31,105.283,11.246,105.188,10.967,105.233,10.813,105.313,10.826,105.329,10.819,105.449,10.553,105.475,10.54,105.494,10.551,105.506,10.55,105.532,10.539,105.559,10.513,105.585,10.446,105.686,10.435,105.751,10.361,105.762,10.358,105.799,10.328,105.826,10.322,105.835,10.316,105.845,10.303,105.899,10.276,105.984,10.335,106.367,10.352,106.389,10.347,106.452,10.313,106.681,10.297,106.759,10.268,106.733,10.48,106.676,10.435,106.659,10.443,106.645,10.48,106.629,10.449,106.587,10.424,106.556,10.502,106.599,10.44,106.642,10.486,106.723,10.515,106.733,10.513,106.722,10.568,106.778,10.585,106.767,10.637,106.744,10.648,106.728,10.667,106.724,10.681,106.751,10.766,106.724,10.757,106.714,10.762,106.707,10.769,106.723,10.815,106.75,10.829,106.74,10.834,106.731,10.824,106.724,10.82,106.714,10.818,106.709,10.823,106.615,10.991,106.752,10.83,106.713,10.765,106.82,10.783,106.828,10.776,106.826,10.761,106.836,10.832,106.853,10.839,106.84,10.878,106.837,10.912,106.785,10.954,106.768,10.979,106.77,10.982,106.776,10.983,106.778,10.986,106.767,10.991,106.774,10.999,106.778,11.036,106.786,11.053,106.798,11.059,106.812,11.045,106.815,11.032,106.81,11.026,106.785,11.017,106.783,11.012,106.776,11.006,106.798,10.974,106.785,10.958,106.85,10.948,106.885,10.81,106.875,10.765,106.832,10.762,106.845,10.737,106.84,10.725,106.759,10.699,106.81,10.658,106.825,10.631,106.837,10.63,106.843,10.639,106.851,10.641,106.86,10.638,106.872,10.649,106.882,10.649,106.928,10.604,106.931,10.646,106.981,10.643,106.992,10.616,106.987,10.537,107.012,10.528,106.995,10.631,107.016,10.657,106.98,10.672,106.97,10.69,106.978,10.703,107.019,10.659,106.998,10.628,107.015,10.623,107.012,10.612,107.021,10.62,107.031,10.614,107.016,10.519,107.058,10.541,107.076,10.512,107.048,10.478,107.084,10.504,107.169,10.479,107.207,10.448,107.213,10.424,107.204,10.416,107.193,10.42,107.188,10.428,107.177,10.401,107.257,10.38,107.826,10.703,107.994,10.699,108.082,10.911,108.295,10.914,108.476,11.049,108.525,11.203,108.705,11.164,108.788,11.313,109.019,11.36,109.013,11.633,109.129,11.563,109.233,11.726,109.186,11.793,109.191,11.875,109.107,11.854,109.208,11.981,109.164,12.119,109.215,11.978,109.202,11.881,109.281,11.879,109.173,12.21,109.218,12.211,109.164,12.273,109.243,12.305,109.141,12.431,109.177,12.458,109.294,12.346,109.342,12.4,109.192,12.634,109.344,12.801,109.329,12.663,109.397,12.684,109.432,12.562,109.462,12.65,109.358,12.819,109.456,12.912,109.332,13.086,109.334,13.074,109.271,13.03,109.204,13.008,109.327,13.091,109.312,13.271,109.282,13.233,109.247,13.29,109.295,13.363,109.263,13.347,109.243,13.363,109.239,13.353,109.227,13.347,109.216,13.326,109.186,13.323,109.24,13.364,109.261,13.357,109.213,13.398,109.222,13.457,109.295,13.405,109.331,13.463,109.248,13.524,109.201,13.625,109.209,13.65,109.269,13.533,109.288,13.528,109.213,13.898,109.259,13.757,109.298,13.879,109.162,14.14,109.228,14.137,109.183,14.196,109.186,14.282,109.127,14.363,109.125,14.445,109.101,14.483,109.114,14.455,108.969,14.373,109.002,14.411,108.942,14.418,108.92,14.439,108.901,14.518,108.941,14.42,109.089,14.477,109.078,14.701,108.903,15.092,108.891,15.062,108.871,15.045,108.852,15.039,108.851,15.022,108.837,15.014,108.844,15.005,108.844,14.996,108.772,14.96,108.777,14.934,108.769,14.92,108.779,14.894,108.766,14.902,108.771,14.961,108.839,15.001,108.834,15.015,108.853,15.043,108.888,15.064,108.869,15.118,108.895,15.14,108.822,15.124,108.766,15.128,108.701,15.152,108.669,15.135,108.633,15.162,108.672,15.139,108.697,15.157,108.775,15.134,108.898,15.148,108.942,15.243,108.829,15.417,108.797,15.424,108.802,15.409,108.807,15.41,108.818,15.403,108.817,15.399,108.776,15.389,108.782,15.367,108.78,15.348,108.79,15.336,108.753,15.296,108.705,15.266,108.651,15.262,108.774,15.384,108.689,15.486,108.684,15.474,108.668,15.462,108.676,15.462,108.682,15.45,108.659,15.411,108.5,15.565,108.593,15.529,108.445,15.71,108.465,15.703,108.596,15.526,108.649,15.495,108.401,15.872,108.388,15.784,108.338,15.843,108.331,15.826,108.32,15.817,108.267,15.811,108.081,15.82,108.061,15.722,107.994,15.654,108.085,15.565,108.028,15.579,107.992,15.654,108.059,15.721,108.039,15.776,108.107,15.867,108.091,15.859,108.074,15.873,108.055,15.858,108.01,15.846,107.954,15.85,107.902,15.811,107.848,15.818,107.984,15.862,108.074,15.878,108.092,15.861,108.113,15.892,108.139,15.899,108.101,15.85,108.17,15.872,108.183,15.862,108.181,15.847,108.208,15.871,108.276,15.862,108.309,15.879,108.381,15.874,108.327,15.91,108.367,15.896,108.389,15.876,108.247,16.056,108.325,16.14,108.23,16.15,108.249,16.017,108.221,15.976,108.22,16.096,108.194,16.073,108.073,16.11,108.195,16.21,108.041,16.229,108.09,16.226,108.023,16.344,107.817,16.29,107.767,16.346,107.826,16.375,107.643,16.553,107.622,16.544,107.593,16.547,107.597,16.473,107.582,16.461,107.528,16.446,107.564,16.427,107.577,16.399,107.575,16.388,107.527,16.446,107.587,16.469,107.592,16.492,107.573,16.526,107.595,16.55,107.625,16.548,107.624,16.552,107.496,16.599,107.434,16.64,107.418,16.672,107.39,16.679,107.382,16.689,107.419,16.674,107.43,16.653,107.618,16.571,107.198,16.903,107.182,16.855,107.127,16.846,107.164,16.805,107.164,16.798,107.158,16.788,107.186,16.772,107.187,16.763,107.182,16.733,107.169,16.724,107.102,16.828,107.141,16.877,107.191,16.909,107.118,17.086,106.633,17.485,106.652,17.406,106.63,17.36,106.65,17.347,106.672,17.353,106.695,17.344,106.698,17.34,106.703,17.323,106.713,17.321,106.726,17.301,106.728,17.294,106.625,17.324,106.627,17.494,106.496,17.703,106.399,17.718,106.404,17.682,106.381,17.633,106.345,17.619,106.319,17.616,106.381,17.635,106.378,17.709,106.389,17.722,106.403,17.73,106.416,17.73,106.433,17.722,106.431,17.731,106.299,17.76,106.182,17.815,106.428,17.744,106.49,17.706,106.408,17.89,106.511,17.96,106.435,18.122,106.401,18.099,106.315,18.107,106.252,18.215,106.085,18.249,105.942,18.472,105.923,18.394,105.875,18.39,105.834,18.423,105.922,18.454,105.775,18.76,105.77,18.67,105.68,18.575,105.607,18.526,105.572,18.544,105.558,18.533,105.554,18.511,105.547,18.501,105.544,18.5,105.559,18.54,105.571,18.546,105.584,18.547,105.609,18.529,105.68,18.581,105.766,18.682,105.719,18.85,105.636,18.898,105.605,18.997,105.739,19.105,105.726,19.233,105.817,19.313,105.766,19.404,105.814,19.427,105.805,19.585,105.815,19.606,105.806,19.616,105.8,19.6,105.796,19.596,105.786,19.594,105.778,19.605,105.772,19.598,105.77,19.598,105.932,19.777,105.859,19.769,105.838,19.777,105.817,19.796,105.817,19.801,105.939,19.793,105.973,19.963,106.097,19.924,106.093,19.972,106.114,20.048,106.167,20.138,106.152,20.224,106.108,20.215,106.1,20.227,106.099,20.245,106.153,20.226,106.163,20.187,106.169,20.13,106.096,19.965,106.143,19.921,106.209,20.045,106.176,20.145,106.18,20.179,106.185,20.196,106.192,20.204,106.256,20.251,106.265,20.25,106.276,20.238,106.289,20.241,106.329,20.29,106.312,20.299,106.311,20.332,106.318,20.344,106.27,20.326,106.256,20.331,106.251,20.343,106.254,20.357,106.285,20.393,106.226,20.407,106.209,20.421,106.224,20.464,106.179,20.486,106.17,20.552,106.339,20.506,106.36,20.45,106.363,20.452,106.362,20.458,106.377,20.489,106.472,20.505,106.518,20.444,106.563,20.475,106.582,20.477,106.604,20.467,106.606,20.471,106.578,20.558,106.649,20.612,106.61,20.627,106.601,20.639,106.597,20.653,106.586,20.665,106.656,20.617,106.705,20.672,106.617,20.7,106.547,20.76,106.809,20.675,106.708,20.806,106.808,20.825,106.623,20.881,106.591,20.945,106.623,20.882,106.68,20.868,106.713,20.887,106.734,20.886,106.739,20.876,106.764,20.943,106.701,20.95,106.669,20.957,106.635,20.973,106.621,21.002,106.76,20.983,106.746,20.995,106.729,20.994,106.706,20.998,106.651,21.018,106.637,21.011,106.623,21.011,106.613,21.02,106.654,21.02,106.763,20.994,106.775,20.939,106.803,20.934,106.83,20.923,106.849,20.902,106.857,20.87,106.866,20.988,107.032,20.946,107.032,21.029,107.13,21.027,107.07,20.949,107.147,20.943,107.152,20.923,107.199,20.934,107.204,20.962,107.249,20.999,107.373,21.023,107.356,21.076,107.377,21.085,107.368,21.099,107.386,21.113,107.387,21.155,107.392,21.173,107.397,21.18,107.376,21.176,107.367,21.189,107.439,21.242,107.367,21.209,107.37,21.315,107.421,21.268,107.535,21.334,107.604,21.291,107.639,21.384,107.755,21.404,107.766,21.51,107.896,21.526,107.969,21.433,108.079,21.484,108.012,21.49,108.046,21.508,108.017,21.51,108.012,21.518,108.034,21.547,108.187,21.532,108.143,21.558,108.14,21.575,108.234,21.637,108.217,21.498,108.318,21.575,108.301,21.597,108.35,21.67,108.328,21.697,108.342,21.727,108.36,21.675,108.326,21.604,108.342,21.56,108.377,21.667,108.432,21.688,108.391,21.555,108.475,21.556,108.574,21.684,108.529,21.676,108.488,21.737,108.556,21.74,108.524,21.76,108.455,21.867,108.463,21.914,108.483,21.938,108.478,21.843,108.519,21.847,108.494,21.864,108.518,21.862,108.516,21.874,108.524,21.873,108.539,21.905,108.551,21.919,108.558,21.922,108.541,21.902,108.532,21.876,108.532,21.866,108.534,21.862,108.542,21.889,108.603,21.907,108.628,21.887,108.568,21.749,108.583,21.754,108.597,21.707,108.627,21.696,108.628,21.77,108.668,21.792,108.745,21.599,108.869,21.627,108.84,21.652,108.812,21.719,108.847,21.795,108.814,21.81,108.814,21.821,108.808,21.827,108.808,21.835,108.799,21.829,108.775,21.83,108.77,21.834,108.773,21.845,108.774,21.832,108.806,21.837,108.845,21.802,108.866,21.748,108.897,21.77,108.861,21.74,108.867,21.71,108.849,21.686,108.968,21.594,109.066,21.65,109.068,21.629,109.063,21.62,109.045,21.607,109.074,21.584,109.07,21.619,109.072,21.63,109.075,21.634,109.078,21.636,109.09,21.636,109.096,21.649,109.127,21.668,109.147,21.672,109.162,21.719,109.191,21.748,109.202,21.75,109.209,21.747,109.146,21.669,109.071,21.621,109.165,21.524,109.043,21.451,109.171,21.392,109.242,21.469,109.287,21.424,109.543,21.505,109.585,21.582,109.497,21.657,109.565,21.745,109.67,21.502,109.753,21.458,109.751,21.566,109.813,21.458,109.899,21.457,109.92,21.483,109.926,21.486,109.937,21.487,109.934,21.353,109.758,21.346,109.666,20.9,109.826,20.711,109.736,20.664,109.855,20.565,109.821,20.513,110.018,20.444,109.982,20.346,109.971,20.405,109.855,20.406,109.92,20.223,109.988,20.294,110.285,20.241,110.519,20.426,110.491,20.561,110.446,20.565,110.396,20.607,110.419,20.639,110.482,20.581,110.47,20.662,110.301,20.656,110.327,20.78,110.362,20.708,110.394,20.756,110.326,20.854,110.174,20.84,110.011,20.875,110.194,20.852,110.173,21.126,110.231,21.018,110.242,21.049,110.294,21.101,110.326,21.074,110.3,21.062,110.343,21.061,110.202,20.944,110.414,21.002,110.527,20.928,110.547,21.067,110.432,21.052,110.328,21.073,110.312,21.109,110.424,21.226,110.35,21.434,110.489,21.296,110.426,21.271,110.449,21.19,110.456,21.21,110.49,21.222,110.633,21.229,110.616,21.317,110.623,21.368,110.742,21.467,110.753,21.383,110.887,21.385,111.081,21.464,111.014,21.475,111.001,21.502,111.032,21.527,111.082,21.484,111.112,21.485,111.101,21.46,111.304,21.485,111.253,21.447,111.258,21.415,111.45,21.533,111.424,21.592,111.474,21.504,111.61,21.548,111.646,21.516,111.786,21.62,111.788,21.712,111.699,21.743,111.675,21.769,111.68,21.799,111.847,21.761,111.795,21.74,111.858,21.67,111.912,21.689,111.812,21.56,111.92,21.608,112.002,21.617,111.913,21.696,112.054,21.776,112.007,21.761,111.942,21.787,111.917,21.914,111.902,21.92,111.894,21.918,111.888,21.921,111.88,21.926,111.878,21.935,111.873,21.934,111.868,21.94,111.851,21.94,111.808,21.953,111.8,21.957,111.797,21.965,111.872,21.942,111.894,21.919,111.904,21.922,111.916,21.918,111.978,21.809,112.012,21.821,112.0,21.828,111.998,21.835,112.051,21.879,112.021,21.792,112.198,21.839,112.255,21.698,112.408,21.742,112.366,21.999,112.377,22.042,112.405,22.055,112.372,22.003,112.404,21.956,112.391,21.957,112.393,21.94,112.516,21.959,112.501,21.934,112.423,21.918,112.423,21.83,112.565,21.754,112.832,22.013,112.895,21.858,112.983,21.89,113.009,22.111,113.084,22.146,113.049,22.434,113.004,22.444,112.949,22.491,112.95,22.485,112.942,22.475,112.933,22.47,112.801,22.429,112.756,22.442,112.758,22.408,112.754,22.397,112.741,22.378,112.673,22.351,112.639,22.35,112.635,22.335,112.627,22.33,112.59,22.324,112.755,22.444,112.91,22.474,112.898,22.498,112.923,22.515,112.95,22.505,112.992,22.508,113.011,22.501,113.067,22.432,113.082,22.478,113.101,22.207,113.178,22.296,113.175,22.419,113.176,22.426,113.191,22.44,113.183,22.447,113.165,22.5,113.173,22.584,113.115,22.604,113.09,22.635,113.059,22.794,112.971,22.804,112.914,22.858,112.868,22.924,112.793,23.096,112.788,23.136,112.812,23.151,112.795,23.182,112.803,23.203,112.944,23.119,112.97,23.083,112.987,23.029,113.003,23.036,113.02,23.033,113.047,23.005,113.069,23.007,113.103,22.976,113.143,22.99,113.158,22.987,113.158,23.005,113.163,23.012,113.182,23.021,113.21,23.016,113.207,23.034,113.213,23.045,113.268,23.058,113.163,23.133,113.157,23.256,113.142,23.267,113.127,23.253,113.112,23.246,113.107,23.246,113.095,23.252,113.087,23.242,113.067,23.232,113.06,23.233,113.038,23.252,113.036,23.286,113.063,23.234,113.093,23.254,113.125,23.253,113.144,23.269,113.158,23.258,113.166,23.241,113.172,23.24,113.173,23.28,113.213,23.246,113.219,23.266,113.227,23.274,113.245,23.275,113.26,23.292,113.275,23.296,113.275,23.31,113.3,23.342,113.315,23.342,113.332,23.368,113.347,23.371,113.331,23.391,113.334,23.4,113.356,23.416,113.388,23.417,113.335,23.399,113.349,23.372,113.276,23.294,113.216,23.246,113.175,23.24,113.213,23.186,113.228,23.111,113.269,23.118,113.397,23.109,113.484,23.083,113.515,23.042,113.544,23.082,113.697,23.159,113.714,23.161,113.747,23.137,113.766,23.158,113.793,23.176,113.808,23.176,113.814,23.203,113.826,23.22,113.842,23.234,113.822,23.257,113.819,23.27,113.83,23.338,113.848,23.354,113.832,23.36,113.826,23.372,113.842,23.42,113.913,23.459,113.844,23.419,113.85,23.352,113.821,23.272,113.842,23.238,113.841,23.227,113.812,23.177,113.749,23.135,113.806,23.133,113.855,23.118,113.869,23.105,113.901,23.12,113.968,23.119,114.014,23.088,114.047,23.104,114.061,23.106,114.075,23.099,114.087,23.087,114.099,23.055,114.115,23.049,114.149,23.085,114.18,23.092,114.204,23.115,114.231,23.124,114.239,23.145,114.26,23.159,114.309,23.162,114.345,23.148,114.349,23.137,114.364,23.147,114.377,23.145,114.384,23.14,114.408,23.097,114.444,23.126,114.445,23.155,114.456,23.177,114.467,23.185,114.604,23.167,114.55,23.235,114.574,23.261,114.551,23.276,114.529,23.297,114.518,23.325,114.503,23.319,114.495,23.327,114.503,23.364,114.524,23.379,114.5,23.325,114.519,23.329,114.578,23.262,114.555,23.234,114.606,23.177,114.608,23.173,114.605,23.165,114.597,23.159,114.466,23.182,114.451,23.13,114.415,23.094,114.404,23.096,114.376,23.142,114.349,23.133,114.266,23.155,114.235,23.121,114.185,23.091,114.151,23.083,114.117,23.047,114.097,23.054,114.064,23.102,114.022,23.084,114.0,23.085,113.96,23.115,113.73,23.061,113.858,23.113,113.713,23.141,113.591,23.024,113.594,22.993,113.592,22.976,113.587,22.971,113.583,22.947,113.59,22.924,113.588,22.954,113.607,22.97,113.6,22.997,113.623,23.027,113.597,22.925,113.634,23.023,113.651,23.043,113.623,22.957,113.635,22.983,113.644,22.987,113.662,22.987,113.662,22.994,113.667,23.004,113.671,23.005,113.676,23.003,113.699,23.026,113.712,23.027,113.573,22.859,113.618,22.82,113.618,22.903,113.666,22.745,113.78,22.752,113.886,22.448,113.95,22.522,114.051,22.503,114.059,22.514,114.089,22.537,114.109,22.533,113.917,22.372,114.106,22.372,114.3,22.26,114.253,22.354,114.279,22.394,114.317,22.393,114.359,22.336,114.403,22.436,114.321,22.48,114.188,22.377,114.181,22.452,114.337,22.508,114.21,22.534,114.396,22.613,114.502,22.45,114.543,22.484,114.61,22.497,114.499,22.555,114.596,22.654,114.501,22.648,114.521,22.708,114.782,22.837,114.745,22.587,114.893,22.573,114.872,22.609,114.879,22.62,114.888,22.625,114.896,22.577,114.884,22.544,114.969,22.678,114.885,22.71,114.847,22.761,114.942,22.771,114.979,22.688,115.033,22.716,115.016,22.818,115.056,22.851,115.03,22.801,115.179,22.778,115.314,22.902,115.23,22.782,115.41,22.792,115.351,22.712,115.57,22.65,115.601,22.753,115.516,22.756,115.614,22.872,115.593,22.868,115.576,22.846,115.555,22.841,115.537,22.852,115.507,22.895,115.615,22.877,115.609,22.903,115.616,22.999,115.632,23.047,115.61,23.066,115.621,23.073,115.633,23.046,115.617,22.868,115.655,22.884,115.67,22.885,115.692,22.921,115.743,22.942,115.78,22.927,115.663,22.877,115.812,22.818,115.815,22.741,116.075,22.848,116.075,22.876,116.084,22.883,116.094,22.819,116.189,22.878,116.241,22.937,116.203,22.978,116.176,22.981,116.122,23.024,116.22,22.971,116.244,22.937,116.492,22.936,116.609,23.216,116.545,23.214,116.577,23.229,116.531,23.224,116.515,23.246,116.494,23.246,116.486,23.26,116.608,23.23,116.62,23.214,116.607,23.183,116.643,23.163,116.742,23.25,116.598,23.304,116.459,23.443,116.481,23.501,116.454,23.495,116.427,23.471,116.421,23.47,116.287,23.535,116.256,23.517,116.234,23.517,116.207,23.527,116.169,23.511,116.158,23.493,116.125,23.477,116.156,23.493,116.158,23.502,116.165,23.512,116.18,23.52,116.204,23.529,116.259,23.521,116.276,23.538,116.286,23.54,116.428,23.473,116.433,23.551,116.396,23.568,116.385,23.547,116.346,23.543,116.338,23.544,116.334,23.55,116.334,23.567,116.407,23.569,116.437,23.55,116.459,23.501,116.477,23.505,116.489,23.497,116.463,23.442,116.765,23.335,116.619,23.624,116.65,23.672,116.567,23.758,116.559,23.775,116.559,23.801,116.536,23.804,116.49,23.867,116.493,23.884,116.473,23.884,116.464,23.892,116.464,23.912,116.483,23.941,116.473,23.964,116.497,24.082,116.496,23.999,116.476,23.965,116.483,23.949,116.484,23.935,116.467,23.893,116.495,23.884,116.562,23.802,116.572,23.755,116.648,23.682,116.652,23.674,116.654,23.645,116.638,23.628,116.795,23.555,116.741,23.518,116.76,23.512,116.805,23.462,116.878,23.458,116.896,23.544,116.831,23.54,116.822,23.543,116.815,23.555,117.029,23.616,117.02,23.651,116.991,23.671,116.989,23.688,117.064,23.648,117.087,23.585,117.082,23.544,117.226,23.625,117.155,23.755,117.163,23.805,117.244,23.697,117.259,23.645,117.232,23.6,117.282,23.636,117.303,23.801,117.316,23.758,117.376,23.799,117.439,23.758,117.496,23.83,117.492,23.895,117.365,23.925,117.387,23.937,117.342,23.951,117.34,23.96,117.605,23.901,117.591,23.719,117.707,23.954,117.652,24.063,117.806,24.042,117.731,23.935,117.773,23.913,117.906,24.037,117.911,24.187,117.963,24.224,117.985,24.157,118.136,24.312,118.031,24.341,118.065,24.409,117.902,24.37,117.611,24.512,117.588,24.537,117.583,24.564,117.563,24.581,117.533,24.566,117.511,24.568,117.505,24.551,117.465,24.53,117.46,24.531,117.451,24.54,117.447,24.532,117.433,24.521,117.422,24.518,117.451,24.542,117.506,24.555,117.498,24.578,117.467,24.57,117.451,24.58,117.44,24.592,117.562,24.583,117.783,24.481,117.624,24.653,117.629,24.697,117.803,24.478,117.85,24.486,117.89,24.485,118.037,24.441,118.034,24.474,118.067,24.512,117.992,24.549,118.083,24.568,118.067,24.459,118.132,24.424,118.197,24.484,118.092,24.565,118.143,24.669,118.21,24.656,118.238,24.539,118.265,24.608,118.422,24.608,118.441,24.705,118.441,24.629,118.609,24.528,118.684,24.609,118.651,24.681,118.78,24.776,118.656,24.795,118.634,24.865,118.588,24.877,118.564,24.909,118.555,24.943,118.534,24.942,118.517,24.947,118.492,24.978,118.439,24.952,118.383,24.965,118.276,25.012,118.261,25.027,118.253,25.051,118.231,25.051,118.223,25.068,118.206,25.051,118.2,25.055,118.197,25.069,118.184,25.052,118.177,25.054,118.176,25.061,118.198,25.07,118.203,25.054,118.223,25.069,118.439,24.953,118.464,24.975,118.461,24.978,118.454,24.976,118.444,24.98,118.442,24.982,118.443,24.988,118.436,24.988,118.434,24.99,118.434,24.993,118.487,25.05,118.515,25.059,118.5,25.093,118.512,25.08,118.518,25.068,118.515,25.057,118.435,24.99,118.492,24.98,118.557,24.944,118.648,24.853,118.691,24.947,118.702,24.851,118.983,24.878,118.883,24.946,119.01,24.944,119.018,25.052,118.948,25.005,118.844,25.09,118.967,25.117,118.979,25.196,118.873,25.245,118.987,25.291,119.051,25.274,118.976,25.229,119.073,25.237,119.022,25.162,119.056,25.115,119.156,25.126,119.126,25.226,119.276,25.172,119.372,25.27,119.25,25.332,119.252,25.304,119.216,25.276,119.213,25.369,119.18,25.358,119.164,25.392,119.136,25.388,119.117,25.41,119.113,25.425,119.1,25.419,119.081,25.418,119.076,25.437,119.114,25.427,119.145,25.392,119.315,25.567,119.283,25.488],"holes":[[113.248,22.467,113.429,22.18,113.518,22.169,113.51,22.24,113.557,22.186,113.627,22.375,113.545,22.399,113.57,22.563,113.079,22.761,113.248,22.467],[110.688,20.162,110.572,20.091,110.61,19.923,110.372,20.085,110.159,20.061,109.991,19.909,109.941,19.993,109.83,19.901,109.835,19.979,109.708,20.016,109.536,19.949,109.568,19.858,109.474,19.826,109.262,19.901,109.168,19.713,109.276,19.785,109.299,19.72,108.663,19.348,108.676,19.277,108.819,19.256,108.78,19.246,108.709,19.26,108.634,19.291,108.687,18.506,109.176,18.286,109.457,18.282,109.482,18.188,109.567,18.263,109.574,18.16,109.728,18.213,109.755,18.386,110.049,18.383,110.197,18.619,110.414,18.66,110.434,18.749,110.541,18.781,110.575,19.132,110.556,19.097,110.557,19.151,110.527,19.134,110.509,19.141,110.441,19.248,110.386,19.198,110.409,19.24,110.421,19.249,110.45,19.249,110.516,19.142,110.585,19.159,110.676,19.39,110.836,19.543,110.795,19.618,110.882,19.616,110.82,19.598,110.86,19.524,111.042,19.642,110.939,20.006,110.78,20.014,110.688,20.162],[106.6,9.957,106.31,10.25,105.94,10.289,106.148,10.224,106.622,9.803,106.69,9.885,106.6,9.957],[106.512,9.811,106.114,10.229,105.787,10.276,105.466,10.539,105.33,10.54,106.262,9.621,106.512,9.62,106.571,9.72,106.512,9.811],[105.361,10.57,105.347,10.716,105.184,10.876,105.254,11.387,104.948,11.551,105.056,11.441,105.005,11.374,105.126,10.711,105.271,10.559,105.361,10.57],[108.177,4.174,107.962,4.003,108.063,3.834,108.23,3.825,108.275,3.71,108.19,3.784,108.121,3.747,108.156,3.631,108.297,3.658,108.41,3.859,108.241,4.219,108.177,4.174],[111.631,2.846,111.432,2.7,111.396,2.535,111.597,2.451,111.657,2.524,111.706,2.531,111.712,2.694,111.711,2.53,111.591,2.377,111.807,2.239,111.86,2.679,111.631,2.846],[104.553,0.027,104.43,-0.237,104.703,-0.205,104.847,-0.322,105.002,-0.304,104.827,-0.133,104.751,-0.189,104.553,0.027],[104.484,-0.34,104.346,-0.446,104.324,-0.34,104.242,-0.486,104.33,-0.515,104.359,-0.682,104.429,-0.577,104.504,-0.646,104.592,-0.494,104.484,-0.34],[109.571,-0.767,109.466,-0.755,109.245,-0.666,109.296,-0.899,109.551,-0.885,109.683,-1.014,109.748,-0.87,109.571,-0.767],[109.692,-1.014,109.488,-0.973,109.382,-1.26,109.502,-1.309,109.777,-1.149,109.762,-1.006,109.692,-1.014],[104.048,-1.009,103.903,-0.982,103.813,-1.063,104.139,-1.272,104.201,-1.067,104.048,-1.009]]}],"names":{"ar":"بحر الصين الجنوبي","de":"Südchinesisches Meer","es":"mar de China Meridional","fr":"mer de Chine méridionale","he":"ים סין הדרומי","hu":"Dél-kínai-tenger","it":"Mar Cinese Meridionale","nl":"Zuid-Chinese Zee","pt":"Mar da China Meridional","zh":"南海"}},{"name":"Northwestern Passages","bbox":[-128.006,63.757,-69.09,81.702],"area":300.7504,"polygons":[{"outer":[-71.863,67.502,-71.866,67.499,-71.889,67.509,-71.966,67.509,-71.976,67.524,-72.028,67.526,-72.094,67.541,-72.074,67.54,-72.057,67.543,-72.014,67.537,-72.046,67.549,-72.084,67.541,-72.258,67.575,-72.367,67.511,-72.472,67.515,-72.399,67.475,-72.341,67.349,-72.369,67.321,-72.272,67.305,-72.19,67.268,-72.351,67.11,-72.83,67.026,-73.029,66.779,-72.771,66.702,-72.138,66.692,-72.052,66.683,-71.861,66.651,-71.733,66.639,-71.696,66.63,-71.641,66.626,-71.633,66.622,-71.655,66.622,-71.615,66.622,-71.59,66.607,-71.535,66.594,-71.582,66.617,-71.526,66.614,-71.444,66.595,-71.393,66.598,-71.413,66.607,-71.485,66.606,-71.558,66.62,-71.505,66.633,-71.369,66.629,-71.356,66.619,-71.359,66.63,-71.408,66.64,-71.278,66.806,-71.268,66.812,-71.188,66.807,-71.174,66.813,-71.181,66.928,-71.115,66.951,-71.18,66.974,-71.256,66.979,-71.264,66.985,-71.199,67.03,-70.966,67.033,-70.772,66.98,-70.841,66.942,-70.755,66.931,-70.78,66.912,-70.673,66.913,-70.675,66.896,-70.609,66.908,-70.598,66.906,-70.586,66.883,-70.635,66.857,-70.576,66.851,-70.552,66.813,-70.592,66.808,-70.546,66.793,-70.547,66.789,-70.569,66.786,-70.583,66.776,-70.54,66.789,-70.454,66.77,-70.462,66.758,-70.547,66.77,-70.49,66.754,-70.52,66.738,-70.351,66.74,-70.283,66.693,-70.342,66.751,-69.971,66.703,-69.908,66.674,-69.932,66.659,-69.91,66.655,-69.895,66.69,-69.777,66.737,-69.749,66.746,-69.618,66.771,-69.577,66.777,-69.546,66.779,-69.545,66.794,-69.492,66.816,-69.501,66.822,-69.518,66.822,-69.52,66.826,-69.471,66.844,-69.376,66.809,-69.831,66.701,-69.71,66.687,-69.736,66.671,-69.71,66.662,-69.703,66.652,-69.875,66.697,-69.864,66.672,-69.882,66.678,-69.901,66.654,-69.78,66.662,-69.637,66.619,-69.728,66.634,-69.698,66.609,-69.823,66.607,-69.749,66.6,-69.813,66.57,-69.847,66.577,-69.822,66.561,-69.808,66.57,-69.797,66.55,-69.679,66.598,-69.658,66.586,-69.665,66.573,-69.656,66.586,-69.601,66.601,-69.619,66.582,-69.585,66.592,-69.547,66.577,-69.579,66.591,-69.569,66.608,-69.652,66.637,-69.661,66.646,-69.687,66.646,-69.699,66.649,-69.702,66.652,-69.667,66.651,-69.176,66.512,-69.702,66.453,-69.599,66.419,-69.418,66.464,-69.227,66.47,-69.202,66.424,-69.169,66.437,-69.09,66.405,-69.23,66.372,-69.221,66.422,-69.477,66.419,-69.248,66.392,-69.27,66.362,-69.36,66.333,-69.268,66.32,-69.305,66.295,-69.33,66.292,-69.334,66.315,-69.43,66.319,-69.323,66.274,-69.26,66.291,-69.249,66.289,-69.234,66.267,-69.276,66.257,-69.354,66.283,-69.372,66.268,-69.487,66.301,-69.493,66.232,-69.621,66.212,-69.493,66.214,-69.568,66.176,-69.593,66.2,-69.615,66.2,-69.631,66.193,-69.649,66.193,-69.612,66.254,-69.726,66.181,-69.842,66.185,-69.862,66.264,-69.913,66.231,-70.089,66.258,-70.142,66.212,-70.163,66.214,-70.169,66.222,-70.182,66.224,-70.191,66.229,-70.183,66.236,-70.189,66.239,-70.223,66.223,-70.172,66.192,-70.247,66.207,-70.212,66.195,-70.204,66.167,-70.316,66.152,-70.648,66.303,-70.606,66.263,-70.643,66.214,-70.677,66.203,-70.692,66.212,-70.693,66.196,-70.732,66.189,-70.716,66.167,-70.749,66.137,-70.763,66.153,-70.847,66.161,-70.939,66.083,-70.896,66.073,-71.011,66.055,-70.964,66.004,-71.185,65.936,-71.26,65.935,-71.297,65.949,-71.251,65.927,-71.283,65.889,-71.372,65.881,-71.284,65.856,-71.248,65.824,-71.258,65.79,-71.344,65.765,-71.3,65.832,-71.423,65.87,-71.336,65.895,-71.365,65.932,-71.342,65.935,-71.387,65.95,-71.41,65.928,-71.426,65.922,-71.392,65.953,-71.294,65.925,-71.343,65.963,-71.328,65.968,-71.304,65.968,-71.294,65.971,-71.382,65.992,-71.051,66.055,-71.089,66.087,-71.166,66.057,-71.269,66.063,-71.295,66.088,-71.274,66.102,-71.186,66.115,-71.185,66.176,-71.144,66.215,-71.103,66.224,-71.162,66.225,-71.15,66.295,-71.09,66.322,-71.213,66.358,-71.319,66.488,-71.3,66.542,-71.645,66.579,-72.105,66.673,-72.399,66.662,-72.596,66.67,-72.873,66.697,-72.99,66.716,-73.056,66.729,-73.191,66.71,-74.484,66.153,-74.383,66.018,-73.742,65.735,-73.721,65.713,-73.754,65.662,-73.654,65.633,-73.656,65.614,-73.683,65.607,-73.633,65.587,-73.582,65.546,-73.551,65.496,-73.57,65.476,-73.523,65.474,-73.463,65.447,-73.499,65.449,-73.565,65.471,-73.571,65.476,-73.57,65.47,-73.562,65.469,-73.559,65.466,-73.581,65.468,-73.487,65.438,-73.509,65.432,-73.513,65.435,-73.508,65.443,-73.532,65.447,-73.581,65.465,-73.586,65.476,-73.69,65.477,-73.861,65.541,-73.978,65.522,-73.946,65.51,-73.935,65.497,-73.907,65.421,-73.94,65.382,-73.914,65.376,-73.884,65.334,-73.788,65.298,-73.754,65.263,-73.94,65.363,-73.937,65.366,-73.933,65.363,-73.932,65.367,-73.938,65.374,-73.947,65.376,-73.949,65.38,-73.951,65.414,-73.936,65.446,-73.932,65.438,-73.924,65.443,-73.965,65.469,-73.964,65.506,-74.147,65.548,-74.156,65.536,-74.224,65.534,-74.194,65.503,-74.209,65.491,-74.315,65.478,-74.344,65.496,-74.329,65.475,-74.367,65.403,-74.673,65.271,-74.637,65.393,-74.745,65.443,-74.79,65.386,-74.996,65.369,-75.129,65.389,-75.091,65.324,-75.16,65.262,-75.947,65.319,-75.738,65.22,-75.718,65.198,-75.739,65.18,-75.564,65.174,-75.57,65.159,-75.493,65.127,-75.341,64.982,-75.204,65.106,-75.179,65.056,-75.235,65.005,-75.123,64.924,-75.25,64.94,-75.313,64.974,-75.339,64.972,-75.356,64.986,-75.38,64.946,-75.507,64.897,-75.339,64.928,-75.274,64.869,-75.387,64.839,-75.285,64.727,-75.436,64.721,-75.498,64.775,-75.49,64.707,-75.541,64.701,-75.517,64.732,-75.528,64.76,-75.545,64.769,-75.54,64.762,-75.561,64.761,-75.591,64.771,-75.558,64.744,-75.619,64.736,-75.601,64.772,-75.654,64.789,-75.665,64.801,-75.654,64.818,-75.608,64.813,-75.558,64.763,-75.506,64.808,-75.489,64.789,-75.439,64.774,-75.471,64.798,-75.443,64.834,-75.586,64.856,-75.684,64.942,-75.642,64.966,-75.508,64.934,-75.512,64.944,-75.497,64.946,-75.504,64.948,-75.446,64.962,-75.425,64.959,-75.399,65.02,-75.44,65.048,-75.472,65.084,-75.465,65.092,-75.52,65.1,-75.545,65.131,-75.564,65.107,-75.733,65.142,-75.77,65.192,-75.755,65.223,-75.762,65.221,-75.842,65.245,-75.853,65.244,-75.877,65.247,-75.912,65.259,-76.02,65.258,-76.052,65.279,-76.099,65.267,-76.664,65.403,-76.776,65.391,-76.826,65.428,-77.404,65.464,-77.275,65.363,-77.337,65.355,-77.476,65.38,-77.467,65.339,-77.521,65.317,-77.309,65.181,-78.108,64.979,-78.089,64.789,-78.285,64.704,-78.158,64.704,-78.195,64.647,-78.097,64.585,-78.185,64.57,-78.121,64.558,-78.005,64.519,-78.075,64.506,-77.949,64.457,-80.183,63.757,-80.664,63.876,-80.477,63.896,-80.574,63.927,-80.54,63.981,-80.741,63.989,-80.741,64.06,-80.921,64.119,-80.978,64.047,-80.934,63.969,-81.389,64.085,-81.49,64.026,-81.971,63.983,-81.998,64.014,-81.977,64.033,-82.007,64.014,-82.013,64.04,-81.579,64.168,-81.731,64.268,-81.744,64.488,-82.056,64.671,-83.188,64.933,-83.395,65.139,-84.107,65.201,-84.231,65.269,-84.128,65.336,-84.506,65.48,-84.711,65.35,-84.77,65.214,-84.907,65.214,-84.989,65.41,-85.278,65.54,-85.023,65.611,-85.172,65.72,-85.115,65.726,-85.181,65.803,-85.453,65.791,-85.491,65.923,-85.992,66.029,-86.027,66.12,-85.984,66.124,-85.98,66.118,-85.984,66.112,-85.929,66.118,-85.86,66.16,-85.876,66.185,-85.898,66.197,-86.594,66.309,-86.784,66.445,-86.752,66.452,-86.613,66.435,-86.746,66.54,-86.67,66.514,-86.578,66.511,-86.573,66.562,-86.217,66.555,-86.118,66.501,-85.918,66.48,-85.99,66.516,-85.431,66.599,-85.298,66.47,-85.212,66.259,-85.108,66.344,-84.933,66.23,-84.885,66.265,-84.574,66.232,-84.33,66.155,-84.631,66.324,-84.51,66.323,-84.596,66.355,-84.47,66.408,-84.361,66.278,-84.283,66.269,-84.303,66.3,-84.125,66.314,-83.952,66.195,-83.778,66.221,-83.755,66.181,-83.791,66.189,-83.822,66.162,-83.808,66.145,-83.658,66.21,-83.869,66.308,-83.884,66.451,-84.207,66.598,-84.109,66.683,-84.291,66.679,-84.411,66.722,-84.288,66.78,-84.666,66.834,-84.533,66.818,-84.477,66.831,-84.755,66.898,-84.751,66.904,-84.667,66.9,-84.689,66.906,-84.543,66.898,-84.588,66.938,-84.702,66.927,-84.716,66.953,-84.874,66.938,-84.794,66.91,-84.965,66.925,-84.991,66.913,-84.839,66.893,-85.026,66.834,-85.182,66.909,-85.027,66.964,-84.911,66.959,-84.921,66.969,-84.875,66.985,-84.86,66.959,-84.601,66.972,-84.743,67.013,-84.846,66.97,-84.926,67.031,-84.873,67.049,-84.319,66.964,-84.572,66.975,-84.353,66.912,-84.413,66.908,-84.377,66.892,-84.38,66.851,-84.17,66.834,-84.48,66.849,-84.228,66.782,-84.283,66.718,-84.165,66.713,-84.108,66.699,-84.013,66.788,-83.884,66.811,-83.902,66.831,-83.885,66.883,-83.918,66.919,-83.912,66.925,-83.861,66.877,-83.853,66.819,-83.942,66.725,-83.862,66.707,-83.843,66.679,-83.997,66.695,-83.955,66.645,-83.977,66.615,-83.625,66.527,-83.469,66.391,-83.616,66.413,-83.517,66.339,-83.339,66.346,-83.287,66.378,-83.209,66.399,-83.151,66.434,-83.077,66.455,-83.027,66.459,-82.98,66.55,-82.857,66.565,-82.579,66.563,-82.327,66.697,-82.418,66.744,-82.107,66.71,-82.165,66.749,-82.154,66.776,-81.986,66.878,-82.091,66.906,-81.968,66.905,-82.004,66.906,-82.047,66.918,-82.063,66.926,-82.07,66.937,-81.81,66.996,-81.459,66.995,-81.331,67.215,-81.377,67.322,-81.188,67.454,-82.083,67.908,-82.173,68.005,-81.976,68.213,-82.138,68.112,-82.245,68.125,-82.332,68.166,-82.225,68.23,-82.244,68.294,-82.492,68.312,-82.32,68.357,-82.05,68.297,-82.004,68.344,-82.645,68.437,-82.48,68.443,-82.63,68.499,-82.535,68.526,-82.19,68.458,-82.166,68.483,-82.235,68.518,-82.251,68.541,-82.226,68.543,-82.465,68.615,-82.593,68.545,-82.654,68.596,-82.667,68.594,-82.681,68.595,-82.69,68.591,-82.7,68.59,-82.733,68.594,-82.749,68.598,-82.774,68.599,-82.788,68.604,-82.804,68.617,-82.668,68.595,-82.703,68.722,-82.486,68.697,-82.627,68.761,-82.395,68.688,-82.337,68.73,-82.19,68.743,-82.341,68.856,-82.31,68.923,-82.133,68.805,-81.977,68.857,-82.034,68.797,-81.953,68.641,-82.218,68.638,-82.17,68.57,-82.207,68.564,-82.282,68.593,-82.296,68.569,-82.057,68.505,-81.945,68.407,-81.763,68.527,-81.346,68.589,-81.237,68.639,-81.205,68.759,-81.321,68.861,-82.044,68.882,-81.24,69.085,-81.323,69.199,-81.704,69.268,-81.989,69.273,-82.287,69.238,-82.118,69.292,-82.387,69.401,-82.196,69.394,-83.26,69.542,-83.225,69.559,-82.442,69.492,-82.823,69.568,-82.563,69.55,-82.765,69.575,-82.516,69.593,-82.653,69.63,-82.598,69.653,-82.447,69.651,-82.391,69.61,-82.393,69.638,-82.215,69.636,-82.504,69.66,-82.548,69.701,-82.679,69.708,-82.597,69.677,-83.467,69.688,-83.341,69.656,-84.053,69.762,-84.405,69.861,-84.879,69.855,-84.853,69.807,-85.053,69.788,-84.999,69.76,-85.198,69.801,-85.375,69.852,-85.555,69.857,-85.157,69.786,-85.297,69.744,-85.247,69.783,-85.497,69.758,-85.394,69.725,-85.525,69.647,-85.344,69.568,-85.522,69.486,-85.308,69.445,-85.482,69.411,-85.315,69.305,-85.483,69.321,-85.337,69.22,-85.389,69.21,-85.177,69.194,-85.309,69.154,-85.231,69.129,-85.139,69.172,-84.978,69.172,-85.133,69.134,-84.508,69.005,-84.552,68.993,-84.803,69.038,-84.84,69.024,-84.806,69.008,-84.926,69.024,-84.942,69.011,-84.979,69.006,-84.896,69.007,-84.744,68.952,-84.81,68.943,-84.732,68.913,-85.131,68.961,-84.967,68.936,-85.028,68.892,-85.005,68.905,-84.896,68.892,-85.19,68.858,-85.136,68.821,-85.049,68.835,-85.07,68.823,-84.992,68.806,-84.859,68.824,-84.8,68.812,-84.705,68.729,-85.273,68.758,-85.17,68.711,-85.411,68.784,-85.506,68.786,-85.43,68.764,-85.643,68.721,-85.725,68.465,-85.652,68.445,-85.812,68.322,-85.761,68.29,-85.845,68.236,-85.761,68.213,-85.887,68.185,-85.873,68.043,-86.055,67.996,-86.023,67.995,-86.025,67.993,-86.019,67.991,-86.017,67.985,-86.014,67.986,-85.992,67.98,-85.98,67.973,-85.983,67.97,-86.1,68.009,-86.511,67.685,-86.407,67.611,-86.48,67.613,-86.431,67.587,-86.512,67.33,-86.598,67.408,-86.611,67.352,-86.854,67.411,-86.96,67.362,-86.938,67.356,-86.939,67.306,-86.988,67.328,-86.994,67.317,-87.037,67.312,-86.965,67.304,-86.837,67.155,-87.049,67.259,-87.091,67.164,-87.126,67.206,-87.092,67.211,-87.122,67.211,-87.16,67.24,-87.129,67.205,-87.236,67.19,-87.233,67.125,-87.254,67.1,-87.252,67.096,-87.201,67.089,-87.215,67.077,-87.291,67.099,-87.314,67.187,-87.515,67.097,-87.444,67.172,-87.489,67.173,-87.575,67.194,-87.449,67.177,-87.477,67.201,-87.333,67.261,-87.399,67.274,-87.454,67.244,-87.389,67.287,-87.443,67.299,-87.467,67.366,-88.118,67.676,-88.416,67.97,-88.339,67.997,-88.368,68.032,-88.319,68.047,-88.327,68.067,-88.316,68.08,-88.327,68.081,-88.268,68.111,-88.377,68.291,-88.173,68.383,-88.183,68.252,-87.92,68.205,-87.786,68.316,-87.961,68.76,-88.77,69.097,-88.935,69.234,-89.272,69.265,-89.754,68.967,-89.674,68.817,-89.726,68.64,-89.777,68.63,-89.742,68.669,-89.78,68.697,-89.772,68.707,-89.8,68.722,-89.94,68.606,-89.771,68.545,-89.868,68.46,-89.985,68.441,-89.987,68.444,-89.987,68.429,-89.895,68.44,-90.099,68.332,-89.971,68.344,-89.629,68.255,-89.337,68.165,-89.271,68.112,-89.339,68.164,-89.389,68.17,-89.361,68.154,-89.357,68.121,-89.422,68.059,-89.696,67.968,-89.692,67.99,-89.632,68.033,-89.596,68.052,-89.571,68.055,-89.885,68.026,-89.923,68.008,-89.894,67.969,-89.938,67.926,-90.062,67.869,-90.159,67.844,-90.138,67.831,-90.113,67.739,-90.168,67.687,-90.188,67.683,-90.223,67.684,-90.226,67.662,-90.103,67.65,-90.028,67.616,-89.972,67.559,-89.991,67.541,-90.016,67.534,-90.017,67.524,-90.009,67.5,-89.975,67.491,-89.939,67.45,-89.96,67.412,-90.031,67.53,-89.983,67.56,-90.041,67.619,-90.201,67.64,-90.231,67.663,-90.232,67.675,-90.225,67.69,-90.236,67.686,-90.267,67.68,-90.123,67.736,-90.167,67.844,-89.943,67.935,-89.941,68.008,-89.931,68.017,-89.898,68.032,-89.754,68.063,-89.697,68.07,-89.58,68.067,-89.549,68.053,-89.465,68.067,-89.366,68.144,-89.982,68.341,-90.13,68.308,-90.071,68.221,-90.279,68.256,-90.329,68.36,-90.35,68.347,-90.411,68.338,-90.497,68.325,-90.579,68.317,-90.606,68.319,-90.635,68.304,-90.691,68.294,-90.609,68.321,-90.277,68.369,-90.595,68.459,-90.448,68.552,-90.567,68.614,-90.468,68.681,-90.52,68.735,-90.519,68.791,-90.428,68.829,-90.596,68.805,-90.43,68.882,-90.617,68.918,-90.551,68.956,-90.684,68.997,-90.641,69.081,-91.428,69.361,-91.15,69.293,-90.924,69.359,-90.977,69.311,-91.098,69.281,-90.863,69.25,-90.819,69.365,-90.598,69.423,-90.663,69.436,-90.708,69.462,-90.536,69.424,-90.353,69.46,-90.635,69.543,-90.713,69.465,-90.765,69.463,-90.694,69.49,-90.644,69.543,-90.687,69.548,-90.723,69.488,-90.872,69.444,-90.808,69.492,-90.989,69.472,-90.886,69.512,-90.941,69.521,-91.06,69.496,-91.106,69.459,-91.293,69.411,-91.089,69.509,-91.073,69.497,-90.974,69.528,-91.151,69.518,-91.116,69.535,-91.164,69.527,-91.12,69.543,-91.175,69.56,-91.151,69.566,-91.175,69.564,-91.188,69.556,-91.315,69.541,-91.327,69.534,-91.307,69.52,-91.55,69.507,-91.626,69.52,-91.314,69.554,-91.134,69.644,-91.239,69.626,-91.262,69.657,-91.325,69.643,-91.417,69.669,-91.752,69.475,-92.228,69.59,-92.291,69.644,-92.241,69.631,-92.163,69.635,-92.271,69.665,-92.259,69.668,-92.818,69.653,-93.003,69.686,-92.723,69.681,-92.663,69.663,-92.693,69.678,-92.508,69.713,-92.843,69.696,-92.855,69.713,-91.935,70.022,-92.151,70.097,-92.207,70.097,-92.211,70.067,-92.295,70.043,-92.331,70.041,-92.222,70.087,-92.585,70.083,-92.374,70.158,-92.542,70.177,-92.274,70.19,-92.368,70.249,-92.284,70.282,-92.013,70.127,-91.51,70.183,-91.688,70.197,-91.687,70.342,-91.728,70.371,-91.767,70.338,-91.777,70.366,-91.901,70.362,-91.827,70.317,-91.938,70.244,-92.065,70.291,-92.081,70.31,-91.997,70.32,-91.998,70.362,-91.962,70.375,-91.992,70.38,-92.007,70.377,-92.029,70.341,-92.004,70.407,-92.06,70.379,-92.109,70.485,-92.138,70.469,-92.296,70.499,-92.185,70.511,-92.262,70.561,-92.149,70.601,-92.653,70.663,-92.672,70.78,-92.764,70.814,-92.873,70.817,-92.943,70.828,-92.998,70.845,-92.944,70.833,-93.031,70.884,-92.92,70.844,-92.82,70.863,-92.929,70.893,-92.871,70.88,-92.897,70.905,-92.905,70.968,-92.833,71.048,-92.955,71.351,-93.781,71.646,-93.681,71.763,-94.107,71.762,-94.176,71.804,-94.232,71.749,-94.396,71.667,-94.378,71.785,-94.328,71.808,-94.466,71.824,-94.505,71.782,-94.432,71.781,-94.538,71.748,-94.593,71.757,-94.633,71.825,-94.371,71.935,-94.504,71.959,-94.486,71.995,-95.17,71.957,-95.192,71.841,-95.075,71.853,-94.833,71.86,-94.703,71.839,-94.588,71.873,-94.577,71.863,-94.718,71.826,-95.223,71.835,-95.282,71.761,-95.208,71.735,-95.897,71.606,-95.837,71.585,-95.919,71.551,-95.813,71.51,-95.172,71.524,-95.498,71.468,-95.536,71.413,-95.396,71.378,-95.466,71.352,-95.565,71.36,-95.492,71.329,-95.522,71.317,-95.513,71.288,-95.547,71.268,-95.523,71.291,-96.03,71.421,-96.263,71.289,-96.311,71.278,-96.366,71.297,-96.467,71.271,-96.365,71.266,-96.425,71.197,-96.332,71.148,-96.527,71.131,-96.32,71.089,-96.454,71.038,-96.571,70.782,-96.146,70.616,-96.083,70.666,-95.908,70.705,-95.786,70.719,-95.75,70.721,-95.736,70.719,-96.041,70.645,-95.765,70.53,-96.22,70.569,-96.304,70.405,-96.528,70.332,-96.542,70.222,-96.462,70.084,-96.043,69.943,-96.117,69.915,-96.069,69.868,-96.192,69.867,-95.926,69.781,-95.797,69.803,-95.815,69.761,-95.746,69.741,-95.73,69.791,-95.682,69.805,-95.634,69.779,-95.644,69.821,-95.363,69.674,-94.993,69.622,-94.818,69.562,-94.708,69.607,-94.786,69.672,-94.524,69.73,-94.614,69.644,-94.282,69.442,-93.888,69.439,-93.821,69.494,-93.758,69.479,-93.519,69.537,-93.416,69.479,-93.573,69.365,-93.462,69.351,-93.411,69.385,-93.432,69.368,-93.36,69.384,-93.341,69.381,-93.55,69.301,-93.62,69.262,-93.539,69.297,-93.53,69.295,-93.829,69.163,-93.788,69.223,-93.868,69.223,-93.788,69.272,-93.852,69.244,-93.857,69.264,-93.654,69.354,-93.511,69.443,-94.264,69.322,-94.283,69.154,-93.987,69.158,-94.407,68.95,-94.583,68.976,-94.519,68.893,-94.616,68.751,-94.074,68.761,-93.911,68.851,-93.787,68.889,-94.075,68.877,-94.006,68.926,-93.907,68.955,-93.929,68.965,-93.887,69.009,-93.855,69.019,-93.619,68.98,-93.564,68.854,-93.743,68.616,-93.471,68.616,-93.414,68.64,-93.396,68.628,-93.219,68.671,-93.176,68.677,-93.152,68.677,-93.154,68.68,-93.116,68.693,-93.094,68.695,-93.079,68.7,-93.049,68.705,-93.031,68.707,-93.004,68.705,-93.114,68.693,-93.152,68.676,-93.323,68.641,-93.302,68.637,-93.339,68.639,-93.445,68.612,-93.479,68.607,-93.432,68.595,-93.37,68.565,-93.343,68.495,-93.345,68.491,-93.362,68.481,-93.374,68.48,-93.378,68.471,-93.272,68.423,-93.37,68.459,-93.381,68.471,-93.379,68.478,-93.346,68.497,-93.378,68.547,-93.442,68.564,-93.44,68.567,-93.489,68.554,-93.583,68.56,-94.018,68.456,-94.189,68.375,-94.152,68.284,-94.176,68.254,-94.856,68.007,-95.079,68.092,-95.278,68.028,-95.212,68.081,-95.262,68.065,-95.465,68.064,-95.398,67.985,-95.695,67.734,-95.669,67.699,-95.315,67.565,-95.381,67.468,-95.137,67.295,-95.328,67.142,-95.126,67.17,-95.048,67.156,-95.045,67.172,-95.014,67.175,-95.03,67.188,-94.93,67.21,-94.927,67.216,-94.94,67.221,-94.939,67.229,-94.715,67.378,-94.623,67.397,-94.581,67.419,-94.441,67.452,-94.422,67.491,-94.387,67.51,-94.327,67.527,-94.331,67.566,-94.307,67.575,-94.207,67.559,-94.191,67.55,-94.186,67.541,-94.165,67.544,-94.145,67.539,-94.135,67.535,-94.084,67.532,-94.055,67.525,-93.97,67.486,-93.918,67.432,-93.813,67.418,-93.771,67.408,-93.762,67.403,-93.706,67.399,-93.662,67.378,-93.642,67.381,-93.627,67.381,-93.466,67.322,-93.92,67.43,-94.057,67.523,-94.186,67.541,-94.21,67.558,-94.323,67.569,-94.324,67.527,-94.449,67.442,-94.551,67.424,-94.614,67.396,-94.701,67.38,-94.935,67.222,-94.918,67.213,-94.951,67.197,-94.979,67.189,-95.024,67.188,-95.01,67.176,-95.032,67.158,-95.057,67.152,-95.09,67.151,-95.124,67.167,-95.166,67.161,-95.195,67.134,-95.317,67.133,-95.264,67.053,-95.282,67.01,-95.105,66.998,-95.266,66.888,-95.397,66.937,-95.718,66.903,-96.054,66.945,-96.068,66.918,-96.038,66.917,-95.774,66.746,-95.721,66.666,-95.66,66.691,-95.625,66.676,-95.657,66.735,-95.543,66.694,-95.782,66.596,-95.741,66.663,-95.814,66.725,-95.831,66.776,-95.985,66.804,-96.39,66.997,-96.369,67.014,-96.393,67.044,-96.437,67.048,-96.44,67.079,-96.42,67.099,-96.276,67.08,-96.255,67.046,-96.281,67.016,-96.117,66.956,-96.086,66.954,-96.07,66.966,-95.995,66.971,-96.009,67.027,-95.958,67.063,-95.926,67.066,-95.894,67.085,-95.868,67.091,-95.968,67.017,-95.918,67.007,-95.881,66.945,-95.57,66.997,-95.387,66.951,-95.308,66.988,-95.398,67.078,-95.321,67.116,-95.467,67.142,-95.45,67.21,-95.707,67.18,-95.67,67.185,-95.616,67.242,-95.68,67.2,-95.834,67.16,-95.567,67.313,-95.557,67.306,-95.539,67.313,-95.545,67.391,-95.592,67.355,-95.655,67.334,-95.716,67.393,-95.759,67.338,-95.806,67.329,-95.837,67.306,-95.952,67.27,-95.901,67.306,-96.109,67.221,-96.072,67.271,-96.236,67.27,-96.193,67.335,-96.06,67.429,-96.088,67.45,-96.061,67.477,-96.064,67.485,-96.182,67.413,-96.317,67.417,-96.362,67.454,-96.325,67.484,-96.348,67.511,-96.445,67.468,-96.396,67.553,-96.4,67.555,-96.43,67.547,-96.464,67.557,-96.285,67.617,-96.313,67.706,-96.132,67.631,-96.185,67.692,-96.109,67.755,-96.173,67.757,-96.196,67.836,-95.998,68.1,-96.05,68.162,-95.875,68.3,-96.416,68.158,-96.532,68.083,-96.437,68.103,-96.417,68.044,-96.582,68.071,-96.674,67.992,-96.755,68.038,-96.735,68.071,-96.758,68.073,-96.817,68.1,-96.841,68.117,-96.644,68.086,-96.368,68.322,-96.609,68.237,-96.561,68.204,-96.571,68.178,-96.67,68.232,-96.576,68.254,-96.658,68.292,-96.779,68.285,-96.8,68.258,-96.868,68.275,-96.819,68.245,-97.025,68.277,-96.888,68.268,-96.965,68.313,-96.967,68.352,-97.157,68.404,-97.092,68.393,-97.306,68.507,-97.302,68.501,-97.41,68.511,-97.41,68.502,-97.441,68.516,-97.418,68.502,-97.471,68.517,-97.458,68.508,-97.509,68.515,-97.522,68.519,-97.498,68.507,-97.546,68.513,-97.531,68.499,-97.562,68.488,-97.59,68.496,-97.474,68.414,-97.607,68.448,-97.686,68.543,-97.981,68.545,-97.686,68.388,-97.684,68.357,-97.852,68.42,-97.779,68.392,-97.843,68.403,-97.835,68.38,-97.875,68.388,-97.887,68.371,-97.906,68.371,-97.88,68.355,-97.862,68.372,-97.758,68.358,-97.862,68.37,-97.857,68.355,-97.825,68.349,-97.805,68.338,-97.802,68.332,-97.943,68.37,-97.884,68.312,-98.05,68.376,-98.006,68.322,-98.172,68.301,-98.476,68.428,-98.419,68.348,-98.679,68.373,-98.476,68.204,-98.286,68.184,-98.275,68.098,-98.356,68.082,-98.423,68.195,-98.488,68.147,-98.562,68.161,-98.177,67.919,-98.085,67.837,-97.971,67.99,-97.919,67.926,-97.633,68.024,-97.428,67.923,-97.402,67.871,-97.207,67.95,-97.079,67.784,-97.147,67.743,-97.184,67.733,-97.194,67.699,-97.014,67.787,-97.098,67.714,-97.016,67.693,-97.014,67.671,-97.62,67.593,-97.629,67.65,-97.921,67.693,-97.977,67.758,-98.117,67.738,-98.064,67.766,-98.186,67.875,-98.233,67.873,-98.197,67.917,-98.51,68.044,-98.54,68.097,-98.653,68.129,-98.583,68.091,-98.729,68.043,-98.628,67.936,-98.707,67.955,-98.417,67.873,-98.328,67.786,-98.45,67.797,-98.393,67.76,-98.391,67.757,-98.399,67.751,-98.497,67.793,-98.491,67.784,-98.473,67.779,-98.44,67.758,-98.449,67.753,-98.436,67.742,-98.438,67.738,-98.442,67.737,-98.697,67.815,-98.685,67.781,-98.746,67.738,-98.829,67.755,-98.839,67.77,-98.883,67.687,-98.992,67.683,-99.018,67.702,-99.009,67.728,-99.087,67.757,-99.116,67.714,-99.187,67.71,-99.221,67.719,-99.27,67.759,-99.261,67.703,-99.417,67.802,-99.433,67.782,-99.475,67.806,-99.497,67.793,-99.538,67.811,-99.546,67.805,-99.565,67.808,-99.599,67.786,-99.754,67.841,-99.771,67.831,-99.776,67.805,-99.952,67.8,-100.242,67.856,-100.619,67.739,-100.568,67.676,-100.527,67.676,-100.482,67.673,-100.474,67.671,-100.453,67.657,-100.441,67.658,-100.419,67.646,-100.416,67.629,-100.392,67.621,-100.384,67.627,-100.326,67.616,-100.305,67.605,-100.308,67.59,-100.296,67.591,-100.287,67.585,-100.314,67.585,-100.318,67.61,-100.398,67.619,-100.427,67.634,-100.424,67.646,-100.455,67.651,-100.478,67.67,-100.562,67.674,-100.57,67.676,-100.575,67.679,-100.583,67.688,-100.592,67.688,-100.623,67.725,-100.632,67.763,-100.615,67.792,-100.545,67.831,-100.678,67.853,-100.69,67.816,-100.745,67.837,-100.784,67.799,-100.89,67.792,-100.872,67.784,-100.866,67.774,-100.886,67.773,-100.756,67.733,-100.745,67.727,-100.742,67.724,-100.747,67.717,-100.717,67.707,-100.71,67.701,-100.71,67.691,-100.888,67.772,-101.116,67.703,-101.065,67.795,-101.141,67.753,-101.216,67.785,-101.203,67.745,-101.238,67.723,-101.252,67.725,-101.252,67.721,-101.244,67.718,-101.235,67.706,-101.236,67.696,-101.251,67.686,-101.241,67.664,-101.245,67.642,-101.233,67.619,-101.245,67.608,-101.256,67.688,-101.269,67.691,-101.297,67.71,-101.307,67.711,-101.322,67.716,-101.327,67.725,-101.356,67.718,-101.371,67.741,-101.412,67.738,-101.387,67.69,-101.523,67.674,-101.439,67.552,-101.528,67.678,-101.564,67.687,-101.575,67.699,-101.574,67.706,-101.803,67.702,-101.942,67.798,-102.193,67.743,-102.028,67.596,-102.039,67.584,-102.102,67.573,-102.084,67.524,-102.093,67.515,-102.107,67.51,-102.063,67.496,-102.021,67.471,-102.011,67.474,-101.994,67.466,-101.91,67.381,-101.9,67.35,-102.111,67.509,-102.087,67.524,-102.111,67.567,-102.106,67.575,-102.082,67.585,-102.085,67.587,-102.097,67.587,-102.102,67.589,-102.093,67.606,-102.031,67.598,-102.209,67.685,-102.134,67.605,-102.158,67.598,-102.225,67.67,-102.215,67.719,-102.26,67.7,-102.51,67.8,-102.742,67.77,-102.877,67.832,-102.931,67.935,-103.05,67.857,-103.122,67.975,-103.186,67.956,-103.35,68.009,-103.376,68.097,-103.303,68.104,-103.383,68.159,-103.786,68.026,-104.478,68.02,-104.563,68.117,-104.51,68.107,-104.498,68.127,-104.646,68.148,-104.57,68.254,-104.578,68.248,-104.601,68.246,-104.71,68.26,-104.772,68.231,-104.784,68.272,-104.84,68.239,-104.9,68.235,-104.911,68.249,-104.937,68.249,-104.944,68.253,-104.954,68.223,-104.963,68.216,-104.952,68.213,-104.945,68.204,-104.948,68.186,-104.959,68.184,-104.948,68.204,-104.965,68.216,-104.96,68.25,-105.019,68.243,-105.103,68.289,-105.101,68.303,-105.079,68.3,-105.096,68.306,-105.082,68.313,-105.128,68.303,-105.141,68.318,-105.201,68.32,-105.208,68.327,-105.21,68.341,-105.228,68.339,-105.232,68.348,-105.261,68.351,-105.278,68.356,-105.28,68.358,-105.18,68.349,-105.204,68.366,-105.192,68.386,-105.231,68.359,-105.329,68.379,-105.302,68.431,-105.342,68.409,-105.403,68.409,-105.363,68.454,-105.415,68.419,-105.532,68.406,-105.496,68.46,-105.434,68.464,-105.449,68.5,-105.275,68.489,-105.384,68.507,-105.546,68.694,-105.434,68.737,-105.769,68.883,-106.194,68.946,-106.224,68.913,-107.278,68.704,-108.355,68.603,-108.574,68.408,-108.807,68.265,-108.362,68.303,-108.426,68.189,-108.296,68.255,-108.325,68.167,-108.272,68.17,-108.392,68.163,-108.362,68.119,-108.121,68.181,-107.967,68.171,-108.032,68.119,-107.564,68.166,-107.72,68.207,-107.716,68.238,-107.805,68.24,-107.895,68.278,-107.814,68.346,-107.525,68.367,-107.125,68.273,-107.117,68.314,-107.072,68.314,-107.007,68.374,-106.969,68.372,-106.98,68.385,-106.978,68.404,-106.965,68.42,-106.992,68.431,-106.849,68.377,-106.923,68.373,-106.91,68.35,-106.952,68.345,-106.952,68.328,-106.922,68.342,-106.865,68.312,-106.896,68.37,-106.843,68.331,-106.798,68.331,-106.756,68.349,-106.796,68.348,-106.849,68.376,-106.786,68.423,-106.517,68.294,-106.551,68.357,-106.441,68.409,-106.566,68.429,-106.616,68.474,-106.375,68.509,-106.391,68.518,-106.496,68.518,-106.54,68.524,-106.553,68.523,-106.568,68.526,-106.581,68.525,-106.585,68.527,-105.893,68.642,-105.619,68.638,-105.649,68.59,-105.769,68.552,-105.657,68.526,-105.686,68.525,-105.665,68.491,-105.694,68.437,-105.735,68.414,-105.794,68.466,-106.223,68.327,-106.196,68.361,-106.132,68.378,-106.201,68.4,-106.425,68.342,-106.49,68.245,-106.263,68.183,-106.592,68.258,-106.599,68.179,-106.777,68.22,-106.725,68.183,-106.705,68.132,-106.738,68.109,-106.728,68.101,-106.696,68.101,-106.69,68.099,-106.686,68.09,-106.688,68.074,-106.802,68.131,-107.128,68.085,-107.115,68.137,-107.285,68.056,-107.674,68.053,-107.665,68.048,-107.677,68.027,-107.735,68.025,-107.701,68.016,-107.71,67.989,-107.75,67.985,-107.635,67.964,-107.635,67.928,-107.929,67.867,-108.024,67.776,-107.567,67.503,-107.618,67.399,-107.639,67.391,-107.66,67.41,-107.707,67.404,-107.522,67.207,-107.129,67.146,-107.272,67.116,-107.229,67.101,-107.219,67.082,-107.384,67.081,-107.304,67.045,-107.307,67.034,-107.428,67.061,-107.19,66.971,-107.064,66.821,-107.432,66.985,-107.369,66.911,-107.406,66.884,-107.494,66.879,-107.387,66.819,-107.558,66.841,-107.532,66.864,-107.5,66.874,-107.68,66.964,-107.46,66.923,-107.625,67.08,-107.607,66.964,-107.739,67.018,-107.761,66.963,-107.671,66.754,-107.79,66.775,-107.63,66.704,-107.528,66.575,-107.246,66.435,-107.158,66.371,-107.183,66.362,-107.143,66.346,-107.131,66.337,-107.126,66.333,-107.129,66.322,-107.113,66.323,-107.125,66.321,-107.23,66.352,-107.491,66.54,-107.7,66.609,-107.856,66.77,-107.843,66.688,-107.915,66.708,-107.961,66.84,-108.071,66.825,-108.036,66.765,-108.093,66.785,-108.116,66.801,-108.074,66.828,-108.038,66.842,-108.137,66.839,-108.248,66.856,-108.277,66.87,-108.096,66.852,-108.08,66.921,-108.454,67.07,-108.501,67.028,-108.605,67.153,-108.171,67.004,-108.125,67.082,-107.853,67.042,-107.964,67.284,-108.277,67.378,-108.375,67.45,-108.463,67.343,-108.556,67.597,-108.663,67.64,-108.831,67.353,-108.978,67.45,-108.879,67.535,-109.016,67.722,-109.387,67.732,-109.387,67.773,-109.497,67.693,-109.699,67.727,-109.7,67.717,-109.718,67.745,-109.663,67.737,-109.691,67.746,-109.688,67.753,-109.598,67.737,-109.8,67.874,-109.973,67.852,-109.948,67.946,-110.036,68.011,-110.173,68.003,-110.026,67.973,-110.189,67.972,-110.095,67.915,-110.211,67.976,-110.328,67.965,-110.287,67.941,-111.058,67.756,-111.195,67.754,-111.116,67.834,-111.194,67.838,-111.354,67.736,-111.572,67.745,-111.519,67.728,-111.524,67.71,-111.903,67.755,-111.837,67.697,-111.854,67.665,-111.877,67.659,-111.918,67.654,-111.9,67.645,-111.904,67.635,-111.924,67.651,-111.918,67.655,-111.854,67.666,-111.967,67.683,-111.969,67.753,-112.597,67.672,-113.086,67.673,-113.268,67.714,-113.315,67.666,-113.29,67.698,-114.37,67.731,-114.486,67.802,-114.99,67.793,-115.278,67.856,-115.209,67.895,-115.367,67.876,-115.531,67.894,-115.57,67.918,-115.633,67.911,-115.739,67.91,-115.788,67.9,-115.746,67.914,-115.487,67.929,-115.091,68.008,-115.224,68.03,-115.141,68.142,-115.246,68.19,-114.728,68.182,-115.023,68.29,-114.0,68.241,-114.046,68.295,-113.892,68.402,-114.085,68.404,-114.036,68.438,-114.105,68.527,-114.542,68.728,-114.768,68.75,-114.972,68.869,-115.603,68.982,-116.329,68.956,-115.904,68.808,-116.392,68.891,-116.647,68.874,-116.606,68.845,-116.96,68.946,-117.163,68.9,-118.017,69.024,-118.698,69.234,-120.003,69.354,-120.922,69.66,-121.884,69.817,-123.001,69.821,-123.151,69.769,-123.087,69.673,-123.169,69.494,-123.209,69.479,-123.265,69.483,-123.269,69.49,-123.286,69.482,-123.303,69.485,-123.303,69.478,-123.328,69.487,-123.39,69.469,-123.375,69.462,-123.384,69.454,-123.414,69.455,-123.424,69.446,-123.382,69.413,-123.467,69.385,-123.479,69.386,-123.529,69.374,-123.665,69.353,-123.691,69.338,-123.741,69.333,-123.7,69.364,-123.738,69.352,-123.849,69.383,-123.806,69.352,-123.844,69.333,-123.983,69.397,-124.012,69.325,-124.499,69.367,-124.551,69.412,-124.431,69.392,-124.451,69.477,-124.036,69.68,-124.511,69.725,-124.385,69.852,-124.506,69.895,-124.425,69.95,-124.532,69.957,-124.394,69.982,-124.501,69.998,-124.483,70.02,-124.364,70.008,-124.334,70.047,-124.447,70.106,-124.392,70.143,-124.692,70.141,-124.506,70.18,-124.533,70.2,-124.604,70.182,-124.761,70.183,-124.692,70.146,-124.761,70.104,-124.513,70.096,-124.6,70.07,-124.449,70.074,-124.416,70.032,-124.696,70.071,-125.018,70.006,-125.059,70.039,-125.012,70.033,-124.996,70.039,-125.02,70.076,-125.22,70.006,-125.217,69.928,-125.127,69.934,-125.193,69.961,-125.042,70.008,-124.639,69.979,-124.874,69.969,-124.82,69.952,-125.079,69.785,-125.178,69.782,-125.189,69.851,-125.301,69.812,-125.244,69.757,-125.165,69.772,-124.899,69.744,-124.79,69.707,-124.88,69.717,-124.912,69.625,-125.024,69.74,-125.374,69.684,-125.392,69.615,-125.458,69.606,-125.507,69.586,-125.513,69.575,-125.256,69.593,-125.322,69.549,-125.294,69.52,-125.138,69.487,-125.491,69.511,-125.593,69.474,-125.646,69.41,-125.128,69.425,-125.485,69.398,-125.353,69.379,-125.341,69.356,-125.52,69.321,-126.313,69.537,-126.708,69.744,-126.838,69.915,-126.799,69.932,-126.783,69.931,-126.798,69.943,-126.779,69.948,-126.811,69.951,-126.811,69.963,-126.832,69.959,-126.851,69.98,-126.885,69.971,-127.176,70.237,-128.006,70.564,-125.952,71.966,-124.77,74.343,-124.677,74.32,-124.655,74.344,-124.52,74.326,-124.689,74.286,-124.495,74.271,-124.526,74.222,-124.452,74.222,-124.378,74.12,-124.456,74.079,-124.356,74.064,-124.295,74.003,-124.321,73.981,-124.278,73.986,-124.179,73.947,-124.188,73.879,-124.1,73.857,-123.864,73.845,-123.739,73.75,-123.838,73.752,-124.055,73.666,-123.988,73.58,-124.045,73.579,-124.006,73.558,-124.038,73.55,-124.061,73.549,-124.026,73.549,-124.014,73.555,-124.004,73.552,-124.123,73.491,-124.268,73.499,-124.203,73.473,-124.301,73.459,-124.35,73.47,-124.365,73.465,-124.547,73.294,-124.519,73.237,-124.546,73.247,-124.634,73.209,-124.62,73.212,-124.576,73.211,-124.57,73.208,-124.644,73.207,-124.67,73.192,-124.678,73.173,-124.669,73.191,-124.658,73.197,-124.597,73.171,-124.864,73.103,-124.373,72.978,-125.126,72.873,-124.904,72.753,-125.006,72.715,-124.889,72.714,-125.099,72.637,-124.955,72.618,-124.877,72.576,-125.021,72.57,-125.232,72.482,-125.33,72.483,-125.305,72.463,-125.257,72.46,-125.251,72.458,-125.25,72.455,-125.342,72.453,-125.335,72.432,-125.314,72.431,-125.31,72.423,-125.437,72.372,-125.383,72.364,-125.492,72.346,-125.536,72.293,-125.474,72.316,-125.473,72.268,-125.673,72.252,-125.555,72.191,-125.783,72.131,-125.637,72.093,-125.856,71.961,-125.287,71.985,-125.087,71.98,-124.926,71.965,-124.821,71.946,-124.784,71.93,-125.269,71.967,-124.044,71.694,-123.807,71.533,-123.809,71.545,-123.701,71.556,-123.791,71.53,-123.621,71.494,-123.431,71.222,-123.144,71.08,-122.787,71.086,-121.565,71.462,-121.828,71.449,-121.797,71.504,-121.583,71.472,-121.545,71.454,-121.511,71.422,-121.612,71.409,-121.362,71.381,-120.526,71.533,-120.369,71.699,-120.443,71.739,-120.407,71.843,-120.381,71.876,-120.44,71.949,-120.124,72.16,-120.237,72.274,-119.791,72.226,-119.287,72.367,-119.15,72.656,-115.297,73.478,-115.591,73.664,-117.166,74.174,-117.616,74.251,-118.775,74.211,-118.796,74.043,-119.088,73.965,-119.203,73.979,-119.055,74.083,-119.081,74.206,-119.45,74.23,-119.563,74.156,-119.652,74.127,-119.749,74.117,-119.783,74.091,-119.776,74.081,-119.742,74.072,-119.701,74.044,-119.694,73.952,-119.773,73.911,-119.779,73.897,-119.747,73.884,-119.747,73.877,-119.815,73.843,-119.775,73.92,-119.705,73.961,-119.74,74.027,-119.821,74.083,-119.818,74.094,-119.626,74.222,-120.687,74.363,-121.257,74.543,-121.741,74.552,-121.87,74.529,-121.817,74.511,-122.295,74.477,-124.769,74.344,-123.015,76.09,-122.801,76.135,-122.923,76.161,-122.906,76.173,-122.594,76.194,-122.482,76.149,-122.719,76.119,-122.468,76.129,-122.438,76.106,-122.537,76.042,-122.706,76.021,-122.542,76.033,-122.723,75.977,-122.581,75.941,-121.851,76.05,-121.286,75.909,-121.079,76.009,-120.958,75.994,-121.002,75.947,-120.909,75.956,-121.038,76.124,-120.856,76.214,-120.644,76.16,-120.737,76.103,-120.638,76.119,-120.726,76.08,-120.66,76.006,-120.379,75.976,-120.466,75.831,-120.246,75.822,-120.152,75.914,-119.912,75.851,-119.718,75.888,-119.641,75.999,-119.475,75.988,-119.566,76.086,-119.818,76.114,-119.531,76.121,-119.698,76.167,-119.537,76.171,-119.712,76.266,-119.615,76.289,-119.699,76.349,-119.446,76.352,-119.478,76.291,-119.325,76.233,-119.287,76.138,-119.038,76.093,-118.852,76.179,-118.944,76.273,-118.533,76.337,-118.655,76.456,-118.95,76.507,-118.304,76.56,-118.312,76.653,-118.455,76.725,-117.836,76.832,-117.677,76.802,-117.926,76.686,-117.899,76.541,-118.05,76.416,-117.515,76.272,-117.078,76.303,-116.91,76.388,-117.035,76.548,-116.296,76.588,-115.864,76.699,-115.993,76.777,-115.955,76.819,-116.353,76.944,-115.724,76.946,-116.238,77.044,-116.235,77.108,-116.397,77.151,-115.344,77.314,-116.111,77.47,-116.066,77.508,-116.593,77.548,-115.096,77.96,-114.295,77.711,-113.735,77.759,-113.55,77.827,-114.132,77.958,-114.029,77.989,-114.371,78.039,-114.303,78.048,-114.349,78.079,-113.293,78.348,-113.187,78.271,-112.166,78.375,-111.444,78.271,-111.155,78.393,-110.369,78.277,-109.397,78.314,-109.216,78.483,-110.403,78.767,-105.451,79.335,-105.4,79.292,-105.65,79.168,-105.613,79.034,-104.68,79.022,-104.998,78.853,-104.991,78.799,-104.106,78.983,-103.82,78.899,-104.207,78.775,-103.716,78.808,-103.81,78.75,-103.447,78.791,-103.295,78.734,-104.013,78.626,-103.388,78.62,-103.541,78.496,-104.728,78.584,-105.009,78.506,-104.702,78.327,-104.038,78.24,-102.773,78.398,-102.619,78.364,-102.814,78.292,-102.569,78.239,-102.156,78.296,-101.006,78.195,-100.746,78.093,-100.829,78.065,-100.499,78.056,-100.835,78.014,-100.584,77.869,-100.275,77.818,-99.165,77.842,-98.99,77.9,-99.13,77.996,-98.912,78.061,-99.504,78.223,-99.499,78.278,-99.962,78.2,-99.514,78.289,-99.621,78.297,-99.717,78.274,-99.94,78.272,-99.64,78.303,-99.8,78.297,-99.757,78.405,-99.922,78.386,-99.481,78.587,-100.039,78.637,-99.86,78.689,-100.362,78.836,-101.082,78.748,-100.931,78.772,-101.224,78.825,-100.958,78.941,-101.259,78.968,-101.589,79.074,-101.866,79.091,-102.388,79.017,-102.39,78.935,-102.533,78.869,-102.594,78.87,-102.58,78.953,-102.774,78.944,-102.623,79.026,-102.625,79.101,-102.935,79.207,-102.867,79.235,-103.727,79.365,-105.436,79.337,-100.032,80.093,-100.16,80.038,-100.16,79.898,-99.505,79.891,-99.283,79.837,-99.346,79.764,-98.831,79.669,-98.624,79.791,-98.821,80.078,-99.74,80.15,-94.266,81.231,-93.005,81.184,-93.103,81.091,-93.505,81.079,-93.424,81.063,-94.275,81.112,-94.337,81.098,-93.858,81.021,-94.346,80.963,-94.485,80.993,-94.394,81.012,-94.614,81.049,-95.226,81.009,-95.449,80.885,-95.083,80.88,-95.489,80.814,-94.114,80.725,-94.031,80.708,-94.606,80.681,-94.52,80.601,-93.998,80.591,-93.76,80.521,-95.96,80.584,-95.754,80.522,-96.047,80.488,-95.219,80.377,-95.466,80.362,-95.42,80.33,-96.031,80.385,-96.251,80.363,-96.173,80.332,-96.536,80.361,-96.628,80.334,-95.911,80.193,-95.198,80.24,-95.697,80.169,-95.331,80.119,-94.552,80.228,-94.067,80.183,-94.783,80.087,-94.338,79.981,-96.681,80.147,-96.739,80.084,-96.312,80.048,-96.639,80.019,-96.585,79.962,-96.128,79.907,-96.636,79.882,-96.358,79.866,-96.516,79.836,-96.34,79.833,-96.231,79.793,-96.204,79.736,-95.713,79.637,-94.959,79.668,-94.804,79.66,-94.336,79.774,-94.22,79.775,-94.154,79.772,-94.54,79.629,-95.728,79.538,-95.738,79.481,-95.644,79.47,-95.743,79.411,-95.218,79.407,-95.287,79.393,-95.137,79.348,-95.295,79.33,-95.124,79.275,-94.492,79.344,-94.372,79.373,-94.475,79.37,-94.519,79.418,-94.386,79.431,-93.834,79.398,-94.247,79.27,-94.118,79.281,-94.015,79.256,-93.478,79.35,-93.29,79.457,-93.222,79.438,-93.349,79.338,-92.955,79.394,-93.094,79.486,-92.846,79.41,-92.196,79.443,-92.557,79.366,-91.139,79.395,-91.111,79.363,-92.673,79.252,-92.262,79.207,-90.344,79.251,-92.106,79.149,-93.373,79.163,-93.609,79.045,-94.301,78.983,-93.867,78.829,-93.004,78.741,-93.822,78.772,-93.351,78.673,-93.483,78.648,-93.289,78.583,-92.822,78.63,-91.635,78.565,-92.98,78.476,-92.483,78.309,-92.164,78.321,-92.34,78.288,-92.046,78.205,-90.34,78.142,-90.269,78.187,-90.365,78.221,-90.301,78.275,-90.751,78.323,-90.238,78.335,-89.453,78.155,-90.11,78.553,-89.968,78.614,-89.166,78.21,-88.845,78.148,-88.531,78.401,-88.798,78.606,-88.563,78.607,-88.187,78.453,-87.88,78.557,-88.023,78.659,-88.375,78.661,-88.102,78.679,-88.244,78.817,-88.16,78.995,-87.718,79.087,-87.987,78.967,-87.997,78.793,-87.624,78.643,-86.978,78.885,-86.988,79.057,-86.697,78.957,-86.499,79.059,-84.88,79.272,-85.699,79.621,-85.822,79.615,-85.885,79.499,-86.035,79.44,-86.169,79.468,-86.006,79.479,-86.136,79.526,-86.017,79.572,-86.33,79.651,-87.434,79.531,-87.138,79.645,-86.936,79.918,-87.48,79.833,-87.002,79.945,-87.252,80.077,-87.728,80.081,-87.668,80.049,-87.731,80.03,-87.833,80.01,-87.843,80.011,-87.688,80.052,-88.249,80.152,-87.555,80.175,-87.606,80.406,-88.599,80.388,-88.645,80.357,-88.566,80.32,-88.667,80.279,-88.24,80.193,-88.408,80.182,-88.099,80.089,-88.761,80.125,-89.202,80.293,-89.044,80.331,-89.095,80.352,-89.032,80.39,-89.232,80.395,-88.993,80.453,-89.061,80.484,-89.82,80.525,-89.675,80.479,-89.709,80.455,-90.748,80.561,-90.545,80.638,-91.124,80.769,-91.108,80.818,-91.837,81.059,-91.734,81.085,-91.853,81.177,-93.522,81.378,-91.695,81.702,-91.643,81.683,-91.905,81.632,-91.392,81.587,-91.319,81.539,-91.41,81.516,-91.04,81.523,-90.693,81.674,-90.233,81.632,-90.319,81.684,-90.227,81.701,-89.91,81.639,-90.021,81.61,-89.491,81.625,-90.82,81.438,-90.333,81.362,-88.254,81.579,-87.218,81.49,-88.762,81.502,-88.892,81.471,-88.772,81.446,-89.931,81.329,-88.881,81.242,-89.929,81.242,-90.279,81.171,-90.193,81.077,-89.858,81.017,-87.452,81.06,-85.354,81.276,-84.816,81.268,-85.914,81.097,-85.76,81.069,-89.414,80.911,-87.476,80.623,-87.157,80.634,-87.04,80.718,-85.669,81.045,-82.804,81.167,-81.963,81.178,-85.678,80.965,-86.707,80.597,-86.012,80.529,-85.567,80.621,-85.505,80.616,-85.804,80.542,-84.498,80.511,-83.776,80.544,-83.654,80.623,-83.779,80.729,-83.221,80.838,-83.09,80.819,-83.566,80.738,-83.32,80.679,-81.74,80.816,-82.197,80.717,-83.068,80.643,-83.163,80.561,-82.998,80.538,-79.949,80.77,-79.465,80.849,-79.164,80.968,-79.361,80.998,-79.028,81.087,-79.444,81.193,-78.887,81.101,-78.606,81.123,-78.73,81.127,-78.249,81.299,-76.808,81.452,-76.716,81.437,-78.43,81.161,-78.33,81.145,-78.957,80.979,-78.856,80.945,-78.969,80.878,-77.539,80.915,-76.469,80.853,-78.173,80.804,-79.987,80.614,-77.961,80.582,-78.025,80.567,-80.196,80.53,-80.388,80.5,-80.065,80.471,-83.194,80.326,-82.164,80.024,-81.403,79.95,-81.669,79.899,-81.539,79.815,-81.637,79.76,-81.455,79.747,-81.511,79.716,-80.789,79.649,-79.732,79.7,-80.645,79.588,-80.586,79.564,-81.45,79.642,-81.597,79.586,-81.73,79.589,-81.616,79.615,-81.673,79.689,-81.968,79.719,-82.08,79.851,-83.768,80.247,-86.494,80.295,-86.623,80.124,-86.469,80.013,-85.232,79.927,-86.396,79.967,-86.452,79.852,-86.362,79.83,-86.457,79.78,-85.139,79.646,-84.896,79.49,-84.492,79.415,-84.312,79.19,-83.909,79.221,-84.018,79.167,-83.341,79.056,-83.899,79.041,-84.514,79.15,-84.778,79.069,-84.705,79.021,-82.524,78.891,-82.081,78.914,-81.855,79.016,-81.546,79.064,-81.487,79.06,-81.477,79.051,-81.759,78.924,-81.639,78.896,-81.684,78.844,-83.263,78.836,-82.818,78.733,-82.39,78.747,-82.184,78.729,-82.575,78.706,-82.203,78.588,-82.34,78.568,-83.689,78.828,-85.073,78.922,-86.845,78.744,-87.089,78.588,-86.833,78.576,-86.824,78.55,-87.167,78.543,-87.5,78.43,-87.49,78.216,-87.065,78.204,-87.527,78.139,-86.703,78.12,-86.472,78.22,-86.219,78.2,-85.792,78.389,-85.925,78.236,-86.252,78.164,-86.272,78.076,-85.448,78.102,-85.0,78.292,-84.845,78.505,-84.611,78.602,-84.854,78.376,-84.581,78.354,-84.809,78.332,-84.964,78.215,-84.414,78.218,-84.011,78.175,-84.981,78.179,-85.073,78.099,-84.274,78.079,-85.118,78.054,-85.689,77.933,-85.469,77.863,-85.288,77.905,-84.657,77.931,-84.303,77.895,-85.409,77.838,-84.933,77.846,-85.3,77.728,-85.239,77.7,-85.288,77.67,-84.945,77.599,-84.415,77.747,-84.852,77.578,-84.78,77.521,-83.905,77.489,-83.403,77.61,-82.735,77.937,-82.754,78.027,-82.581,78.067,-82.295,78.072,-82.585,78.021,-82.536,77.919,-83.231,77.572,-83.838,77.461,-83.471,77.35,-84.613,77.384,-84.387,77.305,-84.454,77.295,-85.455,77.382,-85.537,77.462,-85.811,77.421,-85.716,77.477,-85.944,77.708,-86.415,77.842,-87.304,77.9,-88.218,77.855,-88.073,77.804,-88.212,77.775,-88.14,77.764,-88.227,77.658,-87.622,77.489,-87.778,77.427,-87.727,77.363,-86.85,77.368,-87.241,77.308,-86.864,77.265,-87.231,77.204,-86.591,77.186,-88.551,77.099,-88.38,77.095,-89.523,76.85,-89.359,76.726,-89.664,76.572,-88.902,76.406,-88.669,76.601,-88.585,76.484,-88.707,76.412,-88.591,76.399,-88.487,76.559,-88.65,76.597,-88.57,76.634,-88.708,76.716,-88.468,76.826,-88.559,76.737,-88.46,76.666,-88.504,76.608,-88.332,76.514,-88.417,76.467,-88.349,76.465,-88.431,76.411,-88.374,76.391,-87.408,76.352,-87.541,76.45,-87.487,76.498,-87.572,76.529,-87.545,76.626,-87.444,76.587,-87.488,76.533,-87.377,76.514,-87.436,76.457,-86.711,76.344,-86.596,76.448,-86.645,76.472,-86.285,76.523,-86.591,76.642,-86.207,76.528,-86.396,76.469,-86.384,76.387,-84.925,76.282,-84.381,76.316,-84.972,76.422,-85.052,76.571,-85.217,76.64,-84.908,76.598,-84.958,76.567,-84.64,76.435,-84.141,76.478,-84.259,76.538,-84.191,76.614,-84.315,76.659,-84.09,76.628,-84.048,76.533,-83.71,76.426,-83.153,76.423,-83.257,76.455,-83.171,76.493,-83.236,76.572,-83.536,76.687,-83.386,76.762,-83.417,76.675,-83.185,76.62,-83.046,76.501,-83.133,76.467,-83.045,76.436,-82.785,76.381,-82.132,76.442,-82.243,76.507,-82.11,76.572,-82.493,76.643,-82.773,76.821,-82.726,76.826,-82.292,76.636,-81.771,76.689,-82.102,76.61,-81.995,76.583,-82.093,76.516,-81.488,76.467,-81.213,76.547,-81.25,76.495,-80.759,76.419,-81.098,76.217,-80.964,76.191,-81.039,76.132,-79.332,76.306,-78.904,76.482,-78.796,76.088,-79.112,76.12,-79.754,75.881,-79.432,75.804,-80.134,75.595,-81.287,75.65,-81.317,75.691,-81.094,75.765,-81.49,75.801,-82.323,75.837,-83.179,75.735,-83.99,75.823,-84.318,75.708,-84.687,75.697,-84.518,75.627,-85.049,75.666,-85.209,75.605,-85.337,75.597,-85.268,75.591,-85.252,75.581,-85.351,75.578,-85.181,75.576,-85.208,75.552,-85.558,75.603,-86.162,75.507,-85.54,75.401,-86.615,75.361,-86.348,75.421,-87.306,75.622,-87.543,75.563,-87.431,75.487,-87.563,75.445,-87.632,75.448,-87.497,75.489,-87.737,75.576,-88.25,75.471,-88.315,75.498,-88.187,75.527,-88.756,75.685,-88.922,75.609,-88.742,75.477,-88.772,75.436,-88.852,75.433,-88.838,75.425,-88.848,75.421,-88.866,75.422,-89.115,75.485,-89.213,75.588,-89.43,75.591,-89.507,75.553,-89.718,75.555,-89.536,75.564,-89.753,75.576,-89.62,75.61,-89.675,75.626,-89.253,75.624,-89.162,75.771,-89.569,75.862,-89.703,75.797,-89.786,75.792,-89.668,75.893,-89.934,76.007,-90.547,75.897,-90.43,75.975,-90.8,76.0,-91.201,75.819,-90.955,75.968,-91.08,75.991,-90.947,75.998,-91.154,76.018,-90.187,76.061,-90.876,76.065,-90.653,76.117,-91.433,76.149,-91.263,76.196,-91.623,76.264,-90.105,76.132,-90.465,76.173,-89.489,76.176,-89.191,76.244,-91.614,76.497,-91.385,76.518,-91.129,76.48,-90.976,76.51,-90.464,76.471,-90.976,76.653,-93.118,76.62,-93.598,76.428,-93.463,76.409,-93.548,76.387,-93.664,76.444,-93.302,76.548,-93.179,76.749,-93.717,76.924,-94.295,76.893,-94.287,76.913,-94.441,76.915,-94.608,76.988,-95.691,77.07,-96.398,77.035,-96.219,77.003,-96.476,76.955,-96.801,76.983,-96.856,76.966,-96.735,76.963,-96.641,76.952,-96.64,76.948,-96.88,76.886,-96.323,76.812,-96.607,76.77,-96.479,76.78,-96.304,76.75,-96.677,76.75,-96.877,76.822,-96.916,76.783,-96.821,76.764,-96.97,76.733,-96.469,76.69,-96.045,76.556,-95.605,76.611,-95.8,76.514,-96.124,76.504,-95.825,76.395,-95.405,76.399,-95.486,76.373,-95.295,76.378,-95.205,76.343,-95.124,76.362,-94.876,76.341,-94.803,76.313,-95.306,76.283,-95.356,76.311,-95.401,76.231,-95.047,76.22,-94.731,76.319,-93.692,76.249,-93.62,76.256,-93.763,76.312,-93.58,76.298,-93.082,76.363,-93.048,76.342,-93.096,76.308,-92.795,76.213,-92.651,76.128,-92.673,76.011,-92.107,75.855,-92.12,75.719,-91.999,75.663,-92.099,75.618,-91.953,75.591,-92.422,75.436,-92.46,75.367,-92.328,75.371,-92.504,75.217,-92.042,75.094,-91.831,75.126,-91.819,75.123,-91.979,75.087,-92.254,75.072,-92.038,74.961,-92.045,74.777,-91.648,74.722,-91.699,74.672,-91.591,74.653,-91.362,74.646,-91.317,74.712,-91.298,74.628,-91.115,74.628,-91.231,74.731,-90.752,74.886,-91.025,74.709,-90.737,74.737,-90.793,74.723,-90.618,74.62,-89.995,74.589,-89.984,74.531,-89.586,74.539,-89.15,74.608,-89.097,74.694,-89.278,74.753,-89.092,74.715,-88.997,74.778,-89.102,74.837,-88.923,74.776,-88.92,74.677,-88.841,74.662,-88.693,74.847,-88.551,74.84,-88.608,74.892,-88.553,74.913,-88.463,74.836,-88.511,74.783,-88.312,74.773,-88.471,74.769,-88.414,74.742,-88.589,74.571,-88.541,74.498,-87.769,74.519,-87.668,74.458,-87.478,74.47,-87.443,74.522,-87.284,74.544,-87.4,74.521,-86.613,74.468,-86.806,74.536,-86.791,74.615,-86.453,74.478,-86.214,74.532,-86.23,74.596,-86.183,74.612,-86.094,74.559,-86.13,74.48,-85.61,74.495,-85.485,74.553,-85.556,74.688,-85.367,74.501,-85.232,74.488,-85.052,74.522,-85.008,74.696,-84.907,74.499,-84.262,74.505,-83.443,74.591,-83.313,74.781,-83.561,74.871,-83.51,74.903,-83.104,74.824,-83.041,74.786,-83.126,74.733,-83.093,74.638,-82.79,74.528,-82.554,74.519,-82.462,74.589,-82.375,74.553,-82.459,74.523,-81.76,74.471,-81.229,74.587,-80.212,74.6,-78.164,73.67,-79.518,73.636,-80.022,73.735,-80.842,73.751,-80.768,73.672,-80.889,73.615,-80.868,73.551,-80.708,73.494,-80.86,73.478,-80.876,73.335,-80.148,73.232,-80.177,73.047,-80.001,72.868,-79.332,72.741,-78.224,72.902,-76.499,72.816,-76.107,72.861,-75.161,72.495,-75.975,72.586,-76.075,72.536,-76.052,72.474,-76.138,72.465,-76.122,72.591,-76.653,72.642,-76.739,72.724,-77.591,72.761,-78.413,72.59,-78.55,72.438,-77.001,72.137,-77.579,72.186,-77.63,72.164,-77.579,72.137,-77.642,72.1,-77.658,72.207,-78.42,72.335,-78.476,72.269,-78.41,72.229,-78.417,72.171,-78.6,72.366,-78.804,72.341,-78.749,72.282,-78.881,72.235,-78.43,72.121,-78.285,71.987,-78.092,71.986,-77.681,71.814,-77.74,71.747,-78.117,71.974,-78.299,71.954,-77.88,71.772,-78.189,71.83,-78.38,71.938,-78.48,72.1,-78.849,72.181,-78.929,72.023,-78.508,71.865,-79.187,71.96,-79.009,72.089,-78.932,72.278,-79.001,72.282,-79.345,72.409,-79.543,72.296,-79.452,72.258,-79.432,72.193,-79.585,72.283,-79.735,72.218,-79.578,72.346,-79.792,72.506,-79.873,72.421,-80.168,72.33,-79.68,72.13,-80.222,72.306,-80.286,72.268,-80.189,72.199,-80.487,72.184,-80.336,72.063,-80.745,71.987,-80.922,71.879,-80.775,72.022,-81.079,72.047,-80.983,72.072,-80.984,72.093,-81.039,72.089,-81.064,72.091,-81.077,72.095,-80.734,72.107,-80.533,72.07,-80.986,72.204,-80.818,72.21,-80.502,72.381,-80.5,72.513,-80.879,72.447,-81.237,72.249,-81.381,72.241,-80.222,72.732,-80.632,72.929,-80.545,73.122,-81.184,73.259,-81.206,73.531,-81.556,73.726,-82.844,73.736,-84.01,73.508,-83.652,73.442,-83.586,73.305,-83.748,73.428,-84.168,73.481,-84.633,73.403,-84.312,73.226,-84.767,73.39,-85.12,73.315,-85.177,73.239,-83.761,73.047,-83.606,72.985,-85.208,73.15,-85.122,73.052,-85.403,73.143,-85.54,73.037,-85.082,73.03,-83.938,72.749,-85.315,72.978,-85.7,72.905,-85.642,72.877,-85.711,72.65,-85.467,72.586,-85.618,72.539,-85.514,72.461,-85.101,72.385,-85.146,72.36,-84.775,72.471,-84.76,72.414,-84.874,72.372,-84.39,72.393,-84.943,72.289,-84.547,72.231,-84.745,72.233,-84.144,72.007,-84.194,71.933,-84.511,72.118,-84.537,72.116,-84.562,72.118,-84.621,72.138,-84.61,72.143,-84.706,72.144,-84.975,72.251,-85.224,72.228,-85.23,72.267,-85.492,72.26,-85.381,72.225,-85.447,72.211,-85.514,72.242,-85.502,72.178,-85.368,72.139,-85.546,72.125,-85.477,72.089,-85.507,72.06,-85.691,72.064,-85.78,72.031,-85.712,72.004,-85.992,72.032,-85.949,71.982,-86.036,72.024,-86.025,71.987,-85.553,71.902,-85.432,71.81,-85.591,71.786,-85.304,71.682,-84.953,71.652,-84.852,71.632,-84.644,71.688,-84.504,71.639,-84.687,71.608,-84.514,71.493,-84.642,71.426,-84.61,71.413,-84.606,71.405,-84.583,71.41,-84.507,71.391,-84.5,71.387,-84.499,71.38,-84.433,71.383,-84.242,71.342,-84.304,71.345,-84.425,71.379,-84.496,71.377,-84.604,71.402,-84.675,71.443,-84.758,71.419,-84.778,71.269,-84.643,71.225,-84.682,71.209,-84.761,71.213,-84.748,71.202,-84.777,71.197,-84.786,71.199,-84.789,71.185,-84.801,71.174,-84.799,71.181,-84.807,71.181,-84.768,70.934,-84.945,70.924,-84.91,71.01,-85.001,71.037,-84.93,71.068,-85.16,71.087,-84.863,71.075,-84.834,71.154,-84.958,71.199,-84.963,71.185,-85.042,71.186,-85.067,71.195,-85.069,71.186,-85.007,71.18,-84.955,71.166,-84.998,71.16,-85.097,71.189,-85.077,71.155,-85.099,71.16,-85.105,71.146,-85.141,71.147,-85.136,71.142,-85.111,71.14,-85.103,71.134,-85.113,71.132,-85.389,71.172,-85.371,71.188,-86.233,71.013,-86.757,70.984,-86.816,70.992,-86.774,71.012,-86.235,71.06,-85.943,71.186,-85.497,71.177,-85.448,71.223,-85.406,71.223,-85.423,71.203,-85.361,71.201,-85.423,71.254,-85.178,71.272,-85.236,71.268,-85.313,71.296,-84.825,71.275,-84.927,71.344,-84.929,71.373,-84.903,71.388,-84.91,71.423,-85.155,71.439,-85.487,71.521,-86.202,71.855,-86.4,72.015,-86.42,72.18,-86.457,72.204,-86.234,72.418,-86.761,72.711,-86.698,72.849,-85.93,73.365,-84.833,73.745,-85.222,73.821,-86.554,73.857,-88.004,73.652,-88.855,73.335,-88.81,73.311,-88.607,73.288,-88.589,73.28,-88.592,73.277,-88.933,73.3,-89.028,73.236,-88.872,73.22,-89.154,73.184,-89.1,73.151,-89.313,73.043,-89.177,73.027,-89.353,73.001,-89.289,72.939,-89.289,72.914,-89.338,72.893,-89.296,72.892,-89.411,72.833,-89.234,72.784,-89.561,72.798,-89.587,72.706,-89.509,72.698,-89.469,72.661,-89.739,72.616,-89.768,72.485,-89.903,72.433,-89.828,72.425,-89.931,72.425,-89.833,72.399,-89.968,72.32,-89.901,72.195,-89.562,72.17,-90.021,72.068,-89.956,72.029,-90.111,71.93,-89.771,71.77,-90.034,71.602,-89.941,71.51,-90.023,71.521,-89.808,71.334,-88.077,71.208,-87.85,71.267,-87.857,71.261,-87.837,71.265,-87.842,71.26,-87.835,71.261,-87.812,71.26,-87.8,71.259,-87.796,71.258,-87.858,71.194,-87.818,71.178,-87.749,71.137,-86.991,70.999,-88.054,70.936,-88.475,71.034,-89.535,71.092,-89.188,70.976,-89.417,70.909,-88.784,70.497,-87.905,70.338,-88.258,70.337,-87.856,70.243,-87.609,70.294,-87.804,70.31,-87.756,70.338,-86.981,70.286,-86.975,70.368,-87.248,70.387,-86.989,70.468,-86.91,70.443,-86.973,70.419,-86.866,70.415,-86.93,70.385,-86.736,70.38,-86.869,70.323,-86.655,70.319,-86.702,70.375,-86.335,70.482,-86.381,70.529,-86.292,70.478,-86.599,70.363,-86.563,70.241,-85.72,70.0,-85.231,69.988,-85.86,70.091,-84.788,70.118,-84.836,70.047,-84.703,70.011,-83.701,69.959,-83.067,70.018,-82.464,69.854,-82.422,69.879,-82.302,69.878,-82.179,69.837,-82.221,69.831,-82.32,69.846,-82.335,69.826,-82.306,69.834,-82.268,69.836,-82.243,69.833,-82.311,69.822,-82.289,69.809,-82.301,69.807,-82.298,69.801,-82.291,69.799,-82.21,69.793,-81.716,69.939,-82.83,70.219,-83.034,70.316,-81.576,69.979,-81.622,69.948,-81.436,69.927,-80.935,69.719,-80.746,69.771,-81.226,70.016,-81.45,69.999,-81.703,70.08,-81.746,70.154,-80.559,70.056,-80.472,70.038,-80.444,70.009,-80.358,70.005,-80.326,69.989,-80.226,70.027,-80.092,70.001,-80.041,70.023,-80.035,70.013,-79.984,70.025,-79.988,70.019,-79.961,70.025,-79.943,70.023,-79.913,70.012,-79.908,69.991,-79.934,69.982,-80.017,70.002,-80.058,69.975,-80.018,69.947,-79.888,69.991,-79.677,69.859,-78.797,69.888,-78.653,69.978,-78.764,70.196,-78.919,70.309,-79.586,70.386,-79.486,70.458,-79.633,70.506,-79.652,70.525,-79.639,70.536,-79.679,70.542,-79.68,70.564,-79.727,70.557,-79.861,70.583,-79.806,70.584,-79.847,70.607,-79.784,70.614,-79.669,70.569,-79.636,70.542,-79.625,70.504,-79.536,70.489,-79.427,70.425,-79.409,70.464,-79.366,70.465,-79.421,70.47,-79.445,70.497,-79.5,70.489,-79.478,70.571,-79.4,70.585,-79.376,70.536,-79.479,70.511,-79.166,70.416,-79.116,70.486,-79.126,70.511,-79.18,70.52,-78.985,70.626,-79.157,70.632,-79.021,70.688,-78.954,70.631,-78.848,70.637,-78.967,70.705,-78.855,70.668,-78.708,70.547,-78.853,70.605,-79.046,70.573,-79.07,70.475,-78.86,70.41,-78.885,70.487,-78.727,70.455,-78.577,70.32,-78.598,70.359,-78.51,70.368,-78.382,70.352,-78.547,70.314,-78.426,70.269,-78.379,70.206,-78.162,70.192,-78.181,70.265,-78.042,70.232,-78.048,70.216,-77.888,70.265,-77.825,70.237,-77.776,70.265,-77.767,70.266,-77.745,70.265,-77.643,70.168,-77.694,70.0,-77.626,69.749,-77.44,69.797,-77.499,69.83,-77.565,69.814,-77.626,69.826,-76.923,70.012,-76.978,69.93,-77.311,69.835,-76.992,69.862,-77.073,69.812,-76.923,69.81,-76.804,69.85,-76.694,69.843,-76.752,69.81,-76.926,69.791,-76.776,69.748,-76.837,69.692,-77.187,69.642,-76.804,69.637,-76.872,69.586,-76.719,69.562,-76.534,69.647,-76.651,69.68,-76.567,69.704,-76.179,69.674,-76.213,69.534,-76.233,69.622,-76.447,69.663,-76.655,69.556,-76.406,69.413,-75.947,69.396,-75.585,69.236,-75.56,69.156,-75.596,69.078,-76.003,69.005,-76.595,69.035,-76.663,68.93,-76.524,68.865,-76.661,68.763,-76.65,68.691,-76.333,68.691,-75.616,68.874,-75.494,68.935,-75.575,68.982,-75.508,69.015,-75.44,69.014,-75.118,68.889,-75.06,69.013,-74.799,69.101,-74.819,69.093,-74.638,69.045,-74.625,69.02,-74.927,69.005,-75.045,68.947,-74.85,68.962,-75.054,68.925,-74.827,68.942,-75.05,68.906,-74.727,68.934,-74.677,68.923,-74.977,68.817,-74.777,68.768,-74.783,68.781,-74.653,68.785,-74.6,68.814,-74.642,68.815,-74.651,68.824,-74.711,68.818,-74.729,68.82,-74.678,68.846,-74.555,68.827,-74.5,68.832,-74.572,68.799,-74.708,68.77,-74.719,68.771,-74.735,68.769,-74.715,68.756,-74.734,68.737,-74.377,68.551,-74.013,68.499,-73.889,68.554,-74.194,68.7,-74.187,68.736,-73.763,68.689,-73.794,68.667,-73.655,68.665,-73.813,68.625,-73.696,68.622,-73.824,68.574,-73.722,68.527,-73.863,68.5,-73.95,68.41,-73.669,68.487,-73.486,68.446,-73.168,68.559,-73.222,68.643,-72.933,68.645,-73.017,68.631,-72.927,68.576,-73.122,68.608,-73.08,68.588,-73.441,68.43,-73.625,68.457,-73.93,68.414,-73.8,68.321,-73.549,68.313,-73.683,68.29,-73.639,68.25,-73.264,68.293,-73.222,68.228,-73.127,68.288,-73.024,68.284,-73.169,68.219,-73.004,68.24,-72.892,68.025,-72.892,68.041,-72.788,68.082,-72.811,68.087,-72.813,68.092,-72.746,68.115,-72.858,68.108,-72.794,68.125,-72.724,68.124,-72.751,68.142,-72.873,68.123,-72.892,68.168,-72.681,68.218,-72.581,68.23,-72.553,68.232,-72.535,68.227,-72.522,68.233,-72.455,68.24,-72.418,68.239,-72.37,68.232,-72.333,68.223,-72.364,68.244,-72.166,68.33,-72.229,68.401,-72.091,68.501,-72.043,68.496,-72.169,68.399,-72.099,68.351,-72.195,68.312,-72.087,68.297,-72.332,68.218,-72.81,68.177,-72.853,68.142,-72.666,68.159,-72.769,68.086,-72.709,68.093,-72.95,67.989,-72.445,67.909,-72.185,67.938,-72.147,67.934,-72.139,67.931,-72.125,67.914,-72.078,67.921,-72.083,67.909,-72.144,67.897,-72.144,67.932,-72.173,67.933,-72.271,67.881,-72.699,67.921,-72.659,67.893,-72.696,67.847,-72.732,67.848,-72.573,67.769,-72.565,67.74,-72.657,67.681,-72.539,67.663,-72.516,67.701,-72.393,67.732,-72.38,67.73,-72.415,67.72,-72.424,67.71,-72.313,67.743,-71.948,67.752,-71.981,67.797,-71.938,67.83,-71.89,67.845,-71.876,67.887,-71.78,67.951,-71.685,67.982,-71.562,68.0,-71.649,67.984,-71.709,67.947,-71.756,67.951,-71.779,67.923,-71.835,67.908,-71.875,67.878,-71.829,67.868,-71.901,67.838,-71.861,67.83,-71.961,67.795,-71.925,67.759,-71.971,67.738,-72.016,67.732,-71.906,67.735,-71.839,67.689,-71.813,67.725,-71.643,67.716,-71.673,67.672,-71.607,67.669,-71.517,67.648,-71.521,67.636,-71.591,67.633,-71.723,67.656,-71.661,67.607,-71.694,67.568,-71.741,67.575,-71.745,67.559,-71.81,67.552,-71.866,67.562,-71.899,67.552,-71.78,67.529,-71.671,67.546,-71.686,67.554,-71.659,67.545,-71.644,67.556,-71.616,67.555,-71.601,67.55,-71.559,67.575,-71.493,67.553,-71.664,67.525,-71.76,67.529,-71.707,67.51,-71.544,67.513,-71.499,67.496,-71.414,67.551,-71.326,67.507,-71.384,67.482,-71.326,67.495,-71.294,67.466,-71.319,67.404,-71.23,67.379,-71.271,67.38,-71.267,67.372,-71.3,67.357,-71.266,67.364,-71.284,67.355,-71.252,67.349,-71.247,67.341,-71.225,67.338,-71.232,67.36,-71.177,67.327,-71.156,67.294,-71.165,67.282,-71.197,67.278,-71.196,67.281,-71.224,67.292,-71.207,67.281,-71.218,67.268,-71.16,67.241,-71.143,67.248,-71.127,67.25,-71.073,67.251,-71.072,67.259,-71.065,67.262,-71.031,67.267,-70.97,67.263,-70.972,67.249,-71.166,67.236,-71.381,67.322,-71.31,67.374,-71.288,67.368,-71.294,67.379,-71.286,67.381,-71.275,67.379,-71.269,67.382,-71.456,67.415,-71.395,67.431,-71.432,67.474,-71.668,67.463,-71.863,67.502],"holes":[[-71.977,67.541,-71.851,67.6,-72.054,67.637,-72.002,67.662,-72.106,67.695,-72.05,67.732,-72.646,67.672,-72.42,67.534,-72.276,67.588,-71.977,67.541],[-75.102,67.506,-75.027,67.661,-75.119,67.952,-74.961,68.198,-75.767,68.344,-76.31,68.345,-76.744,68.237,-76.992,68.076,-77.292,67.709,-77.216,67.437,-77.046,67.269,-76.681,67.207,-75.747,67.261,-75.306,67.37,-75.102,67.506],[-75.008,68.381,-74.856,68.313,-74.704,68.402,-74.851,68.434,-74.741,68.474,-74.943,68.585,-75.029,68.686,-74.979,68.695,-75.262,68.731,-75.395,68.626,-75.391,68.51,-75.008,68.381],[-78.459,68.569,-78.472,68.628,-78.838,68.689,-78.901,68.647,-78.671,68.589,-78.958,68.483,-78.795,68.431,-78.621,68.501,-78.694,68.569,-78.459,68.569],[-74.269,68.166,-74.354,68.187,-74.438,68.064,-74.762,68.09,-74.804,67.98,-74.586,67.813,-74.32,67.754,-73.342,67.797,-73.497,68.042,-74.247,68.065,-74.196,68.122,-74.269,68.166],[-79.203,68.841,-78.804,68.918,-78.449,69.217,-78.208,69.301,-78.322,69.388,-78.718,69.347,-78.616,69.272,-78.75,69.271,-78.938,69.112,-79.208,69.096,-79.414,68.924,-79.203,68.841],[-80.336,69.577,-80.207,69.639,-80.214,69.519,-80.016,69.49,-79.906,69.526,-80.061,69.648,-79.64,69.613,-79.319,69.728,-79.669,69.823,-79.966,69.723,-80.164,69.719,-80.101,69.763,-80.178,69.804,-80.487,69.788,-80.297,69.71,-80.403,69.67,-80.633,69.751,-80.817,69.695,-80.485,69.678,-80.336,69.577],[-78.186,69.755,-78.234,69.675,-78.387,69.701,-78.857,69.479,-78.508,69.489,-77.941,69.646,-78.186,69.755],[-77.115,69.455,-77.348,69.409,-77.372,69.257,-77.275,69.152,-76.847,69.173,-76.905,69.213,-76.647,69.301,-76.623,69.393,-77.115,69.455],[-86.746,69.974,-86.449,70.012,-86.62,70.123,-87.375,70.103,-87.133,70.001,-86.746,69.974],[-94.809,71.999,-94.085,72.059,-93.827,72.313,-93.44,72.448,-93.562,72.588,-93.796,72.655,-93.756,72.748,-94.285,72.715,-94.291,72.776,-93.304,72.823,-92.109,72.74,-91.337,73.207,-91.666,73.227,-91.26,73.263,-90.925,73.481,-90.983,73.553,-90.77,73.556,-90.349,73.792,-90.36,73.865,-90.161,73.905,-91.529,74.031,-92.33,73.947,-92.26,74.019,-92.591,74.114,-93.488,74.179,-93.768,74.146,-93.733,74.08,-93.919,74.145,-94.423,74.124,-95.292,73.993,-95.337,73.927,-95.283,73.88,-94.984,73.854,-95.125,73.81,-94.598,73.654,-95.027,73.681,-95.294,73.781,-95.66,73.73,-95.698,73.637,-95.532,73.588,-95.678,73.561,-95.588,73.55,-95.67,73.451,-95.563,73.38,-95.641,73.325,-95.539,73.293,-95.583,73.205,-95.479,73.13,-95.655,73.119,-95.626,73.027,-95.697,72.995,-95.596,72.928,-95.667,72.805,-95.546,72.79,-95.597,72.704,-95.349,72.647,-95.312,72.544,-95.115,72.465,-95.239,72.444,-95.155,72.368,-95.161,72.133,-94.762,72.161,-95.19,72.107,-95.075,72.096,-95.208,71.997,-94.809,71.999],[-96.475,71.964,-96.464,72.045,-96.865,72.044,-96.46,72.108,-96.497,72.243,-96.606,72.253,-96.532,72.28,-96.84,72.33,-96.274,72.413,-96.432,72.539,-96.436,72.642,-96.577,72.691,-96.493,72.716,-96.526,72.758,-97.207,72.598,-97.006,72.743,-97.433,72.868,-97.207,72.945,-97.831,73.052,-98.234,72.989,-98.465,72.84,-98.379,72.945,-98.466,72.952,-98.407,72.996,-98.472,73.024,-97.839,73.279,-97.16,73.36,-97.261,73.437,-97.192,73.477,-97.611,73.458,-97.712,73.538,-97.465,73.516,-97.517,73.554,-97.45,73.59,-97.203,73.558,-96.957,73.637,-96.937,73.735,-97.196,73.858,-97.849,73.87,-97.766,73.919,-98.761,73.727,-99.123,73.752,-99.176,73.691,-99.839,73.946,-100.306,73.87,-99.824,73.875,-100.035,73.768,-100.543,73.866,-100.853,73.841,-101.124,73.735,-100.849,73.661,-100.907,73.627,-100.53,73.613,-100.613,73.499,-100.424,73.409,-100.897,73.607,-101.621,73.497,-100.956,73.278,-100.338,73.324,-100.389,73.404,-100.085,73.254,-99.75,73.225,-100.047,73.187,-100.356,73.294,-100.589,73.146,-100.507,73.083,-100.208,73.138,-100.319,73.109,-100.16,73.098,-100.225,73.062,-100.109,73.001,-100.18,72.998,-100.035,72.971,-100.102,72.93,-99.996,72.888,-100.324,72.875,-100.219,72.936,-100.412,72.988,-100.29,73.027,-100.361,73.058,-100.491,72.961,-100.167,72.788,-101.16,72.708,-101.052,72.653,-101.398,72.757,-101.262,72.748,-101.288,72.803,-101.491,72.802,-101.427,72.818,-101.564,72.926,-101.61,72.89,-101.88,72.995,-101.717,72.974,-101.775,73.019,-102.234,73.092,-102.522,73.023,-102.748,72.801,-102.593,72.657,-101.901,72.483,-101.927,72.415,-101.761,72.4,-101.899,72.366,-101.782,72.298,-101.474,72.297,-101.489,72.237,-101.204,72.348,-100.917,72.165,-100.61,72.188,-100.059,71.869,-99.652,71.77,-99.545,71.617,-99.378,71.612,-99.217,71.353,-98.949,71.391,-98.717,71.281,-98.438,71.326,-98.043,71.53,-98.492,71.719,-98.209,71.94,-98.36,71.742,-98.192,71.65,-97.486,71.617,-96.746,71.858,-96.669,71.854,-96.736,71.8,-96.527,71.837,-96.474,71.944,-96.737,71.923,-96.475,71.964],[-112.896,70.565,-112.147,70.513,-111.969,70.384,-111.238,70.284,-112.268,70.302,-112.072,70.272,-112.565,70.233,-112.523,70.199,-114.507,70.323,-116.971,70.124,-117.392,69.989,-117.214,69.758,-116.822,69.652,-116.905,69.606,-116.851,69.579,-116.59,69.562,-116.53,69.5,-116.619,69.461,-116.5,69.409,-115.467,69.259,-114.242,69.294,-113.343,69.171,-113.695,69.19,-113.571,69.147,-113.685,69.126,-113.557,69.116,-113.637,69.074,-113.44,69.044,-113.623,69.038,-113.545,68.956,-113.667,68.816,-113.348,68.598,-113.008,68.511,-113.276,68.487,-113.2,68.461,-111.169,68.526,-111.398,68.579,-110.817,68.607,-111.031,68.564,-110.945,68.553,-110.464,68.655,-110.522,68.625,-110.326,68.58,-109.006,68.732,-108.501,68.904,-108.616,68.946,-107.317,69.025,-106.918,69.22,-106.955,69.358,-106.739,69.385,-106.715,69.474,-106.596,69.498,-106.445,69.473,-106.38,69.42,-106.348,69.418,-106.343,69.411,-106.319,69.411,-106.294,69.404,-106.288,69.398,-105.897,69.492,-104.817,69.428,-104.286,69.507,-104.776,69.312,-104.999,69.4,-106.022,69.358,-106.025,69.393,-106.175,69.412,-106.284,69.396,-106.313,69.405,-106.23,69.289,-106.394,69.203,-104.879,69.078,-105.243,68.964,-105.122,68.896,-104.529,68.863,-104.346,68.974,-104.34,68.915,-104.095,68.858,-102.896,68.8,-102.691,68.849,-102.729,68.909,-102.52,68.865,-102.393,68.963,-101.791,69.0,-101.851,69.056,-101.709,69.156,-102.151,69.337,-101.911,69.407,-101.962,69.459,-102.293,69.52,-102.471,69.487,-102.49,69.417,-102.833,69.398,-103.183,69.102,-103.242,69.189,-103.01,69.259,-102.999,69.498,-103.068,69.547,-103.131,69.425,-103.17,69.583,-103.47,69.618,-103.455,69.707,-103.284,69.733,-103.229,69.648,-102.787,69.529,-102.478,69.598,-102.474,69.701,-102.666,69.772,-102.499,69.751,-102.376,69.833,-102.33,69.76,-102.276,69.811,-102.328,69.848,-102.217,69.848,-102.207,69.928,-102.036,69.844,-102.044,69.959,-101.89,69.692,-101.854,69.756,-101.649,69.65,-101.437,69.938,-101.433,69.796,-101.263,69.667,-100.919,69.682,-100.847,69.882,-100.94,69.939,-100.87,69.963,-101.002,70.205,-101.216,70.186,-101.191,70.131,-101.369,70.207,-101.316,70.154,-101.502,70.114,-101.639,70.215,-101.527,70.272,-101.682,70.316,-101.874,70.258,-102.129,70.354,-102.138,70.3,-102.218,70.4,-102.845,70.512,-102.804,70.603,-103.05,70.695,-103.116,70.667,-103.094,70.586,-102.882,70.503,-103.286,70.607,-103.479,70.567,-103.749,70.736,-104.041,70.754,-104.058,70.922,-104.631,71.139,-104.384,71.182,-104.479,71.347,-104.266,71.356,-104.368,71.546,-104.302,71.598,-104.826,71.897,-104.875,71.966,-104.793,71.901,-104.782,71.968,-104.982,72.079,-104.953,72.193,-105.29,72.477,-105.189,72.45,-105.335,72.631,-105.281,72.66,-105.45,72.699,-105.386,72.73,-105.508,72.802,-105.366,72.733,-105.319,72.782,-105.541,72.869,-105.296,72.828,-105.457,72.922,-105.623,72.955,-105.563,72.895,-105.648,72.902,-105.83,73.049,-106.2,73.083,-106.442,73.226,-106.853,73.314,-107.094,73.236,-106.891,73.154,-107.988,73.352,-108.1,73.271,-107.856,73.191,-108.245,73.175,-108.262,73.124,-108.095,73.053,-108.206,73.044,-108.082,72.98,-108.121,72.882,-107.921,72.639,-107.99,72.635,-107.828,72.579,-107.941,72.509,-107.75,72.456,-107.862,72.436,-107.704,72.315,-107.837,72.31,-107.758,72.15,-107.581,72.126,-107.662,72.121,-107.574,72.081,-107.594,72.018,-107.235,71.903,-107.466,71.889,-107.245,71.815,-107.816,71.727,-107.709,71.638,-107.789,71.629,-107.744,71.578,-107.978,71.618,-108.014,71.724,-108.219,71.729,-108.146,71.763,-108.268,71.792,-108.28,71.874,-108.151,71.964,-108.333,71.938,-108.255,71.951,-108.395,72.009,-108.38,72.126,-108.443,72.123,-108.366,72.161,-108.496,72.14,-108.632,72.309,-108.583,72.552,-109.008,72.571,-108.999,72.652,-108.883,72.67,-109.118,72.763,-109.358,72.746,-109.717,72.89,-109.603,72.922,-109.901,72.976,-110.723,72.974,-110.505,72.847,-110.161,72.813,-110.131,72.771,-110.23,72.729,-110.053,72.764,-109.734,72.724,-109.851,72.698,-109.793,72.66,-110.059,72.731,-110.286,72.675,-109.897,72.598,-109.762,72.5,-110.235,72.56,-109.743,72.43,-110.034,72.431,-110.317,72.559,-110.519,72.515,-110.292,72.424,-110.653,72.519,-110.44,72.553,-110.686,72.576,-110.816,72.509,-110.736,72.473,-110.932,72.489,-111.069,72.399,-110.996,72.284,-111.355,72.34,-111.224,72.47,-111.589,72.366,-111.384,72.352,-111.457,72.317,-111.682,72.267,-111.738,72.31,-111.583,72.333,-111.67,72.359,-112.068,72.263,-111.975,72.357,-111.433,72.466,-111.389,72.494,-111.469,72.504,-111.18,72.627,-111.191,72.713,-111.284,72.686,-111.184,72.727,-112.986,73.011,-113.232,72.989,-113.603,72.775,-113.398,72.743,-113.424,72.666,-113.724,72.622,-113.484,72.713,-114.804,72.538,-114.195,72.698,-114.349,72.741,-114.211,72.802,-113.933,72.823,-114.027,72.969,-113.943,73.03,-113.943,73.164,-114.196,73.325,-114.513,73.378,-117.315,72.927,-118.155,72.644,-118.496,72.508,-118.549,72.404,-118.046,72.273,-118.583,72.168,-119.052,71.92,-119.123,71.801,-119.073,71.667,-118.896,71.584,-118.828,71.667,-118.486,71.66,-118.385,71.627,-118.413,71.579,-118.165,71.606,-118.161,71.669,-117.67,71.681,-117.957,71.617,-117.66,71.584,-117.809,71.555,-117.738,71.536,-118.056,71.552,-118.306,71.472,-118.153,71.382,-117.507,71.386,-117.475,71.44,-117.639,71.474,-117.558,71.51,-117.327,71.47,-117.415,71.384,-115.601,71.57,-115.429,71.546,-115.549,71.513,-115.453,71.494,-115.038,71.535,-115.347,71.457,-115.753,71.506,-116.204,71.431,-115.7,71.38,-117.809,71.168,-118.408,71.003,-118.2,70.853,-117.657,70.71,-117.709,70.654,-117.555,70.605,-116.284,70.642,-115.573,70.574,-113.965,70.72,-112.896,70.565],[-106.389,73.38,-105.238,72.85,-105.176,72.878,-105.31,72.927,-105.185,72.916,-105.334,72.963,-105.143,72.945,-104.694,73.185,-104.521,73.337,-104.445,73.548,-105.073,73.75,-105.498,73.771,-106.559,73.706,-107.01,73.488,-106.389,73.38],[-98.821,68.902,-98.817,68.838,-98.451,68.74,-98.327,68.876,-98.086,68.681,-97.764,68.682,-97.543,68.568,-97.122,68.515,-97.043,68.541,-97.081,68.603,-96.53,68.453,-96.259,68.469,-95.953,68.64,-95.832,68.607,-95.758,68.759,-95.537,68.769,-95.524,68.674,-95.157,68.869,-95.799,68.869,-95.819,68.984,-95.963,69.058,-95.885,69.092,-95.988,69.228,-96.125,69.184,-96.059,69.128,-96.135,69.024,-96.233,69.168,-96.133,69.194,-96.141,69.281,-96.262,69.349,-96.759,69.514,-96.853,69.483,-97.256,69.708,-97.382,69.582,-97.48,69.674,-97.309,69.7,-97.383,69.758,-97.961,69.906,-98.247,69.761,-98.339,69.612,-97.973,69.438,-98.536,69.593,-98.563,69.529,-98.378,69.448,-98.553,69.492,-98.579,69.435,-98.341,69.292,-98.614,69.307,-98.7,69.208,-98.625,69.187,-99.23,69.149,-99.188,69.116,-99.173,69.058,-99.271,69.17,-99.41,69.15,-99.543,69.034,-99.408,68.9,-99.17,68.835,-99.021,68.873,-99.232,68.974,-98.765,68.893,-98.821,68.902],[-100.327,68.746,-100.134,68.796,-100.192,68.855,-100.118,68.91,-100.369,69.046,-100.618,68.998,-100.551,68.947,-100.609,68.93,-100.602,68.774,-100.438,68.814,-100.394,68.786,-100.481,68.771,-100.327,68.746],[-101.677,68.669,-101.699,68.792,-101.889,68.83,-102.336,68.701,-101.814,68.562,-101.822,68.651,-101.677,68.669],[-104.845,68.446,-104.56,68.396,-104.392,68.464,-104.614,68.572,-105.076,68.556,-104.845,68.446],[-95.744,69.631,-95.904,69.603,-95.856,69.579,-95.964,69.511,-95.9,69.428,-95.98,69.372,-95.804,69.336,-95.858,69.375,-95.716,69.474,-95.801,69.587,-95.629,69.533,-95.583,69.407,-95.721,69.376,-95.615,69.329,-95.449,69.351,-95.547,69.398,-95.404,69.387,-95.322,69.505,-95.744,69.631],[-96.109,69.352,-96.05,69.494,-96.146,69.589,-96.288,69.529,-96.701,69.588,-96.109,69.352],[-99.141,73.95,-99.429,73.909,-98.762,73.814,-98.732,73.885,-98.064,73.899,-97.619,74.077,-97.998,74.113,-99.141,73.95],[-97.098,73.106,-97.079,72.988,-96.929,72.926,-96.668,72.941,-96.541,73.044,-96.793,73.194,-97.098,73.106],[-112.852,74.404,-111.677,74.5,-110.558,74.715,-110.63,74.799,-109.53,74.862,-108.75,75.08,-108.474,74.975,-108.794,74.99,-108.344,74.918,-107.928,74.932,-108.016,74.948,-107.716,75.037,-107.755,75.1,-107.678,75.085,-107.723,75.026,-107.659,74.976,-107.199,74.913,-106.004,75.051,-106.081,75.103,-105.804,75.187,-105.914,75.224,-105.586,75.444,-105.664,75.449,-105.79,75.481,-105.575,75.449,-105.711,75.493,-105.457,75.56,-105.351,75.681,-105.549,75.929,-106.315,76.062,-106.638,76.064,-106.872,75.978,-106.876,75.836,-106.57,75.804,-106.865,75.774,-106.842,75.65,-107.0,75.775,-106.937,75.798,-107.112,75.853,-107.032,75.901,-107.735,75.89,-107.952,75.79,-107.813,75.754,-108.0,75.785,-107.609,76.0,-107.862,76.07,-108.486,76.04,-108.344,76.088,-108.442,76.097,-108.351,76.179,-108.103,76.213,-108.061,76.232,-108.271,76.225,-108.04,76.279,-108.26,76.393,-108.573,76.417,-108.449,76.466,-108.622,76.524,-108.488,76.563,-108.714,76.61,-108.406,76.739,-108.71,76.774,-108.615,76.816,-108.667,76.836,-109.116,76.827,-109.827,76.543,-109.676,76.531,-109.761,76.487,-110.391,76.43,-110.356,76.374,-110.421,76.357,-109.269,76.106,-109.377,76.074,-109.242,76.025,-110.049,75.901,-108.802,75.701,-108.922,75.677,-108.772,75.662,-108.887,75.595,-108.833,75.565,-109.054,75.556,-108.765,75.523,-108.907,75.518,-108.829,75.489,-110.458,75.578,-111.251,75.521,-111.403,75.622,-111.342,75.641,-111.4,75.716,-111.315,75.717,-111.612,75.817,-111.441,75.848,-112.228,75.815,-111.686,75.92,-112.407,76.047,-112.509,76.109,-112.407,76.182,-112.978,76.273,-113.98,76.197,-114.042,76.236,-113.966,76.27,-114.179,76.344,-114.056,76.418,-114.169,76.477,-114.892,76.523,-115.485,76.461,-115.877,76.36,-115.798,76.329,-115.905,76.29,-114.629,76.171,-116.209,76.198,-116.607,76.122,-116.663,76.037,-116.415,75.98,-116.675,75.973,-116.732,75.905,-114.782,75.896,-116.811,75.805,-117.22,75.598,-116.362,75.567,-114.999,75.705,-115.999,75.49,-117.256,75.474,-117.666,75.257,-116.684,75.119,-116.21,75.215,-116.298,75.113,-116.006,75.127,-116.251,75.065,-115.687,74.97,-115.511,75.032,-115.669,75.146,-115.388,75.099,-115.23,75.187,-115.177,74.996,-115.034,74.964,-114.379,75.091,-114.278,75.174,-114.613,75.281,-114.153,75.227,-114.025,75.365,-114.098,75.474,-113.867,75.376,-113.306,75.421,-113.8,75.326,-113.916,75.18,-113.632,75.191,-113.913,75.147,-113.912,75.053,-112.681,75.141,-112.728,75.21,-112.651,75.286,-112.543,75.183,-112.288,75.207,-112.45,75.158,-112.397,75.128,-111.738,75.192,-111.553,75.143,-111.601,75.166,-111.214,75.271,-110.884,75.243,-111.62,75.0,-112.848,74.983,-113.312,74.88,-113.191,74.848,-114.084,74.787,-114.443,74.681,-113.69,74.453,-112.852,74.404],[-118.545,75.508,-117.914,75.739,-117.929,75.791,-117.444,76.096,-117.715,76.127,-118.587,75.926,-119.406,75.618,-118.545,75.508],[-114.024,76.707,-113.612,76.714,-113.442,76.792,-114.047,76.898,-114.655,76.864,-114.859,76.782,-114.024,76.707],[-100.49,76.764,-100.919,76.741,-101.701,76.593,-101.347,76.557,-100.256,76.745,-100.49,76.764],[-99.803,75.655,-99.857,75.621,-99.627,75.61,-100.298,75.523,-99.656,75.511,-100.715,75.434,-100.411,75.385,-100.78,75.355,-100.188,75.325,-100.369,75.261,-99.911,75.23,-100.542,75.196,-100.376,75.163,-100.362,75.026,-99.56,74.975,-99.402,74.991,-99.471,75.027,-99.288,75.128,-99.393,74.997,-98.832,75.062,-98.869,75.016,-98.655,74.99,-97.957,75.03,-97.955,75.113,-98.133,75.151,-98.066,75.23,-97.85,75.109,-97.568,75.145,-97.808,75.203,-97.729,75.209,-97.878,75.31,-98.011,75.283,-97.885,75.329,-98.062,75.281,-98.17,75.333,-97.86,75.358,-98.048,75.415,-97.789,75.433,-98.064,75.494,-97.724,75.468,-97.982,75.561,-97.698,75.57,-97.26,75.402,-97.385,75.455,-97.367,75.691,-97.982,75.737,-97.545,75.872,-97.651,75.985,-97.476,76.176,-97.791,76.311,-97.651,76.415,-97.672,76.489,-98.357,76.572,-98.137,76.585,-98.502,76.685,-98.862,76.669,-98.449,76.651,-98.547,76.649,-98.476,76.634,-98.593,76.617,-98.554,76.584,-99.071,76.578,-98.831,76.432,-99.133,76.459,-99.068,76.394,-99.247,76.447,-99.122,76.502,-99.664,76.632,-100.263,76.642,-100.991,76.498,-100.586,76.366,-100.062,76.397,-100.165,76.364,-99.826,76.281,-100.183,76.323,-100.023,76.276,-100.503,76.261,-100.434,76.217,-99.411,76.159,-100.236,76.132,-99.825,75.952,-99.878,75.943,-99.436,75.966,-99.909,75.876,-100.19,75.915,-100.067,75.946,-100.301,76.056,-100.597,76.081,-100.37,76.119,-100.701,76.088,-100.6,76.136,-101.124,76.24,-101.052,76.33,-101.281,76.414,-101.897,76.449,-102.037,76.399,-101.949,76.362,-102.199,76.29,-102.043,76.266,-102.167,76.235,-101.348,76.249,-101.92,76.101,-101.667,75.978,-101.246,76.014,-101.604,75.93,-101.357,75.855,-101.359,75.778,-100.91,75.812,-101.298,75.75,-101.838,75.91,-102.15,75.884,-102.359,75.782,-102.1,75.792,-102.251,75.742,-102.043,75.699,-102.348,75.736,-102.709,75.632,-102.6,75.621,-102.866,75.634,-102.62,75.499,-101.479,75.61,-98.848,75.705,-99.803,75.655],[-102.86,75.755,-102.554,75.781,-102.438,75.875,-101.97,75.954,-102.139,76.001,-102.885,75.929,-103.385,75.771,-102.86,75.755],[-103.649,75.898,-103.222,75.923,-102.265,76.033,-102.619,76.091,-103.947,75.95,-103.649,75.898],[-103.845,76.043,-102.824,76.092,-102.505,76.215,-102.892,76.324,-104.477,76.164,-104.383,76.093,-103.845,76.043],[-103.684,76.312,-102.993,76.446,-103.836,76.629,-104.072,76.571,-103.912,76.642,-104.116,76.681,-104.676,76.564,-104.361,76.457,-104.46,76.393,-104.03,76.358,-104.372,76.338,-103.684,76.312],[-94.501,75.989,-94.838,75.951,-94.868,75.919,-94.707,75.76,-94.283,75.766,-94.501,75.989],[-96.397,75.644,-96.367,75.588,-97.011,75.46,-96.814,75.357,-96.455,75.463,-96.518,75.525,-96.398,75.58,-96.314,75.564,-96.418,75.527,-96.191,75.459,-95.907,75.573,-96.397,75.644],[-94.926,75.64,-95.829,75.475,-95.696,75.409,-96.137,75.413,-96.175,75.39,-95.944,75.409,-95.767,75.373,-95.967,75.384,-96.167,75.286,-95.903,75.301,-96.115,75.275,-95.979,75.249,-96.441,75.206,-96.625,74.993,-96.367,75.009,-96.387,74.929,-96.234,74.911,-96.08,75.034,-96.134,74.952,-96.02,74.963,-96.128,74.925,-95.838,74.851,-95.901,74.828,-95.494,74.824,-95.523,74.747,-95.272,74.808,-95.048,74.752,-95.077,74.683,-94.638,74.633,-93.723,74.643,-93.444,74.727,-93.546,74.748,-93.422,74.772,-93.416,74.97,-93.638,75.047,-93.485,75.08,-93.581,75.201,-93.499,75.276,-93.822,75.313,-93.637,75.321,-94.308,75.59,-94.926,75.64],[-104.169,75.037,-103.582,75.165,-103.751,75.234,-103.807,75.367,-104.222,75.445,-104.5,75.416,-104.914,75.145,-104.169,75.037],[-104.888,77.136,-104.454,77.14,-104.349,77.239,-104.75,77.42,-105.015,77.409,-104.992,77.528,-105.404,77.695,-106.116,77.733,-105.839,77.624,-105.872,77.549,-105.65,77.516,-105.768,77.466,-105.306,77.223,-105.44,77.22,-104.888,77.136],[-112.029,77.327,-110.183,77.518,-110.057,77.569,-110.146,77.599,-110.053,77.623,-110.112,77.685,-110.055,77.776,-110.961,77.824,-109.719,77.951,-109.557,78.079,-111.251,78.094,-113.173,77.926,-113.311,77.846,-113.164,77.734,-113.262,77.67,-113.065,77.628,-113.171,77.616,-113.194,77.524,-112.029,77.327],[-101.753,77.909,-102.451,77.88,-102.52,77.792,-101.801,77.674,-100.89,77.735,-101.753,77.909],[-96.847,77.788,-96.534,77.849,-96.75,77.877,-96.561,77.906,-96.233,77.86,-95.108,77.95,-94.89,78.11,-95.437,78.233,-94.822,78.364,-95.539,78.514,-96.295,78.529,-96.158,78.623,-97.511,78.802,-98.397,78.771,-98.31,78.712,-98.347,78.65,-98.035,78.545,-98.412,78.506,-98.349,78.45,-97.923,78.411,-98.101,78.376,-97.89,78.406,-98.079,78.359,-97.541,78.384,-98.064,78.354,-97.663,78.328,-98.058,78.307,-97.774,78.242,-97.952,78.225,-97.315,78.212,-96.914,78.101,-96.914,78.111,-96.804,78.154,-96.878,78.081,-97.795,78.035,-97.01,77.918,-97.131,77.804,-96.847,77.788],[-95.476,77.813,-96.259,77.693,-96.327,77.604,-95.824,77.464,-93.785,77.435,-93.855,77.457,-93.553,77.446,-93.39,77.629,-93.081,77.666,-93.641,77.785,-95.381,77.74,-95.476,77.813],[-90.853,77.657,-90.859,77.626,-90.953,77.582,-90.871,77.638,-91.25,77.603,-91.216,77.39,-90.33,77.196,-89.638,77.329,-89.853,77.353,-89.664,77.351,-89.815,77.501,-90.35,77.588,-90.208,77.612,-90.853,77.657],[-89.994,76.842,-90.473,76.802,-90.597,76.743,-90.082,76.483,-89.674,76.508,-89.876,76.608,-89.702,76.741,-89.994,76.842],[-86.011,78.913,-85.281,78.984,-85.157,79.023,-85.836,79.07,-86.279,79.016,-86.498,78.9,-86.011,78.913],[-94.95,80.672,-95.17,80.701,-96.095,80.672,-95.187,80.61,-94.95,80.672],[-86.663,68.305,-86.983,68.091,-86.847,68.021,-86.959,67.909,-86.811,67.774,-86.57,67.725,-86.387,67.843,-86.351,67.957,-86.41,68.204,-86.663,68.305],[-83.994,66.039,-84.395,66.143,-84.463,66.066,-84.149,65.969,-84.065,65.803,-84.124,65.762,-83.664,65.745,-83.805,65.641,-83.6,65.696,-83.317,65.615,-83.263,65.631,-83.363,65.652,-83.177,65.707,-83.516,65.698,-83.78,65.804,-83.641,65.907,-83.994,66.039],[-84.61,65.581,-84.559,65.683,-84.903,65.955,-84.84,65.974,-85.157,65.996,-85.103,65.747,-84.729,65.542,-84.61,65.581]]}],"names":{"ar":"الممر الشمالي الغربي","de":"Nordwestpassage","es":"paso del Noroeste","fr":"passage du Nord-Ouest","he":"המעבר הצפון-מערבי","hu":"Északnyugati átjáró","it":"passaggio a nord-ovest","nl":"Noordwestelijke Doorvaart","pt":"Passagem do Noroeste","zh":"西北航道"}},{"name":"Kara Sea","bbox":[54.931,64.433,107.606,81.268],"area":302.1219,"polygons":[{"outer":[103.372,77.619,103.234,77.622,102.995,77.569,103.036,77.568,103.026,77.567,103.05,77.566,103.102,77.556,103.105,77.554,103.021,77.563,103.045,77.545,102.686,77.483,102.644,77.491,102.407,77.37,102.238,77.35,102.305,77.324,102.215,77.326,102.148,77.34,102.086,77.34,101.341,77.089,101.281,77.05,101.392,77.038,101.307,77.014,101.339,76.969,101.196,76.949,101.29,76.928,101.072,76.961,100.927,76.854,101.082,76.749,101.185,76.756,101.273,76.742,101.557,76.642,101.188,76.678,101.111,76.55,100.961,76.524,101.791,76.462,102.025,76.389,102.273,76.371,102.357,76.347,102.657,76.402,102.398,76.342,102.426,76.331,102.498,76.338,102.619,76.328,102.667,76.318,102.433,76.329,102.414,76.331,102.372,76.346,102.396,76.351,102.355,76.343,102.271,76.346,102.578,76.293,102.61,76.294,102.625,76.305,102.686,76.315,102.762,76.319,102.789,76.33,102.795,76.321,102.807,76.33,102.848,76.337,102.992,76.33,103.017,76.355,103.044,76.364,103.091,76.371,103.249,76.383,103.512,76.373,103.529,76.378,103.525,76.39,103.539,76.395,103.612,76.404,103.66,76.406,103.679,76.404,103.519,76.367,103.254,76.38,103.191,76.366,103.076,76.368,103.041,76.362,102.996,76.329,102.584,76.29,101.105,76.426,100.956,76.519,100.331,76.447,98.884,76.483,99.103,76.332,99.627,76.26,99.598,76.208,99.896,76.088,99.861,76.037,99.958,75.971,99.989,75.957,100.111,75.966,100.141,75.965,100.146,75.964,100.153,75.961,100.15,75.958,100.139,75.954,100.115,75.95,100.115,75.947,100.164,75.946,100.112,75.947,100.129,75.965,99.978,75.954,99.874,75.868,99.725,75.829,99.935,75.795,99.284,75.743,99.353,75.716,99.356,75.683,99.168,75.534,100.0,75.421,99.777,75.414,99.832,75.398,99.826,75.34,99.868,75.339,100.064,75.301,100.117,75.28,100.133,75.226,100.038,75.209,100.169,75.185,100.227,75.154,100.224,75.101,100.632,75.058,100.625,74.903,100.415,74.881,100.652,74.879,100.598,74.771,101.386,74.607,103.082,74.612,104.489,74.876,104.1,74.85,104.08,74.871,103.873,74.912,103.849,74.928,104.188,75.021,104.139,75.035,104.207,75.071,104.164,75.122,104.222,75.141,104.386,75.115,104.533,75.11,104.625,75.059,104.718,74.944,104.937,74.893,105.363,74.843,105.798,74.852,106.558,74.822,106.447,74.849,106.438,74.883,106.44,74.89,106.531,74.89,106.576,74.956,106.399,75.009,106.537,75.05,106.433,75.038,106.335,75.052,106.276,75.041,106.235,75.051,106.191,75.089,106.254,75.16,106.156,75.19,106.061,75.176,105.991,75.181,106.02,75.168,106.016,75.163,105.977,75.156,105.909,75.152,105.993,75.184,106.081,75.189,106.045,75.192,106.03,75.204,106.04,75.214,106.021,75.215,105.963,75.231,105.912,75.251,105.898,75.268,105.682,75.296,105.665,75.312,105.687,75.329,105.711,75.332,105.659,75.345,105.72,75.343,105.715,75.298,105.924,75.276,105.948,75.25,105.915,75.252,106.043,75.216,106.06,75.195,106.121,75.216,106.247,75.173,106.273,75.136,106.273,75.108,106.215,75.091,106.245,75.088,106.275,75.063,106.239,75.053,106.363,75.064,106.548,75.05,106.55,75.045,106.529,75.032,106.418,75.009,106.567,74.967,106.676,75.057,107.386,75.168,107.44,75.173,107.49,75.173,107.519,75.172,107.555,75.189,107.547,75.21,107.55,75.213,107.586,75.221,107.571,75.23,107.572,75.241,107.601,75.252,107.517,75.289,107.471,75.286,107.41,75.293,107.388,75.29,107.314,75.289,107.288,75.294,107.261,75.294,107.222,75.301,107.218,75.305,107.24,75.312,107.245,75.329,107.259,75.331,107.252,75.335,107.278,75.342,107.274,75.349,107.277,75.354,107.288,75.346,107.277,75.354,107.277,75.365,107.284,75.369,107.29,75.345,107.262,75.337,107.259,75.325,107.244,75.321,107.246,75.312,107.222,75.303,107.316,75.29,107.412,75.294,107.526,75.289,107.537,75.286,107.556,75.275,107.571,75.281,107.604,75.261,107.606,75.249,107.592,75.22,107.551,75.209,107.578,75.198,107.586,75.189,107.541,75.171,107.372,75.163,107.082,75.092,107.075,75.097,107.034,75.094,107.023,75.09,107.0,75.092,106.994,75.088,107.01,75.087,107.045,75.093,107.064,75.092,106.875,75.063,106.745,75.058,106.842,75.043,106.844,75.039,106.818,75.036,106.84,75.027,106.909,75.038,106.933,75.034,106.953,75.022,106.997,75.026,107.004,75.025,107.016,75.017,107.047,75.027,107.138,75.033,107.163,75.027,107.157,75.021,107.17,75.022,107.162,75.019,107.019,75.015,106.941,75.023,106.914,75.036,106.847,75.025,106.686,75.051,106.545,74.888,106.592,74.833,106.537,74.796,106.576,74.798,106.595,74.794,106.662,74.744,106.64,74.736,106.597,74.729,106.638,74.726,106.692,74.714,106.688,74.68,106.674,74.678,106.642,74.686,106.626,74.683,106.634,74.659,106.684,74.669,106.704,74.668,106.712,74.661,106.697,74.654,106.705,74.644,106.719,74.641,106.721,74.637,106.63,74.657,106.621,74.683,106.687,74.714,106.597,74.727,106.594,74.73,106.656,74.743,106.531,74.778,106.587,74.793,106.53,74.795,106.545,74.804,105.809,74.837,105.761,74.823,105.724,74.817,105.189,74.754,104.785,74.793,104.781,74.76,104.753,74.78,104.659,74.752,104.174,74.693,104.025,74.732,104.008,74.602,103.719,74.648,102.73,74.538,102.854,74.479,102.894,74.443,102.893,74.431,102.887,74.431,102.89,74.423,102.882,74.426,102.886,74.421,103.117,74.399,103.116,74.392,103.059,74.384,103.322,74.341,103.314,74.32,103.345,74.333,103.378,74.334,103.602,74.289,103.611,74.284,103.607,74.279,103.574,74.271,103.582,74.26,103.676,74.26,103.668,74.238,103.682,74.238,103.689,74.246,103.705,74.248,103.766,74.242,103.806,74.232,103.794,74.226,103.731,74.227,103.75,74.218,103.849,74.22,103.852,74.201,103.883,74.209,104.0,74.207,103.922,74.197,103.892,74.208,103.877,74.201,103.85,74.201,103.837,74.204,103.954,74.168,103.956,74.165,103.937,74.158,103.955,74.149,103.957,74.159,103.992,74.158,104.003,74.148,104.064,74.132,104.076,74.126,104.071,74.122,104.029,74.113,104.016,74.113,104.011,74.114,104.007,74.121,104.0,74.116,104.007,74.113,104.01,74.106,104.008,74.104,103.965,74.103,104.001,74.095,104.003,74.078,103.957,74.057,103.888,74.05,103.867,74.05,103.856,74.051,103.849,74.053,103.842,74.062,103.962,74.059,103.962,74.103,104.005,74.122,104.073,74.125,103.95,74.148,103.82,74.206,103.845,74.219,103.779,74.212,103.726,74.222,103.734,74.23,103.796,74.234,103.666,74.237,103.635,74.248,103.667,74.26,103.583,74.255,103.568,74.27,103.594,74.288,103.308,74.319,103.312,74.34,103.291,74.343,103.258,74.334,103.236,74.333,103.167,74.364,103.088,74.371,103.06,74.377,103.047,74.385,103.05,74.39,103.075,74.394,103.105,74.393,103.108,74.398,102.887,74.419,102.808,74.466,102.842,74.464,102.839,74.467,102.818,74.466,102.806,74.471,102.821,74.483,102.741,74.469,102.768,74.478,102.626,74.501,102.68,74.49,102.721,74.471,102.507,74.534,102.023,74.528,101.924,74.473,102.152,74.325,102.127,74.264,102.254,74.213,102.13,74.235,102.086,74.198,101.967,74.198,102.072,74.217,101.333,74.368,101.35,74.492,100.6,74.408,101.22,74.392,101.097,74.291,101.602,73.977,101.512,73.932,101.63,73.856,101.592,73.821,100.976,73.85,101.275,74.014,100.813,74.308,100.647,74.301,100.578,74.273,100.396,74.339,100.248,74.325,99.977,74.338,99.877,74.317,99.831,74.267,99.789,74.258,99.92,74.234,99.582,74.197,99.664,74.182,99.669,74.157,98.709,74.095,98.28,74.156,97.985,74.067,97.807,74.098,97.775,74.076,97.674,74.053,97.694,73.97,97.632,73.939,97.674,73.982,97.564,74.041,97.573,74.04,97.569,74.044,97.58,74.05,97.599,74.054,97.655,74.058,97.799,74.105,97.978,74.133,98.051,74.103,98.081,74.147,98.295,74.172,98.709,74.103,99.412,74.143,99.545,74.245,99.59,74.256,99.646,74.258,99.685,74.302,99.716,74.307,99.685,74.334,99.711,74.37,99.181,74.418,99.708,74.444,100.099,74.361,100.321,74.487,100.269,74.494,100.533,74.519,100.498,74.568,100.641,74.592,100.409,74.709,100.468,74.793,100.28,74.819,100.163,74.88,100.511,74.913,100.608,75.033,100.493,75.065,100.384,75.044,100.318,75.056,100.123,75.043,100.08,75.052,100.147,75.091,100.063,75.124,100.204,75.157,100.029,75.204,100.035,75.215,100.123,75.227,100.099,75.278,99.684,75.351,99.802,75.389,99.753,75.403,99.765,75.414,99.806,75.426,99.795,75.442,99.264,75.516,99.28,75.496,99.254,75.482,99.23,75.476,99.277,75.447,99.289,75.415,99.109,75.375,99.001,75.375,98.941,75.384,98.851,75.376,98.748,75.379,98.713,75.354,98.587,75.336,98.557,75.336,98.54,75.339,98.489,75.356,98.367,75.352,98.181,75.411,98.157,75.404,98.172,75.393,98.168,75.388,98.152,75.38,98.155,75.368,98.143,75.362,97.999,75.323,97.949,75.319,97.892,75.301,97.874,75.297,97.86,75.295,97.816,75.297,97.753,75.31,97.734,75.31,97.7,75.308,97.69,75.309,97.592,75.337,97.529,75.342,97.482,75.34,97.367,75.347,97.298,75.356,97.288,75.366,97.314,75.376,97.282,75.382,97.184,75.354,97.075,75.352,97.199,75.345,97.217,75.34,97.237,75.33,97.265,75.329,97.271,75.327,97.27,75.325,96.692,75.332,97.292,75.384,97.317,75.378,97.303,75.357,97.593,75.339,97.854,75.296,97.948,75.321,98.005,75.326,98.139,75.362,98.153,75.404,98.187,75.413,98.368,75.353,98.49,75.358,98.57,75.337,98.737,75.38,99.286,75.417,99.223,75.477,99.27,75.5,99.156,75.531,99.149,75.576,99.323,75.715,99.267,75.732,99.192,75.731,99.101,75.748,99.524,75.795,99.525,75.848,99.688,75.91,99.678,75.967,99.862,76.019,99.45,76.053,99.408,76.062,99.402,76.068,99.592,76.054,99.449,76.096,99.618,76.154,99.266,76.12,99.191,76.142,99.382,76.192,98.775,76.207,98.881,76.25,98.829,76.256,97.953,76.115,98.294,76.085,98.231,76.071,97.56,76.037,97.868,75.969,97.173,75.925,97.185,75.934,97.274,75.954,97.18,75.984,97.365,76.025,96.983,75.961,96.841,75.873,96.361,75.847,96.71,75.969,96.422,76.008,95.886,75.9,95.815,75.854,95.868,75.809,95.766,75.757,95.849,75.816,95.604,75.877,96.157,76.047,96.004,76.064,96.277,76.093,95.455,76.162,95.077,76.09,94.749,76.16,94.545,76.148,94.825,76.109,93.729,76.122,94.053,76.053,93.438,76.01,93.48,76.042,93.361,76.067,93.509,76.098,93.27,76.116,93.177,76.095,93.34,76.076,93.127,76.006,93.044,76.034,93.096,76.066,92.937,76.052,92.998,75.996,92.831,75.961,92.943,75.918,92.871,75.887,93.09,75.884,93.152,75.901,93.06,75.923,92.995,75.924,92.953,75.916,92.946,75.922,93.369,75.984,93.28,75.936,93.549,75.87,93.493,75.921,94.199,75.937,94.182,75.92,93.864,75.878,93.886,75.868,91.73,75.731,91.637,75.686,91.689,75.668,91.662,75.647,91.722,75.618,91.715,75.613,91.241,75.667,90.981,75.566,90.919,75.665,89.94,75.543,90.094,75.513,90.25,75.465,90.166,75.477,90.148,75.493,90.046,75.505,90.03,75.514,89.45,75.425,89.252,75.489,88.703,75.387,89.038,75.412,89.122,75.379,89.117,75.355,89.094,75.349,89.201,75.357,89.256,75.334,89.387,75.312,89.393,75.309,89.086,75.347,89.049,75.405,88.911,75.4,88.996,75.392,88.564,75.295,88.628,75.249,88.693,75.241,88.691,75.239,88.719,75.232,88.72,75.227,88.731,75.226,88.737,75.221,88.618,75.242,88.556,75.272,88.571,75.272,88.5,75.281,88.277,75.202,88.336,75.155,88.16,75.105,88.23,75.093,88.201,75.089,88.231,75.077,88.234,75.072,88.226,75.058,88.186,75.036,88.23,75.074,88.151,75.1,88.135,75.113,88.139,75.122,88.195,75.14,88.188,75.143,87.881,75.077,86.978,75.128,86.907,75.107,86.897,75.1,86.901,75.089,87.815,75.015,87.562,74.954,87.682,74.915,87.625,74.901,87.4,74.948,87.477,74.974,87.424,75.002,87.192,74.982,87.391,74.944,87.078,74.854,87.075,74.719,86.746,74.701,86.984,74.671,86.869,74.646,86.976,74.599,86.705,74.61,86.745,74.655,86.584,74.66,86.658,74.716,86.354,74.699,86.252,74.75,86.408,74.778,85.9,74.85,86.17,74.774,85.75,74.652,86.772,74.563,86.574,74.513,86.818,74.429,87.143,74.48,87.043,74.417,87.056,74.41,87.099,74.406,87.114,74.402,87.16,74.382,87.194,74.381,87.241,74.361,87.341,74.374,87.284,74.404,87.311,74.42,87.284,74.437,87.301,74.444,87.426,74.452,87.439,74.449,87.454,74.444,87.473,74.449,87.483,74.45,87.498,74.45,87.523,74.448,87.519,74.454,87.529,74.458,87.588,74.459,87.531,74.458,87.527,74.447,87.457,74.441,87.29,74.439,87.411,74.381,87.32,74.354,87.241,74.358,87.037,74.393,87.1,74.3,87.051,74.276,86.972,74.284,86.998,74.361,86.855,74.307,86.694,74.317,86.989,74.409,86.644,74.407,86.722,74.433,86.563,74.478,86.065,74.431,86.29,74.409,86.315,74.322,86.12,74.401,85.908,74.36,86.725,74.229,86.798,74.18,86.723,74.129,86.908,74.063,87.569,74.133,87.47,74.153,87.504,74.153,87.572,74.142,87.572,74.134,87.555,74.11,87.602,74.111,87.302,74.06,87.534,74.057,87.558,74.047,87.58,74.055,87.596,74.057,87.646,74.051,87.642,74.06,87.655,74.064,87.67,74.064,87.692,74.059,87.656,74.064,87.652,74.052,87.636,74.049,87.596,74.056,87.566,74.046,87.546,74.048,87.536,74.056,87.519,74.052,87.659,74.028,87.651,74.026,87.657,74.025,87.661,74.021,87.65,74.016,87.651,74.008,87.642,74.004,87.366,74.043,87.733,73.977,87.622,73.981,87.748,73.931,87.858,73.817,87.851,73.807,87.773,73.783,87.723,73.746,87.861,73.655,87.858,73.649,87.834,73.638,87.862,73.628,87.869,73.617,87.899,73.615,87.907,73.614,87.907,73.612,87.877,73.607,87.765,73.684,87.713,73.748,87.608,73.728,87.536,73.738,87.5,73.757,87.461,73.726,87.269,73.655,87.271,73.652,87.293,73.65,87.299,73.647,87.302,73.637,87.295,73.625,87.357,73.624,87.361,73.616,87.356,73.611,86.217,73.555,85.88,73.466,85.976,73.416,85.865,73.4,85.896,73.341,86.402,73.258,86.867,73.07,86.765,73.069,86.888,72.936,87.434,73.018,87.481,73.017,87.599,73.002,87.694,73.028,87.78,73.033,87.93,73.072,88.281,73.103,88.132,73.082,88.244,73.087,88.291,73.1,88.353,73.165,88.569,73.206,89.7,73.283,89.611,73.235,90.138,73.179,90.606,73.291,91.05,73.278,91.047,73.303,91.097,73.327,91.169,73.341,91.303,73.36,91.286,73.383,91.215,73.38,91.194,73.383,91.17,73.395,91.176,73.401,91.194,73.406,91.288,73.415,91.302,73.424,91.239,73.427,91.197,73.433,91.177,73.44,91.174,73.447,91.249,73.467,91.341,73.471,91.367,73.489,91.325,73.495,91.315,73.522,91.234,73.542,91.061,73.527,91.028,73.534,91.014,73.542,91.026,73.548,91.072,73.552,91.086,73.569,91.113,73.579,91.116,73.589,91.18,73.613,91.21,73.617,91.195,73.619,91.253,73.634,91.29,73.64,91.17,73.656,91.154,73.66,91.147,73.665,91.15,73.671,91.183,73.678,91.189,73.689,91.205,73.697,91.217,73.717,91.23,73.725,91.211,73.731,91.196,73.741,91.178,73.765,91.093,73.764,90.999,73.772,90.961,73.783,90.863,73.782,90.852,73.784,90.85,73.792,90.838,73.795,90.811,73.793,90.804,73.782,90.76,73.771,90.745,73.769,90.728,73.771,90.724,73.774,90.735,73.779,90.779,73.785,90.734,73.785,90.713,73.79,90.721,73.797,90.751,73.801,90.749,73.804,90.72,73.805,90.714,73.797,90.703,73.797,90.695,73.798,90.707,73.797,90.715,73.805,90.739,73.806,90.749,73.805,90.754,73.803,90.75,73.799,90.723,73.796,90.722,73.791,90.782,73.786,90.775,73.781,90.73,73.776,90.746,73.77,90.8,73.783,90.808,73.794,90.848,73.795,90.842,73.798,90.856,73.808,90.876,73.813,91.029,73.813,91.041,73.818,91.033,73.827,91.041,73.83,91.086,73.83,91.114,73.827,91.15,73.839,91.181,73.844,91.272,73.85,91.287,73.862,91.315,73.869,91.335,73.871,91.405,73.873,91.324,73.869,91.278,73.85,91.109,73.825,91.042,73.83,91.045,73.816,91.033,73.812,90.847,73.8,90.86,73.79,90.855,73.785,90.865,73.783,90.868,73.792,90.879,73.783,90.975,73.785,90.993,73.782,91.003,73.773,91.162,73.769,91.18,73.766,91.186,73.763,91.235,73.725,91.186,73.678,91.15,73.665,91.162,73.659,91.22,73.656,91.286,73.644,91.295,73.639,91.253,73.632,91.203,73.613,91.176,73.609,91.075,73.551,91.02,73.541,91.242,73.542,91.322,73.521,91.375,73.488,91.347,73.47,91.181,73.444,91.303,73.429,91.308,73.421,91.296,73.414,91.183,73.393,91.297,73.382,91.311,73.357,91.06,73.277,90.843,73.259,90.935,73.24,91.088,73.162,91.237,72.884,91.793,72.664,91.782,72.655,91.676,72.65,91.756,72.638,91.77,72.616,91.819,72.609,91.888,72.595,91.857,72.564,91.913,72.551,91.979,72.524,91.999,72.506,92.0,72.497,91.671,72.643,91.671,72.65,91.706,72.66,91.787,72.663,91.358,72.82,91.372,72.789,91.338,72.766,91.425,72.747,91.463,72.727,91.348,72.668,91.37,72.661,91.452,72.655,91.537,72.643,91.561,72.637,91.542,72.641,91.664,72.58,91.67,72.552,91.54,72.498,91.458,72.487,90.974,72.537,90.941,72.499,91.114,72.458,91.102,72.42,91.075,72.409,90.988,72.391,90.869,72.379,90.755,72.346,90.55,72.349,90.612,72.276,90.495,72.245,90.504,72.181,90.266,72.087,90.158,71.951,90.069,71.896,90.018,71.891,89.959,71.811,90.028,71.746,89.989,71.7,89.941,71.686,90.002,71.735,89.968,71.752,89.942,71.804,89.987,71.878,89.947,71.881,89.957,71.931,90.042,71.959,90.064,71.963,90.093,71.963,90.073,71.982,90.108,72.023,90.473,72.179,90.48,72.242,90.596,72.286,90.531,72.349,91.096,72.443,90.921,72.488,90.903,72.518,90.926,72.533,91.079,72.562,91.45,72.551,91.434,72.571,91.365,72.577,91.234,72.615,91.13,72.663,91.128,72.669,91.145,72.675,91.247,72.695,91.216,72.713,91.202,72.764,91.236,72.789,91.078,72.855,91.18,72.899,91.058,73.112,91.054,73.173,90.77,73.259,90.212,73.17,90.102,73.164,89.956,73.169,90.0,73.15,89.898,73.184,89.687,73.201,89.566,73.23,88.887,73.169,88.565,73.199,88.367,73.165,88.27,73.015,88.303,73.026,88.32,73.028,88.332,73.028,88.344,73.026,88.354,73.023,88.357,73.02,88.343,73.012,88.304,73.0,88.293,72.982,88.613,72.9,88.623,72.892,88.613,72.874,88.678,72.872,88.691,72.87,88.75,72.824,88.78,72.834,88.836,72.838,88.858,72.836,88.876,72.824,88.862,72.803,88.92,72.784,88.897,72.757,88.917,72.751,88.939,72.761,88.951,72.761,88.967,72.758,89.011,72.727,89.058,72.741,89.079,72.74,89.171,72.708,89.198,72.675,89.193,72.648,89.446,72.666,89.385,72.64,89.194,72.646,88.894,72.756,88.916,72.784,88.858,72.802,88.858,72.834,88.749,72.823,88.47,72.908,88.287,72.983,88.301,73.001,88.318,73.011,88.354,73.02,88.333,73.027,88.267,73.01,88.192,73.001,87.906,72.998,87.86,73.008,87.795,72.985,87.711,72.973,87.211,72.947,87.173,72.924,86.376,72.91,86.19,72.981,85.94,72.973,85.755,72.976,85.685,72.927,85.417,72.823,85.49,72.761,85.569,72.621,85.566,72.61,85.524,72.595,85.529,72.584,85.643,72.555,85.732,72.517,85.778,72.487,85.804,72.447,85.783,72.407,85.891,72.385,85.854,72.378,85.874,72.334,85.836,72.302,85.561,72.24,85.555,72.204,85.457,72.138,85.474,72.131,85.491,72.13,85.499,72.126,85.52,72.108,85.512,72.089,85.531,72.079,85.509,72.084,85.495,72.128,85.488,72.125,85.457,72.133,85.453,72.142,85.552,72.238,85.277,72.28,85.258,72.284,85.246,72.289,85.254,72.297,85.232,72.288,85.209,72.289,85.204,72.292,85.256,72.298,85.559,72.241,85.834,72.305,85.869,72.335,85.771,72.408,85.77,72.487,85.518,72.584,85.517,72.585,85.52,72.596,85.562,72.621,85.371,72.838,85.756,72.978,86.14,72.993,86.509,72.921,86.768,72.984,86.676,73.136,86.302,73.199,86.326,73.26,86.069,73.257,85.906,73.295,85.697,73.397,85.817,73.394,85.856,73.476,85.682,73.45,85.669,73.456,85.648,73.463,85.648,73.464,86.199,73.588,86.4,73.603,86.392,73.62,86.484,73.657,86.675,73.678,86.882,73.746,86.9,73.761,86.948,73.782,87.004,73.8,87.037,73.802,87.095,73.801,87.142,73.845,86.115,73.86,85.564,73.798,85.526,73.774,85.566,73.764,85.549,73.761,85.565,73.749,85.561,73.744,85.574,73.74,85.573,73.738,85.283,73.671,84.746,73.709,84.848,73.739,84.773,73.755,83.846,73.668,83.914,73.644,83.921,73.634,83.917,73.629,83.908,73.627,82.391,73.647,82.44,73.637,82.465,73.6,82.374,73.538,82.386,73.536,82.421,73.518,82.438,73.52,82.484,73.509,82.483,73.506,82.475,73.504,82.477,73.51,82.422,73.516,82.376,73.533,82.382,73.536,82.373,73.537,82.374,73.547,82.381,73.554,82.371,73.59,82.453,73.604,82.355,73.65,80.53,73.554,80.567,73.464,80.7,73.501,80.659,73.413,80.675,73.428,80.725,73.435,80.773,73.433,80.782,73.435,80.796,73.434,80.368,73.291,80.726,73.241,80.685,73.207,80.977,73.19,81.031,73.162,80.974,73.189,80.65,73.189,80.586,73.204,80.608,73.194,80.396,73.189,80.322,73.165,80.635,73.129,80.511,73.081,80.803,73.03,80.88,72.949,80.836,72.789,80.919,72.806,80.932,72.804,80.692,72.704,80.733,72.621,80.924,72.615,80.767,72.508,80.898,72.424,81.533,72.334,81.551,72.357,81.705,72.383,81.552,72.344,81.898,72.311,81.933,72.342,81.953,72.348,82.008,72.354,82.004,72.362,82.014,72.363,82.01,72.353,81.951,72.346,81.929,72.338,81.907,72.303,82.218,72.261,82.125,72.282,82.442,72.371,82.558,72.382,82.64,72.371,82.651,72.38,82.595,72.396,82.593,72.402,82.599,72.404,82.672,72.404,82.594,72.401,82.654,72.381,82.651,72.373,82.633,72.368,82.442,72.37,82.357,72.301,82.292,72.286,82.252,72.28,82.374,72.169,82.26,72.101,82.53,72.086,82.51,72.074,82.443,72.08,82.411,72.066,82.711,71.872,83.236,71.866,83.426,71.812,83.428,71.731,83.441,71.739,83.465,71.74,83.486,71.737,83.508,71.74,83.541,71.76,83.63,71.783,83.464,71.72,83.672,71.635,83.672,71.562,83.16,71.24,83.269,71.126,83.154,71.108,83.426,70.996,83.813,70.519,83.752,70.497,83.801,70.441,83.568,70.341,83.749,70.332,83.768,70.328,83.799,70.303,83.562,70.336,82.99,70.307,83.182,70.146,83.151,70.078,83.507,70.026,83.669,69.878,83.988,69.798,84.029,69.776,84.066,69.746,84.017,69.743,84.087,69.71,84.366,69.685,84.403,69.611,84.815,69.777,85.491,69.777,85.722,69.684,86.158,69.402,86.217,69.378,86.235,69.391,86.253,69.394,86.293,69.385,86.352,69.388,86.425,69.376,86.446,69.367,86.436,69.356,86.484,69.335,86.492,69.322,86.526,69.316,86.532,69.313,86.502,69.272,86.515,69.27,86.518,69.281,86.526,69.283,86.542,69.284,86.552,69.275,86.551,69.263,86.647,69.259,86.686,69.252,86.694,69.248,86.543,69.263,86.54,69.283,86.521,69.27,86.512,69.269,86.503,69.27,86.413,69.377,86.223,69.375,86.164,69.398,85.887,69.19,86.144,69.098,85.942,68.821,86.352,68.66,86.413,68.351,86.643,68.233,86.549,68.116,86.601,68.097,86.619,68.109,86.636,68.109,87.067,68.087,87.295,68.053,87.505,68.057,87.545,68.05,87.633,68.093,87.694,68.094,87.733,68.086,87.758,68.092,87.778,68.087,87.544,68.048,87.294,68.052,87.058,68.083,86.75,68.08,86.742,68.084,86.742,68.096,86.6,68.095,86.547,68.113,86.439,68.054,86.615,67.864,86.267,67.63,86.419,67.505,86.439,67.446,86.577,67.46,86.662,67.449,86.698,67.431,86.663,67.4,86.707,67.274,86.668,67.188,86.762,67.139,86.761,66.965,86.706,66.889,86.771,66.792,86.305,66.595,86.904,66.624,87.228,66.491,87.433,66.554,87.237,66.486,87.283,66.445,87.215,66.445,86.834,66.597,86.196,66.528,86.216,66.598,86.295,66.667,86.728,66.797,86.613,66.948,86.633,67.1,86.5,67.208,86.571,67.404,86.365,67.433,86.215,67.639,86.549,67.87,86.308,68.011,86.585,68.245,86.343,68.341,86.267,68.395,86.268,68.637,85.97,68.739,85.836,68.841,85.847,68.912,86.065,69.11,85.697,69.223,85.755,69.291,85.984,69.356,86.062,69.452,85.4,69.663,85.326,69.738,85.047,69.759,84.489,69.59,84.492,69.583,84.537,69.581,84.546,69.578,84.551,69.572,84.536,69.564,84.483,69.562,84.481,69.555,84.565,69.551,84.688,69.528,84.705,69.514,84.644,69.475,84.701,69.474,84.723,69.456,84.693,69.415,84.713,69.41,84.727,69.43,84.742,69.434,84.763,69.432,84.83,69.371,84.83,69.363,84.811,69.348,84.782,69.339,84.787,69.328,84.774,69.323,84.714,69.328,84.686,69.312,84.621,69.309,84.553,69.316,84.551,69.313,84.593,69.301,84.599,69.292,84.639,69.279,84.643,69.274,84.612,69.231,84.572,69.222,84.641,69.275,84.549,69.312,84.55,69.316,84.826,69.365,84.691,69.413,84.697,69.474,84.64,69.474,84.701,69.517,84.478,69.553,84.48,69.562,84.547,69.574,84.535,69.58,84.495,69.581,84.483,69.587,84.258,69.551,84.458,69.401,84.443,69.388,84.343,69.384,84.424,69.358,84.388,69.296,84.188,69.254,84.158,69.183,84.038,69.147,83.977,69.137,83.912,69.136,83.841,69.148,83.85,69.105,83.833,69.07,83.762,69.047,83.896,68.921,83.898,68.906,83.814,68.879,83.876,68.874,83.981,68.857,84.0,68.849,83.997,68.837,83.88,68.79,83.927,68.765,83.842,68.713,83.881,68.7,83.892,68.675,83.895,68.646,83.873,68.62,83.777,68.59,83.753,68.592,83.928,68.555,83.929,68.541,83.891,68.507,83.864,68.494,83.839,68.488,83.558,68.469,83.532,68.472,83.515,68.478,83.502,68.491,83.505,68.507,83.488,68.518,83.307,68.555,83.273,68.551,83.231,68.525,83.199,68.518,83.017,68.53,83.133,68.462,83.166,68.41,83.124,68.368,83.089,68.352,83.169,68.313,83.166,68.309,83.128,68.293,83.177,68.265,83.189,68.24,83.15,68.19,83.214,68.194,83.261,68.19,83.329,68.163,83.324,68.138,83.4,68.126,83.363,68.124,83.344,68.093,83.272,68.056,83.246,68.052,83.175,68.051,83.208,68.039,83.208,68.033,83.2,68.03,83.114,68.035,83.14,67.991,83.113,67.972,83.138,67.965,83.18,67.984,83.192,67.985,83.234,67.961,83.25,67.966,83.243,67.992,83.254,67.998,83.274,67.999,83.329,67.98,83.376,67.99,83.395,67.991,83.367,67.915,83.412,67.899,83.467,67.917,83.493,67.916,83.51,67.909,83.512,67.891,83.46,67.855,83.51,67.835,83.455,67.854,83.503,67.909,83.413,67.898,83.362,67.914,83.389,67.99,83.234,67.96,83.187,67.984,83.14,67.963,83.111,67.97,83.102,68.032,83.358,68.122,83.145,68.188,83.176,68.263,83.121,68.289,83.156,68.315,83.083,68.351,83.162,68.409,83.011,68.531,83.311,68.556,83.494,68.52,83.566,68.47,83.881,68.506,83.924,68.555,83.748,68.592,83.87,68.625,83.837,68.714,83.918,68.768,83.87,68.785,83.918,68.816,83.995,68.846,83.812,68.875,83.817,68.894,83.892,68.915,83.876,68.933,83.803,68.947,83.793,68.951,83.753,69.045,83.827,69.149,84.033,69.151,84.149,69.186,84.181,69.258,84.379,69.296,84.41,69.361,84.329,69.383,84.449,69.402,84.247,69.539,84.124,69.507,84.083,69.504,84.024,69.508,83.588,69.652,83.538,69.682,83.546,69.703,83.498,69.717,83.501,69.723,83.523,69.734,83.435,69.794,83.405,69.796,83.352,69.816,83.296,69.818,83.268,69.826,83.254,69.835,83.209,69.831,83.191,69.833,83.193,69.854,83.133,69.845,82.984,69.862,82.944,69.852,82.883,69.876,82.742,69.885,82.551,69.918,82.526,69.93,82.517,69.942,82.483,69.948,82.461,69.963,82.338,69.958,82.312,69.965,82.296,69.974,82.254,70.006,82.225,70.056,82.102,70.027,81.872,70.028,81.868,70.021,81.815,69.991,81.77,69.973,81.741,69.97,81.87,70.028,82.102,70.028,82.215,70.062,82.196,70.076,82.189,70.1,82.197,70.126,82.356,70.163,82.346,70.185,82.33,70.189,82.298,70.201,82.263,70.225,82.219,70.273,81.47,70.149,81.315,70.16,81.296,70.145,81.277,70.141,81.23,70.141,81.169,70.153,81.149,70.152,81.151,70.142,81.143,70.139,81.095,70.146,81.104,70.141,81.108,70.137,81.097,70.123,81.119,70.128,81.141,70.129,81.168,70.128,81.179,70.123,81.151,70.1,81.132,70.094,81.124,70.093,81.119,70.103,81.09,70.09,81.085,70.084,81.09,70.083,81.1,70.087,81.11,70.087,81.12,70.085,81.126,70.08,81.078,70.063,81.124,70.08,81.083,70.083,81.088,70.091,81.116,70.103,81.151,70.101,81.176,70.123,81.096,70.122,81.092,70.146,81.268,70.187,81.502,70.16,81.324,70.2,81.356,70.219,81.707,70.201,81.382,70.242,81.357,70.254,81.347,70.302,81.221,70.292,81.208,70.299,81.229,70.314,81.08,70.309,81.056,70.338,80.953,70.315,80.897,70.33,80.857,70.314,80.759,70.325,80.739,70.332,80.727,70.341,80.743,70.346,80.772,70.347,80.776,70.351,80.725,70.353,80.708,70.357,80.699,70.363,80.703,70.37,80.733,70.375,80.731,70.384,80.644,70.397,80.635,70.404,80.656,70.443,80.498,70.461,80.643,70.451,80.674,70.437,80.689,70.406,80.748,70.42,80.756,70.421,80.766,70.42,80.774,70.415,80.774,70.409,80.746,70.397,80.722,70.392,80.75,70.382,80.789,70.386,80.797,70.384,80.808,70.378,80.817,70.378,80.826,70.396,80.787,70.4,80.782,70.404,80.782,70.407,80.789,70.411,80.809,70.415,80.817,70.415,80.855,70.41,80.851,70.413,80.852,70.415,80.876,70.424,81.106,70.39,81.254,70.325,81.221,70.294,81.356,70.304,81.372,70.258,81.386,70.253,81.519,70.279,81.537,70.279,81.578,70.261,81.582,70.295,81.61,70.294,81.665,70.277,81.682,70.32,81.915,70.349,81.908,70.394,81.941,70.421,81.913,70.453,81.954,70.488,81.792,70.525,81.819,70.5,81.779,70.482,81.795,70.477,81.827,70.439,81.827,70.435,81.816,70.418,81.775,70.483,81.789,70.526,81.879,70.555,82.077,70.563,82.201,70.601,82.214,70.68,82.415,70.751,82.199,70.976,82.333,71.072,82.247,71.195,82.258,71.241,82.239,71.238,83.024,71.406,82.983,71.528,83.195,71.58,83.323,71.666,83.278,71.707,82.911,71.771,82.269,71.703,81.554,71.722,81.528,71.712,81.473,71.719,81.472,71.706,81.463,71.701,81.445,71.698,81.408,71.701,81.474,71.72,81.562,71.723,81.128,71.825,81.286,71.834,80.681,72.048,80.778,72.077,80.853,72.0,80.83,72.074,79.495,72.349,78.428,72.333,78.6,72.293,78.67,72.243,78.596,72.229,78.636,72.229,78.654,72.225,78.658,72.219,78.644,72.21,78.608,72.202,78.557,72.204,78.648,72.194,78.63,72.17,78.69,72.178,78.769,72.168,78.774,72.167,78.774,72.164,78.722,72.151,78.765,72.158,78.778,72.155,78.781,72.152,78.777,72.14,78.837,72.141,78.854,72.136,78.88,72.139,78.893,72.137,78.917,72.127,78.894,72.108,78.968,72.122,78.998,72.106,78.992,72.09,79.028,72.102,79.037,72.104,79.054,72.102,79.061,72.099,79.06,72.094,79.015,72.089,79.04,72.089,79.058,72.086,79.089,72.064,79.078,72.057,78.588,72.169,78.636,72.192,78.551,72.203,78.565,72.207,78.613,72.205,78.646,72.214,78.65,72.225,78.64,72.214,78.636,72.226,78.6,72.226,78.594,72.228,78.594,72.232,78.655,72.245,78.582,72.291,78.091,72.356,77.696,72.18,77.463,72.207,77.52,72.175,77.395,72.099,77.997,72.098,78.24,71.965,77.98,71.816,77.638,71.805,76.887,72.053,76.389,72.002,76.574,71.898,76.378,72.002,75.992,71.887,76.268,71.667,76.271,71.578,76.641,71.469,77.01,71.411,77.124,71.469,77.071,71.413,77.212,71.344,77.503,71.313,77.691,71.265,77.999,71.368,77.916,71.238,78.266,71.249,78.313,71.223,78.262,71.079,78.411,71.027,78.529,71.038,78.451,71.047,78.34,71.078,78.371,71.092,78.465,71.098,78.533,71.05,78.542,71.034,78.46,71.016,78.426,70.899,78.565,70.952,78.708,70.922,78.736,70.922,78.884,70.967,78.816,70.965,78.785,70.972,78.811,70.993,78.982,70.963,79.007,70.992,79.063,71.009,79.103,70.986,79.01,70.948,78.947,70.953,79.199,70.92,78.285,70.856,78.375,70.904,78.177,70.953,78.119,70.903,78.047,70.9,78.162,70.953,77.927,70.973,77.794,71.127,77.087,71.172,77.071,71.167,77.068,71.151,77.051,71.142,77.021,71.132,76.978,71.131,76.972,71.111,76.939,71.076,76.896,71.058,76.963,71.025,76.957,70.958,76.913,70.943,76.991,70.932,77.037,70.904,77.036,70.862,76.925,70.836,76.963,70.823,76.973,70.845,76.985,70.85,77.051,70.84,77.075,70.826,77.074,70.82,77.012,70.796,77.062,70.803,77.058,70.797,77.083,70.797,77.093,70.796,77.104,70.791,77.106,70.787,77.081,70.772,77.008,70.769,76.97,70.746,76.993,70.738,77.039,70.752,77.062,70.755,77.071,70.754,77.066,70.73,77.002,70.717,77.012,70.714,77.052,70.712,77.081,70.707,77.08,70.668,77.007,70.666,77.016,70.641,77.005,70.631,76.991,70.627,76.735,70.603,76.651,70.605,76.595,70.619,76.59,70.612,76.57,70.606,76.576,70.602,76.603,70.601,76.614,70.598,76.593,70.589,76.568,70.605,76.593,70.619,76.989,70.628,77.003,70.666,77.077,70.67,76.998,70.716,77.068,70.753,77.044,70.751,77.008,70.739,76.984,70.736,76.967,70.744,76.973,70.753,76.971,70.757,76.964,70.753,76.964,70.756,77.01,70.772,77.077,70.772,77.103,70.787,77.082,70.796,77.026,70.79,77.007,70.798,77.071,70.825,76.991,70.847,76.967,70.823,76.946,70.824,76.912,70.838,76.928,70.876,77.034,70.863,77.027,70.906,77.007,70.913,76.979,70.932,76.921,70.932,76.899,70.943,76.8,70.888,76.731,70.891,76.743,70.88,76.818,70.874,76.834,70.867,76.775,70.796,76.785,70.796,76.82,70.807,76.829,70.807,76.894,70.784,76.89,70.78,76.864,70.776,76.866,70.769,76.876,70.769,76.894,70.777,76.907,70.779,76.92,70.777,76.934,70.77,76.952,70.778,76.962,70.772,76.964,70.766,76.961,70.763,76.949,70.76,76.941,70.759,76.923,70.759,76.914,70.763,76.902,70.755,76.964,70.749,76.9,70.754,76.904,70.759,76.912,70.764,76.959,70.763,76.956,70.776,76.928,70.768,76.9,70.778,76.884,70.769,76.869,70.767,76.774,70.795,76.762,70.847,76.827,70.867,76.745,70.877,76.724,70.89,76.754,70.897,76.794,70.89,76.777,70.908,76.94,70.955,76.946,71.032,76.864,71.04,76.851,71.053,76.851,71.09,76.836,71.093,76.833,71.074,76.8,71.063,76.763,71.058,76.775,71.044,76.771,71.036,76.692,71.027,76.67,71.033,76.922,71.176,76.096,71.213,76.071,71.201,76.141,71.186,76.16,71.168,76.115,71.156,76.138,71.154,76.135,71.147,76.162,71.145,76.161,71.138,76.114,71.155,76.12,71.161,76.096,71.163,76.123,71.149,76.092,71.129,76.0,71.126,76.09,71.131,76.059,71.155,76.084,71.165,76.149,71.169,75.756,71.236,75.737,71.233,75.766,71.229,75.769,71.227,75.766,71.221,75.719,71.203,75.753,71.2,75.779,71.179,75.776,71.177,75.757,71.171,75.725,71.167,75.76,71.158,75.743,71.148,75.716,71.142,75.747,71.141,75.766,71.132,75.734,71.133,75.757,71.129,75.763,71.113,75.751,71.11,75.713,71.142,75.724,71.167,75.777,71.179,75.733,71.182,75.752,71.199,75.718,71.202,75.718,71.217,75.766,71.228,75.736,71.232,75.736,71.24,75.695,71.244,75.676,71.249,75.696,71.199,75.677,71.194,75.64,71.197,75.623,71.191,75.642,71.172,75.585,71.168,75.627,71.158,75.636,71.152,75.632,71.148,75.525,71.116,75.63,71.149,75.583,71.169,75.628,71.197,75.689,71.198,75.663,71.245,75.671,71.25,75.704,71.246,75.638,71.259,75.554,71.282,75.563,71.268,75.538,71.26,75.502,71.264,75.512,71.253,75.499,71.252,75.499,71.267,75.56,71.271,75.242,71.382,75.508,71.524,75.408,71.519,75.504,71.529,75.415,71.58,75.473,71.654,75.428,71.65,75.355,71.655,75.344,71.674,75.313,71.665,75.235,71.669,75.227,71.673,75.277,71.682,75.249,71.671,75.345,71.676,75.362,71.655,75.463,71.658,75.441,71.658,75.256,71.733,75.268,71.897,75.252,71.899,75.229,71.891,75.212,71.89,75.207,71.892,75.389,71.952,75.262,71.993,75.411,71.995,75.557,72.217,75.701,72.248,75.492,72.494,75.653,72.526,75.304,72.683,75.44,72.751,74.989,72.872,74.784,72.819,75.067,72.456,75.004,72.172,74.324,71.92,73.528,71.807,73.455,71.766,73.527,71.711,73.481,71.638,73.002,71.398,73.569,71.209,73.907,70.959,73.919,70.841,74.296,70.608,74.262,70.46,74.118,70.376,74.142,70.378,74.153,70.376,74.152,70.371,74.134,70.364,74.138,70.363,74.145,70.367,74.151,70.368,74.185,70.356,74.186,70.363,74.198,70.365,74.248,70.349,74.194,70.365,74.185,70.355,74.151,70.367,74.144,70.363,74.135,70.362,74.132,70.364,74.147,70.376,74.113,70.373,73.676,70.115,73.779,70.082,73.727,69.988,73.778,69.997,73.532,69.74,73.901,69.411,73.772,69.183,73.872,69.078,75.123,69.1,75.485,69.246,75.81,69.236,75.796,69.243,75.778,69.242,75.761,69.244,75.755,69.246,75.757,69.248,75.772,69.249,75.764,69.252,75.76,69.256,75.753,69.257,75.749,69.261,75.751,69.263,75.761,69.266,76.336,69.211,76.274,69.188,76.411,69.13,76.432,69.14,76.47,69.146,76.437,69.15,76.426,69.155,76.423,69.159,76.43,69.164,76.453,69.159,76.468,69.165,76.465,69.167,76.433,69.166,76.426,69.168,76.46,69.207,76.492,69.208,76.526,69.226,76.546,69.226,76.429,69.169,76.466,69.168,76.47,69.166,76.469,69.161,76.45,69.157,76.429,69.162,76.473,69.147,76.467,69.143,76.431,69.138,76.426,69.132,76.416,69.13,76.456,69.12,76.634,69.12,76.591,69.123,76.618,69.143,76.602,69.143,76.544,69.148,76.538,69.153,76.784,69.141,76.764,69.135,76.836,69.123,76.842,69.111,76.869,69.103,76.829,69.128,76.834,69.132,76.849,69.135,76.89,69.134,76.896,69.141,76.875,69.139,76.87,69.143,76.892,69.159,76.934,69.173,76.918,69.184,76.925,69.186,76.938,69.186,76.948,69.189,76.948,69.193,76.941,69.196,76.94,69.199,76.944,69.201,76.956,69.202,76.975,69.19,76.874,69.143,76.874,69.14,76.887,69.142,76.898,69.141,76.895,69.127,76.834,69.127,76.877,69.107,76.913,69.125,76.901,69.14,76.902,69.143,76.91,69.143,76.913,69.139,76.927,69.138,76.99,69.144,76.994,69.143,76.929,69.137,76.914,69.137,76.903,69.142,76.917,69.13,76.917,69.126,76.876,69.103,76.923,69.091,76.907,69.072,76.897,69.07,76.862,69.074,76.87,69.057,76.815,69.04,76.895,69.018,76.916,69.02,76.934,69.03,76.973,69.038,76.991,69.028,76.995,69.029,76.994,69.034,77.001,69.038,77.032,69.042,76.986,69.068,76.994,69.079,77.001,69.081,77.006,69.08,77.034,69.041,76.991,69.027,76.956,69.034,76.907,69.013,77.675,68.881,77.676,68.785,77.75,68.677,77.703,68.608,77.79,68.509,77.953,68.496,77.862,68.452,78.167,68.253,77.53,68.121,77.477,67.97,77.61,67.923,77.464,67.885,77.486,67.745,77.658,67.708,77.651,67.738,77.656,67.739,77.667,67.736,77.673,67.737,77.685,67.735,77.67,67.747,77.702,67.747,77.711,67.764,77.717,67.766,77.733,67.766,77.737,67.768,77.718,67.775,77.715,67.781,77.759,67.802,77.801,67.8,77.755,67.8,77.718,67.78,77.739,67.768,77.723,67.753,77.684,67.734,77.652,67.736,77.984,67.703,77.917,67.704,77.813,67.67,77.801,67.66,77.798,67.655,77.802,67.654,77.854,67.666,77.919,67.694,77.929,67.696,78.104,67.68,78.148,67.667,78.159,67.711,78.165,67.713,78.171,67.713,78.204,67.706,78.233,67.716,78.229,67.717,78.198,67.716,78.193,67.717,78.194,67.743,78.177,67.747,78.178,67.751,78.196,67.744,78.196,67.717,78.232,67.719,78.234,67.716,78.15,67.666,77.928,67.694,77.843,67.658,78.384,67.621,78.418,67.709,78.64,67.637,78.425,67.611,78.661,67.589,78.647,67.609,78.654,67.65,78.603,67.67,78.577,67.704,78.381,67.729,78.347,67.746,78.345,67.752,78.459,67.741,78.461,67.721,78.566,67.71,78.583,67.702,78.593,67.694,78.61,67.668,78.647,67.658,78.656,67.652,78.662,67.638,78.655,67.607,78.666,67.593,78.84,67.635,78.783,67.616,78.849,67.617,79.138,67.547,79.312,67.47,79.271,67.44,79.297,67.442,79.348,67.435,79.357,67.427,79.347,67.435,79.267,67.435,79.284,67.428,79.319,67.43,79.348,67.425,79.327,67.416,79.297,67.413,79.347,67.405,79.367,67.38,79.468,67.377,79.518,67.358,79.538,67.26,79.556,67.257,79.551,67.264,79.568,67.272,79.576,67.272,79.645,67.23,79.552,67.197,79.668,67.16,79.841,67.168,79.821,67.173,79.836,67.188,79.766,67.213,79.764,67.216,79.767,67.224,79.764,67.225,79.982,67.155,79.964,67.196,80.027,67.223,80.049,67.211,80.064,67.217,80.136,67.221,80.25,67.207,80.191,67.189,80.17,67.2,80.184,67.186,80.254,67.198,80.339,67.185,80.382,67.164,80.416,67.174,80.496,67.178,80.505,67.188,80.568,67.188,80.586,67.184,80.6,67.171,80.575,67.131,80.612,67.128,80.676,67.145,80.679,67.143,80.703,67.147,80.717,67.146,80.776,67.123,80.768,67.112,80.814,67.107,80.834,67.099,80.867,67.079,80.886,67.076,80.888,67.072,80.926,67.079,80.991,67.04,81.013,67.074,81.064,67.075,81.153,67.062,81.293,67.02,81.334,67.062,81.349,67.067,81.388,67.072,81.4,67.077,81.353,67.07,81.338,67.074,81.332,67.079,81.383,67.129,81.333,67.079,81.405,67.078,81.338,67.031,81.393,67.025,81.453,66.977,81.675,66.97,81.746,66.958,82.044,66.797,82.101,66.726,82.215,66.716,82.305,66.638,82.237,66.574,82.314,66.515,82.322,66.482,82.256,66.447,82.194,66.444,82.229,66.412,82.334,66.403,82.346,66.395,82.345,66.386,82.309,66.366,82.083,66.324,82.152,66.266,82.134,66.245,82.084,66.229,82.219,66.216,82.285,66.188,82.271,66.154,82.119,66.077,82.115,66.048,82.157,66.005,82.273,65.962,82.289,65.945,82.336,65.91,82.336,65.896,82.284,65.848,82.302,65.831,82.318,65.794,82.29,65.778,82.403,65.713,82.282,65.76,82.254,65.838,82.319,65.913,82.169,65.98,82.093,66.05,82.107,66.085,82.199,66.15,82.266,66.167,82.274,66.188,82.079,66.218,82.079,66.252,82.127,66.274,82.039,66.307,82.055,66.326,82.325,66.39,82.23,66.403,82.18,66.434,82.196,66.455,82.3,66.478,82.222,66.552,82.227,66.575,82.287,66.632,82.073,66.719,81.799,66.894,81.599,66.843,81.646,66.879,81.605,66.89,81.607,66.884,81.603,66.875,81.584,66.87,81.593,66.864,81.59,66.861,81.537,66.87,81.53,66.866,81.549,66.861,81.554,66.853,81.525,66.847,81.511,66.851,81.505,66.854,81.539,66.872,81.588,66.862,81.58,66.869,81.595,66.875,81.6,66.889,81.609,66.891,81.782,66.899,81.838,66.883,81.755,66.946,81.491,66.943,81.39,66.964,81.404,66.953,81.386,66.966,81.558,66.958,81.412,66.974,81.362,67.026,81.309,67.018,81.248,67.021,81.055,67.07,81.006,67.035,80.983,67.038,80.955,67.051,80.938,67.067,80.861,67.057,80.91,67.059,80.871,67.015,80.902,67.026,80.937,67.013,80.967,66.993,80.947,66.979,81.04,66.997,81.121,66.962,81.096,66.954,81.082,66.96,81.098,66.936,81.117,66.958,81.134,66.961,81.149,66.96,81.277,66.91,81.167,66.886,81.148,66.901,81.135,66.958,81.109,66.931,81.092,66.936,81.077,66.945,81.07,66.961,81.063,66.956,81.024,66.971,80.997,66.969,81.051,66.955,81.017,66.96,81.012,66.957,81.026,66.95,81.009,66.945,81.015,66.941,81.015,66.936,81.0,66.93,81.053,66.948,81.082,66.921,80.965,66.925,80.954,66.94,81.009,66.964,80.963,66.971,80.927,66.984,80.968,66.967,80.936,66.95,80.917,66.95,80.895,66.961,80.789,67.108,80.664,67.076,80.534,67.101,80.586,67.053,80.552,67.042,80.307,67.087,80.251,67.126,80.199,67.101,80.102,67.08,80.254,67.089,80.232,67.043,80.179,67.039,80.145,67.048,80.177,67.037,80.169,67.022,80.225,67.029,80.256,67.006,80.241,67.031,80.247,67.036,80.265,67.039,80.419,67.023,80.393,67.015,80.436,67.022,80.437,67.032,80.421,67.04,80.419,67.053,80.561,67.035,80.453,67.039,80.448,66.998,80.423,66.998,80.41,66.992,80.409,66.996,80.402,67.001,80.408,66.996,80.409,66.986,80.41,66.989,80.417,66.992,80.421,66.993,80.42,66.995,80.476,66.989,80.508,66.97,80.375,66.969,80.36,67.016,80.246,67.033,80.324,66.988,80.165,67.021,80.111,67.057,80.105,67.078,80.103,67.024,80.017,67.031,79.824,67.153,79.568,67.144,79.445,67.193,79.446,67.201,79.485,67.209,79.372,67.242,79.364,67.256,79.382,67.287,79.297,67.24,79.296,67.27,79.181,67.293,79.086,67.361,78.894,67.389,78.563,67.547,77.833,67.486,77.947,67.365,77.813,67.298,77.874,67.289,77.876,67.269,77.904,67.271,77.929,67.265,77.942,67.24,77.961,67.289,78.005,67.302,77.974,67.315,77.977,67.322,77.993,67.331,77.993,67.336,77.947,67.344,77.932,67.355,77.997,67.337,78.006,67.301,77.95,67.238,78.055,67.175,78.028,67.133,78.057,67.124,78.115,67.13,78.147,67.122,78.153,67.104,78.121,67.064,78.203,67.036,78.218,67.011,78.2,66.987,78.115,66.949,78.071,66.945,78.067,66.926,77.965,66.951,77.984,66.909,77.954,66.873,78.006,66.901,78.059,66.918,77.949,66.869,77.833,66.856,77.928,66.795,77.949,66.734,77.924,66.707,77.907,66.802,77.763,66.78,77.801,66.777,77.829,66.764,77.85,66.736,77.828,66.709,77.865,66.678,77.866,66.665,77.903,66.706,77.907,66.7,77.926,66.707,78.121,66.489,78.142,66.433,78.134,66.415,78.113,66.404,78.054,66.401,78.033,66.394,78.031,66.391,78.067,66.359,78.045,66.306,78.212,66.229,78.223,66.217,78.229,66.19,78.197,66.173,78.236,66.154,78.26,66.132,78.244,66.117,78.321,66.097,78.335,66.071,78.268,66.036,78.331,66.006,78.361,65.977,78.357,65.957,78.327,65.934,78.322,65.941,78.263,65.924,78.242,65.91,78.349,65.838,78.339,65.822,78.323,65.818,78.31,65.817,78.342,65.841,78.289,65.856,78.284,65.866,78.258,65.882,78.284,65.861,78.28,65.845,78.204,65.785,78.282,65.752,78.303,65.729,78.303,65.683,78.228,65.652,78.238,65.644,78.243,65.621,78.215,65.601,78.236,65.582,78.234,65.575,78.125,65.575,78.131,65.55,78.091,65.501,78.068,65.482,77.983,65.477,77.972,65.46,77.99,65.434,77.985,65.421,77.923,65.394,78.006,65.355,78.006,65.342,78.04,65.327,78.079,65.265,78.061,65.259,78.065,65.223,78.031,65.182,77.988,65.165,78.002,65.147,77.999,65.136,77.974,65.121,77.916,65.103,77.944,65.068,77.943,65.039,77.94,65.046,77.93,65.033,77.881,65.031,77.882,64.992,77.824,64.959,77.835,64.95,77.88,64.953,77.93,64.945,77.959,64.93,77.963,64.922,77.963,64.915,77.954,64.907,77.936,64.898,77.944,64.893,77.927,64.884,77.956,64.86,77.946,64.835,77.967,64.797,77.99,64.776,78.011,64.768,78.024,64.757,77.988,64.774,78.025,64.751,78.077,64.532,78.078,64.518,78.062,64.497,78.039,64.485,78.044,64.481,78.074,64.475,78.083,64.47,78.07,64.439,78.061,64.433,78.025,64.433,78.073,64.466,78.07,64.475,78.046,64.476,78.037,64.485,78.043,64.642,78.001,64.662,77.922,64.886,77.933,64.904,77.952,64.908,77.959,64.925,77.869,64.951,77.75,64.917,77.651,64.94,77.814,64.947,77.816,64.967,77.855,64.987,77.85,64.995,77.866,65.006,77.861,65.026,77.896,65.036,77.928,65.034,77.906,65.099,78.061,65.228,78.034,65.328,77.91,65.326,77.879,65.376,77.981,65.494,78.08,65.497,78.079,65.515,78.111,65.567,78.092,65.594,78.127,65.617,78.102,65.63,78.114,65.633,78.173,65.639,78.125,65.648,78.175,65.648,78.18,65.662,78.191,65.667,78.287,65.69,78.153,65.764,78.198,65.768,78.197,65.794,78.249,65.823,78.234,65.824,78.251,65.834,78.265,65.856,78.275,65.861,78.24,65.863,78.217,65.902,78.343,65.98,78.252,66.04,78.318,66.095,78.234,66.116,78.19,66.17,78.204,66.215,78.089,66.235,78.015,66.302,78.018,66.293,77.994,66.273,77.966,66.263,78.003,66.24,78.008,66.235,78.008,66.23,77.965,66.257,77.965,66.265,78.016,66.296,77.983,66.312,77.978,66.335,77.979,66.339,77.988,66.349,78.011,66.364,78.019,66.391,78.04,66.402,78.118,66.41,78.084,66.49,78.095,66.497,78.049,66.512,78.025,66.542,78.027,66.556,78.017,66.553,77.988,66.58,77.986,66.586,77.958,66.589,77.943,66.594,77.935,66.601,77.893,66.609,77.883,66.614,77.739,66.797,77.874,66.9,77.973,66.906,77.934,66.924,77.946,66.951,78.114,66.951,78.128,66.973,78.154,66.989,78.18,66.998,78.194,67.031,78.084,67.05,78.007,67.137,78.049,67.176,77.916,67.233,77.908,67.245,77.91,67.266,77.815,67.239,77.841,67.274,77.798,67.272,77.701,67.31,77.7,67.316,77.711,67.333,77.67,67.333,77.683,67.337,77.719,67.334,77.799,67.276,77.871,67.286,77.823,67.288,77.807,67.3,77.889,67.383,77.722,67.445,77.69,67.389,77.777,67.362,77.642,67.405,77.655,67.365,77.61,67.344,77.691,67.349,77.597,67.328,77.61,67.277,77.595,67.263,77.616,67.263,77.665,67.239,77.702,67.258,77.733,67.264,77.801,67.258,77.709,67.24,77.815,67.21,77.819,67.197,77.764,67.166,77.818,67.167,77.901,67.15,77.87,67.135,77.819,67.165,77.786,67.16,77.74,67.165,77.726,67.17,77.818,67.199,77.707,67.238,77.717,67.25,77.755,67.26,77.705,67.256,77.674,67.238,77.56,67.243,77.594,67.32,77.558,67.312,77.553,67.291,77.511,67.267,77.548,67.219,77.562,67.217,77.585,67.218,77.604,67.215,77.618,67.199,77.618,67.187,77.433,67.241,77.551,67.303,77.458,67.329,77.635,67.371,77.592,67.419,77.568,67.415,77.514,67.423,77.505,67.432,77.499,67.423,77.447,67.418,77.443,67.42,77.437,67.43,77.409,67.438,77.405,67.443,77.446,67.427,77.448,67.419,77.504,67.433,77.647,67.412,77.652,67.526,77.702,67.573,77.075,67.773,77.296,67.885,77.195,67.947,77.182,67.989,77.338,68.229,77.124,68.273,77.291,68.364,77.162,68.422,77.292,68.492,77.159,68.504,77.153,68.507,77.162,68.515,77.136,68.511,77.107,68.523,77.089,68.514,77.125,68.507,77.13,68.501,77.127,68.498,77.049,68.501,77.031,68.495,77.071,68.499,77.081,68.496,77.083,68.489,77.075,68.483,77.041,68.482,77.063,68.478,77.068,68.474,77.029,68.463,77.044,68.454,77.042,68.45,77.03,68.444,77.013,68.443,76.993,68.452,76.995,68.459,77.012,68.462,77.002,68.465,76.982,68.466,76.979,68.453,76.96,68.449,76.933,68.456,76.921,68.463,76.925,68.466,76.958,68.467,76.92,68.477,76.826,68.465,76.922,68.477,76.96,68.468,76.953,68.464,76.925,68.466,76.933,68.458,76.975,68.452,76.978,68.465,76.986,68.467,77.004,68.466,77.014,68.462,76.993,68.453,77.029,68.444,77.026,68.463,77.067,68.475,77.038,68.482,77.082,68.489,77.035,68.489,77.028,68.493,77.032,68.498,77.046,68.502,77.128,68.5,77.088,68.511,77.087,68.518,77.101,68.524,77.127,68.522,77.136,68.512,77.163,68.516,77.162,68.504,77.281,68.499,76.654,68.745,76.7,68.848,76.565,68.972,75.332,68.882,74.474,68.7,74.471,68.505,74.315,68.361,74.479,68.33,74.821,67.995,74.815,67.724,73.922,67.294,73.955,67.194,73.864,66.992,73.614,66.932,73.644,66.924,73.561,66.833,73.022,66.726,72.924,66.652,72.979,66.641,72.981,66.651,73.012,66.656,73.012,66.671,73.046,66.697,73.1,66.705,73.113,66.703,73.125,66.695,73.131,66.702,73.159,66.711,73.191,66.712,73.239,66.702,72.941,66.631,73.106,66.639,73.116,66.635,73.116,66.623,73.15,66.632,73.176,66.625,73.203,66.645,73.249,66.654,73.275,66.65,73.187,66.622,73.116,66.621,73.108,66.638,73.054,66.626,73.087,66.621,73.09,66.611,73.079,66.607,73.087,66.62,72.977,66.63,73.039,66.619,73.038,66.613,73.04,66.615,73.047,66.614,73.054,66.602,73.107,66.609,73.091,66.602,72.922,66.602,73.048,66.599,73.042,66.593,72.484,66.597,72.411,66.531,72.39,66.521,72.308,66.501,72.396,66.489,72.411,66.353,72.376,66.321,72.363,66.353,72.22,66.32,72.33,66.31,72.238,66.283,72.247,66.272,72.249,66.201,72.288,66.191,72.252,66.188,72.299,66.167,72.265,66.155,72.3,66.14,72.314,66.113,72.265,66.112,72.221,66.25,72.096,66.245,72.126,66.24,72.202,66.113,72.234,66.106,72.244,66.042,72.229,66.1,72.171,66.081,72.19,66.125,72.113,66.203,72.134,66.099,72.068,66.1,72.096,66.081,72.1,66.063,72.167,66.044,72.172,66.035,72.14,66.025,72.094,66.065,72.066,66.101,72.113,66.133,72.052,66.242,72.024,66.084,71.968,66.084,72.04,66.059,72.058,66.041,72.032,66.04,72.029,66.021,72.009,66.028,72.0,66.014,71.985,66.014,71.972,66.006,71.962,66.005,72.028,66.041,72.055,66.042,71.966,66.083,71.971,66.09,72.023,66.105,72.012,66.183,72.0,66.148,71.973,66.138,71.93,66.133,71.991,66.248,71.719,66.219,71.783,66.255,71.732,66.293,71.63,66.317,70.384,66.326,69.894,66.403,69.79,66.461,69.446,66.485,69.126,66.613,69.14,66.77,68.788,66.755,68.531,66.608,68.11,66.49,67.879,66.518,67.385,66.649,67.17,66.541,67.125,66.533,66.424,66.655,66.524,66.691,66.592,66.697,66.657,66.699,66.887,66.682,66.95,66.66,67.015,66.681,67.066,66.668,67.102,66.673,67.128,66.674,67.311,66.646,67.508,66.696,67.515,66.724,67.358,66.676,67.323,66.691,67.551,66.741,67.406,66.772,67.525,66.838,67.541,66.798,67.616,66.831,67.631,66.829,67.633,66.82,67.647,66.824,67.658,66.821,67.547,66.761,67.612,66.787,67.733,66.792,67.945,66.761,68.028,66.775,68.078,66.806,68.13,66.816,68.367,66.798,68.229,66.833,68.232,66.841,68.208,66.858,68.203,66.878,68.194,66.869,68.156,66.869,68.147,66.872,68.151,66.875,68.164,66.875,68.165,66.878,68.203,66.879,68.212,66.857,68.235,66.841,68.32,66.87,68.276,66.831,68.404,66.801,68.447,66.776,68.55,66.771,68.543,66.77,68.64,66.76,68.672,66.761,68.744,66.78,68.676,66.773,68.634,66.787,68.642,66.809,68.576,66.819,68.562,66.827,68.51,66.805,68.52,66.834,68.529,66.823,68.562,66.828,68.649,66.814,68.643,66.826,68.644,66.843,68.574,66.843,68.519,66.87,68.574,66.844,68.614,66.851,68.648,66.842,68.677,66.775,68.754,66.791,68.798,66.792,68.746,66.781,68.817,66.793,68.797,66.804,68.811,66.82,68.814,66.822,68.836,66.823,68.822,66.831,68.826,66.854,68.844,66.87,68.753,66.874,68.79,66.894,69.045,66.872,68.995,66.914,69.056,66.927,69.11,66.922,69.052,66.881,69.078,66.871,69.083,66.879,69.166,66.89,69.112,66.886,69.091,66.893,69.19,66.948,69.13,66.907,69.186,66.903,69.327,66.85,69.393,66.853,69.505,66.843,69.565,66.833,69.657,66.806,69.953,66.851,70.143,66.726,70.28,66.768,70.738,66.761,70.849,66.855,70.928,66.869,70.831,66.816,71.066,66.81,71.282,66.888,71.262,66.885,71.238,66.882,71.174,66.89,71.137,66.9,71.066,66.893,71.14,66.901,71.241,66.883,71.328,66.896,71.394,66.969,71.744,66.904,71.804,66.963,72.025,67.044,71.894,67.087,71.945,67.097,71.961,67.099,72.02,67.094,71.91,67.107,71.926,67.121,72.165,67.153,72.107,67.185,72.102,67.202,72.118,67.207,72.212,67.142,72.183,67.26,72.035,67.292,72.414,67.322,72.408,67.457,72.43,67.476,72.509,67.492,72.529,67.499,72.549,67.515,72.55,67.514,72.557,67.563,72.527,67.585,73.101,67.691,73.052,67.794,73.194,67.919,73.036,68.062,73.102,68.149,73.075,68.203,73.496,68.465,73.588,68.47,73.577,68.412,73.604,68.451,73.586,68.508,72.589,68.913,72.479,69.112,72.621,69.287,72.54,69.384,72.015,69.396,72.237,69.418,72.209,69.429,72.194,69.438,72.196,69.445,72.184,69.446,72.084,69.413,72.01,69.431,72.02,69.407,72.009,69.388,72.025,69.384,72.048,69.377,72.069,69.347,71.951,69.385,72.006,69.388,71.999,69.429,72.015,69.434,72.315,69.467,72.256,69.476,72.426,69.453,72.481,69.437,72.564,69.39,72.638,69.452,72.494,69.641,72.506,69.711,72.541,69.664,72.675,69.77,72.486,70.01,72.59,70.131,72.392,70.183,72.409,70.146,72.34,70.099,72.262,70.106,72.382,70.139,72.338,70.172,72.378,70.153,72.388,70.184,72.538,70.183,72.41,70.24,72.399,70.24,72.377,70.243,72.415,70.248,72.358,70.271,72.34,70.305,72.308,70.3,72.293,70.304,72.291,70.306,72.296,70.313,72.322,70.318,72.321,70.323,72.281,70.314,72.233,70.316,72.371,70.35,72.319,70.327,72.347,70.32,72.406,70.262,72.741,70.397,72.747,70.517,72.676,70.604,72.662,70.605,72.671,70.596,72.661,70.594,72.629,70.599,72.626,70.609,72.747,70.664,72.802,70.87,72.698,70.919,72.616,70.904,72.582,70.906,72.566,70.916,72.554,70.914,72.56,70.907,72.547,70.904,72.536,70.906,72.524,70.913,72.566,70.916,72.615,70.905,72.683,70.919,72.568,71.01,72.686,71.045,72.551,71.146,72.418,71.108,72.234,71.106,72.222,71.096,72.256,71.084,72.246,71.071,72.143,71.057,72.155,71.095,72.48,71.155,71.826,71.288,72.051,71.287,71.984,71.353,71.867,71.326,71.859,71.334,71.871,71.342,71.991,71.356,71.869,71.418,71.854,71.419,71.847,71.423,71.827,71.561,71.973,71.559,71.872,71.589,71.913,71.6,71.869,71.601,71.787,71.626,71.84,71.644,71.797,71.652,71.784,71.672,71.76,71.653,71.745,71.65,71.677,71.645,71.663,71.645,71.656,71.649,71.649,71.658,71.65,71.668,71.659,71.672,71.692,71.674,71.694,71.68,71.679,71.68,71.651,71.671,71.615,71.667,71.608,71.668,71.602,71.671,71.609,71.687,71.595,71.678,71.582,71.675,71.607,71.688,71.622,71.683,71.617,71.667,71.675,71.68,71.694,71.682,71.7,71.679,71.67,71.645,71.784,71.673,71.923,71.601,71.877,71.588,71.983,71.584,72.015,71.565,72.167,71.643,72.016,71.646,71.964,71.667,71.957,71.666,71.969,71.66,71.962,71.653,71.947,71.652,71.938,71.654,71.925,71.653,71.919,71.655,71.919,71.658,71.949,71.662,71.914,71.665,71.926,71.673,71.899,71.67,71.881,71.674,71.88,71.676,71.897,71.68,71.869,71.676,71.843,71.682,71.843,71.684,72.32,71.703,72.326,71.854,72.472,71.892,72.585,72.069,72.703,72.111,72.71,72.204,72.845,72.255,72.777,72.411,72.834,72.491,72.812,72.483,72.753,72.476,72.607,72.481,72.828,72.495,72.736,72.524,72.715,72.588,72.819,72.626,72.715,72.692,72.852,72.689,72.847,72.692,72.06,72.824,72.102,72.814,72.042,72.771,71.898,72.792,71.897,72.77,71.854,72.731,71.845,72.729,71.824,72.727,71.803,72.727,71.795,72.728,71.727,72.747,71.847,72.73,71.882,72.787,71.899,72.793,71.924,72.79,71.928,72.794,71.949,72.789,71.956,72.795,71.864,72.809,72.023,72.819,71.617,72.892,70.93,72.883,70.997,72.864,70.959,72.843,71.026,72.845,71.045,72.838,71.005,72.814,70.859,72.795,70.862,72.792,70.878,72.788,70.908,72.773,70.902,72.769,70.837,72.768,70.814,72.758,70.83,72.768,70.904,72.772,70.856,72.791,70.855,72.795,71.04,72.837,69.583,72.853,69.837,72.918,69.405,72.945,68.983,72.653,69.107,72.636,68.983,72.642,68.8,72.311,68.858,72.309,68.913,72.289,68.799,72.305,68.69,72.114,68.813,72.117,68.869,72.082,68.687,72.108,68.338,71.713,68.496,71.654,68.334,71.708,68.195,71.628,68.294,71.609,68.361,71.587,68.375,71.548,68.432,71.581,68.521,71.592,68.53,71.591,68.535,71.589,68.537,71.575,68.554,71.578,68.536,71.574,68.521,71.591,68.43,71.579,68.374,71.547,68.183,71.623,68.112,71.577,68.135,71.577,68.161,71.571,68.167,71.569,68.175,71.571,68.21,71.573,68.218,71.569,68.215,71.562,68.223,71.56,68.255,71.566,68.285,71.563,68.304,71.559,68.306,71.555,68.297,71.552,68.301,71.549,68.325,71.551,68.299,71.549,68.283,71.563,68.222,71.558,68.211,71.562,68.211,71.572,68.167,71.568,68.135,71.576,68.113,71.576,68.108,71.578,68.035,71.549,68.237,71.487,68.031,71.547,67.861,71.493,67.868,71.491,67.861,71.485,67.896,71.483,67.931,71.459,67.915,71.446,67.957,71.457,67.928,71.443,67.915,71.443,67.911,71.45,67.929,71.459,67.859,71.485,67.859,71.492,67.584,71.423,67.724,71.396,67.719,71.377,67.8,71.374,67.822,71.371,67.833,71.365,67.825,71.358,67.795,71.357,67.764,71.361,67.712,71.374,67.693,71.372,67.726,71.368,67.73,71.365,67.705,71.343,67.779,71.342,67.799,71.339,67.785,71.31,67.763,71.293,67.766,71.284,67.774,71.282,67.809,71.297,67.838,71.302,67.879,71.288,67.865,71.279,67.815,71.272,67.849,71.266,67.86,71.257,67.854,71.253,67.8,71.241,67.793,71.232,67.8,71.242,67.858,71.257,67.809,71.269,67.818,71.274,67.864,71.281,67.871,71.288,67.837,71.301,67.778,71.282,67.768,71.282,67.761,71.284,67.763,71.299,67.796,71.337,67.701,71.343,67.692,71.371,67.693,71.373,67.7,71.376,67.718,71.375,67.796,71.358,67.822,71.368,67.717,71.376,67.597,71.413,67.682,71.331,67.618,71.326,67.577,71.42,67.322,71.338,66.941,71.275,66.748,71.102,67.104,71.052,66.95,71.036,66.96,71.033,67.0,71.035,67.015,71.033,67.004,71.008,67.027,71.019,67.048,71.024,67.211,71.018,67.209,71.01,67.166,70.989,67.164,70.98,67.179,70.982,67.204,70.992,67.228,70.995,67.344,70.992,67.355,70.99,67.362,70.985,67.347,70.966,67.369,70.976,67.411,70.98,67.425,70.98,67.463,70.971,67.478,70.947,67.491,70.949,67.487,70.959,67.499,70.965,67.553,70.965,67.558,70.962,67.531,70.938,67.548,70.937,67.559,70.951,67.572,70.956,67.585,70.957,67.645,70.933,67.664,70.936,67.663,70.939,67.645,70.943,67.637,70.947,67.658,70.968,67.639,70.946,67.665,70.939,67.666,70.936,67.661,70.934,67.653,70.933,67.627,70.934,67.585,70.947,67.587,70.955,67.552,70.936,67.534,70.936,67.526,70.94,67.546,70.965,67.481,70.946,67.421,70.979,67.374,70.974,67.354,70.965,67.344,70.966,67.344,70.991,67.275,70.983,67.263,70.962,67.251,70.958,67.22,70.955,67.204,70.96,67.205,70.965,67.265,70.988,67.17,70.978,67.16,70.98,67.157,70.984,67.175,71.001,67.143,70.999,67.201,71.015,67.0,71.007,66.948,71.037,67.003,71.055,66.923,71.073,66.846,70.998,67.061,70.983,66.993,70.981,66.84,70.998,66.828,70.96,66.628,70.86,66.723,70.805,66.699,70.746,67.217,70.832,67.361,70.786,67.368,70.705,67.44,70.705,67.523,70.698,67.505,70.702,67.485,70.709,67.454,70.709,67.417,70.714,67.414,70.715,67.449,70.714,67.491,70.709,67.558,70.691,67.608,70.699,67.639,70.696,67.66,70.704,67.677,70.704,67.685,70.702,67.687,70.7,67.67,70.693,67.69,70.693,67.699,70.692,67.715,70.676,67.728,70.687,67.743,70.692,67.749,70.692,67.754,70.692,67.757,70.69,67.714,70.675,67.669,70.693,67.685,70.701,67.662,70.704,67.597,70.675,67.436,70.705,67.327,70.686,67.296,70.661,67.321,70.661,67.357,70.657,67.362,70.652,67.392,70.66,67.421,70.663,67.573,70.654,67.591,70.647,67.58,70.633,67.662,70.625,67.684,70.617,67.754,70.624,67.764,70.623,67.766,70.621,67.661,70.61,67.615,70.612,67.619,70.596,67.668,70.601,67.684,70.6,67.696,70.598,67.698,70.594,67.694,70.592,67.666,70.593,67.658,70.59,67.292,70.643,67.228,70.523,67.334,70.451,67.329,70.45,67.388,70.442,67.406,70.424,67.444,70.426,67.449,70.424,67.456,70.416,67.48,70.424,67.488,70.424,67.552,70.412,67.605,70.393,67.638,70.369,67.631,70.359,67.638,70.357,67.655,70.356,67.662,70.355,67.678,70.331,67.732,70.337,67.743,70.335,67.749,70.33,67.761,70.33,67.852,70.337,67.899,70.332,67.997,70.284,68.138,70.331,68.203,70.325,68.169,70.356,68.181,70.362,68.194,70.363,68.25,70.348,68.23,70.357,68.227,70.368,68.271,70.389,68.287,70.391,68.293,70.389,68.288,70.371,68.311,70.379,68.345,70.386,68.353,70.386,68.355,70.381,68.332,70.373,68.388,70.368,68.382,70.35,68.396,70.356,68.414,70.358,68.413,70.365,68.431,70.37,68.416,70.38,68.418,70.384,68.428,70.387,68.441,70.386,68.465,70.373,68.468,70.383,68.473,70.386,68.543,70.382,68.523,70.39,68.552,70.411,68.521,70.403,68.501,70.403,68.497,70.404,68.496,70.406,68.531,70.42,68.567,70.428,68.559,70.433,68.559,70.441,68.563,70.448,68.572,70.451,68.603,70.449,68.669,70.422,68.691,70.421,68.699,70.428,68.714,70.431,68.722,70.43,68.731,70.425,68.732,70.422,68.703,70.419,68.722,70.406,68.761,70.415,68.768,70.414,68.77,70.407,68.76,70.4,68.732,70.399,68.704,70.39,68.706,70.389,68.719,70.395,68.726,70.395,68.819,70.38,68.704,70.388,68.703,70.391,68.728,70.399,68.761,70.401,68.769,70.409,68.721,70.405,68.57,70.449,68.569,70.427,68.5,70.406,68.554,70.411,68.543,70.381,68.381,70.349,68.33,70.372,68.353,70.384,68.345,70.385,68.293,70.37,68.283,70.37,68.284,70.39,68.254,70.347,68.216,70.348,68.19,70.361,68.173,70.354,68.209,70.328,68.22,70.315,68.217,70.306,68.26,70.31,68.275,70.31,68.383,70.298,68.439,70.254,68.452,70.285,68.503,70.298,68.518,70.296,68.532,70.286,68.568,70.291,68.585,70.289,68.651,70.247,68.578,70.29,68.441,70.253,68.391,70.274,68.381,70.297,68.216,70.304,68.144,70.329,68.102,70.31,68.058,70.301,68.014,70.28,67.982,70.284,67.948,70.306,67.872,70.28,67.759,70.272,67.564,70.28,67.549,70.287,67.568,70.308,67.551,70.311,67.489,70.286,67.441,70.295,67.411,70.359,67.322,70.361,67.293,70.393,67.218,70.374,67.13,70.193,67.282,70.17,67.286,70.092,67.381,70.081,67.286,70.016,67.377,70.004,66.971,69.96,67.025,70.052,66.881,69.949,66.806,69.708,66.828,69.564,66.921,69.467,66.979,69.519,66.848,69.606,67.028,69.638,66.97,69.67,67.018,69.692,67.968,69.472,68.058,69.479,68.01,69.507,68.127,69.536,68.191,69.454,68.027,69.336,68.149,69.284,68.08,69.257,68.491,68.966,68.695,68.909,68.97,68.952,68.973,68.936,69.061,68.939,69.067,68.944,69.091,68.952,69.115,68.954,69.25,68.941,69.22,68.96,69.22,68.975,69.235,68.982,69.265,68.989,69.296,68.989,69.289,68.995,69.292,68.998,69.336,68.99,69.336,68.994,69.291,69.003,69.285,69.008,69.332,69.059,69.411,69.063,69.41,69.069,69.383,69.068,69.373,69.069,69.37,69.071,69.377,69.073,69.393,69.072,69.372,69.084,69.372,69.086,69.375,69.089,69.381,69.09,69.405,69.09,69.406,69.101,69.355,69.101,69.396,69.105,69.408,69.101,69.411,69.093,69.402,69.088,69.375,69.088,69.396,69.074,69.392,69.072,69.372,69.07,69.408,69.07,69.412,69.069,69.414,69.064,69.289,69.007,69.3,69.01,69.303,69.003,69.331,69.0,69.341,68.994,69.332,68.984,69.303,68.984,69.353,68.98,69.261,68.986,69.224,68.961,69.265,68.938,69.277,68.952,69.318,68.966,69.347,68.948,69.358,68.968,69.423,68.988,69.449,68.984,69.465,68.974,69.48,68.975,69.466,68.973,69.425,68.987,69.351,68.947,69.28,68.951,69.263,68.934,69.292,68.922,69.279,68.915,69.298,68.916,69.306,68.906,69.3,68.889,69.516,68.941,69.564,68.924,69.592,68.906,69.601,68.918,69.607,68.92,69.641,68.906,69.672,68.918,69.706,68.912,69.751,68.934,69.759,68.95,69.767,68.956,69.776,68.957,69.833,68.953,69.856,68.926,69.857,68.952,69.869,68.954,69.888,68.954,69.922,68.948,69.936,68.981,69.944,68.984,69.949,68.985,69.977,68.982,69.991,68.977,69.992,68.972,69.981,68.965,70.014,68.974,70.024,68.974,70.069,68.963,70.087,68.97,70.134,68.96,70.133,68.954,70.079,68.936,70.139,68.944,70.175,68.936,70.181,68.93,70.179,68.92,70.153,68.895,70.175,68.908,70.204,68.906,70.237,68.94,70.243,68.942,70.41,68.941,70.417,68.941,70.427,68.923,70.476,68.926,70.483,68.925,70.487,68.922,70.487,68.918,70.481,68.914,70.462,68.909,70.592,68.891,70.154,68.893,70.076,68.936,70.133,68.96,69.98,68.963,69.951,68.983,69.929,68.948,69.858,68.95,69.864,68.925,69.712,68.908,69.642,68.904,69.606,68.918,69.6,68.906,69.59,68.905,69.52,68.937,69.509,68.899,69.449,68.917,69.397,68.89,69.233,68.887,69.224,68.888,69.198,68.91,69.167,68.915,69.087,68.892,68.989,68.891,69.116,68.833,69.213,68.836,69.244,68.83,69.243,68.824,69.234,68.817,69.303,68.824,69.324,68.821,69.352,68.809,69.063,68.798,69.063,68.795,69.05,68.789,69.039,68.781,69.023,68.76,69.036,68.757,68.998,68.731,68.926,68.615,68.674,68.429,68.702,68.428,68.707,68.434,68.726,68.436,68.728,68.439,68.732,68.441,68.75,68.444,68.76,68.444,68.769,68.442,68.78,68.438,68.578,68.353,68.571,68.316,68.66,68.32,68.687,68.315,68.626,68.311,68.635,68.311,68.655,68.306,68.664,68.295,68.671,68.301,68.725,68.291,68.749,68.282,68.763,68.267,68.793,68.261,68.761,68.267,68.747,68.282,68.724,68.29,68.662,68.295,68.633,68.31,68.571,68.298,68.57,68.272,68.553,68.28,68.547,68.277,68.54,68.276,68.539,68.269,68.533,68.266,68.541,68.268,68.545,68.274,68.546,68.267,68.54,68.266,68.604,68.255,68.625,68.248,68.64,68.237,68.632,68.232,68.603,68.223,68.678,68.219,68.682,68.209,68.693,68.216,68.706,68.216,68.733,68.21,68.743,68.198,68.764,68.199,68.747,68.196,68.742,68.198,68.729,68.21,68.712,68.214,68.681,68.208,68.602,68.222,68.603,68.226,68.635,68.237,68.573,68.258,68.551,68.232,68.496,68.24,68.395,68.219,68.451,68.195,68.199,68.117,68.266,68.112,68.288,68.102,68.288,68.097,68.199,68.067,68.084,68.065,68.116,68.054,68.109,68.031,68.067,68.012,68.023,68.005,68.033,67.991,68.032,67.986,68.025,67.984,68.02,68.006,68.107,68.032,68.079,68.061,68.081,68.065,68.095,68.072,68.288,68.099,68.194,68.116,68.282,68.144,68.252,68.16,68.269,68.209,68.124,68.199,68.175,68.234,68.192,68.267,68.174,68.28,68.076,68.283,68.066,68.286,68.075,68.294,68.058,68.294,68.046,68.298,68.247,68.337,67.67,68.495,67.845,68.469,67.038,68.808,67.17,68.816,66.741,68.84,66.28,68.991,65.792,69.049,65.551,69.136,65.976,69.093,65.368,69.172,65.375,69.175,65.209,69.208,65.114,69.237,65.165,69.229,65.074,69.263,64.925,69.252,64.939,69.193,64.697,69.068,64.605,69.035,64.538,69.027,64.542,69.007,64.525,68.996,64.527,68.985,64.574,68.994,64.594,68.992,64.606,68.989,64.628,68.949,64.624,68.931,64.55,68.909,64.578,68.895,64.563,68.864,64.787,68.894,64.865,68.873,64.858,68.863,64.907,68.878,64.92,68.877,64.936,68.859,64.565,68.862,64.551,68.868,64.573,68.897,64.546,68.908,64.621,68.932,64.626,68.948,64.604,68.941,64.513,68.977,64.513,68.992,64.404,68.973,64.427,68.958,64.463,68.951,64.471,68.946,64.477,68.953,64.485,68.946,64.494,68.95,64.505,68.95,64.519,68.957,64.523,68.954,64.343,68.915,64.405,68.983,64.503,68.991,64.446,69.012,64.48,69.05,64.529,69.057,64.527,69.062,64.534,69.072,64.564,69.089,64.562,69.097,64.624,69.109,64.614,69.114,64.618,69.121,64.628,69.129,64.652,69.14,64.641,69.152,64.651,69.162,64.632,69.162,64.628,69.164,64.628,69.167,64.792,69.204,64.769,69.219,64.733,69.212,64.709,69.192,64.66,69.174,64.558,69.162,64.552,69.164,64.552,69.172,64.537,69.172,64.504,69.179,64.518,69.175,64.522,69.171,64.491,69.159,64.471,69.163,64.504,69.179,64.689,69.185,64.766,69.234,64.798,69.227,64.764,69.237,64.729,69.231,64.678,69.248,64.81,69.252,64.556,69.283,64.993,69.278,64.281,69.502,63.093,69.654,63.138,69.68,63.009,69.695,62.994,69.693,63.003,69.677,62.982,69.664,62.957,69.66,62.926,69.667,62.904,69.653,62.841,69.649,62.995,69.695,61.561,69.742,61.494,69.762,61.643,69.762,61.125,69.816,61.102,69.861,60.723,69.808,60.78,69.726,60.688,69.696,60.792,69.629,60.777,69.624,60.749,69.634,60.743,69.643,60.731,69.634,60.778,69.623,60.79,69.562,61.409,69.468,61.356,69.454,61.465,69.441,61.503,69.431,61.516,69.422,61.513,69.417,61.507,69.416,61.486,69.417,61.472,69.422,61.472,69.402,61.456,69.394,61.469,69.383,61.464,69.351,61.49,69.337,61.503,69.316,61.496,69.308,61.48,69.304,61.493,69.295,61.478,69.287,61.502,69.282,61.513,69.277,61.506,69.27,61.555,69.264,61.586,69.253,61.597,69.257,61.611,69.258,61.639,69.255,61.641,69.266,61.658,69.27,61.666,69.27,61.677,69.267,61.677,69.259,61.724,69.273,61.753,69.265,61.792,69.272,61.808,69.273,61.868,69.27,61.902,69.257,61.894,69.213,61.977,69.209,61.999,69.192,61.988,69.183,61.891,69.212,61.864,69.269,61.636,69.251,61.611,69.257,61.587,69.252,61.573,69.253,61.537,69.265,61.52,69.262,61.475,69.285,61.454,69.353,61.452,69.395,61.469,69.401,61.457,69.41,61.474,69.424,61.511,69.417,61.503,69.43,61.062,69.473,60.788,69.561,60.66,69.656,60.203,69.598,59.985,69.658,60.051,69.682,60.008,69.716,60.186,69.689,60.221,69.673,60.514,69.714,60.474,69.749,60.563,69.795,60.458,69.929,59.807,70.118,59.07,70.465,58.799,70.425,58.803,70.347,58.657,70.312,58.509,70.334,58.834,70.214,58.769,70.2,57.129,70.471,57.034,70.516,57.221,70.5,56.89,70.578,57.321,70.553,57.172,70.62,56.956,70.61,56.829,70.629,56.745,70.681,57.432,70.586,57.358,70.607,57.476,70.591,57.535,70.684,57.423,70.722,57.613,70.709,57.015,70.863,56.05,71.294,55.604,71.661,55.549,71.846,55.442,71.9,55.524,71.884,55.439,71.919,55.187,71.898,55.093,71.917,55.432,71.936,55.367,71.979,55.453,72.064,55.241,72.062,55.187,72.073,55.441,72.074,55.574,72.168,55.548,72.236,55.371,72.267,55.487,72.351,55.481,72.436,54.931,72.449,55.559,72.459,55.48,72.544,55.313,72.563,55.25,72.583,55.246,72.578,55.232,72.581,55.24,72.582,55.206,72.582,55.201,72.583,55.229,72.586,55.256,72.584,55.333,72.57,55.354,72.565,55.368,72.563,55.493,72.559,55.602,72.523,55.701,72.593,55.674,72.658,55.916,72.644,55.845,72.738,55.466,72.76,56.124,72.778,56.275,72.947,55.894,72.924,55.613,72.943,56.316,72.986,56.433,73.032,56.28,73.05,55.88,73.046,55.855,73.046,55.833,73.053,56.569,73.134,56.389,73.227,55.959,73.233,55.799,73.319,55.513,73.304,55.337,73.327,55.349,73.342,55.734,73.338,55.971,73.297,55.989,73.374,55.889,73.419,55.916,73.429,55.866,73.446,55.816,73.492,55.834,73.496,56.046,73.376,56.121,73.257,56.732,73.233,56.809,73.308,56.75,73.317,56.758,73.355,56.784,73.362,56.905,73.3,57.242,73.452,56.721,73.581,56.89,73.599,56.781,73.623,56.646,73.705,56.671,73.704,56.702,73.699,56.747,73.68,56.795,73.678,56.951,73.591,57.208,73.553,57.622,73.629,57.473,73.753,57.438,73.76,57.301,73.809,56.988,73.82,56.942,73.843,56.751,73.833,56.605,73.873,56.792,73.846,57.003,73.856,57.399,73.833,57.709,73.72,57.882,73.767,57.756,73.888,57.873,73.898,57.863,73.911,57.53,74.063,57.472,74.055,57.373,74.07,57.31,74.065,57.264,74.064,57.226,74.072,57.208,74.071,57.176,74.074,57.154,74.079,57.543,74.08,57.382,74.18,57.422,74.211,57.712,74.045,58.146,73.973,58.255,73.986,58.072,74.039,58.183,74.055,58.166,74.127,58.303,74.072,58.331,74.123,58.535,74.118,58.363,74.13,58.265,74.155,58.261,74.176,58.507,74.144,58.473,74.199,58.64,74.201,58.523,74.207,58.534,74.22,58.732,74.213,58.636,74.234,58.722,74.249,58.613,74.38,58.337,74.432,58.594,74.448,58.197,74.536,58.261,74.561,59.144,74.424,59.061,74.472,59.17,74.595,58.941,74.646,59.27,74.618,59.078,74.714,59.147,74.732,59.766,74.572,59.864,74.606,59.84,74.661,59.349,74.809,59.659,74.718,60.254,74.736,60.305,74.779,60.146,74.842,60.527,74.843,60.628,74.912,59.911,74.983,60.438,75.004,60.384,75.042,60.484,75.048,60.372,75.06,60.307,75.085,60.52,75.064,60.479,75.105,60.742,75.014,60.814,75.072,60.758,75.12,61.345,75.281,61.291,75.292,61.28,75.324,61.401,75.313,61.377,75.226,61.465,75.207,61.937,75.431,62.301,75.417,63.687,75.69,63.59,75.697,63.582,75.712,63.703,75.704,63.815,75.651,63.792,75.612,65.963,75.972,68.311,76.249,68.201,76.296,68.959,76.536,68.77,76.609,69.091,76.702,68.762,76.836,68.834,76.868,68.502,76.929,68.583,76.954,65.389,81.011,95.44,81.268,95.095,81.231,95.699,81.205,93.183,80.958,93.262,80.897,93.193,80.893,93.302,80.889,92.578,80.729,93.46,80.765,92.87,80.604,92.907,80.479,91.982,80.419,92.692,80.348,91.768,80.351,91.484,80.263,91.784,80.304,91.727,80.271,92.955,80.306,93.374,80.282,93.464,80.25,92.214,80.265,92.156,80.257,92.327,80.252,92.413,80.222,92.886,80.183,92.997,80.162,92.157,80.167,93.804,79.999,94.614,80.14,95.897,80.215,97.187,80.236,97.666,80.166,96.168,80.082,95.584,80.11,95.305,80.024,94.99,80.11,94.94,80.087,95.018,80.064,94.359,79.978,94.417,79.934,94.325,79.916,94.729,79.797,94.026,79.756,93.347,79.546,93.024,79.572,92.905,79.562,93.334,79.536,93.161,79.467,93.286,79.447,93.481,79.456,93.366,79.464,93.534,79.493,93.415,79.509,93.452,79.537,93.625,79.573,93.781,79.573,93.735,79.597,93.928,79.633,93.825,79.572,93.677,79.573,93.695,79.553,94.017,79.507,93.753,79.469,93.949,79.435,94.057,79.454,93.998,79.491,94.457,79.489,94.365,79.475,94.481,79.455,94.349,79.411,94.451,79.275,94.372,79.242,94.754,79.178,94.691,79.113,94.911,79.045,95.554,79.103,95.763,79.056,95.727,78.993,96.916,79.007,97.176,78.883,97.915,78.785,97.868,78.799,99.458,78.804,99.985,78.924,99.868,79.005,99.904,79.079,99.577,79.112,99.721,79.14,99.138,79.287,99.607,79.228,99.811,79.278,99.765,79.361,99.824,79.396,99.752,79.437,99.888,79.496,99.776,79.546,99.947,79.666,99.86,79.741,100.096,79.706,99.966,79.575,100.104,79.646,100.342,79.659,102.457,79.424,102.22,79.354,102.318,79.22,101.989,79.239,101.853,79.359,101.598,79.317,101.681,79.23,101.331,79.211,101.222,79.139,101.456,79.11,101.076,79.044,101.65,78.958,101.018,79.009,100.818,78.852,101.264,78.741,100.634,78.795,100.359,78.657,100.415,78.575,100.169,78.486,100.247,78.458,100.051,78.405,100.151,78.346,99.388,78.013,99.535,77.999,99.487,77.956,99.566,77.921,99.999,77.939,101.345,78.179,102.639,78.198,102.892,78.137,103.092,78.222,104.746,78.319,105.928,77.552,104.976,77.56,104.834,77.599,104.844,77.668,104.408,77.645,104.247,77.721,103.372,77.619],"holes":[[77.571,79.497,76.68,79.631,76.093,79.655,76.782,79.487,77.571,79.497],[92.107,79.742,92.18,79.72,91.978,79.682,93.129,79.703,93.875,79.911,92.966,80.031,90.982,80.062,91.344,80.02,91.152,79.952,91.407,79.91,91.144,79.866,91.202,79.838,92.416,79.744,92.107,79.742],[79.155,80.742,79.895,80.761,80.17,80.844,79.454,80.902,78.806,80.785,79.155,80.742],[90.94,81.027,91.639,81.122,91.082,81.225,90.095,81.161,90.199,81.077,90.94,81.027],[96.473,77.178,96.044,77.091,95.384,76.994,96.028,76.998,95.722,76.909,96.233,76.974,96.286,77.004,96.192,77.003,96.576,77.119,96.473,77.178],[96.398,76.136,96.481,76.165,96.239,76.218,96.493,76.201,96.403,76.233,96.471,76.299,96.147,76.225,96.163,76.287,95.915,76.261,95.886,76.317,95.331,76.233,96.462,76.117,96.398,76.136],[82.172,75.489,82.289,75.505,82.114,75.507,81.881,75.47,82.105,75.492,81.886,75.446,81.979,75.392,81.929,75.36,81.706,75.399,81.882,75.462,81.743,75.426,81.626,75.373,81.592,75.339,81.595,75.289,81.704,75.202,81.896,75.151,81.671,75.234,81.604,75.334,81.72,75.336,81.637,75.306,81.768,75.259,81.722,75.287,81.915,75.308,81.938,75.198,82.041,75.158,81.999,75.22,82.091,75.263,82.003,75.266,82.087,75.28,82.036,75.334,82.329,75.319,82.055,75.436,82.202,75.417,82.172,75.489],[87.01,74.99,86.522,74.976,86.666,74.895,86.226,74.885,86.887,74.802,87.0,74.836,86.71,74.905,86.975,74.867,87.183,74.931,87.01,74.99],[71.32,73.375,71.149,73.352,71.153,73.292,71.106,73.419,71.264,73.41,70.836,73.478,70.012,73.378,70.135,73.297,70.044,73.304,69.94,73.04,70.134,72.998,70.023,73.05,70.153,73.084,70.406,73.011,71.682,73.157,71.417,73.322,71.28,73.238,71.002,73.265,71.377,73.313,71.32,73.375],[74.42,73.11,74.111,72.995,74.65,72.834,74.629,72.942,74.718,73.066,74.918,73.034,74.705,73.085,74.42,73.11],[79.091,73.057,78.599,72.807,79.041,72.721,79.577,72.745,79.474,72.91,79.534,72.91,79.384,73.012,79.239,73.089,79.091,73.057],[71.476,66.848,71.237,66.862,70.727,66.734,71.112,66.764,71.266,66.726,71.428,66.761,71.331,66.73,71.543,66.682,71.393,66.671,71.523,66.682,71.422,66.681,71.092,66.761,70.841,66.74,71.416,66.627,71.343,66.618,71.585,66.646,71.535,66.667,71.648,66.718,71.414,66.752,71.536,66.766,71.426,66.845,71.476,66.848],[70.977,66.589,70.599,66.677,70.286,66.599,70.602,66.51,71.031,66.555,70.977,66.589],[68.302,66.685,68.04,66.693,68.62,66.745,67.731,66.789,67.471,66.678,68.021,66.519,68.293,66.547,68.638,66.71,68.216,66.56,68.388,66.644,68.346,66.635,68.227,66.642,68.193,66.635,68.067,66.625,68.019,66.635,68.0,66.649,68.067,66.626,68.226,66.643,68.259,66.641,68.179,66.661,68.309,66.637,68.469,66.668,68.224,66.668,68.302,66.685],[77.624,72.601,76.94,72.359,76.855,72.291,77.804,72.287,78.359,72.458,78.192,72.555,77.624,72.601],[82.763,70.34,82.592,70.527,82.596,70.479,82.464,70.47,82.594,70.479,82.467,70.582,82.227,70.489,82.493,70.463,82.649,70.383,82.468,70.394,82.489,70.377,82.461,70.364,82.553,70.312,82.46,70.363,82.46,70.367,82.487,70.377,82.466,70.392,82.471,70.399,82.246,70.426,82.584,70.411,82.223,70.485,82.229,70.414,82.564,70.251,82.666,70.231,82.805,70.306,82.763,70.34],[82.799,69.886,83.231,69.886,82.956,69.975,82.66,70.002,82.77,69.95,82.852,69.95,82.886,69.938,82.828,69.93,82.885,69.938,82.772,69.948,82.662,69.997,82.453,70.013,82.578,69.974,82.521,69.979,82.249,70.114,82.365,70.157,82.218,70.124,82.315,69.966,82.799,69.886],[82.504,70.68,82.436,70.667,82.458,70.595,82.689,70.453,82.947,70.374,83.027,70.437,82.982,70.554,82.683,70.5,82.882,70.627,82.568,70.726,82.504,70.681,82.555,70.752,82.529,70.741,82.494,70.681,82.504,70.68],[82.794,70.943,82.652,70.755,82.834,70.669,83.087,70.732,82.794,70.943]]}],"names":{"ar":"بحر كارا","de":"Karasee","es":"mar de Kara","fr":"mer de Kara","he":"ים קארה","hu":"Kara-tenger","it":"mare di Kara","nl":"Karazee","pt":"Mar de Kara","zh":"喀拉海"}},{"name":"Norwegian Sea","bbox":[-13.501,60.866,25.787,76.565],"area":321.0467,"polygons":[{"outer":[16.721,76.565,19.092,74.52,18.769,74.477,18.828,74.431,25.777,71.172,25.522,71.155,25.731,71.071,25.45,71.121,25.455,71.087,25.271,71.1,25.269,71.085,25.319,71.071,25.243,71.051,25.545,71.032,25.39,70.995,25.55,70.932,25.746,70.99,25.787,70.897,25.438,70.847,25.548,70.914,25.321,70.98,25.394,70.888,25.233,70.797,25.113,70.932,25.028,70.867,24.886,70.875,25.031,70.931,25.013,70.972,24.95,70.92,24.804,70.915,24.856,70.924,24.774,70.991,24.558,71.021,24.654,70.994,24.536,70.97,24.643,70.96,24.551,70.901,24.707,70.871,24.557,70.865,24.624,70.794,24.593,70.784,24.435,70.824,24.351,70.877,24.242,70.778,24.535,70.749,24.661,70.695,24.591,70.688,24.692,70.627,24.298,70.686,24.09,70.541,24.25,70.509,24.306,70.48,24.331,70.446,23.857,70.495,23.43,70.314,23.594,70.292,23.247,70.248,23.507,70.241,23.179,70.216,23.372,70.18,23.267,70.173,23.381,70.116,23.134,70.084,23.529,70.016,23.474,69.981,23.354,69.957,23.001,69.92,23.144,69.974,22.954,70.043,23.004,70.143,22.839,70.097,22.273,70.038,22.831,70.107,22.928,70.205,22.358,70.26,22.291,70.161,22.439,70.149,22.54,70.113,22.224,70.127,22.299,70.277,22.077,70.231,22.094,70.303,21.954,70.277,21.984,70.329,21.903,70.313,21.774,70.247,21.87,70.215,21.814,70.204,21.861,70.14,21.566,70.324,21.474,70.3,21.498,70.195,21.419,70.273,21.283,70.254,21.189,70.201,21.478,70.174,21.406,70.144,21.723,70.052,22.071,70.125,21.778,70.035,22.087,70.029,22.063,70.02,22.01,70.013,22.042,69.935,21.966,69.993,21.865,69.935,22.029,69.848,21.89,69.835,22.092,69.776,22.032,69.767,22.052,69.767,22.138,69.749,22.143,69.745,22.062,69.723,21.963,69.737,22.057,69.739,21.579,69.914,21.441,70.014,21.243,70.01,21.186,69.936,21.326,69.907,21.2,69.904,21.227,69.849,21.138,69.803,21.132,69.826,21.197,69.862,21.194,69.88,21.036,69.874,20.99,69.82,21.047,69.794,21.012,69.792,21.021,69.789,21.022,69.764,21.029,69.759,21.029,69.754,21.04,69.75,21.035,69.746,20.848,69.854,21.071,69.96,20.772,69.886,20.785,69.828,20.709,69.792,20.534,69.766,20.461,69.763,20.527,69.724,20.483,69.615,20.819,69.496,20.415,69.59,20.267,69.377,19.92,69.262,20.245,69.474,20.21,69.496,20.281,69.549,20.222,69.576,20.35,69.616,20.254,69.681,20.394,69.878,20.331,69.96,20.202,69.939,20.201,69.855,20.134,69.938,19.984,69.766,19.991,69.846,19.77,69.684,20.145,69.582,19.73,69.607,19.733,69.486,19.648,69.445,19.659,69.424,19.651,69.424,19.654,69.422,19.647,69.411,19.644,69.409,19.49,69.395,19.674,69.484,19.64,69.54,19.74,69.641,19.647,69.667,19.646,69.66,19.64,69.656,19.627,69.657,19.624,69.66,19.733,69.805,19.131,69.748,18.943,69.561,19.18,69.572,19.249,69.518,19.078,69.547,18.967,69.46,19.117,69.375,19.372,69.375,19.418,69.254,19.553,69.216,19.237,69.235,19.35,69.311,18.99,69.389,18.841,69.49,18.838,69.561,18.775,69.543,18.447,69.511,18.482,69.478,18.454,69.44,18.64,69.368,18.934,69.328,19.006,69.289,18.873,69.327,18.641,69.313,18.717,69.28,18.701,69.253,18.543,69.351,18.537,69.279,18.529,69.265,18.523,69.261,18.514,69.261,18.512,69.237,18.481,69.207,18.566,69.181,18.607,69.138,18.581,69.124,18.606,69.123,18.618,69.117,18.625,69.103,18.621,69.093,18.573,69.064,18.621,69.102,18.579,69.122,18.603,69.139,18.474,69.206,18.5,69.367,18.317,69.364,18.368,69.459,18.154,69.466,18.179,69.391,17.985,69.308,17.97,69.228,18.166,69.149,17.645,69.108,17.696,69.086,17.476,68.995,17.559,68.964,17.458,68.893,17.718,68.933,17.86,68.865,17.702,68.903,17.471,68.832,17.763,68.79,17.803,68.748,17.47,68.822,17.246,68.763,17.423,68.744,17.682,68.67,16.981,68.718,16.59,68.631,16.618,68.533,16.429,68.542,16.47,68.53,16.45,68.507,16.527,68.488,16.507,68.445,16.997,68.525,17.104,68.484,17.059,68.476,17.013,68.455,17.213,68.459,17.552,68.55,17.464,68.47,17.632,68.475,17.899,68.417,17.296,68.42,17.26,68.321,17.405,68.26,17.356,68.26,17.365,68.208,17.323,68.192,17.337,68.28,17.24,68.312,17.181,68.393,16.855,68.339,16.897,68.387,16.737,68.423,16.274,68.375,16.621,68.279,16.804,68.152,16.506,68.284,16.259,68.331,16.17,68.322,16.365,68.247,16.199,68.211,16.477,68.243,16.609,68.182,16.4,68.208,16.482,68.187,16.481,68.166,16.241,68.138,16.327,68.097,16.496,68.133,16.738,68.085,16.458,68.122,16.358,68.061,16.519,67.966,16.318,68.03,16.461,67.916,16.285,68.027,16.191,68.022,16.283,67.986,16.236,67.904,16.466,67.859,16.517,67.817,16.205,67.898,16.22,67.973,16.063,68.048,16.141,68.187,16.114,68.199,16.094,68.179,16.046,68.178,16.02,68.189,16.088,68.205,16.122,68.206,16.002,68.266,15.806,68.097,15.633,68.057,15.6,68.111,15.727,68.15,15.62,68.189,15.628,68.141,15.482,68.161,15.312,68.079,15.351,68.065,15.277,68.061,15.414,67.995,15.52,68.099,15.598,68.073,15.589,68.069,15.57,68.07,15.577,68.045,15.83,68.055,15.993,67.996,15.88,67.977,15.929,67.965,15.869,67.907,15.735,67.97,15.178,67.85,15.256,67.888,15.058,67.908,14.851,67.878,15.113,67.869,14.811,67.849,14.758,67.799,14.841,67.806,14.872,67.787,14.823,67.771,15.027,67.793,15.125,67.763,14.785,67.738,14.895,67.712,14.736,67.669,15.168,67.718,15.379,67.804,15.369,67.879,15.856,67.71,15.295,67.735,15.248,67.702,15.338,67.692,15.161,67.632,15.286,67.639,15.439,67.611,15.24,67.617,15.216,67.556,15.523,67.51,15.675,67.564,15.891,67.582,15.537,67.49,15.577,67.411,15.672,67.42,15.592,67.347,15.453,67.493,15.14,67.433,15.241,67.509,14.931,67.49,15.08,67.53,15.0,67.577,15.09,67.543,15.154,67.564,15.08,67.593,14.931,67.576,14.651,67.477,15.054,67.453,14.845,67.384,14.864,67.437,14.809,67.448,14.652,67.442,14.573,67.404,14.632,67.385,14.436,67.309,14.389,67.307,14.395,67.289,14.31,67.264,14.734,67.314,14.714,67.241,14.613,67.243,14.701,67.224,15.172,67.33,15.038,67.272,15.43,67.26,15.349,67.222,15.482,67.184,15.47,67.098,15.219,67.222,15.003,67.232,14.918,67.167,14.976,67.155,14.971,67.119,14.886,67.172,14.979,67.249,14.883,67.229,14.799,67.24,14.706,67.212,14.498,67.239,14.356,67.191,14.514,67.21,14.746,67.19,14.47,67.182,14.467,67.201,14.24,67.093,14.398,67.096,14.585,67.071,14.612,67.035,14.572,67.009,14.398,67.092,14.23,67.077,14.269,67.037,14.151,66.976,14.052,67.082,14.063,66.992,14.014,66.963,13.906,66.949,13.961,67.013,13.74,66.948,13.493,66.952,13.725,66.86,13.674,66.832,13.997,66.798,13.488,66.795,13.758,66.759,13.604,66.762,13.473,66.74,13.508,66.738,13.553,66.742,13.573,66.71,13.772,66.733,13.901,66.73,13.708,66.7,13.217,66.726,13.567,66.649,13.229,66.681,13.168,66.66,13.383,66.637,13.205,66.556,13.445,66.562,13.495,66.591,13.564,66.608,13.733,66.609,13.47,66.567,13.653,66.537,13.667,66.516,13.264,66.53,13.268,66.466,13.426,66.474,13.258,66.446,13.126,66.551,12.981,66.531,13.172,66.466,13.078,66.466,13.159,66.406,13.001,66.331,13.562,66.308,13.192,66.278,13.018,66.179,13.57,66.291,13.631,66.241,13.737,66.295,13.78,66.292,13.732,66.265,13.753,66.257,14.128,66.341,14.164,66.336,14.177,66.331,14.17,66.324,14.193,66.324,13.787,66.191,13.821,66.238,13.599,66.225,13.842,66.163,13.806,66.114,13.754,66.173,13.549,66.104,13.61,66.201,13.561,66.201,13.544,66.194,13.487,66.192,13.586,66.217,13.525,66.242,12.678,66.071,13.052,66.077,12.895,65.973,13.14,65.948,13.134,65.921,13.171,65.91,13.159,65.884,13.192,65.865,13.185,65.839,13.211,65.827,13.216,65.816,13.211,65.8,13.235,65.778,13.241,65.764,13.24,65.758,13.126,65.896,12.87,65.974,12.682,65.929,12.598,65.825,12.691,65.846,12.72,65.779,12.704,65.776,12.69,65.782,12.648,65.815,12.538,65.75,12.55,65.746,12.606,65.722,12.611,65.71,12.626,65.714,12.624,65.701,12.67,65.69,12.652,65.651,12.701,65.653,12.79,65.633,12.612,65.651,12.515,65.74,12.348,65.628,12.374,65.582,12.418,65.59,12.426,65.528,12.539,65.585,12.458,65.52,12.525,65.514,12.565,65.588,12.628,65.528,12.55,65.508,12.666,65.481,12.605,65.464,12.781,65.465,12.612,65.435,12.711,65.365,12.527,65.456,12.455,65.453,12.521,65.403,12.514,65.383,12.442,65.417,12.291,65.588,12.23,65.554,12.274,65.563,12.24,65.547,12.26,65.545,12.257,65.52,12.177,65.441,12.222,65.482,12.297,65.504,12.237,65.466,12.345,65.493,12.353,65.467,12.161,65.391,12.276,65.393,12.199,65.346,12.148,65.371,12.041,65.216,12.264,65.275,12.234,65.33,12.472,65.37,12.251,65.231,12.363,65.267,12.462,65.257,12.334,65.229,12.511,65.196,12.352,65.159,12.494,65.134,12.919,65.324,12.943,65.333,12.959,65.313,12.435,65.072,12.095,65.04,11.979,65.067,12.182,65.055,12.341,65.113,12.233,65.11,12.304,65.161,12.201,65.213,12.112,65.184,12.166,65.124,11.977,65.084,12.011,65.101,12.036,65.149,11.988,65.168,11.739,65.02,11.943,65.073,11.951,65.061,11.476,64.938,11.617,64.93,11.572,64.887,11.542,64.888,11.46,64.931,11.462,64.938,11.307,64.883,11.308,64.84,11.25,64.829,11.264,64.813,11.24,64.807,11.444,64.81,11.35,64.765,11.182,64.741,11.393,64.752,11.47,64.792,11.411,64.788,11.66,64.863,11.614,64.866,12.056,64.971,12.149,64.968,12.182,64.959,12.213,64.944,12.254,64.95,12.299,64.947,12.392,64.925,12.455,64.89,12.174,64.934,12.138,64.964,11.694,64.874,11.653,64.836,11.751,64.842,11.817,64.861,11.795,64.837,11.625,64.815,11.743,64.809,11.739,64.794,11.805,64.806,11.867,64.794,11.381,64.67,11.522,64.563,11.602,64.586,11.538,64.601,11.601,64.62,11.79,64.589,11.549,64.532,11.576,64.557,11.521,64.558,11.492,64.575,11.485,64.559,11.365,64.641,11.383,64.595,11.342,64.571,11.437,64.54,11.427,64.55,11.505,64.562,11.527,64.551,11.537,64.519,11.446,64.508,11.446,64.453,11.619,64.48,11.746,64.463,11.795,64.443,11.854,64.449,11.887,64.448,11.897,64.444,11.908,64.48,11.955,64.493,11.99,64.498,12.0,64.493,11.9,64.443,11.79,64.441,11.621,64.477,11.405,64.448,11.392,64.389,11.211,64.306,11.346,64.461,11.165,64.44,11.089,64.57,10.958,64.606,10.952,64.594,10.905,64.579,10.985,64.497,10.656,64.453,10.819,64.437,10.846,64.372,10.762,64.379,10.617,64.459,10.553,64.438,10.64,64.391,10.531,64.44,10.463,64.411,10.69,64.362,10.444,64.354,10.495,64.29,10.265,64.237,10.394,64.202,10.378,64.181,10.213,64.207,10.228,64.15,10.119,64.125,10.17,64.104,10.253,64.116,10.257,64.103,10.028,64.098,10.083,64.05,9.999,64.013,10.057,63.989,9.934,63.944,10.011,63.971,10.114,63.965,9.932,63.906,10.194,63.96,10.222,63.927,9.769,63.823,9.688,63.822,9.818,63.866,9.692,63.85,9.56,63.768,9.614,63.756,9.764,63.769,9.798,63.769,9.803,63.762,9.53,63.713,9.575,63.709,9.567,63.65,9.679,63.702,10.12,63.783,10.015,63.732,10.121,63.751,10.044,63.718,9.804,63.661,9.94,63.499,10.103,63.496,10.956,63.745,11.049,63.881,10.939,63.905,10.816,63.855,10.688,63.841,10.668,63.818,10.6,63.799,10.591,63.807,10.684,63.843,10.89,63.893,11.022,63.957,11.101,63.982,11.17,63.994,11.187,64.004,11.207,64.007,11.223,64.073,11.377,64.11,11.233,64.038,11.359,64.039,11.494,64.005,11.072,63.86,11.137,63.839,11.226,63.865,11.247,63.862,11.292,63.871,11.303,63.869,11.374,63.949,11.3,63.836,11.402,63.848,11.506,63.783,10.997,63.713,10.628,63.549,10.933,63.613,10.753,63.51,10.945,63.453,10.189,63.451,10.057,63.386,10.261,63.34,10.264,63.335,10.253,63.328,10.278,63.323,10.278,63.317,9.827,63.303,9.979,63.442,9.695,63.643,9.494,63.58,9.564,63.583,9.585,63.529,9.489,63.573,9.418,63.5,9.391,63.516,9.393,63.56,9.149,63.488,9.511,63.485,9.494,63.466,9.177,63.414,9.49,63.389,9.095,63.287,9.121,63.349,9.181,63.374,9.002,63.465,9.024,63.413,8.906,63.388,8.659,63.41,8.845,63.347,8.492,63.278,8.53,63.272,8.622,63.189,8.986,63.208,8.977,63.202,8.547,63.159,8.624,63.118,8.62,63.095,8.16,63.124,8.221,63.029,8.494,63.019,8.323,62.996,8.366,62.97,8.578,63.023,8.464,62.971,8.679,62.978,8.422,62.948,8.475,62.902,8.613,62.881,8.699,62.821,8.583,62.868,8.42,62.902,8.521,62.845,8.308,62.858,8.334,62.965,8.134,63.035,8.111,63.107,7.886,63.025,7.983,63.039,8.205,62.912,8.15,62.891,8.192,62.829,8.544,62.73,8.547,62.677,8.142,62.82,8.036,62.968,7.471,62.901,7.292,62.999,7.309,63.01,7.347,63.015,7.334,63.017,6.902,62.912,6.978,62.896,6.997,62.835,7.173,62.85,7.241,62.81,7.025,62.83,6.969,62.735,7.719,62.793,7.362,62.739,7.427,62.718,7.417,62.708,7.345,62.696,8.069,62.776,8.02,62.75,8.147,62.688,7.958,62.746,7.502,62.664,7.535,62.588,7.395,62.63,7.549,62.554,7.727,62.585,7.785,62.574,7.563,62.498,7.359,62.608,7.148,62.629,7.14,62.524,7.083,62.653,6.91,62.647,6.939,62.615,6.917,62.6,6.934,62.586,6.915,62.575,6.77,62.656,6.626,62.629,6.635,62.598,6.613,62.558,6.572,62.625,6.258,62.585,6.288,62.543,6.254,62.529,6.574,62.543,6.464,62.515,6.61,62.524,6.722,62.492,6.53,62.51,6.354,62.474,6.256,62.47,6.185,62.484,6.091,62.471,6.094,62.455,6.124,62.472,6.252,62.456,6.353,62.46,6.394,62.419,6.813,62.482,6.943,62.427,6.992,62.313,7.341,62.293,7.433,62.25,7.037,62.276,7.04,62.172,6.964,62.114,7.143,62.126,7.189,62.114,7.196,62.099,6.874,62.083,7.017,62.177,7.009,62.285,6.916,62.412,6.725,62.453,6.55,62.411,6.594,62.335,6.409,62.386,6.527,62.207,6.594,62.212,6.653,62.194,6.554,62.194,6.61,62.144,6.552,62.105,6.344,62.375,5.959,62.249,6.001,62.239,6.011,62.216,6.117,62.191,5.932,62.222,6.141,62.095,6.309,62.081,6.314,62.06,6.079,62.057,5.98,62.143,5.847,62.006,5.944,62.117,5.914,62.19,5.67,62.175,5.723,62.089,5.488,62.202,5.403,62.129,5.622,62.068,5.457,62.004,5.495,62.043,5.315,62.123,5.327,62.169,5.098,62.195,5.117,62.101,5.233,62.105,5.424,62.02,5.22,61.934,5.161,61.984,5.173,61.897,5.322,61.91,5.384,61.931,5.446,61.94,5.987,61.906,5.731,61.894,5.807,61.857,5.91,61.883,6.51,61.832,6.556,61.878,6.699,61.903,6.71,61.882,6.846,61.867,6.477,61.805,6.079,61.847,6.178,61.765,6.018,61.845,5.966,61.766,5.919,61.733,5.911,61.732,5.982,61.848,5.649,61.813,5.78,61.853,5.433,61.918,5.187,61.882,5.322,61.885,5.381,61.847,5.095,61.779,5.295,61.773,5.166,61.765,5.16,61.759,5.149,61.756,5.22,61.727,5.164,61.704,4.989,61.74,4.989,61.629,5.003,61.643,5.409,61.643,5.373,61.63,5.138,61.605,5.07,61.613,5.053,61.6,4.996,61.599,5.002,61.579,5.228,61.612,5.337,61.614,5.388,61.608,5.457,61.573,5.237,61.607,5.175,61.595,5.397,61.551,5.156,61.535,5.501,61.496,5.48,61.455,5.72,61.515,5.842,61.462,5.713,61.495,5.585,61.443,5.075,61.451,4.973,61.415,5.164,61.432,5.032,61.394,5.079,61.322,5.19,61.36,5.409,61.386,5.655,61.377,5.675,61.362,5.201,61.347,5.227,61.308,5.061,61.307,4.946,61.247,5.311,61.189,5.072,61.17,5.343,61.139,5.394,61.07,5.816,61.209,6.447,61.112,6.545,61.14,6.501,61.277,6.653,61.285,6.753,61.42,6.742,61.348,6.596,61.208,6.995,61.157,7.113,61.246,7.173,61.262,7.119,61.211,7.17,61.2,7.077,61.199,6.995,61.152,6.926,61.142,6.993,61.106,7.394,61.18,7.249,61.3,7.348,61.344,7.343,61.369,7.293,61.388,7.285,61.403,7.597,61.494,7.313,61.297,7.42,61.201,7.715,61.232,7.374,61.147,7.468,61.101,7.042,61.104,7.082,60.977,7.193,60.939,7.138,60.866,7.155,60.939,7.05,60.986,6.842,60.881,7.038,61.0,7.009,61.066,6.642,61.176,6.362,61.003,6.409,61.071,6.342,61.07,6.305,61.04,6.269,61.035,6.319,61.083,5.92,61.132,5.806,61.05,5.803,61.127,5.513,61.065,5.482,60.987,5.182,61.087,5.034,61.0,4.733,61.0,4.748,61.016,4.718,61.039,4.705,61.104,4.693,61.109,4.674,61.0,-0.883,61.0,-6.264,62.346,-13.501,65.083,-9.032,70.825,-7.991,71.016,-7.93,71.143,-8.007,71.159,16.489,76.516,16.583,76.465,16.501,76.519,16.721,76.565],"holes":[[23.319,70.872,23.296,70.769,23.05,70.817,23.173,70.737,23.014,70.655,22.796,70.771,22.678,70.741,22.836,70.653,22.71,70.618,22.567,70.633,22.658,70.684,22.52,70.721,22.37,70.721,22.39,70.626,21.929,70.643,22.046,70.627,22.011,70.581,22.31,70.601,22.087,70.475,22.298,70.54,22.319,70.484,22.932,70.529,22.884,70.573,23.066,70.57,23.159,70.699,23.504,70.782,23.319,70.872],[23.72,70.748,23.595,70.601,23.736,70.523,23.973,70.518,24.129,70.602,23.72,70.748],[23.598,70.507,23.531,70.522,23.565,70.623,23.39,70.628,23.521,70.537,23.247,70.549,23.322,70.525,23.255,70.468,23.217,70.542,23.13,70.547,23.226,70.516,22.816,70.411,23.005,70.335,23.121,70.407,23.039,70.327,23.174,70.273,23.639,70.438,23.598,70.507],[22.977,70.287,22.777,70.391,22.708,70.324,22.685,70.393,22.344,70.343,22.565,70.261,22.992,70.237,22.977,70.287],[20.684,70.233,20.41,70.177,20.368,70.085,20.801,70.054,20.751,70.103,20.811,70.201,20.684,70.233],[19.484,69.934,19.674,69.998,19.534,69.984,19.49,70.054,19.353,69.991,19.271,70.035,19.305,70.083,19.185,70.09,18.704,69.934,19.099,69.786,19.344,69.822,19.484,69.934],[19.042,69.784,18.688,69.883,18.662,69.827,18.779,69.8,18.679,69.701,18.749,69.687,18.648,69.69,18.641,69.805,18.49,69.739,18.369,69.793,18.322,69.713,18.622,69.696,18.187,69.71,18.287,69.62,18.401,69.633,18.006,69.568,18.738,69.558,18.841,69.602,18.747,69.676,19.042,69.784],[17.986,69.517,17.848,69.448,17.921,69.52,17.882,69.58,17.784,69.533,17.626,69.599,17.679,69.482,17.482,69.6,17.682,69.468,17.314,69.541,17.404,69.485,17.197,69.497,17.499,69.425,17.285,69.409,17.396,69.362,16.869,69.378,17.192,69.35,16.899,69.314,17.19,69.288,16.889,69.2,17.212,69.196,16.814,69.123,16.783,69.057,17.166,69.083,17.026,69.034,17.17,69.014,17.232,69.028,17.177,69.084,17.494,69.207,17.544,69.194,17.487,69.146,18.01,69.191,17.852,69.228,17.966,69.252,17.879,69.318,18.095,69.361,18.065,69.439,17.9,69.424,18.069,69.489,17.986,69.517],[16.594,68.721,16.541,68.718,16.593,68.839,16.261,68.921,16.336,68.834,16.087,68.822,16.273,68.741,16.065,68.744,16.024,68.687,16.086,68.638,15.994,68.674,15.807,68.618,15.827,68.566,15.725,68.534,15.805,68.566,15.775,68.628,16.01,68.779,15.771,68.736,15.957,68.897,15.902,68.967,15.622,68.954,15.636,68.887,15.549,68.864,15.675,68.815,15.474,68.819,15.46,68.754,15.716,68.694,15.452,68.73,15.63,68.618,15.207,68.575,15.178,68.533,15.257,68.53,15.367,68.502,15.229,68.494,15.404,68.468,15.212,68.466,14.98,68.273,15.535,68.517,15.612,68.487,15.332,68.349,15.657,68.309,15.771,68.435,15.839,68.407,15.872,68.51,15.875,68.378,16.137,68.512,16.091,68.557,16.514,68.566,16.594,68.721],[14.378,68.191,14.671,68.24,14.622,68.279,14.714,68.358,14.8,68.33,14.723,68.258,14.849,68.294,15.196,68.475,14.882,68.459,14.896,68.392,14.779,68.369,14.874,68.431,14.412,68.393,14.433,68.302,14.336,68.343,14.355,68.277,14.262,68.261,14.386,68.262,14.211,68.229,14.209,68.17,14.378,68.191],[13.844,68.142,13.795,68.16,14.273,68.3,13.654,68.311,13.511,68.247,13.661,68.238,13.48,68.205,13.539,68.193,13.488,68.146,13.62,68.171,13.462,68.086,13.844,68.142],[14.849,68.59,15.374,68.664,15.406,68.733,15.265,68.756,15.393,68.853,15.246,68.824,15.321,68.918,15.108,68.952,15.194,68.98,15.107,69.021,15.005,68.946,15.156,68.813,14.911,68.854,14.992,68.775,14.897,68.74,14.874,68.794,14.704,68.749,14.707,68.835,14.643,68.833,14.639,68.735,14.544,68.817,14.43,68.756,14.584,68.736,14.37,68.684,14.527,68.6,14.678,68.711,14.744,68.655,15.148,68.753,14.784,68.636,14.849,68.59],[15.487,69.024,15.417,68.902,15.484,68.871,15.603,68.98,15.547,69.006,15.878,69.027,16.18,69.287,15.85,69.255,15.814,69.164,15.487,69.024],[8.992,63.61,8.722,63.653,8.664,63.639,8.783,63.638,8.546,63.594,8.746,63.562,8.477,63.56,8.272,63.473,8.531,63.427,9.202,63.566,8.992,63.61]]}],"names":{"ar":"بحر النرويج","de":"Europäisches Nordmeer","es":"mar de Noruega","fr":"mer de Norvège","he":"הים הנורווגי","hu":"Norvég-tenger","it":"Mar di Norvegia","nl":"Noorse Zee","pt":"Mar da Noruega","zh":"挪威海"}},{"name":"Coral Sea","bbox":[141.022,-30.0,169.865,-6.793],"area":350.4941,"polygons":[{"outer":[168.491,-16.795,168.584,-16.871,168.529,-16.935,168.598,-16.886,168.594,-17.688,168.444,-17.53,168.313,-17.522,168.156,-17.712,168.263,-17.688,168.31,-17.722,168.313,-17.752,168.296,-17.744,168.257,-17.781,168.3,-17.776,168.328,-17.738,168.4,-17.826,168.455,-17.802,168.547,-17.797,168.594,-17.697,169.235,-18.736,169.036,-18.633,168.992,-18.877,169.284,-18.996,169.343,-18.932,169.507,-19.521,169.835,-20.135,169.741,-20.186,169.865,-20.246,167.567,-22.733,159.119,-29.472,159.033,-30.0,153.221,-30.0,153.371,-29.428,153.294,-29.523,153.289,-29.382,153.282,-29.418,153.274,-29.427,153.197,-29.454,153.205,-29.475,153.184,-29.538,152.947,-29.698,152.936,-29.701,152.92,-29.696,152.896,-29.662,152.898,-29.646,152.928,-29.604,152.902,-29.578,152.893,-29.587,152.903,-29.601,152.903,-29.614,152.899,-29.622,152.889,-29.625,152.863,-29.616,152.857,-29.642,152.851,-29.643,152.843,-29.64,152.863,-29.614,152.899,-29.619,152.891,-29.587,152.903,-29.577,152.929,-29.596,152.931,-29.61,152.899,-29.659,152.949,-29.694,153.043,-29.583,153.1,-29.544,153.103,-29.498,153.162,-29.452,153.11,-29.425,153.189,-29.464,153.217,-29.384,153.232,-29.368,153.274,-29.361,153.355,-29.377,153.347,-29.408,153.358,-29.423,153.373,-29.426,153.346,-29.291,153.585,-28.875,153.531,-28.874,153.526,-28.899,153.513,-28.905,153.483,-28.944,153.472,-28.953,153.462,-28.956,153.461,-28.992,153.423,-29.018,153.415,-29.017,153.41,-29.013,153.404,-29.0,153.396,-29.003,153.519,-28.869,153.577,-28.839,153.577,-28.867,153.592,-28.876,153.639,-28.636,153.553,-28.53,153.572,-28.2,153.551,-28.171,153.564,-28.209,153.553,-28.238,153.509,-28.267,153.486,-28.273,153.463,-28.271,153.461,-28.275,153.462,-28.287,153.459,-28.294,153.454,-28.298,153.438,-28.301,153.552,-28.165,153.457,-28.098,153.429,-27.936,153.426,-27.99,153.416,-27.992,153.421,-27.974,153.41,-27.953,153.396,-27.887,153.4,-27.872,153.381,-27.892,153.369,-27.897,153.359,-27.894,153.357,-27.912,153.347,-27.92,153.36,-27.798,153.412,-27.79,153.378,-27.79,153.362,-27.775,153.352,-27.754,153.353,-27.699,153.31,-27.713,153.24,-27.697,153.34,-27.69,153.309,-27.657,153.319,-27.581,153.29,-27.509,153.175,-27.44,153.19,-27.354,153.163,-27.401,153.111,-27.443,153.088,-27.45,153.054,-27.442,153.057,-27.469,153.053,-27.474,153.044,-27.478,153.035,-27.465,153.034,-27.477,153.03,-27.482,153.004,-27.477,153.023,-27.5,153.01,-27.522,153.005,-27.524,152.994,-27.524,152.97,-27.507,152.967,-27.516,152.972,-27.526,152.971,-27.533,152.975,-27.539,152.974,-27.545,152.894,-27.58,152.904,-27.544,152.972,-27.505,153.008,-27.521,152.995,-27.488,153.009,-27.47,153.05,-27.44,153.109,-27.44,153.161,-27.378,153.063,-27.282,153.033,-27.3,153.017,-27.289,153.118,-27.232,153.034,-27.192,153.024,-27.126,153.146,-27.077,153.069,-27.052,153.089,-27.047,153.113,-27.054,153.048,-26.977,153.072,-26.915,153.208,-27.085,153.131,-26.815,153.118,-26.882,153.101,-26.898,153.081,-26.897,153.139,-26.68,153.123,-26.682,153.044,-26.627,153.102,-26.641,153.12,-26.38,153.062,-26.398,153.045,-26.396,153.038,-26.388,153.192,-25.932,153.111,-25.925,153.071,-25.813,153.048,-25.808,153.071,-25.823,153.059,-25.913,153.019,-25.986,153.021,-25.829,152.922,-25.829,152.986,-25.772,152.875,-25.692,152.864,-25.553,152.958,-25.433,152.846,-25.5,152.775,-25.511,152.762,-25.511,152.751,-25.502,152.732,-25.468,152.739,-25.492,152.728,-25.52,152.707,-25.537,152.726,-25.552,152.728,-25.556,152.726,-25.56,152.719,-25.562,152.713,-25.561,152.681,-25.541,152.675,-25.535,152.726,-25.557,152.706,-25.537,152.729,-25.517,152.738,-25.491,152.729,-25.47,152.729,-25.468,152.734,-25.465,152.85,-25.486,152.875,-25.432,152.91,-25.427,152.887,-25.427,152.878,-25.425,152.863,-25.41,152.862,-25.42,152.852,-25.42,152.854,-25.408,152.896,-25.409,152.925,-25.431,152.911,-25.292,152.661,-25.259,152.616,-25.184,152.539,-25.226,152.536,-25.212,152.547,-25.204,152.54,-25.19,152.554,-25.176,152.535,-25.174,152.527,-25.165,152.529,-25.157,152.633,-25.153,152.459,-24.952,152.414,-24.758,152.362,-24.86,152.254,-24.943,152.227,-24.948,152.194,-24.939,152.179,-24.945,152.362,-24.859,152.359,-24.801,152.395,-24.748,152.196,-24.652,152.215,-24.669,152.165,-24.703,152.062,-24.515,152.056,-24.528,152.046,-24.531,152.028,-24.532,151.91,-24.508,152.035,-24.509,152.039,-24.513,152.041,-24.526,152.054,-24.518,151.887,-24.149,151.881,-24.178,151.855,-24.206,151.753,-24.123,151.764,-24.015,151.715,-24.085,151.729,-24.023,151.612,-23.979,151.68,-24.055,151.681,-24.104,151.566,-24.036,151.564,-24.112,151.465,-24.072,151.443,-24.026,151.444,-24.088,151.39,-23.985,151.429,-23.99,151.324,-23.874,151.293,-23.939,151.233,-23.815,151.184,-23.914,151.168,-23.916,151.22,-23.832,151.172,-23.814,151.093,-23.654,151.213,-23.731,151.222,-23.8,151.261,-23.757,151.317,-23.76,151.235,-23.483,151.222,-23.51,151.206,-23.528,151.193,-23.532,151.016,-23.453,151.078,-23.639,150.974,-23.557,150.966,-23.607,150.93,-23.621,150.931,-23.679,150.842,-23.564,150.787,-23.617,150.7,-23.625,150.704,-23.585,150.71,-23.578,150.725,-23.568,150.706,-23.562,150.706,-23.581,150.702,-23.583,150.694,-23.58,150.686,-23.572,150.688,-23.567,150.702,-23.556,150.702,-23.551,150.704,-23.547,150.719,-23.546,150.712,-23.534,150.711,-23.53,150.713,-23.524,150.705,-23.515,150.705,-23.508,150.683,-23.5,150.656,-23.521,150.634,-23.519,150.632,-23.514,150.637,-23.505,150.661,-23.493,150.63,-23.482,150.554,-23.379,150.543,-23.391,150.53,-23.394,150.492,-23.36,150.48,-23.319,150.461,-23.341,150.456,-23.343,150.448,-23.341,150.422,-23.247,150.37,-23.267,150.358,-23.265,150.342,-23.247,150.341,-23.24,150.375,-23.21,150.392,-23.203,150.357,-23.263,150.42,-23.243,150.427,-23.25,150.436,-23.287,150.454,-23.317,150.449,-23.337,150.48,-23.316,150.494,-23.359,150.558,-23.38,150.633,-23.482,150.662,-23.487,150.668,-23.501,150.731,-23.486,150.862,-23.5,150.74,-23.294,150.832,-23.255,150.722,-22.906,150.801,-22.933,150.836,-22.685,150.778,-22.525,150.75,-22.634,150.712,-22.636,150.704,-22.627,150.653,-22.479,150.763,-22.469,150.694,-22.431,150.669,-22.346,150.663,-22.452,150.626,-22.328,150.53,-22.402,150.65,-22.537,150.567,-22.52,150.634,-22.657,150.18,-22.362,150.043,-22.126,149.914,-22.345,150.063,-22.656,149.813,-22.383,149.633,-22.574,149.71,-22.388,149.579,-22.408,149.658,-22.356,149.626,-22.3,149.566,-22.299,149.532,-22.341,149.504,-22.339,149.603,-22.272,149.436,-21.844,149.438,-21.642,149.485,-21.533,149.464,-21.539,149.453,-21.584,149.447,-21.588,149.339,-21.474,149.301,-21.502,149.304,-21.527,149.3,-21.533,149.295,-21.535,149.313,-21.26,149.252,-21.244,149.203,-21.315,149.172,-21.322,149.213,-21.242,149.154,-21.217,149.213,-21.145,149.102,-21.149,149.195,-21.126,149.221,-21.148,149.226,-21.064,149.088,-20.976,149.032,-20.988,149.022,-21.007,149.015,-21.012,149.007,-21.011,149.046,-20.903,148.914,-20.9,148.884,-20.849,148.877,-20.883,148.858,-20.87,148.852,-20.888,148.829,-20.891,148.838,-20.899,148.839,-20.904,148.826,-20.896,148.842,-20.792,148.634,-20.548,148.716,-20.498,148.674,-20.44,148.69,-20.44,148.736,-20.482,148.763,-20.48,148.797,-20.422,148.932,-20.536,148.839,-20.43,148.841,-20.353,148.77,-20.236,148.68,-20.278,148.677,-20.176,148.582,-20.196,148.557,-20.066,148.515,-20.094,148.445,-20.066,148.448,-20.176,148.411,-20.183,148.346,-20.153,148.231,-20.048,148.267,-20.026,148.261,-19.975,148.112,-19.942,148.085,-19.857,148.005,-19.926,147.916,-19.914,147.884,-19.88,147.873,-19.895,147.826,-19.711,147.752,-19.707,147.812,-19.834,147.77,-19.857,147.656,-19.823,147.582,-19.708,147.572,-19.708,147.565,-19.703,147.537,-19.7,147.49,-19.65,147.472,-19.644,147.443,-19.628,147.441,-19.623,147.415,-19.622,147.368,-19.672,147.348,-19.688,147.345,-19.687,147.393,-19.638,147.412,-19.622,147.421,-19.619,147.438,-19.619,147.505,-19.649,147.522,-19.665,147.588,-19.652,147.515,-19.594,147.574,-19.601,147.525,-19.546,147.538,-19.528,147.517,-19.513,147.491,-19.471,147.448,-19.356,147.414,-19.314,147.439,-19.421,147.135,-19.407,147.126,-19.46,147.017,-19.182,146.957,-19.308,146.822,-19.291,146.768,-19.185,146.455,-19.075,146.262,-18.862,146.34,-18.527,146.278,-18.507,146.291,-18.542,146.282,-18.596,146.276,-18.599,146.232,-18.589,146.287,-18.534,146.201,-18.525,146.007,-18.191,146.15,-17.65,146.032,-17.521,146.073,-17.395,145.922,-17.218,145.964,-17.17,145.889,-17.043,145.957,-16.896,145.849,-16.871,145.775,-16.995,145.769,-16.977,145.774,-16.975,145.767,-16.961,145.784,-16.918,145.772,-16.867,145.412,-16.466,145.451,-16.283,145.418,-16.294,145.346,-16.255,145.488,-16.278,145.476,-16.078,145.353,-15.927,145.323,-15.571,145.193,-15.461,145.357,-15.276,145.266,-15.255,145.22,-15.13,145.352,-14.953,145.28,-14.953,145.23,-14.832,145.044,-14.813,144.91,-14.613,144.682,-14.551,144.524,-14.169,144.339,-14.307,144.196,-14.261,144.143,-14.409,143.942,-14.501,143.942,-14.516,143.947,-14.519,143.955,-14.515,143.96,-14.516,143.981,-14.543,143.984,-14.551,143.982,-14.555,143.977,-14.558,143.79,-14.414,143.532,-13.758,143.556,-13.539,143.598,-13.436,143.503,-13.124,143.545,-12.842,143.407,-12.866,143.393,-12.894,143.406,-12.901,143.418,-12.919,143.419,-12.949,143.356,-12.928,143.358,-12.798,143.438,-12.612,143.24,-12.524,143.247,-12.379,143.08,-12.343,143.068,-12.136,143.244,-11.968,143.165,-11.99,143.097,-11.891,142.971,-11.931,142.854,-11.837,142.869,-11.383,142.748,-10.992,142.672,-10.975,142.659,-11.055,142.64,-10.979,142.676,-10.955,142.581,-10.962,142.621,-10.919,142.588,-10.867,142.557,-10.877,142.576,-10.92,142.544,-10.898,142.568,-10.934,142.567,-10.976,142.532,-10.922,142.527,-10.982,142.424,-10.989,142.61,-10.751,142.559,-10.727,142.531,-10.687,142.216,-10.923,142.25,-10.964,142.136,-10.953,141.022,-9.132,141.139,-9.235,141.34,-9.13,141.627,-9.212,142.046,-9.168,142.049,-9.026,141.962,-8.97,141.902,-8.846,142.028,-8.913,142.042,-8.84,142.059,-8.825,142.073,-8.823,142.055,-8.785,142.067,-8.781,142.102,-8.911,142.209,-8.886,142.255,-8.973,142.221,-9.061,142.26,-9.056,142.255,-9.041,142.258,-9.034,142.296,-8.998,142.301,-8.996,142.309,-8.999,142.317,-8.988,142.34,-8.979,142.349,-8.981,142.214,-9.146,142.64,-9.333,143.012,-9.103,143.248,-8.994,143.185,-9.038,143.373,-9.008,143.401,-8.754,143.099,-8.449,142.935,-8.424,142.793,-8.343,142.758,-8.314,142.447,-8.367,142.39,-8.31,142.354,-8.179,142.18,-8.234,142.162,-8.238,142.099,-8.234,142.021,-8.187,141.976,-8.132,141.983,-8.11,142.045,-8.087,142.035,-8.037,141.875,-7.964,141.881,-7.907,141.861,-7.91,141.853,-7.933,141.838,-7.937,141.808,-7.823,141.772,-7.832,141.678,-7.798,141.692,-7.822,141.687,-7.828,141.675,-7.829,141.424,-7.728,141.348,-7.643,141.349,-7.61,141.356,-7.602,141.379,-7.602,141.417,-7.615,141.365,-7.575,141.29,-7.616,141.249,-7.615,141.265,-7.553,141.112,-7.549,141.226,-7.423,141.232,-7.441,141.213,-7.471,141.228,-7.512,141.271,-7.537,141.31,-7.525,141.318,-7.544,141.31,-7.509,141.272,-7.533,141.266,-7.531,141.263,-7.525,141.264,-7.518,141.29,-7.493,141.266,-7.5,141.242,-7.495,141.227,-7.455,141.235,-7.446,141.259,-7.443,141.261,-7.437,141.258,-7.431,141.25,-7.437,141.231,-7.425,141.206,-7.397,141.257,-7.34,141.228,-7.413,141.248,-7.433,141.256,-7.429,141.264,-7.436,141.243,-7.494,141.284,-7.472,141.287,-7.477,141.284,-7.484,141.29,-7.487,141.292,-7.503,141.265,-7.524,141.311,-7.504,141.32,-7.549,141.304,-7.57,141.373,-7.566,141.384,-7.582,141.396,-7.553,141.364,-7.536,141.359,-7.522,141.361,-7.503,141.394,-7.496,141.419,-7.499,141.455,-7.531,141.456,-7.45,141.53,-7.437,141.492,-7.411,141.483,-7.399,141.481,-7.389,141.487,-7.386,141.511,-7.397,141.519,-7.365,141.525,-7.355,141.531,-7.357,141.572,-7.383,141.53,-7.371,141.509,-7.403,141.484,-7.394,141.493,-7.408,141.524,-7.421,141.537,-7.435,141.521,-7.446,141.46,-7.45,141.458,-7.536,141.362,-7.506,141.403,-7.541,141.387,-7.584,141.425,-7.605,141.413,-7.621,141.388,-7.61,141.354,-7.613,141.35,-7.639,141.429,-7.726,141.815,-7.823,141.825,-7.837,141.843,-7.934,141.858,-7.903,141.875,-7.901,141.89,-7.917,141.88,-7.963,141.943,-7.953,141.955,-7.956,141.963,-7.959,141.973,-7.969,141.973,-7.973,141.967,-7.982,141.951,-7.981,142.026,-7.99,142.052,-8.08,141.992,-8.147,142.031,-8.185,142.348,-8.146,142.491,-8.327,143.692,-8.23,143.704,-8.175,143.63,-8.186,143.462,-8.078,143.39,-7.995,143.336,-7.845,143.317,-7.963,143.307,-7.967,143.293,-7.96,143.285,-7.969,143.278,-7.971,143.273,-7.968,143.272,-7.946,143.317,-7.96,143.254,-7.881,143.259,-7.796,143.247,-7.798,143.239,-7.81,143.233,-7.81,143.214,-7.791,143.213,-7.758,143.204,-7.765,143.195,-7.764,143.198,-7.78,143.182,-7.782,143.191,-7.79,143.191,-7.794,143.173,-7.801,143.171,-7.771,143.174,-7.8,143.182,-7.78,143.197,-7.779,143.193,-7.764,143.213,-7.756,143.221,-7.758,143.236,-7.809,143.245,-7.797,143.291,-7.78,143.257,-7.787,143.246,-7.781,143.24,-7.766,143.288,-7.775,143.292,-7.78,143.29,-7.783,143.265,-7.802,143.259,-7.88,143.348,-7.835,143.367,-7.908,143.438,-7.911,143.486,-8.001,143.604,-7.989,143.865,-8.046,143.744,-7.907,143.773,-7.887,143.921,-7.997,143.744,-7.59,143.736,-7.561,143.746,-7.542,143.696,-7.53,143.629,-7.459,143.64,-7.428,143.692,-7.427,143.687,-7.369,143.638,-7.363,143.579,-7.271,143.509,-7.301,143.506,-7.297,143.506,-7.265,143.498,-7.259,143.497,-7.282,143.49,-7.303,143.487,-7.304,143.48,-7.298,143.471,-7.305,143.465,-7.304,143.499,-7.257,143.508,-7.266,143.509,-7.3,143.575,-7.265,143.632,-7.294,143.699,-7.38,143.698,-7.434,143.644,-7.466,143.765,-7.526,143.906,-7.713,144.087,-7.763,144.024,-7.701,144.027,-7.669,144.005,-7.657,144.011,-7.627,143.992,-7.63,143.986,-7.626,143.989,-7.603,143.96,-7.622,143.895,-7.61,143.871,-7.586,143.875,-7.575,143.867,-7.572,143.866,-7.561,143.858,-7.559,143.856,-7.545,143.846,-7.546,143.837,-7.539,143.857,-7.543,143.896,-7.609,143.928,-7.594,143.957,-7.619,143.979,-7.597,143.997,-7.606,143.988,-7.623,144.014,-7.626,144.007,-7.655,144.031,-7.669,144.027,-7.697,144.11,-7.781,144.179,-7.751,144.122,-7.658,144.12,-7.596,144.038,-7.579,144.028,-7.548,144.005,-7.545,143.986,-7.51,144.004,-7.487,144.018,-7.483,144.017,-7.478,143.998,-7.477,143.978,-7.468,143.978,-7.457,144.018,-7.477,144.043,-7.578,144.088,-7.552,144.11,-7.559,144.195,-7.737,144.251,-7.725,144.162,-7.618,144.249,-7.588,144.235,-7.545,144.256,-7.473,144.242,-7.468,144.238,-7.454,144.244,-7.441,144.232,-7.44,144.245,-7.44,144.245,-7.469,144.261,-7.449,144.181,-7.315,144.189,-7.28,144.165,-7.25,144.161,-7.204,144.175,-7.197,144.16,-7.186,144.152,-7.168,144.156,-7.147,144.131,-7.148,144.102,-7.132,144.087,-7.138,144.08,-7.136,143.997,-7.103,143.988,-7.094,143.983,-7.079,144.155,-7.143,144.178,-7.197,144.17,-7.252,144.191,-7.27,144.199,-7.256,144.217,-7.255,144.214,-7.233,144.239,-7.211,144.218,-7.257,144.196,-7.262,144.185,-7.315,144.288,-7.374,144.29,-7.381,144.287,-7.4,144.301,-7.407,144.302,-7.418,144.321,-7.417,144.34,-7.43,144.35,-7.461,144.38,-7.416,144.405,-7.427,144.404,-7.452,144.43,-7.455,144.433,-7.46,144.432,-7.469,144.463,-7.419,144.438,-7.421,144.41,-7.406,144.405,-7.381,144.422,-7.379,144.405,-7.371,144.407,-7.347,144.352,-7.359,144.336,-7.356,144.328,-7.343,144.391,-7.341,144.411,-7.344,144.424,-7.378,144.412,-7.403,144.464,-7.413,144.453,-7.457,144.489,-7.458,144.485,-7.497,144.522,-7.483,144.529,-7.506,144.554,-7.48,144.46,-7.394,144.457,-7.328,144.513,-7.431,144.573,-7.349,144.623,-7.457,144.656,-7.426,144.62,-7.409,144.632,-7.362,144.66,-7.42,144.639,-7.462,144.676,-7.492,144.673,-7.452,144.682,-7.445,144.696,-7.445,144.673,-7.425,144.672,-7.42,144.676,-7.411,144.679,-7.407,144.677,-7.4,144.698,-7.444,144.689,-7.469,144.752,-7.404,144.738,-7.383,144.752,-7.346,144.746,-7.337,144.738,-7.353,144.732,-7.357,144.727,-7.357,144.714,-7.309,144.731,-7.356,144.744,-7.33,144.759,-7.319,144.771,-7.315,144.775,-7.299,144.798,-7.306,144.746,-7.333,144.778,-7.395,144.76,-7.51,144.723,-7.535,144.752,-7.553,144.764,-7.571,144.754,-7.541,144.759,-7.536,144.772,-7.527,144.769,-7.516,144.785,-7.497,144.801,-7.493,144.756,-7.662,144.83,-7.67,144.811,-7.527,144.876,-7.381,144.82,-7.555,145.019,-7.557,144.857,-7.593,144.851,-7.653,144.866,-7.65,144.875,-7.655,144.886,-7.637,144.915,-7.632,144.862,-7.724,144.897,-7.673,144.877,-7.754,144.903,-7.762,144.956,-7.805,144.967,-7.801,144.928,-7.769,144.922,-7.757,144.923,-7.717,144.929,-7.707,144.939,-7.704,145.01,-7.813,145.02,-7.789,144.994,-7.75,144.989,-7.699,145.002,-7.7,144.996,-7.692,144.992,-7.675,145.005,-7.669,144.994,-7.648,145.0,-7.638,145.006,-7.637,145.051,-7.644,145.038,-7.55,145.072,-7.492,145.109,-7.487,145.096,-7.45,145.113,-7.427,145.098,-7.415,145.09,-7.398,145.126,-7.36,145.191,-7.373,145.197,-7.396,145.204,-7.374,145.211,-7.362,145.255,-7.34,145.268,-7.341,145.295,-7.356,145.303,-7.356,145.299,-7.297,145.34,-7.19,145.321,-7.154,145.316,-7.123,145.322,-7.101,145.195,-7.051,145.175,-7.031,145.157,-6.993,145.129,-7.041,145.108,-7.027,145.082,-7.0,145.072,-6.985,145.021,-7.022,145.005,-7.014,144.996,-6.996,144.991,-6.996,144.985,-7.007,144.98,-7.009,144.938,-6.991,144.933,-6.973,144.954,-6.917,144.894,-6.905,144.889,-6.899,144.859,-6.894,144.849,-6.887,144.801,-6.912,144.794,-6.913,144.786,-6.91,144.783,-6.904,144.787,-6.89,144.792,-6.882,144.786,-6.87,144.789,-6.858,144.774,-6.849,144.773,-6.841,144.801,-6.793,144.773,-6.844,144.791,-6.858,144.793,-6.883,144.786,-6.909,144.799,-6.91,144.814,-6.894,144.848,-6.885,144.948,-6.907,144.957,-6.915,144.958,-6.922,144.939,-6.989,145.074,-6.981,145.128,-7.038,145.158,-6.99,145.315,-7.075,145.325,-7.1,145.324,-7.155,145.337,-7.173,145.343,-7.2,145.306,-7.355,145.197,-7.398,145.129,-7.365,145.092,-7.399,145.116,-7.425,145.112,-7.487,145.095,-7.508,145.178,-7.508,145.239,-7.637,145.243,-7.676,145.237,-7.679,145.227,-7.675,145.235,-7.68,145.238,-7.688,145.236,-7.692,145.232,-7.693,145.226,-7.687,145.215,-7.709,145.226,-7.727,145.226,-7.735,145.221,-7.738,145.208,-7.733,145.212,-7.758,145.164,-7.789,145.195,-7.8,145.199,-7.814,145.213,-7.825,145.223,-7.842,145.227,-7.859,145.368,-7.879,145.388,-7.9,145.405,-7.945,145.436,-7.926,145.408,-7.918,145.401,-7.903,145.404,-7.894,145.426,-7.875,145.433,-7.852,145.517,-7.82,145.507,-7.823,145.502,-7.821,145.495,-7.811,145.5,-7.796,145.489,-7.789,145.486,-7.786,145.483,-7.738,145.49,-7.723,145.496,-7.718,145.478,-7.722,145.466,-7.714,145.47,-7.701,145.46,-7.695,145.455,-7.698,145.445,-7.693,145.456,-7.664,145.462,-7.663,145.465,-7.671,145.471,-7.669,145.482,-7.668,145.448,-7.694,145.46,-7.693,145.47,-7.699,145.479,-7.72,145.494,-7.716,145.497,-7.718,145.518,-7.82,145.434,-7.854,145.427,-7.881,145.404,-7.895,145.439,-7.918,145.43,-7.941,145.415,-7.95,145.813,-7.9,145.817,-7.985,145.759,-7.961,145.828,-8.031,146.096,-8.091,146.109,-8.182,146.289,-8.309,146.235,-8.271,146.348,-8.548,146.562,-8.813,146.589,-8.82,146.61,-8.81,146.613,-8.794,146.619,-8.791,146.59,-8.855,146.548,-8.884,146.592,-8.997,146.882,-9.115,146.946,-9.008,147.018,-9.05,146.89,-9.125,146.898,-9.269,146.998,-9.279,147.088,-9.476,147.124,-9.419,147.243,-9.54,147.289,-9.501,147.725,-10.103,147.889,-10.036,147.855,-10.102,147.987,-10.163,148.23,-10.018,148.181,-10.071,148.179,-10.102,148.399,-10.208,148.649,-10.192,148.704,-10.143,148.704,-10.138,148.698,-10.141,148.691,-10.135,148.692,-10.128,148.686,-10.13,148.684,-10.126,148.693,-10.127,148.698,-10.14,148.704,-10.137,148.684,-10.115,148.697,-10.111,148.739,-10.167,148.759,-10.158,148.759,-10.141,148.781,-10.124,148.808,-10.146,148.745,-10.237,148.965,-10.288,149.097,-10.231,149.485,-10.365,149.751,-10.346,149.914,-10.496,149.954,-10.422,150.117,-10.423,150.114,-10.475,150.043,-10.517,149.88,-10.503,149.854,-10.553,149.969,-10.55,150.202,-10.703,150.565,-10.626,150.975,-11.103,153.006,-11.193,153.312,-11.375,153.173,-11.359,153.358,-11.558,153.456,-11.549,153.556,-11.602,153.551,-11.655,153.748,-11.617,153.947,-11.731,154.278,-11.403,160.561,-11.882,162.377,-10.829,167.062,-9.759,167.19,-9.899,168.051,-14.48,168.148,-15.119,168.038,-14.905,168.124,-15.383,168.18,-15.361,168.219,-15.668,168.153,-15.449,168.112,-15.68,168.183,-15.981,168.264,-15.978,168.312,-16.306,168.165,-16.099,167.912,-16.241,168.129,-16.372,168.313,-16.309,168.491,-16.795],"holes":[[167.541,-16.562,167.603,-16.487,167.768,-16.547,167.831,-16.451,167.777,-16.476,167.766,-16.332,167.406,-16.09,167.334,-15.918,167.187,-15.894,167.155,-16.088,167.394,-16.183,167.421,-16.529,167.541,-16.562],[166.848,-15.651,167.107,-15.6,167.258,-15.476,167.078,-15.091,167.09,-14.945,166.984,-14.954,166.95,-15.16,166.832,-15.162,166.76,-14.826,166.614,-14.644,166.541,-14.846,166.673,-15.247,166.648,-15.398,166.774,-15.651,166.848,-15.651],[167.265,-20.711,167.042,-20.723,167.035,-20.78,167.175,-20.819,167.01,-20.919,167.135,-21.078,167.386,-21.185,167.462,-21.055,167.272,-20.907,167.265,-20.711],[165.334,-20.934,165.245,-20.882,165.256,-20.771,164.938,-20.7,164.519,-20.295,164.299,-20.226,164.366,-20.352,163.995,-20.083,164.067,-20.163,164.0,-20.249,164.164,-20.322,164.125,-20.401,164.419,-20.722,164.369,-20.757,164.494,-20.922,164.677,-20.94,164.707,-21.113,164.797,-21.058,164.824,-21.203,164.949,-21.297,164.912,-21.37,164.778,-21.264,164.815,-21.329,164.921,-21.4,164.876,-21.357,164.906,-21.375,164.963,-21.384,164.98,-21.305,165.04,-21.394,165.116,-21.359,165.213,-21.545,165.258,-21.508,165.429,-21.641,165.35,-21.626,165.429,-21.647,165.464,-21.604,165.681,-21.78,165.724,-21.713,165.751,-21.841,165.793,-21.789,165.789,-21.86,166.004,-21.968,166.064,-21.899,166.172,-21.985,166.115,-22.091,166.289,-22.188,166.333,-22.142,166.346,-22.229,166.43,-22.176,166.441,-22.265,166.385,-22.253,166.445,-22.315,166.496,-22.226,166.773,-22.392,166.857,-22.384,166.842,-22.302,166.883,-22.398,167.012,-22.338,167.04,-22.252,166.901,-22.047,166.687,-21.987,166.437,-21.68,166.056,-21.528,165.958,-21.402,165.968,-21.507,165.884,-21.353,165.846,-21.42,165.76,-21.299,165.617,-21.278,165.549,-21.186,165.611,-21.181,165.389,-21.067,165.417,-20.948,165.334,-20.934],[167.972,-21.331,167.798,-21.379,167.837,-21.594,168.118,-21.639,168.135,-21.446,167.997,-21.444,167.972,-21.331],[153.202,-24.724,153.119,-24.817,153.233,-24.978,152.995,-25.217,153.052,-25.345,152.937,-25.554,153.051,-25.797,153.36,-25.007,153.271,-24.895,153.266,-24.7,153.202,-24.724]]}],"names":{"ar":"بحر المرجان","de":"Korallenmeer","es":"mar del Coral","fr":"mer de Corail","he":"ים האלמוגים","hu":"Korall-tenger","it":"mar dei Coralli","nl":"Koraalzee","pt":"Mar de Coral","zh":"珊瑚海"}},{"name":"Arabian Sea","bbox":[51.025,-0.703,74.335,25.595],"area":351.4158,"polygons":[{"outer":[66.352,25.595,66.439,25.511,66.543,25.511,66.549,25.363,66.746,25.186,66.666,24.825,66.854,24.861,66.982,24.785,66.914,24.842,66.915,24.866,66.966,24.87,67.081,24.748,67.105,24.762,67.082,24.793,67.079,24.804,67.082,24.813,67.218,24.815,67.328,24.764,67.375,24.783,67.446,24.778,67.528,24.749,67.433,24.772,67.424,24.743,67.379,24.715,67.42,24.712,67.388,24.702,67.429,24.697,67.437,24.681,67.417,24.674,67.439,24.648,67.457,24.663,67.469,24.659,67.475,24.645,67.474,24.619,67.459,24.661,67.442,24.636,67.379,24.632,67.383,24.604,67.342,24.612,67.355,24.582,67.323,24.589,67.317,24.572,67.297,24.575,67.348,24.546,67.412,24.6,67.478,24.563,67.378,24.569,67.359,24.488,67.33,24.46,67.371,24.467,67.393,24.515,67.421,24.517,67.379,24.468,67.514,24.409,67.468,24.405,67.472,24.377,67.458,24.363,67.438,24.363,67.435,24.35,67.418,24.338,67.425,24.326,67.449,24.354,67.482,24.324,67.509,24.362,67.513,24.365,67.531,24.371,67.576,24.336,67.519,24.283,67.512,24.339,67.5,24.29,67.489,24.286,67.438,24.301,67.513,24.272,67.505,24.235,67.43,24.24,67.46,24.19,67.358,24.235,67.451,24.172,67.418,24.176,67.432,24.15,67.437,24.156,67.444,24.154,67.446,24.151,67.461,24.163,67.478,24.156,67.47,24.177,67.474,24.185,67.506,24.2,67.518,24.189,67.476,24.184,67.482,24.156,67.443,24.133,67.448,24.123,67.446,24.118,67.461,24.123,67.487,24.12,67.534,24.101,67.438,24.116,67.432,24.089,67.407,24.082,67.41,24.064,67.46,24.085,67.486,24.037,67.547,24.08,67.641,24.065,67.622,24.186,67.633,24.157,67.7,24.189,67.732,24.174,67.729,24.217,67.746,24.263,67.782,24.267,67.8,24.249,67.809,24.26,67.764,24.336,67.806,24.362,67.831,24.362,67.856,24.349,67.842,24.405,67.853,24.435,67.871,24.449,67.885,24.452,67.943,24.437,67.983,24.446,67.934,24.499,67.944,24.544,67.948,24.548,67.955,24.552,67.986,24.559,68.023,24.559,68.026,24.575,68.028,24.564,68.021,24.556,67.946,24.544,67.937,24.501,67.977,24.488,67.989,24.46,67.982,24.441,67.951,24.433,67.873,24.447,67.856,24.346,67.775,24.335,67.812,24.27,67.81,24.25,67.798,24.246,67.777,24.263,67.748,24.259,67.738,24.171,67.632,24.144,67.657,24.081,67.669,24.088,67.68,24.088,67.711,24.043,67.753,24.085,67.839,24.079,67.835,24.101,67.841,24.115,67.826,24.127,67.827,24.138,67.865,24.163,67.946,24.024,68.003,24.087,67.999,24.021,68.07,24.062,68.098,23.969,68.124,24.055,68.134,23.85,68.197,23.865,68.185,24.027,68.222,24.075,68.203,24.085,68.185,24.128,68.184,24.14,68.189,24.155,68.21,24.174,68.233,24.038,68.192,24.022,68.275,24.009,68.204,23.887,68.29,23.958,68.329,23.931,68.337,23.973,68.395,24.01,68.355,24.031,68.398,24.105,68.417,24.114,68.392,23.969,68.429,24.004,68.508,24.021,68.488,24.065,68.536,24.138,68.539,24.082,68.585,24.123,68.559,24.084,68.494,24.058,68.522,24.028,68.504,23.958,68.55,23.987,68.538,23.947,68.559,23.932,68.56,23.93,68.559,23.924,68.622,24.063,68.909,24.07,68.637,24.059,68.596,23.941,68.663,23.917,68.515,23.824,68.537,23.815,68.529,23.755,68.576,23.822,68.604,23.85,68.761,23.875,68.525,23.691,68.484,23.548,68.519,23.489,68.503,23.466,68.633,23.352,68.637,23.344,68.625,23.337,68.644,23.323,68.65,23.305,68.758,23.33,68.63,23.295,68.659,23.238,68.63,23.213,68.584,23.225,68.76,23.08,69.198,22.838,69.634,22.795,69.705,22.728,69.798,22.851,70.132,22.924,70.139,22.941,70.1,22.938,70.091,22.952,70.153,23.029,70.121,22.962,70.225,22.96,70.186,22.993,70.229,22.967,70.208,23.089,70.215,23.099,70.237,23.115,70.254,23.137,70.221,23.092,70.25,23.099,70.268,23.117,70.303,23.134,70.338,23.116,70.371,23.157,70.44,23.173,70.634,23.172,70.507,23.11,70.471,23.011,70.454,22.985,70.438,22.975,70.467,22.986,70.495,23.013,70.517,23.024,70.531,23.027,70.527,23.037,70.555,23.036,70.444,22.961,70.498,22.935,70.462,22.852,70.47,22.891,70.456,22.885,70.445,22.889,70.446,22.927,70.385,22.909,70.253,22.733,70.255,22.706,70.239,22.701,70.253,22.693,70.243,22.663,70.164,22.552,69.976,22.543,69.872,22.447,69.837,22.454,69.843,22.494,69.835,22.5,69.797,22.4,69.729,22.474,69.576,22.319,69.591,22.361,69.499,22.435,69.488,22.338,69.233,22.258,69.158,22.313,69.194,22.424,69.069,22.396,69.068,22.48,68.936,22.31,69.588,21.621,69.711,21.539,69.633,21.643,69.696,21.593,69.738,21.65,69.764,21.579,69.715,21.526,70.448,20.848,70.822,20.691,70.859,20.755,70.977,20.702,72.114,21.196,72.076,21.302,72.306,21.629,72.183,21.824,72.265,21.74,72.253,21.897,72.147,21.955,72.147,21.983,72.221,21.956,72.172,22.041,72.3,22.109,72.242,22.049,72.278,21.932,72.324,22.131,72.31,22.146,72.313,22.18,72.3,22.186,72.294,22.202,72.317,22.182,72.316,22.147,72.342,22.125,72.357,22.271,72.417,22.228,72.373,22.317,72.377,22.385,72.487,22.197,72.526,22.268,72.543,22.281,72.756,22.231,72.844,22.282,72.895,22.246,72.91,22.299,72.915,22.304,72.934,22.311,72.943,22.306,72.948,22.299,72.952,22.258,72.979,22.254,73.061,22.314,73.046,22.363,73.065,22.424,73.074,22.4,73.062,22.313,73.012,22.267,72.973,22.25,72.952,22.255,72.934,22.31,72.901,22.233,72.758,22.174,72.569,22.187,72.505,21.91,72.578,21.883,72.528,21.848,72.61,21.885,72.543,21.66,72.737,21.689,72.795,21.662,72.869,21.689,72.98,21.68,73.067,21.741,73.136,21.754,73.128,21.848,73.141,21.866,73.24,21.841,73.22,21.908,73.223,21.92,73.237,21.932,73.251,21.937,73.264,21.934,73.336,21.876,73.345,21.918,73.356,21.929,73.376,21.93,73.408,21.912,73.434,21.922,73.439,21.975,73.45,21.983,73.547,21.979,73.593,21.947,73.598,21.938,73.592,21.931,73.606,21.9,73.679,21.885,73.689,21.877,73.703,21.852,73.605,21.896,73.54,21.978,73.449,21.981,73.444,21.939,73.431,21.917,73.335,21.873,73.245,21.933,73.246,21.842,73.201,21.821,73.135,21.849,73.149,21.815,73.175,21.797,73.177,21.784,73.16,21.763,73.103,21.715,73.049,21.712,72.983,21.677,72.892,21.659,72.726,21.639,72.701,21.652,72.608,21.583,72.626,21.492,72.683,21.543,72.743,21.547,72.589,21.298,72.653,21.226,72.627,21.076,72.667,21.155,72.814,21.194,72.791,21.233,72.793,21.242,72.811,21.256,72.821,21.257,72.858,21.223,72.909,21.254,72.866,21.221,72.859,21.222,72.852,21.225,72.821,21.255,72.81,21.253,72.816,21.194,72.7,21.101,72.795,21.062,72.723,21.049,72.771,20.916,72.773,20.937,72.824,20.957,72.837,20.957,72.844,20.952,72.847,20.946,72.862,20.947,72.786,20.927,72.835,20.781,72.934,20.763,72.851,20.743,72.885,20.348,72.832,20.41,72.756,20.292,72.755,19.927,72.691,19.958,72.651,19.84,72.719,19.539,72.765,19.504,72.779,19.529,72.783,19.505,72.849,19.54,72.89,19.527,72.865,19.584,72.855,19.633,72.857,19.643,72.895,19.545,72.892,19.524,72.842,19.521,72.822,19.491,72.745,19.464,72.776,19.353,72.86,19.34,72.909,19.29,72.968,19.302,72.989,19.287,73.031,19.2,73.113,19.28,73.129,19.284,73.151,19.27,73.027,19.191,72.967,19.294,72.9,19.288,72.852,19.319,72.787,19.307,72.783,19.193,72.839,19.267,72.84,19.252,72.792,18.94,72.808,18.891,72.956,19.028,72.984,19.183,73.003,19.002,73.08,19.032,73.076,19.003,72.909,18.886,73.005,18.883,73.07,18.628,72.943,18.828,72.855,18.697,72.925,18.545,72.967,18.543,73.009,18.457,73.022,18.461,73.025,18.475,73.04,18.484,73.051,18.487,73.055,18.486,73.063,18.477,73.079,18.473,73.092,18.463,73.011,18.455,72.905,18.539,72.918,18.349,73.028,18.264,73.041,18.302,73.05,18.308,73.068,18.305,73.085,18.33,73.088,18.327,73.087,18.15,72.935,18.281,73.018,17.991,73.083,17.994,73.112,17.981,73.132,18.017,73.172,18.06,73.333,18.107,73.342,18.082,73.363,18.09,73.385,18.087,73.39,18.085,73.395,18.08,73.176,18.059,73.119,17.979,73.029,17.949,73.133,17.83,73.145,17.6,73.401,17.617,73.477,17.57,73.137,17.557,73.235,17.293,73.295,17.294,73.298,17.272,73.334,17.266,73.34,17.258,73.339,17.232,73.366,17.238,73.375,17.215,73.433,17.228,73.424,17.209,73.468,17.194,73.479,17.165,73.339,17.228,73.332,17.265,73.294,17.261,73.29,17.288,73.258,17.265,73.192,17.305,73.32,17.038,73.256,17.048,73.269,16.991,73.345,16.975,73.277,16.893,73.295,16.898,73.313,16.889,73.363,16.747,73.326,16.813,73.323,16.639,73.388,16.633,73.436,16.595,73.323,16.583,73.385,16.514,73.422,16.516,73.467,16.494,73.495,16.52,73.523,16.525,73.53,16.523,73.534,16.518,73.467,16.493,73.417,16.513,73.367,16.513,73.366,16.475,73.31,16.539,73.366,16.397,73.414,16.413,73.459,16.414,73.367,16.378,73.461,16.097,73.459,16.135,73.465,16.154,73.49,16.18,73.537,16.192,73.455,16.054,73.482,16.038,73.489,15.988,73.502,15.968,73.49,15.993,73.492,16.002,73.495,16.007,73.499,16.01,73.516,16.017,73.532,16.018,73.557,16.017,73.57,16.013,73.579,16.021,73.585,16.023,73.61,16.009,73.627,16.022,73.609,16.008,73.501,16.006,73.495,15.994,73.507,15.974,73.502,15.956,73.648,15.746,73.761,15.734,73.69,15.717,73.733,15.617,73.777,15.654,73.823,15.655,73.839,15.648,73.84,15.665,73.859,15.677,73.84,15.646,73.731,15.608,73.768,15.491,73.908,15.549,73.949,15.55,74.018,15.498,74.029,15.481,73.95,15.549,73.916,15.508,73.787,15.463,73.943,15.438,74.071,15.294,73.948,15.402,73.81,15.401,73.783,15.413,73.966,15.182,73.912,15.084,74.117,14.844,74.251,14.9,74.285,14.894,74.321,14.907,74.335,14.904,72.167,13.7,71.982,9.536,72.936,0.396,73.154,-0.703,51.393,10.478,51.195,10.534,51.235,10.432,51.044,10.405,51.025,10.416,51.171,10.571,51.12,10.931,51.183,11.144,51.14,11.138,51.104,11.16,51.119,11.154,51.16,11.152,51.082,11.318,51.289,11.822,51.286,15.22,52.225,15.634,52.147,15.972,52.36,16.345,52.948,16.613,53.673,16.78,54.047,16.991,54.63,17.028,54.791,16.943,55.02,16.999,55.264,17.239,55.307,17.396,55.227,17.532,55.426,17.817,55.987,17.931,56.354,17.919,56.551,18.126,56.635,18.572,56.783,18.736,57.265,18.915,57.834,18.991,57.678,19.72,57.951,20.4,58.192,20.612,58.269,20.588,58.212,20.395,58.444,20.348,58.519,20.415,58.59,20.636,58.742,20.752,58.736,20.809,58.73,20.756,58.649,20.761,58.854,21.065,59.348,21.434,59.81,22.226,59.799,22.534,61.732,25.022,62.094,25.1,62.06,25.178,62.168,25.221,62.307,25.174,62.258,25.105,62.375,25.094,62.347,25.174,62.534,25.268,63.495,25.205,63.451,25.298,63.573,25.368,64.054,25.326,64.042,25.409,63.958,25.404,64.12,25.477,64.179,25.442,64.071,25.396,64.089,25.326,64.092,25.325,64.103,25.327,64.15,25.324,64.225,25.313,64.402,25.236,64.571,25.263,64.592,25.155,64.767,25.32,65.192,25.303,65.27,25.379,65.642,25.354,66.285,25.468,66.522,25.405,66.524,25.491,66.246,25.548,66.352,25.595],"holes":[[58.893,20.697,58.774,20.441,58.634,20.344,58.636,20.161,58.963,20.517,58.893,20.697],[53.543,12.714,53.306,12.53,53.679,12.304,54.141,12.351,54.535,12.542,54.109,12.701,53.837,12.596,53.543,12.714]]}],"names":{"ar":"بحر العرب","de":"Arabisches Meer","es":"mar Arábigo","fr":"mer d'Arabie","he":"הים הערבי","hu":"Arab-tenger","it":"Mar Arabico","nl":"Arabische Zee","pt":"Mar Arábico","zh":"阿拉伯海"}},{"name":"Tasman Sea","bbox":[146.873,-50.867,175.287,-30.0],"area":351.6043,"polygons":[{"outer":[159.033,-30.0,159.3,-31.783,172.146,-34.144,172.996,-34.404,172.642,-34.477,173.141,-34.98,173.169,-35.137,173.055,-35.195,173.2,-35.279,173.212,-35.378,173.227,-35.336,173.248,-35.335,173.246,-35.32,173.249,-35.315,173.253,-35.315,173.26,-35.345,173.219,-35.386,173.356,-35.523,173.393,-35.408,173.541,-35.375,173.522,-35.276,173.527,-35.269,173.543,-35.264,173.542,-35.256,173.55,-35.339,173.611,-35.315,173.603,-35.348,173.521,-35.418,173.55,-35.409,173.554,-35.412,173.554,-35.418,173.569,-35.412,173.584,-35.421,173.585,-35.426,173.423,-35.42,173.364,-35.548,173.925,-36.163,174.044,-36.395,174.18,-36.333,173.968,-36.114,173.865,-35.953,173.874,-35.941,173.917,-35.928,173.89,-35.891,173.893,-35.877,173.897,-35.874,173.921,-35.925,173.914,-35.932,173.872,-35.947,173.868,-35.955,174.044,-36.167,174.12,-36.17,174.237,-36.264,174.287,-36.216,174.181,-36.144,174.251,-36.122,174.315,-36.226,174.373,-36.125,174.359,-36.273,174.271,-36.286,174.341,-36.317,174.374,-36.276,174.474,-36.243,174.355,-36.323,174.256,-36.315,174.262,-36.356,174.25,-36.377,174.416,-36.353,174.442,-36.644,174.235,-36.428,174.161,-36.476,174.417,-36.809,174.483,-37.047,174.772,-36.925,174.829,-36.936,174.757,-36.94,174.764,-37.019,174.93,-37.059,174.69,-37.137,174.739,-37.15,174.745,-37.166,174.771,-37.159,174.787,-37.161,174.792,-37.164,174.745,-37.178,174.703,-37.158,174.738,-37.225,174.729,-37.241,174.662,-37.14,174.659,-37.044,174.536,-37.058,174.719,-37.38,174.744,-37.369,174.74,-37.345,174.745,-37.336,174.751,-37.334,174.753,-37.328,174.86,-37.266,174.874,-37.264,174.883,-37.264,174.921,-37.278,174.939,-37.286,174.951,-37.301,174.976,-37.288,175.0,-37.282,174.961,-37.301,174.95,-37.303,174.861,-37.268,174.742,-37.385,174.715,-37.39,174.703,-37.374,174.839,-37.8,174.936,-37.737,174.974,-37.802,174.756,-37.864,174.795,-38.007,174.848,-37.941,174.889,-37.961,174.781,-38.08,174.855,-38.043,174.909,-38.054,174.916,-38.091,174.828,-38.156,174.775,-38.088,174.677,-38.114,174.736,-38.291,174.634,-38.386,174.65,-38.714,174.439,-38.964,174.029,-39.055,173.751,-39.28,173.949,-39.54,174.299,-39.627,174.56,-39.817,174.982,-39.948,175.068,-39.902,174.988,-39.949,175.19,-40.156,175.214,-40.472,175.229,-40.467,175.243,-40.475,175.234,-40.49,175.254,-40.494,175.259,-40.498,175.259,-40.507,175.209,-40.486,175.074,-40.805,174.844,-41.054,174.912,-41.102,174.612,-41.285,174.82,-41.346,174.787,-41.267,174.901,-41.23,174.867,-41.41,175.144,-41.368,175.287,-41.612,174.195,-41.721,174.016,-41.441,174.116,-41.299,174.189,-41.293,174.139,-41.325,174.146,-41.334,174.131,-41.339,174.133,-41.344,174.125,-41.344,174.119,-41.353,174.238,-41.32,174.317,-41.214,174.202,-41.276,173.906,-41.278,174.203,-41.184,174.147,-41.109,174.221,-41.142,174.209,-41.119,174.247,-41.122,174.315,-40.994,174.219,-41.071,174.168,-41.043,174.199,-41.04,174.231,-40.985,174.097,-40.995,174.107,-41.052,173.995,-40.969,173.934,-41.04,174.049,-41.021,174.046,-41.106,173.966,-41.15,173.933,-41.084,173.873,-41.209,174.045,-41.159,174.121,-41.172,173.862,-41.219,173.927,-41.224,173.841,-41.291,173.78,-41.263,173.772,-41.284,173.76,-41.264,173.743,-41.274,173.738,-41.27,173.794,-41.235,173.85,-41.241,173.848,-41.152,173.786,-41.174,173.779,-41.154,173.951,-41.062,173.762,-41.119,173.773,-41.02,174.014,-40.937,173.979,-40.888,173.702,-41.013,173.762,-41.046,173.665,-41.111,173.594,-41.052,173.482,-41.156,173.433,-41.174,173.402,-41.156,173.203,-41.331,173.096,-41.291,173.005,-41.146,173.058,-40.857,172.989,-40.78,172.811,-40.832,172.653,-40.657,172.755,-40.516,173.018,-40.552,172.816,-40.505,172.647,-40.505,172.548,-40.58,172.624,-40.554,172.62,-40.595,172.53,-40.584,172.131,-40.849,172.117,-41.252,171.876,-41.607,171.589,-41.727,171.658,-41.832,171.587,-41.727,171.456,-41.752,171.456,-41.886,171.304,-42.27,171.14,-42.546,170.954,-42.72,170.96,-42.719,170.983,-42.726,170.985,-42.728,170.988,-42.733,170.993,-42.737,170.999,-42.745,171.005,-42.758,170.946,-42.725,170.723,-42.929,170.409,-43.042,170.438,-43.059,170.441,-43.076,170.459,-43.094,170.46,-43.102,170.403,-43.05,170.26,-43.108,169.588,-43.604,169.155,-43.748,169.032,-43.836,169.044,-43.834,169.057,-43.857,169.051,-43.867,169.059,-43.877,169.055,-43.888,169.076,-43.899,169.088,-43.916,169.107,-43.923,169.111,-43.928,169.086,-43.922,169.058,-43.895,169.03,-43.836,168.801,-43.997,168.674,-43.993,168.672,-44.007,168.681,-44.01,168.713,-44.027,168.73,-44.049,168.73,-44.055,168.719,-44.142,168.671,-44.228,168.693,-44.162,168.718,-44.141,168.725,-44.08,168.726,-44.047,168.67,-44.009,168.675,-43.99,168.364,-44.008,168.052,-44.258,168.094,-44.329,167.993,-44.328,168.007,-44.339,168.004,-44.365,168.017,-44.371,168.017,-44.383,168.057,-44.413,168.077,-44.517,168.056,-44.511,168.037,-44.409,168.0,-44.362,168.003,-44.351,168.003,-44.338,167.816,-44.496,167.817,-44.6,167.913,-44.624,167.929,-44.682,167.75,-44.58,167.494,-44.753,167.548,-44.801,167.53,-44.882,167.49,-44.777,167.355,-44.824,167.442,-44.983,167.382,-44.998,167.326,-44.844,167.136,-44.985,167.305,-45.051,167.126,-45.004,167.108,-45.071,167.137,-45.095,167.198,-45.096,167.218,-45.109,167.14,-45.104,167.143,-45.134,167.168,-45.144,167.165,-45.151,167.133,-45.131,167.079,-45.051,167.025,-45.095,167.148,-45.177,166.974,-45.138,167.01,-45.279,167.171,-45.253,167.19,-45.32,167.007,-45.317,167.084,-45.36,167.163,-45.465,167.058,-45.505,167.116,-45.432,167.037,-45.354,167.018,-45.414,166.918,-45.425,167.028,-45.352,166.844,-45.276,166.771,-45.379,166.864,-45.383,166.905,-45.434,166.752,-45.401,166.669,-45.558,166.978,-45.503,166.882,-45.549,166.961,-45.546,166.987,-45.564,166.723,-45.603,166.734,-45.665,166.969,-45.605,166.728,-45.728,166.953,-45.696,166.98,-45.717,166.856,-45.774,166.43,-45.84,166.461,-46.01,166.706,-45.859,166.647,-45.949,166.771,-45.949,166.619,-45.988,166.576,-46.094,166.917,-45.926,166.713,-46.113,166.618,-46.143,166.64,-46.195,167.216,-46.263,167.507,-46.155,167.716,-46.262,167.687,-46.323,167.783,-46.391,168.128,-46.347,168.275,-46.5,168.246,-46.414,168.338,-46.407,168.382,-46.485,168.223,-46.518,168.328,-46.625,168.301,-46.55,168.35,-46.535,168.527,-46.591,168.415,-46.572,168.36,-46.599,168.432,-46.607,168.771,-46.558,168.847,-46.659,168.122,-46.863,167.925,-46.7,167.723,-46.711,167.766,-46.921,167.665,-46.947,167.715,-47.05,167.62,-47.042,167.568,-47.176,167.545,-47.177,167.518,-47.203,167.466,-47.216,167.452,-47.278,167.537,-47.29,166.563,-48.033,166.121,-50.518,166.05,-50.533,166.085,-50.667,165.868,-50.799,166.015,-50.853,166.019,-50.745,166.054,-50.863,165.913,-50.867,146.873,-43.637,146.901,-43.517,147.002,-43.479,146.917,-43.446,146.921,-43.417,147.017,-43.444,147.06,-43.357,147.02,-43.334,147.007,-43.343,146.972,-43.327,147.1,-43.291,146.965,-43.191,146.961,-43.161,147.023,-43.073,147.045,-43.048,146.985,-43.19,147.094,-43.169,147.089,-43.245,147.141,-43.249,147.167,-43.283,147.216,-43.281,147.272,-43.019,147.344,-43.052,147.326,-42.834,147.257,-42.815,147.207,-42.741,147.155,-42.752,147.142,-42.768,147.103,-42.782,147.193,-42.727,147.404,-42.885,147.408,-42.939,147.485,-42.911,147.47,-43.027,147.431,-43.028,147.409,-42.963,147.391,-42.976,147.417,-43.055,147.554,-42.986,147.506,-42.863,147.613,-42.846,147.48,-42.826,147.437,-42.785,147.448,-42.757,147.595,-42.786,147.669,-42.902,147.817,-42.901,147.847,-43.013,147.924,-43.018,147.773,-43.055,147.676,-42.935,147.627,-43.073,147.741,-43.097,147.694,-43.167,147.788,-43.237,147.875,-43.206,147.846,-43.155,147.863,-43.109,147.896,-43.191,148.012,-43.222,147.926,-43.022,148.003,-42.96,147.976,-42.87,147.812,-42.891,147.963,-42.679,147.861,-42.559,148.012,-42.51,148.018,-42.315,147.959,-42.352,147.934,-42.343,148.07,-42.112,148.234,-42.097,148.12,-42.077,148.154,-42.005,148.246,-41.992,148.157,-42.073,148.221,-42.069,148.301,-42.13,148.26,-42.254,148.357,-42.194,148.298,-42.167,148.345,-42.119,148.264,-41.815,148.317,-41.576,148.237,-41.46,148.36,-41.276,148.273,-41.339,148.249,-41.329,148.339,-41.26,148.252,-41.051,148.35,-40.996,148.482,-40.435,148.287,-40.076,148.279,-40.117,148.223,-40.121,148.289,-39.968,148.0,-39.774,148.005,-39.642,149.979,-37.502,149.919,-37.243,150.052,-37.262,150.006,-37.141,149.86,-37.098,149.949,-37.018,149.877,-36.893,149.94,-36.903,149.992,-36.725,149.972,-36.702,149.959,-36.719,149.93,-36.721,149.92,-36.717,150.069,-36.554,150.03,-36.348,150.149,-36.267,150.138,-36.213,150.109,-36.23,150.072,-36.228,150.061,-36.21,150.133,-36.213,150.134,-36.073,150.072,-36.087,150.045,-36.063,150.165,-36.014,150.162,-35.908,150.08,-35.908,150.068,-35.902,150.066,-35.893,150.236,-35.835,150.119,-35.695,150.138,-35.618,150.14,-35.703,150.277,-35.724,150.26,-35.623,150.324,-35.639,150.423,-35.37,150.494,-35.368,150.452,-35.228,150.512,-35.269,150.593,-35.19,150.55,-35.102,150.65,-35.095,150.594,-35.183,150.695,-35.201,150.765,-35.125,150.699,-35.121,150.663,-35.02,150.786,-34.993,150.773,-35.079,150.834,-35.075,150.77,-34.901,150.758,-34.926,150.702,-34.937,150.681,-34.863,150.569,-34.875,150.536,-34.868,150.552,-34.852,150.495,-34.868,150.547,-34.85,150.663,-34.851,150.718,-34.874,150.826,-34.783,150.904,-34.597,150.812,-34.507,150.921,-34.494,150.928,-34.318,151.172,-34.089,151.092,-34.088,151.082,-34.056,151.23,-34.027,151.138,-34.036,151.118,-33.992,151.25,-34.002,151.286,-33.892,151.281,-33.833,151.267,-33.87,151.189,-33.875,151.186,-33.846,151.151,-33.872,151.154,-33.842,151.119,-33.865,151.094,-33.824,151.077,-33.839,151.084,-33.822,151.063,-33.825,151.049,-33.824,151.046,-33.821,151.308,-33.808,151.336,-33.582,151.304,-33.666,151.294,-33.572,151.16,-33.66,151.247,-33.578,151.215,-33.571,151.176,-33.514,151.158,-33.574,151.119,-33.589,151.126,-33.598,151.125,-33.62,151.118,-33.628,151.115,-33.513,151.159,-33.463,151.143,-33.475,151.121,-33.465,151.105,-33.483,151.091,-33.481,151.08,-33.448,151.077,-33.461,151.073,-33.463,151.066,-33.461,151.03,-33.428,151.031,-33.406,150.993,-33.4,150.987,-33.379,150.98,-33.399,150.965,-33.412,150.952,-33.415,150.955,-33.425,150.951,-33.429,150.943,-33.426,150.939,-33.415,150.928,-33.433,150.895,-33.428,150.901,-33.436,150.877,-33.454,150.898,-33.451,150.904,-33.463,150.906,-33.47,150.904,-33.473,150.9,-33.475,150.872,-33.473,150.89,-33.486,150.873,-33.503,150.871,-33.472,150.905,-33.47,150.875,-33.453,150.898,-33.435,150.892,-33.43,150.902,-33.419,150.939,-33.413,150.952,-33.426,150.95,-33.414,150.977,-33.397,150.981,-33.372,151.033,-33.404,151.071,-33.461,151.08,-33.446,151.097,-33.481,151.158,-33.417,151.171,-33.437,151.16,-33.512,151.182,-33.502,151.199,-33.536,151.194,-33.49,151.214,-33.473,151.232,-33.473,151.23,-33.457,151.238,-33.454,151.259,-33.571,151.271,-33.561,151.266,-33.555,151.267,-33.548,151.26,-33.544,151.264,-33.529,151.35,-33.515,151.301,-33.496,151.32,-33.431,151.382,-33.472,151.363,-33.523,151.336,-33.522,151.345,-33.547,151.416,-33.524,151.505,-33.353,151.445,-33.292,151.508,-33.218,151.564,-33.242,151.521,-33.262,151.502,-33.345,151.573,-33.29,151.564,-33.249,151.666,-33.089,151.567,-33.176,151.492,-33.081,151.613,-33.083,151.615,-32.962,151.659,-33.085,151.804,-32.916,151.785,-32.926,151.764,-32.925,151.713,-32.87,151.785,-32.892,151.683,-32.823,151.681,-32.817,151.684,-32.814,151.706,-32.803,151.695,-32.795,151.694,-32.791,151.696,-32.788,151.808,-32.828,151.789,-32.857,151.785,-32.92,152.115,-32.793,152.188,-32.712,152.064,-32.698,152.072,-32.732,152.052,-32.753,152.02,-32.746,151.966,-32.777,151.923,-32.783,152.019,-32.74,152.043,-32.738,151.99,-32.719,151.944,-32.739,151.933,-32.74,151.93,-32.737,151.978,-32.705,151.942,-32.676,151.97,-32.666,151.939,-32.614,151.961,-32.537,151.974,-32.66,152.171,-32.636,152.172,-32.673,152.147,-32.678,152.205,-32.701,152.33,-32.531,152.54,-32.444,152.459,-32.379,152.534,-32.38,152.57,-32.214,152.508,-32.176,152.532,-32.227,152.527,-32.332,152.498,-32.342,152.476,-32.236,152.394,-32.196,152.403,-32.265,152.395,-32.276,152.362,-32.185,152.509,-32.174,152.598,-31.945,152.541,-31.943,152.482,-31.911,152.425,-31.939,152.42,-31.939,152.415,-31.929,152.504,-31.897,152.546,-31.906,152.566,-31.877,152.614,-31.859,152.661,-31.889,152.754,-31.845,152.85,-31.642,152.757,-31.718,152.728,-31.703,152.939,-31.474,152.898,-31.425,152.902,-31.406,152.887,-31.421,152.874,-31.422,152.829,-31.406,152.786,-31.436,152.764,-31.442,152.769,-31.4,152.84,-31.366,152.872,-31.418,152.888,-31.388,152.888,-31.402,152.908,-31.406,152.914,-31.425,153.067,-31.056,153.088,-30.912,153.028,-30.873,153.036,-30.933,152.987,-30.994,152.943,-31.027,152.923,-31.001,152.91,-31.023,152.848,-31.069,152.92,-30.999,152.978,-31.0,153.032,-30.925,153.01,-30.654,152.984,-30.702,153.003,-30.657,152.965,-30.705,152.942,-30.712,152.912,-30.703,152.912,-30.698,153.02,-30.649,153.001,-30.459,153.032,-30.499,153.155,-30.305,153.221,-30.0,159.033,-30.0]}],"names":{"ar":"بحر تسمان","de":"Tasmansee","es":"mar de Tasmania","fr":"mer de Tasman","he":"ים טסמן","hu":"Tasman-tenger","it":"Mar di Tasman","nl":"Tasmanzee","pt":"Mar da Tasmânia","zh":"塔斯曼海"}},{"name":"Bering Sea","bbox":[-180.0,51.351,180.0,66.563],"area":361.1711,"polygons":[{"outer":[173.153,65.516,173.284,65.514,173.41,65.485,173.431,65.466,173.427,65.454,173.406,65.442,173.433,65.42,173.538,65.414,173.632,65.372,173.638,65.315,173.685,65.317,173.716,65.314,173.73,65.309,173.738,65.25,173.728,65.228,173.585,65.098,173.649,65.097,173.585,65.079,173.634,65.014,173.885,64.977,173.99,64.939,174.045,64.902,174.14,64.767,174.628,64.649,175.066,64.743,175.588,64.774,175.728,64.749,176.056,64.938,176.443,64.846,176.449,64.795,176.685,64.855,177.077,64.753,177.344,64.808,177.009,65.036,176.67,64.975,176.333,65.025,176.421,65.038,176.388,65.087,176.48,65.181,176.579,65.195,176.667,65.186,176.409,65.072,176.604,65.005,176.951,65.068,177.219,65.008,177.272,64.916,177.588,64.93,177.481,64.853,177.492,64.843,177.55,64.846,177.578,64.836,177.406,64.815,177.554,64.798,177.633,64.696,178.831,64.66,178.517,64.583,178.553,64.561,178.794,64.63,179.137,64.76,179.512,64.796,180.0,65.038,180.0,51.394,179.391,51.356,179.469,51.373,179.278,51.39,178.903,51.617,178.617,51.638,178.949,51.542,179.234,51.351,173.497,52.379,173.725,52.356,173.692,52.462,173.773,52.509,173.373,52.421,166.67,54.679,166.644,54.865,166.247,55.177,166.257,55.329,165.979,55.364,165.757,55.285,166.022,55.211,166.179,54.956,166.504,54.83,166.646,54.687,163.049,56.008,163.362,56.184,163.354,56.326,163.25,56.437,163.285,56.687,163.228,56.742,162.922,56.69,162.883,56.694,162.811,56.721,162.881,56.697,162.909,56.696,162.928,56.692,162.777,56.774,162.853,57.033,162.729,57.251,162.783,57.348,162.741,57.329,162.691,57.331,162.56,57.354,162.534,57.369,162.507,57.355,162.497,57.353,162.485,57.354,162.477,57.364,162.462,57.364,162.432,57.339,162.427,57.339,162.404,57.345,162.394,57.351,162.394,57.355,162.397,57.357,162.42,57.359,162.422,57.377,162.394,57.362,162.369,57.361,162.36,57.363,162.422,57.379,162.429,57.34,162.461,57.365,162.542,57.37,162.696,57.332,162.778,57.356,162.784,57.351,163.097,57.484,163.321,57.726,163.212,57.818,162.699,57.95,162.508,57.887,162.531,57.751,162.348,57.662,162.279,57.731,162.35,57.675,162.45,57.74,162.401,57.777,162.483,57.772,162.123,57.839,162.212,57.803,162.137,57.751,161.995,58.025,162.001,57.994,161.987,57.97,162.003,57.969,162.0,57.927,161.898,57.974,161.982,58.003,161.979,58.012,161.991,58.025,161.985,58.033,161.988,58.047,161.981,58.053,161.985,58.13,161.951,58.153,161.95,58.163,161.959,58.167,161.943,58.17,161.937,58.182,161.924,58.184,161.911,58.205,161.893,58.18,161.841,58.173,161.85,58.166,161.845,58.162,161.838,58.161,161.825,58.165,161.824,58.175,161.911,58.206,161.935,58.193,161.925,58.185,161.939,58.184,161.961,58.168,161.987,58.131,161.991,58.065,162.159,58.402,162.616,58.774,162.46,58.707,162.407,58.711,162.358,58.755,162.466,58.711,162.554,58.787,162.632,58.776,162.795,58.837,162.683,58.862,162.903,58.969,162.991,58.942,162.871,58.926,162.844,58.885,163.013,58.944,163.066,58.98,162.901,59.119,163.006,59.143,162.958,59.161,162.93,59.189,162.898,59.183,162.884,59.188,162.88,59.195,163.045,59.182,163.226,59.041,163.233,59.136,163.074,59.246,163.339,59.284,163.284,59.221,163.344,59.274,163.341,59.379,163.203,59.506,163.198,59.482,163.112,59.486,163.416,59.62,163.37,59.737,163.431,59.834,163.705,59.87,163.836,59.992,163.698,59.956,163.639,59.988,163.642,60.022,164.217,59.99,164.065,59.895,164.203,59.852,164.101,59.9,164.344,59.993,164.311,60.073,164.482,60.102,164.714,59.997,164.838,59.782,165.215,59.986,165.162,60.079,165.025,60.052,165.012,60.103,165.177,60.142,165.211,60.085,165.491,60.192,165.433,60.207,165.436,60.263,165.496,60.315,165.68,60.388,165.533,60.328,165.449,60.269,165.455,60.208,165.763,60.264,166.001,60.36,166.093,60.431,165.993,60.361,165.915,60.34,165.998,60.419,166.373,60.469,166.381,60.402,166.258,60.394,166.313,60.194,166.208,60.127,166.09,59.812,166.285,59.811,166.861,60.214,166.817,60.216,166.789,60.227,167.073,60.306,167.004,60.415,167.229,60.338,167.399,60.391,167.361,60.455,167.462,60.406,167.788,60.469,167.777,60.521,167.794,60.54,167.827,60.544,167.813,60.554,167.834,60.542,167.792,60.536,167.874,60.502,168.443,60.598,168.715,60.541,169.225,60.547,168.84,60.57,169.028,60.6,169.228,60.556,169.221,60.637,169.407,60.559,169.367,60.528,169.271,60.538,169.572,60.449,169.607,60.461,169.716,60.447,169.711,60.444,169.745,60.458,169.755,60.458,169.761,60.462,169.753,60.457,169.8,60.467,169.837,60.466,169.821,60.5,169.821,60.506,169.827,60.517,169.873,60.526,169.85,60.541,169.8,60.545,169.795,60.549,169.852,60.598,169.796,60.549,169.855,60.541,169.874,60.528,169.873,60.52,169.823,60.506,169.845,60.502,169.838,60.466,169.712,60.443,169.621,60.446,169.687,60.422,169.725,60.436,169.732,60.436,169.738,60.434,169.687,60.421,169.648,60.435,169.584,60.448,169.743,60.392,169.788,60.265,169.877,60.225,169.908,60.253,170.039,60.227,169.876,60.202,169.87,60.218,169.884,60.168,169.958,60.073,170.315,59.928,170.464,60.058,170.475,60.17,170.39,60.205,170.499,60.243,170.431,60.293,170.535,60.264,170.511,60.247,170.484,60.209,170.472,60.179,170.474,60.171,170.691,60.314,170.643,60.409,171.629,60.717,171.627,60.785,171.715,60.847,171.743,60.854,171.775,60.856,171.746,60.858,171.712,60.852,171.688,60.889,171.732,60.948,171.733,60.862,171.809,60.9,171.794,60.853,171.725,60.849,171.709,60.833,171.996,60.887,172.074,60.837,172.096,60.89,172.217,60.967,172.009,61.086,172.278,61.005,172.484,61.05,172.402,61.045,172.405,61.11,172.31,61.1,172.241,61.131,172.445,61.132,172.33,61.228,172.651,61.167,172.972,61.285,172.941,61.341,172.698,61.41,172.689,61.419,172.695,61.425,172.668,61.429,172.673,61.436,172.906,61.463,172.855,61.465,172.809,61.485,172.832,61.487,172.901,61.475,173.01,61.404,173.122,61.386,173.311,61.455,173.333,61.482,173.303,61.51,173.309,61.551,173.494,61.543,173.522,61.722,173.491,61.709,173.498,61.731,173.471,61.745,173.467,61.753,173.485,61.763,173.418,61.771,173.442,61.769,173.42,61.781,173.418,61.788,173.422,61.793,173.412,61.796,173.39,61.807,173.386,61.81,173.384,61.821,173.487,61.765,173.504,61.733,173.485,61.773,173.529,61.738,173.528,61.746,173.549,61.762,173.566,61.766,173.551,61.765,173.569,61.767,173.583,61.766,173.601,61.763,173.609,61.766,173.598,61.774,173.606,61.781,173.59,61.782,173.582,61.784,173.573,61.797,173.595,61.796,173.583,61.785,173.608,61.781,173.613,61.766,173.526,61.723,173.835,61.645,174.137,61.778,174.032,61.776,174.094,61.816,174.068,61.825,174.054,61.837,174.032,61.837,174.023,61.839,174.018,61.842,174.016,61.845,174.009,61.846,174.017,61.846,174.023,61.84,174.06,61.837,174.025,61.845,173.974,61.874,173.966,61.881,173.963,61.892,173.956,61.897,173.951,61.909,173.954,61.913,174.236,61.804,174.141,61.779,174.583,61.821,174.763,61.894,174.631,61.976,174.865,61.936,174.763,61.897,174.768,61.894,175.256,62.027,175.258,62.169,175.296,62.095,175.415,62.107,175.306,62.061,175.283,62.047,177.018,62.526,176.893,62.51,176.915,62.516,177.627,62.587,179.113,62.275,179.184,62.32,179.14,62.458,179.384,62.485,179.623,62.662,179.566,62.839,179.352,62.884,179.27,62.987,179.462,63.055,179.453,63.116,179.061,63.29,178.852,63.484,178.784,63.74,178.7,63.945,178.682,63.982,178.656,64.017,178.684,63.972,178.687,63.957,178.693,63.954,178.693,63.945,178.696,63.944,178.701,63.937,178.7,63.931,178.401,63.977,178.385,63.957,178.371,63.948,178.365,63.946,178.306,63.938,178.218,63.948,178.178,63.928,178.11,63.917,178.177,63.929,178.2,63.944,178.172,63.936,178.029,63.914,178.09,63.922,178.098,63.921,178.103,63.916,178.11,63.916,177.938,63.833,177.847,63.833,177.891,63.806,177.839,63.825,177.843,63.839,177.865,63.845,177.95,63.844,177.972,63.87,177.952,63.889,177.971,63.912,178.099,63.935,178.224,63.951,178.248,63.98,178.283,63.99,178.315,63.981,178.456,64.024,178.413,64.035,178.65,64.022,178.289,64.395,178.233,64.433,178.333,64.325,178.12,64.228,178.163,64.195,178.228,64.187,178.253,64.176,178.263,64.161,178.276,64.16,178.287,64.166,178.298,64.166,178.323,64.155,178.33,64.146,178.288,64.119,178.266,64.12,178.247,64.124,178.261,64.111,178.261,64.108,178.253,64.102,178.176,64.095,178.174,64.088,178.194,64.079,178.196,64.07,178.193,64.066,178.182,64.061,178.192,64.073,178.172,64.089,178.175,64.096,178.253,64.103,178.246,64.125,178.327,64.149,177.889,64.227,177.787,64.266,177.705,64.324,177.686,64.328,177.651,64.343,177.622,64.348,177.633,64.316,177.629,64.301,177.492,64.393,177.415,64.508,177.537,64.735,177.089,64.706,176.831,64.554,176.731,64.524,176.726,64.593,176.552,64.666,176.243,64.607,176.142,64.472,176.04,64.415,176.067,64.375,176.161,64.368,176.207,64.352,176.223,64.338,176.307,64.334,176.375,64.306,176.407,64.25,176.322,64.281,176.339,64.263,176.335,64.25,176.296,64.247,176.285,64.236,176.248,64.229,176.246,64.224,176.266,64.221,176.268,64.218,176.237,64.209,176.256,64.201,176.257,64.199,176.256,64.198,176.251,64.197,176.234,64.2,176.224,64.195,176.25,64.194,176.259,64.192,176.264,64.188,176.263,64.184,176.258,64.183,176.233,64.183,176.225,64.177,176.237,64.142,176.223,64.178,176.235,64.185,176.263,64.185,176.223,64.194,176.245,64.228,176.334,64.264,176.016,64.341,175.994,64.395,176.088,64.533,176.19,64.609,176.44,64.686,176.168,64.892,176.0,64.887,175.94,64.799,175.702,64.732,174.845,64.679,174.711,64.455,174.582,64.407,174.598,64.408,174.506,64.396,174.483,64.403,174.266,64.398,174.156,64.462,174.161,64.446,174.057,64.399,174.146,64.459,174.046,64.46,174.112,64.536,174.464,64.556,174.391,64.558,174.377,64.568,174.35,64.565,174.339,64.567,174.331,64.595,174.353,64.631,174.341,64.641,174.343,64.666,174.329,64.677,174.352,64.693,174.346,64.697,174.326,64.699,174.276,64.718,174.235,64.743,174.136,64.72,173.907,64.725,173.884,64.735,173.853,64.736,173.85,64.739,173.85,64.752,173.889,64.769,173.864,64.794,173.819,64.79,173.772,64.8,173.864,64.795,173.893,64.769,173.853,64.739,173.882,64.736,173.913,64.725,174.199,64.747,174.117,64.758,174.1,64.763,174.087,64.77,173.925,64.941,173.819,64.98,173.666,64.987,173.582,65.015,173.635,65.002,173.547,65.092,173.707,65.265,173.562,65.288,173.529,65.298,173.495,65.312,173.601,65.287,173.452,65.357,173.294,65.5,173.377,65.437,173.409,65.48,173.27,65.51,172.99,65.467,172.977,65.438,172.912,65.389,172.901,65.348,172.863,65.323,172.623,65.223,172.523,65.204,172.393,65.219,172.338,65.127,172.312,65.119,172.265,65.132,172.246,65.122,172.241,65.124,172.259,65.101,172.257,65.087,172.247,65.079,171.751,64.889,171.697,64.88,171.642,64.88,171.591,64.884,171.617,64.865,171.615,64.85,171.608,64.845,171.461,64.829,171.54,64.824,171.551,64.82,171.566,64.808,171.559,64.796,171.414,64.767,171.563,64.803,171.476,64.819,171.46,64.829,171.468,64.838,171.592,64.886,171.74,64.89,172.25,65.102,172.236,65.118,172.177,65.128,172.169,65.141,172.238,65.122,172.241,65.127,172.265,65.134,172.322,65.126,172.311,65.168,172.368,65.217,172.404,65.227,172.539,65.215,172.99,65.476,173.205,65.51,173.153,65.516],"holes":[[164.567,59.23,164.301,59.111,164.016,59.038,163.762,59.016,163.69,58.988,163.949,59.011,163.774,58.886,163.592,58.594,163.413,58.552,163.442,58.487,163.49,58.449,163.587,58.574,164.167,58.809,164.641,58.871,164.733,59.047,164.567,59.23],[172.74,53.007,172.437,52.92,172.792,52.89,172.737,52.793,172.905,52.746,173.056,52.832,173.427,52.83,173.267,52.863,173.309,52.912,173.246,52.944,173.128,52.923,173.125,52.99,172.74,53.007]]},{"outer":[-164.474,64.541,-164.434,64.564,-164.275,64.572,-164.376,64.554,-163.605,64.565,-163.15,64.398,-163.03,64.513,-163.107,64.503,-163.146,64.508,-163.385,64.58,-163.129,64.656,-163.045,64.54,-162.836,64.49,-162.786,64.325,-162.63,64.387,-162.543,64.532,-161.672,64.786,-161.79,64.809,-161.761,64.844,-161.75,64.849,-161.748,64.847,-161.739,64.848,-161.717,64.842,-161.708,64.848,-161.711,64.852,-161.731,64.851,-161.747,64.858,-161.748,64.861,-161.742,64.864,-161.745,64.858,-161.706,64.852,-161.706,64.846,-161.71,64.842,-161.723,64.841,-161.744,64.847,-161.762,64.832,-161.754,64.828,-161.718,64.829,-161.711,64.825,-161.698,64.829,-161.684,64.828,-161.671,64.833,-161.667,64.831,-161.773,64.81,-161.702,64.809,-161.533,64.738,-161.142,64.931,-160.986,64.833,-160.835,64.852,-160.903,64.823,-160.782,64.714,-160.793,64.619,-161.09,64.539,-161.016,64.501,-161.47,64.506,-161.532,64.38,-161.408,64.448,-161.199,64.415,-161.274,64.391,-160.959,64.218,-160.938,64.054,-160.796,63.873,-160.783,63.894,-160.783,63.873,-160.774,63.877,-160.765,63.879,-160.737,63.875,-160.707,63.861,-160.692,63.862,-160.687,63.871,-160.662,63.876,-160.647,63.87,-160.743,63.844,-160.789,63.87,-160.78,63.757,-161.139,63.502,-162.167,63.405,-162.006,63.484,-162.314,63.541,-162.272,63.486,-162.425,63.403,-162.393,63.409,-162.387,63.396,-162.349,63.4,-162.268,63.376,-162.42,63.397,-162.447,63.367,-162.431,63.366,-162.435,63.356,-162.412,63.356,-162.401,63.354,-162.39,63.338,-162.393,63.334,-162.397,63.333,-162.415,63.337,-162.415,63.324,-162.426,63.319,-162.422,63.314,-162.396,63.319,-162.384,63.32,-162.38,63.318,-162.38,63.317,-162.386,63.311,-162.38,63.318,-162.4,63.313,-162.418,63.313,-162.425,63.314,-162.454,63.371,-162.588,63.271,-162.563,63.268,-162.56,63.272,-162.57,63.277,-162.57,63.281,-162.566,63.284,-162.554,63.285,-162.508,63.284,-162.501,63.275,-162.827,63.204,-163.043,63.062,-163.336,63.033,-163.284,62.984,-163.426,63.046,-163.564,63.031,-163.695,63.034,-163.763,63.019,-163.805,62.985,-163.771,62.956,-163.798,62.921,-163.819,62.919,-163.827,62.929,-163.861,62.92,-163.853,62.908,-163.9,62.871,-163.967,62.844,-163.919,62.868,-163.976,62.865,-163.983,62.878,-163.992,62.869,-164.017,62.869,-164.03,62.859,-164.077,62.833,-164.094,62.833,-163.966,62.757,-163.927,62.671,-163.9,62.679,-163.895,62.678,-163.886,62.669,-163.88,62.679,-163.874,62.682,-163.937,62.601,-163.861,62.54,-163.8,62.46,-163.791,62.415,-163.841,62.315,-163.838,62.312,-163.83,62.313,-163.821,62.31,-163.823,62.3,-163.798,62.299,-163.765,62.277,-163.78,62.269,-163.843,62.312,-163.834,62.236,-163.907,62.206,-163.869,62.186,-163.877,62.152,-163.871,62.15,-163.841,62.147,-163.831,62.144,-163.825,62.123,-163.783,62.124,-163.761,62.113,-163.744,62.073,-163.863,62.08,-163.802,62.067,-163.833,62.071,-163.892,62.086,-163.941,62.107,-163.965,62.133,-163.984,62.207,-164.007,62.213,-164.006,62.227,-164.0,62.265,-163.968,62.313,-163.956,62.324,-163.942,62.332,-163.949,62.334,-163.951,62.337,-163.939,62.352,-163.951,62.36,-163.96,62.371,-163.956,62.394,-163.933,62.43,-163.94,62.439,-163.95,62.433,-163.956,62.436,-163.958,62.438,-163.921,62.481,-164.179,62.691,-164.363,62.685,-164.442,62.701,-164.446,62.704,-164.444,62.72,-164.456,62.718,-164.467,62.719,-164.496,62.723,-164.511,62.729,-164.502,62.719,-164.717,62.601,-164.818,62.582,-164.821,62.573,-164.809,62.567,-164.806,62.557,-164.849,62.573,-164.78,62.538,-164.838,62.478,-164.686,62.466,-164.67,62.525,-164.608,62.508,-164.624,62.54,-164.598,62.558,-164.573,62.561,-164.629,62.585,-164.62,62.591,-164.582,62.603,-164.576,62.603,-164.57,62.599,-164.613,62.592,-164.569,62.562,-164.61,62.502,-164.591,62.511,-164.544,62.51,-164.542,62.512,-164.55,62.517,-164.55,62.52,-164.524,62.522,-164.526,62.524,-164.546,62.526,-164.552,62.529,-164.539,62.529,-164.542,62.537,-164.518,62.547,-164.549,62.55,-164.555,62.553,-164.556,62.555,-164.554,62.557,-164.551,62.558,-164.532,62.555,-164.543,62.561,-164.53,62.563,-164.542,62.565,-164.545,62.568,-164.519,62.582,-164.485,62.558,-164.538,62.528,-164.55,62.529,-164.526,62.526,-164.522,62.523,-164.558,62.481,-164.54,62.487,-164.539,62.501,-164.527,62.51,-164.518,62.512,-164.537,62.487,-164.577,62.475,-164.562,62.46,-164.58,62.442,-164.548,62.421,-164.532,62.39,-164.644,62.421,-164.679,62.381,-164.727,62.365,-164.766,62.373,-164.765,62.357,-164.791,62.349,-164.777,62.34,-164.778,62.337,-164.793,62.35,-164.768,62.373,-164.666,62.401,-164.728,62.422,-164.746,62.466,-164.862,62.475,-164.842,62.532,-165.098,62.521,-165.342,62.344,-165.289,62.338,-165.299,62.355,-165.283,62.364,-165.255,62.367,-165.166,62.349,-165.156,62.331,-165.209,62.322,-165.176,62.301,-165.14,62.302,-165.095,62.285,-165.09,62.303,-165.065,62.317,-164.994,62.305,-164.968,62.282,-164.996,62.271,-164.965,62.259,-164.966,62.256,-164.972,62.253,-164.993,62.252,-164.982,62.242,-164.951,62.244,-164.963,62.251,-164.957,62.26,-164.95,62.264,-164.859,62.263,-165.029,62.227,-164.986,62.198,-164.998,62.156,-164.902,62.157,-164.888,62.133,-164.904,62.126,-164.938,62.129,-164.956,62.12,-164.921,62.108,-164.91,62.089,-164.951,62.063,-165.003,62.059,-164.873,62.018,-165.008,62.059,-164.915,62.09,-164.962,62.119,-164.944,62.131,-164.893,62.139,-165.007,62.146,-165.096,62.22,-164.995,62.291,-165.104,62.279,-165.188,62.3,-165.211,62.319,-165.211,62.323,-165.201,62.328,-165.157,62.331,-165.171,62.349,-165.335,62.334,-165.357,62.357,-165.67,62.154,-165.759,62.006,-165.618,61.847,-165.541,61.864,-165.501,61.857,-165.495,61.843,-165.473,61.843,-165.47,61.874,-165.467,61.845,-165.436,61.845,-165.431,61.842,-165.43,61.837,-165.418,61.838,-165.413,61.837,-165.38,61.819,-165.375,61.831,-165.369,61.833,-165.363,61.833,-165.348,61.823,-165.342,61.824,-165.334,61.831,-165.326,61.833,-165.32,61.832,-165.311,61.824,-165.304,61.824,-165.379,61.818,-165.432,61.833,-165.436,61.844,-165.493,61.841,-165.543,61.863,-165.787,61.812,-166.095,61.815,-166.012,61.721,-165.81,61.685,-166.164,61.654,-166.139,61.506,-165.927,61.552,-165.73,61.483,-165.783,61.451,-165.701,61.438,-165.715,61.391,-165.684,61.406,-165.672,61.404,-165.665,61.392,-165.642,61.402,-165.604,61.407,-165.587,61.404,-165.577,61.43,-165.52,61.43,-165.542,61.446,-165.49,61.454,-165.449,61.451,-165.524,61.439,-165.427,61.429,-165.474,61.398,-165.389,61.354,-165.39,61.349,-165.406,61.339,-165.527,61.303,-165.653,61.327,-165.611,61.294,-165.601,61.277,-165.638,61.266,-165.634,61.254,-165.646,61.253,-165.609,61.252,-165.6,61.242,-165.591,61.247,-165.623,61.157,-165.539,61.089,-165.437,61.086,-165.471,61.129,-165.468,61.132,-165.453,61.134,-165.439,61.125,-165.44,61.134,-165.435,61.142,-165.367,61.081,-165.322,61.175,-165.139,61.156,-165.151,61.196,-165.067,61.212,-165.112,61.216,-165.136,61.247,-165.137,61.253,-165.107,61.269,-165.085,61.274,-165.081,61.271,-165.065,61.226,-165.05,61.227,-165.036,61.246,-165.018,61.252,-165.006,61.249,-164.994,61.23,-164.961,61.227,-164.989,61.256,-164.951,61.301,-164.919,61.308,-164.902,61.306,-164.884,61.277,-164.88,61.294,-164.823,61.309,-164.873,61.312,-164.884,61.316,-164.88,61.327,-164.822,61.338,-164.814,61.337,-164.812,61.335,-164.816,61.322,-164.794,61.31,-164.78,61.325,-164.774,61.326,-164.759,61.32,-164.881,61.276,-164.95,61.299,-164.95,61.227,-165.067,61.225,-165.087,61.271,-165.132,61.252,-165.061,61.209,-165.118,61.151,-165.052,61.161,-165.051,61.148,-165.187,61.125,-165.146,61.101,-165.106,61.114,-165.057,61.099,-165.033,61.137,-165.013,61.139,-164.985,61.133,-164.997,61.148,-164.957,61.164,-164.918,61.169,-164.951,61.176,-164.952,61.179,-164.949,61.182,-164.897,61.195,-164.891,61.194,-164.883,61.186,-164.942,61.184,-164.951,61.178,-164.917,61.17,-164.917,61.168,-165.054,61.096,-165.125,61.089,-164.998,61.051,-164.982,61.056,-164.999,61.061,-164.988,61.066,-165.011,61.082,-165.006,61.097,-164.981,61.109,-164.776,61.149,-164.66,61.126,-164.733,61.165,-164.721,61.171,-164.682,61.166,-164.615,61.188,-164.619,61.204,-164.61,61.222,-164.636,61.21,-164.645,61.21,-164.654,61.214,-164.66,61.229,-164.694,61.226,-164.708,61.241,-164.688,61.256,-164.645,61.246,-164.632,61.26,-164.66,61.27,-164.663,61.281,-164.618,61.296,-164.757,61.32,-164.602,61.328,-164.59,61.327,-164.587,61.324,-164.59,61.304,-164.568,61.311,-164.542,61.314,-164.557,61.342,-164.548,61.356,-164.534,61.36,-164.515,61.346,-164.499,61.354,-164.552,61.38,-164.556,61.416,-164.386,61.497,-164.365,61.435,-164.546,61.416,-164.494,61.352,-164.539,61.312,-164.591,61.303,-164.628,61.323,-164.613,61.291,-164.66,61.273,-164.623,61.265,-164.627,61.257,-164.65,61.242,-164.704,61.242,-164.618,61.24,-164.599,61.218,-164.611,61.167,-164.626,61.178,-164.682,61.162,-164.725,61.166,-164.666,61.137,-164.655,61.128,-164.659,61.122,-164.784,61.142,-164.845,61.049,-164.939,61.102,-164.907,60.996,-165.118,61.014,-165.181,60.949,-164.872,60.942,-164.818,60.916,-164.809,60.894,-164.636,60.944,-164.535,60.847,-164.141,60.879,-163.764,60.851,-163.414,60.727,-163.76,60.574,-163.818,60.623,-163.782,60.754,-164.0,60.77,-164.107,60.668,-164.402,60.545,-164.485,60.559,-164.426,60.517,-164.421,60.488,-164.432,60.477,-164.526,60.459,-164.573,60.458,-164.531,60.387,-164.623,60.373,-164.619,60.323,-164.65,60.328,-164.667,60.287,-164.594,60.292,-164.589,60.286,-164.593,60.282,-164.59,60.279,-164.585,60.282,-164.578,60.279,-164.578,60.272,-164.651,60.258,-164.387,60.077,-164.043,60.027,-164.129,59.98,-164.077,59.974,-164.057,59.939,-164.048,59.94,-164.063,59.955,-164.055,59.965,-164.009,59.978,-163.928,59.971,-163.923,59.945,-163.895,59.966,-163.874,59.963,-163.87,59.982,-163.855,59.987,-163.749,59.98,-163.743,59.977,-163.739,59.971,-163.744,59.967,-163.731,59.963,-163.742,59.954,-163.737,59.956,-163.701,59.955,-163.698,59.976,-163.686,59.979,-163.678,59.973,-163.7,59.953,-163.75,59.952,-163.852,59.987,-163.872,59.961,-163.918,59.944,-163.931,59.944,-163.937,59.949,-163.93,59.97,-164.007,59.975,-164.046,59.936,-164.22,59.945,-164.054,59.821,-163.375,59.814,-162.542,59.982,-162.454,60.193,-162.598,60.245,-162.644,60.243,-162.649,60.233,-162.654,60.232,-162.662,60.235,-162.671,60.232,-162.66,60.227,-162.654,60.206,-162.658,60.202,-162.666,60.202,-162.665,60.198,-162.676,60.201,-162.735,60.3,-162.555,60.267,-162.531,60.317,-162.591,60.313,-162.664,60.343,-162.398,60.381,-162.448,60.409,-162.449,60.443,-162.472,60.452,-162.497,60.436,-162.52,60.433,-162.537,60.436,-162.546,60.44,-162.549,60.447,-162.519,60.465,-162.528,60.468,-162.54,60.462,-162.557,60.459,-162.562,60.459,-162.584,60.461,-162.598,60.468,-162.606,60.488,-162.587,60.463,-162.529,60.469,-162.521,60.467,-162.516,60.463,-162.534,60.437,-162.486,60.452,-162.466,60.453,-162.452,60.448,-162.41,60.405,-162.271,60.604,-162.103,60.683,-162.316,60.781,-162.307,60.796,-162.272,60.809,-162.314,60.81,-162.319,60.814,-162.315,60.824,-162.346,60.818,-162.366,60.821,-162.375,60.861,-162.407,60.847,-162.472,60.862,-162.46,60.902,-162.411,60.85,-162.396,60.861,-162.362,60.861,-162.354,60.86,-162.27,60.809,-162.312,60.781,-162.246,60.768,-162.245,60.772,-162.233,60.778,-162.237,60.783,-162.233,60.793,-162.21,60.796,-162.21,60.789,-162.2,60.793,-162.205,60.801,-162.201,60.807,-162.214,60.811,-162.214,60.814,-162.212,60.815,-162.207,60.815,-162.203,60.815,-162.182,60.808,-162.179,60.813,-162.19,60.818,-162.188,60.821,-162.178,60.813,-162.181,60.808,-162.213,60.814,-162.199,60.807,-162.2,60.793,-162.244,60.769,-162.151,60.743,-162.085,60.675,-162.039,60.683,-162.101,60.661,-161.952,60.683,-161.945,60.654,-161.998,60.628,-162.027,60.637,-162.052,60.614,-162.095,60.615,-162.108,60.603,-162.134,60.594,-162.123,60.583,-162.146,60.568,-162.184,60.55,-162.217,60.552,-162.458,60.299,-162.36,60.244,-162.321,60.26,-162.279,60.256,-162.292,60.286,-162.27,60.309,-162.302,60.324,-162.296,60.33,-162.267,60.332,-162.224,60.316,-162.219,60.326,-162.221,60.317,-162.23,60.315,-162.297,60.329,-162.268,60.309,-162.276,60.255,-162.355,60.241,-162.226,60.203,-162.163,60.216,-162.197,60.239,-162.195,60.244,-162.186,60.247,-162.065,60.258,-162.05,60.245,-162.031,60.245,-162.025,60.243,-162.019,60.239,-162.021,60.233,-162.047,60.225,-162.006,60.219,-162.006,60.248,-161.985,60.262,-161.967,60.263,-161.955,60.262,-161.945,60.256,-161.934,60.255,-161.954,60.241,-161.981,60.262,-162.002,60.248,-162.001,60.221,-162.004,60.218,-162.011,60.217,-162.184,60.246,-162.158,60.232,-162.164,60.212,-162.236,60.192,-162.26,60.199,-162.119,60.114,-162.083,60.148,-162.095,60.152,-162.096,60.159,-162.107,60.16,-162.114,60.166,-162.113,60.172,-162.068,60.144,-162.05,60.155,-162.034,60.157,-162.024,60.155,-162.013,60.149,-162.122,60.112,-162.198,60.138,-162.223,60.049,-162.155,60.05,-162.215,60.036,-161.702,59.498,-161.955,59.379,-161.941,59.262,-162.035,59.233,-161.981,59.148,-161.912,59.091,-161.864,59.062,-161.809,59.119,-161.63,59.131,-161.521,59.116,-161.517,59.103,-161.842,59.034,-161.773,58.767,-161.676,58.774,-161.887,58.655,-162.176,58.647,-162.137,58.632,-161.753,58.579,-161.735,58.613,-161.764,58.619,-161.761,58.638,-161.751,58.642,-161.715,58.613,-161.727,58.582,-161.772,58.551,-161.711,58.553,-161.193,58.783,-161.285,58.776,-161.31,58.794,-161.303,58.804,-160.953,58.883,-160.823,58.829,-160.345,59.071,-160.254,58.99,-160.328,58.943,-160.007,58.879,-159.908,58.768,-159.8,58.849,-159.751,58.842,-159.769,58.932,-159.604,58.953,-159.651,58.839,-159.413,58.776,-159.06,58.423,-158.845,58.398,-158.697,58.491,-158.882,58.71,-158.785,58.755,-158.778,58.888,-158.741,58.926,-158.778,58.932,-158.784,58.957,-158.877,58.973,-158.895,59.021,-158.748,58.971,-158.761,58.987,-158.734,58.996,-158.76,58.999,-158.774,59.009,-158.732,59.009,-158.744,59.024,-158.709,59.034,-158.673,59.013,-158.706,59.055,-158.656,59.055,-158.67,59.013,-158.681,59.013,-158.698,59.021,-158.707,59.032,-158.717,59.004,-158.77,59.008,-158.725,58.996,-158.738,58.968,-158.808,58.964,-158.776,58.959,-158.733,58.925,-158.754,58.876,-158.615,58.914,-158.432,59.057,-158.544,59.14,-158.575,59.254,-158.368,59.04,-158.484,58.998,-158.419,58.968,-158.563,58.806,-158.184,58.613,-157.083,58.875,-157.023,58.964,-156.957,58.99,-156.883,59.005,-156.869,59.101,-156.761,59.18,-156.729,59.182,-156.696,59.163,-156.63,59.161,-156.634,59.202,-156.622,59.212,-156.375,59.262,-156.374,59.253,-156.341,59.254,-156.319,59.25,-156.304,59.252,-156.259,59.244,-156.237,59.236,-156.634,59.157,-156.676,59.156,-156.693,59.157,-156.749,59.178,-156.853,59.008,-156.733,59.026,-156.976,58.946,-157.057,58.712,-157.535,58.393,-157.541,58.276,-157.443,58.207,-157.316,58.256,-157.325,58.224,-157.175,58.203,-157.15,58.184,-157.529,58.196,-157.713,57.622,-157.609,57.61,-157.636,57.635,-157.629,57.663,-157.61,57.67,-157.562,57.48,-157.402,57.488,-157.411,57.52,-157.44,57.541,-157.44,57.55,-157.301,57.593,-157.215,57.601,-157.091,57.561,-157.075,57.58,-156.995,57.572,-157.1,57.557,-157.214,57.599,-157.244,57.578,-157.275,57.578,-157.291,57.586,-157.337,57.551,-157.436,57.551,-157.391,57.495,-157.402,57.482,-157.5,57.463,-157.644,57.489,-157.658,57.447,-157.564,57.438,-157.56,57.425,-157.708,57.305,-157.699,57.292,-157.697,57.282,-157.686,57.282,-157.649,57.276,-157.605,57.266,-157.589,57.256,-157.699,57.281,-157.711,57.307,-157.704,57.326,-157.669,57.349,-157.667,57.384,-157.565,57.425,-157.666,57.445,-157.69,57.566,-158.076,57.375,-158.003,57.333,-158.057,57.306,-158.19,57.269,-158.11,57.355,-158.236,57.319,-158.294,57.293,-158.323,57.273,-158.228,57.303,-158.25,57.245,-158.382,57.245,-158.698,56.943,-158.565,56.725,-158.44,56.708,-158.386,56.687,-158.342,56.688,-158.328,56.681,-158.386,56.684,-158.56,56.719,-158.654,56.772,-158.676,56.798,-158.993,56.825,-158.834,56.896,-159.265,56.704,-159.215,56.703,-159.211,56.69,-160.195,56.375,-160.59,55.979,-160.562,55.996,-160.533,55.992,-160.524,55.984,-160.582,55.913,-160.24,55.852,-160.32,55.819,-160.253,55.768,-160.792,55.885,-160.654,55.728,-160.796,55.722,-161.024,55.897,-160.854,55.93,-160.876,55.999,-161.805,55.893,-162.592,55.453,-162.503,55.463,-162.448,55.306,-162.475,55.373,-162.643,55.392,-162.899,55.269,-162.857,55.181,-163.084,55.175,-163.002,55.248,-163.423,55.067,-163.297,55.11,-163.263,54.953,-163.347,54.926,-163.266,54.917,-163.388,54.857,-163.286,54.754,-163.373,54.787,-163.422,54.945,-163.542,55.026,-163.461,55.043,-163.896,55.036,-164.31,54.889,-164.47,54.926,-164.715,54.657,-164.941,54.585,-164.847,54.42,-164.45,54.421,-164.214,54.594,-163.757,54.633,-164.942,54.086,-164.932,54.136,-165.224,54.101,-164.97,54.073,-166.749,53.448,-166.798,53.557,-166.662,53.484,-166.72,53.54,-166.638,53.522,-166.664,53.598,-166.586,53.533,-166.564,53.605,-166.509,53.579,-166.545,53.649,-166.275,53.686,-166.344,53.787,-166.491,53.692,-166.622,53.738,-166.206,53.932,-166.44,53.954,-166.609,53.827,-166.616,53.835,-166.575,53.879,-166.648,53.925,-166.59,53.963,-166.645,54.013,-167.011,53.961,-167.153,53.828,-166.692,53.715,-166.833,53.708,-166.79,53.649,-166.789,53.626,-167.017,53.715,-167.072,53.666,-166.996,53.621,-167.165,53.601,-166.97,53.529,-167.187,53.527,-167.154,53.468,-167.297,53.482,-167.327,53.406,-167.472,53.448,-167.556,53.415,-167.484,53.38,-167.856,53.31,-167.659,53.232,-167.601,53.287,-167.492,53.258,-167.015,53.476,-166.983,53.422,-166.875,53.491,-166.764,53.443,-168.755,52.91,-168.783,52.931,-168.46,53.055,-168.253,53.252,-167.872,53.368,-167.784,53.499,-168.085,53.565,-168.407,53.423,-168.437,53.331,-168.355,53.255,-168.614,53.274,-168.798,53.159,-168.715,53.056,-169.106,52.82,-172.954,52.094,-173.508,52.102,-173.526,52.156,-173.799,52.088,-174.056,52.129,-173.94,52.054,-173.497,52.02,-173.057,52.074,-173.157,52.055,-176.939,51.584,-180.0,51.394,-180.0,65.038,-179.668,65.139,-179.478,65.369,-179.955,65.362,-179.886,65.297,-180.0,65.36,-179.546,65.394,-179.42,65.442,-179.27,65.534,-179.338,65.57,-179.274,65.63,-179.46,65.63,-179.761,65.774,-179.705,65.802,-179.791,65.965,-179.678,66.167,-179.531,66.123,-179.638,66.111,-179.297,66.128,-179.196,66.194,-179.397,66.319,-179.161,66.263,-179.07,66.367,-179.051,66.345,-179.044,66.268,-179.108,66.25,-178.843,66.157,-178.473,66.383,-178.483,66.131,-178.66,66.091,-178.656,66.023,-178.752,65.977,-178.924,66.041,-178.821,65.942,-178.882,65.909,-178.405,65.702,-178.542,65.514,-178.533,65.49,-177.43,65.467,-177.321,65.487,-177.179,65.572,-177.15,65.584,-177.109,65.59,-177.096,65.601,-177.067,65.603,-177.003,65.643,-176.918,65.668,-176.866,65.658,-176.986,65.646,-176.985,65.633,-176.809,65.609,-176.8,65.594,-177.02,65.618,-176.994,65.607,-177.075,65.601,-177.099,65.59,-177.141,65.584,-177.158,65.579,-177.16,65.577,-175.968,65.48,-176.044,65.448,-175.916,65.41,-175.859,65.281,-176.043,65.399,-175.733,65.14,-175.891,65.001,-175.355,64.83,-175.454,64.8,-175.43,64.774,-174.783,64.764,-174.065,64.54,-173.992,64.415,-173.63,64.313,-173.299,64.436,-173.279,64.478,-173.364,64.585,-173.343,64.603,-173.216,64.521,-173.254,64.416,-173.188,64.442,-173.18,64.443,-173.156,64.438,-173.355,64.373,-173.372,64.294,-173.114,64.231,-173.07,64.251,-173.027,64.258,-172.934,64.26,-172.836,64.3,-172.79,64.427,-173.018,64.439,-172.961,64.49,-172.761,64.507,-172.759,64.463,-172.713,64.435,-172.725,64.398,-172.592,64.372,-172.23,64.404,-172.427,64.544,-172.771,64.612,-172.969,64.569,-172.825,64.656,-173.066,64.651,-172.74,64.761,-172.911,64.838,-173.111,64.757,-173.185,64.773,-172.96,64.863,-172.76,64.833,-172.389,64.923,-172.083,65.085,-172.234,65.207,-172.241,65.217,-172.229,65.224,-172.706,65.203,-172.692,65.245,-172.603,65.266,-172.266,65.26,-172.202,65.206,-172.209,65.337,-172.081,65.484,-172.356,65.422,-172.443,65.465,-172.278,65.458,-172.4,65.533,-172.356,65.575,-172.632,65.614,-172.636,65.663,-172.764,65.668,-172.837,65.687,-172.818,65.699,-172.315,65.677,-172.209,65.585,-172.257,65.545,-171.739,65.512,-172.07,65.476,-171.232,65.515,-171.204,65.543,-171.046,65.466,-170.979,65.588,-171.233,65.646,-171.329,65.716,-171.259,65.721,-171.397,65.757,-171.419,65.816,-171.483,65.826,-171.489,65.842,-171.238,65.767,-171.178,65.703,-170.582,65.605,-170.475,65.805,-170.593,65.84,-170.42,65.935,-170.009,66.047,-169.723,66.005,-169.651,66.082,-169.728,66.153,-170.169,66.136,-170.347,66.187,-170.259,66.195,-170.159,66.169,-170.11,66.204,-170.062,66.192,-170.031,66.19,-170.272,66.297,-170.57,66.217,-170.612,66.241,-170.722,66.238,-170.768,66.251,-170.848,66.278,-170.732,66.243,-170.681,66.253,-170.588,66.246,-170.536,66.232,-170.495,66.297,-170.468,66.28,-170.418,66.319,-170.349,66.309,-171.051,66.558,-171.224,66.484,-171.411,66.498,-171.397,66.492,-171.4,66.483,-171.413,66.481,-171.431,66.488,-171.451,66.48,-171.413,66.457,-171.41,66.447,-171.42,66.436,-171.461,66.425,-171.484,66.425,-171.48,66.416,-171.505,66.411,-171.496,66.399,-171.501,66.397,-171.509,66.396,-171.506,66.412,-171.482,66.416,-171.488,66.426,-171.422,66.436,-171.454,66.481,-171.412,66.5,-171.081,66.563,-164.545,66.563,-164.99,66.374,-165.04,66.429,-165.386,66.411,-165.875,66.24,-165.853,66.213,-165.697,66.203,-165.432,66.136,-165.424,66.12,-165.416,66.12,-165.409,66.123,-165.403,66.124,-165.373,66.121,-165.365,66.112,-165.318,66.113,-165.314,66.13,-165.305,66.116,-165.317,66.113,-165.361,66.111,-165.424,66.118,-165.541,66.157,-166.092,66.109,-166.247,66.17,-166.986,65.964,-166.924,65.952,-166.943,65.954,-166.883,65.939,-166.875,65.928,-166.859,65.927,-166.843,65.921,-166.844,65.918,-167.181,65.833,-167.264,65.902,-167.534,65.82,-167.48,65.819,-167.485,65.815,-167.454,65.813,-167.435,65.803,-167.436,65.8,-167.584,65.793,-167.513,65.726,-167.81,65.714,-167.798,65.694,-167.909,65.646,-168.068,65.643,-167.737,65.777,-168.121,65.638,-167.463,65.414,-166.609,65.351,-166.15,65.287,-166.036,65.245,-166.362,65.266,-166.548,65.117,-166.95,65.162,-166.687,64.984,-166.385,64.887,-166.478,64.735,-166.258,64.591,-165.011,64.434,-164.858,64.459,-164.952,64.484,-164.901,64.502,-165.005,64.538,-164.98,64.546,-164.891,64.539,-164.887,64.475,-164.474,64.541],"holes":[[-171.685,63.785,-171.847,63.487,-171.735,63.365,-171.44,63.307,-171.054,63.425,-170.579,63.363,-169.768,63.014,-169.952,63.141,-169.813,63.116,-169.709,63.051,-169.763,62.962,-169.643,62.938,-169.328,63.182,-168.86,63.147,-168.688,63.294,-170.027,63.481,-170.095,63.613,-170.287,63.687,-170.954,63.572,-171.604,63.679,-171.685,63.785],[-164.048,63.262,-164.28,63.24,-164.266,63.232,-164.258,63.22,-164.26,63.191,-164.208,63.174,-164.262,63.191,-164.305,63.243,-164.542,63.148,-164.013,62.912,-163.802,62.921,-163.801,62.992,-163.89,62.988,-163.658,63.111,-163.97,63.09,-164.136,62.995,-164.053,63.068,-163.821,63.154,-163.828,63.219,-164.048,63.262],[-164.767,62.799,-164.318,62.717,-164.25,62.729,-164.36,62.768,-164.152,62.725,-164.09,62.779,-164.298,62.898,-164.252,62.963,-164.465,63.03,-164.375,62.984,-164.732,62.993,-164.746,62.928,-164.577,62.878,-164.573,62.865,-164.671,62.89,-164.67,62.9,-164.746,62.926,-164.775,62.954,-164.855,62.835,-164.767,62.799],[-164.757,60.892,-164.863,60.859,-164.837,60.796,-165.034,60.786,-164.995,60.7,-165.421,60.549,-165.261,60.493,-164.974,60.54,-164.981,60.476,-165.133,60.437,-164.711,60.293,-164.652,60.335,-164.705,60.356,-164.541,60.389,-164.575,60.461,-164.428,60.485,-164.514,60.582,-164.205,60.682,-164.265,60.788,-164.669,60.819,-164.607,60.904,-164.757,60.892],[-165.684,60.292,-166.09,60.321,-166.16,60.431,-166.216,60.359,-166.712,60.329,-166.852,60.204,-167.459,60.212,-167.323,60.064,-166.154,59.748,-166.083,59.777,-166.275,59.848,-165.57,59.921,-165.547,59.979,-165.73,60.055,-165.661,60.099,-165.726,60.161,-165.684,60.292],[-174.155,52.42,-174.46,52.318,-174.287,52.334,-174.331,52.309,-174.231,52.248,-174.336,52.182,-174.46,52.22,-174.412,52.17,-174.559,52.181,-174.501,52.138,-174.584,52.101,-174.907,52.117,-175.03,52.027,-175.341,52.022,-174.737,52.005,-174.491,52.04,-174.531,52.097,-174.413,52.035,-174.363,52.1,-174.441,52.108,-174.099,52.099,-174.2,52.22,-174.061,52.224,-173.988,52.312,-174.155,52.42],[-176.556,51.913,-176.581,52.002,-176.793,51.955,-176.712,51.85,-176.781,51.813,-176.705,51.788,-176.915,51.806,-176.831,51.738,-176.989,51.607,-176.836,51.722,-176.805,51.609,-176.425,51.74,-176.434,51.845,-176.654,51.853,-176.556,51.913],[-177.957,51.617,-177.811,51.702,-177.821,51.788,-177.614,51.854,-177.851,51.825,-177.963,51.919,-178.199,51.908,-178.224,51.861,-177.961,51.774,-178.122,51.676,-177.957,51.617],[-177.201,51.924,-177.229,51.803,-177.71,51.702,-177.648,51.652,-177.394,51.734,-177.142,51.705,-177.047,51.903,-177.201,51.924]]},{"outer":[-179.999,65.312,-180.0,65.312,-180.0,65.324,-179.99,65.318,-179.999,65.312]}],"names":{"ar":"بحر بيرنغ","de":"Beringmeer","es":"mar de Bering","fr":"mer de Béring","he":"ים ברינג","hu":"Bering-tenger","it":"Mare di Bering","nl":"Beringzee","pt":"Mar de Bering","zh":"白令海"}},{"name":"Greenland Sea","bbox":[-35.987,65.007,17.482,82.781],"area":378.6694,"polygons":[{"outer":[-20.862,82.781,16.263,80.065,15.942,79.96,16.145,79.865,15.715,79.879,15.626,79.799,15.697,79.784,15.638,79.769,15.716,79.642,15.884,79.553,15.84,79.517,15.941,79.294,16.494,78.895,16.368,78.879,15.85,79.16,15.642,79.102,15.607,79.156,15.761,79.229,15.4,79.362,15.406,79.466,15.221,79.52,15.281,79.606,14.794,79.717,14.963,79.731,14.741,79.791,14.488,79.798,14.206,79.679,14.323,79.663,14.121,79.634,14.241,79.61,13.827,79.535,14.105,79.364,14.041,79.265,13.844,79.283,13.903,79.365,13.625,79.469,13.404,79.474,13.46,79.46,13.474,79.41,13.199,79.465,13.465,79.571,12.389,79.526,12.426,79.569,12.314,79.584,13.008,79.693,13.247,79.708,13.715,79.689,13.65,79.698,13.962,79.804,13.776,79.878,12.586,79.762,12.194,79.84,12.121,79.794,12.327,79.694,12.254,79.643,12.127,79.707,12.112,79.653,12.037,79.653,11.956,79.709,12.033,79.717,11.885,79.744,11.943,79.776,11.757,79.79,11.865,79.805,11.826,79.842,11.614,79.828,11.475,79.806,11.533,79.773,11.523,79.755,11.206,79.782,11.29,79.755,11.162,79.726,11.543,79.625,11.014,79.645,10.821,79.6,11.253,79.559,10.665,79.547,10.992,79.473,10.978,79.443,10.9,79.436,10.954,79.417,10.857,79.413,10.971,79.382,10.849,79.366,11.187,79.228,11.192,79.117,11.681,79.174,11.498,79.336,11.807,79.338,11.698,79.306,11.831,79.218,11.886,79.228,11.846,79.296,12.195,79.309,12.013,79.224,12.272,79.199,11.926,79.198,11.827,79.139,11.939,79.108,11.663,79.069,12.52,78.99,12.453,78.935,12.625,78.91,12.489,78.868,11.334,78.967,11.941,78.829,11.594,78.746,11.94,78.703,11.832,78.682,12.025,78.612,12.852,78.527,13.295,78.566,13.202,78.514,12.365,78.489,12.684,78.371,12.892,78.357,12.816,78.374,12.817,78.392,12.858,78.399,12.92,78.382,12.907,78.321,12.844,78.331,12.99,78.204,13.806,78.205,13.861,78.212,13.839,78.232,13.714,78.273,13.937,78.227,13.964,78.28,13.929,78.292,13.889,78.295,13.873,78.301,13.93,78.313,14.081,78.289,14.09,78.249,14.319,78.306,14.085,78.362,14.216,78.368,14.119,78.395,14.152,78.426,14.789,78.377,14.533,78.472,14.227,78.473,14.443,78.516,14.341,78.558,14.61,78.552,14.55,78.651,14.303,78.717,14.518,78.685,14.814,78.728,14.864,78.664,14.774,78.642,14.952,78.591,15.249,78.653,15.171,78.767,15.322,78.827,15.444,78.827,15.395,78.778,15.502,78.74,15.478,78.668,15.19,78.591,15.443,78.463,15.985,78.489,16.562,78.716,16.962,78.64,16.739,78.644,16.295,78.456,16.847,78.385,17.439,78.446,16.856,78.339,15.84,78.351,15.562,78.285,15.872,78.203,15.867,78.197,15.415,78.251,15.007,78.106,14.186,78.099,14.307,77.979,13.962,78.103,13.576,78.053,13.679,77.887,13.639,77.874,13.633,77.844,13.816,77.813,13.738,77.734,15.35,77.868,16.646,77.857,16.781,77.912,17.286,77.965,17.219,77.95,17.482,77.969,16.858,77.873,16.742,77.881,17.17,77.759,16.334,77.81,16.415,77.772,14.727,77.65,16.055,77.544,15.917,77.549,16.408,77.464,16.262,77.389,15.823,77.522,15.117,77.539,14.989,77.568,14.728,77.489,14.327,77.579,13.86,77.49,14.036,77.479,13.941,77.42,14.365,77.199,15.077,77.129,15.03,77.11,15.19,77.023,15.88,77.015,15.789,77.076,15.988,77.092,16.254,76.99,16.374,77.072,16.603,77.061,16.665,77.036,16.544,77.007,16.678,76.992,16.27,76.968,16.362,76.918,15.762,76.949,15.493,76.874,16.414,76.704,16.273,76.669,16.324,76.639,16.272,76.564,16.522,76.612,16.69,76.56,-8.283,71.122,-8.504,70.995,-9.062,70.875,-9.032,70.825,-13.501,65.083,-13.512,65.168,-13.636,65.086,-13.736,65.138,-13.628,65.192,-14.017,65.19,-13.574,65.268,-14.011,65.266,-13.723,65.328,-13.856,65.363,-13.675,65.372,-13.606,65.512,-13.797,65.521,-13.86,65.611,-14.21,65.626,-14.247,65.623,-14.231,65.606,-14.27,65.592,-14.306,65.593,-14.304,65.579,-14.366,65.534,-14.352,65.479,-14.381,65.456,-14.37,65.395,-14.399,65.358,-14.387,65.317,-14.418,65.26,-14.725,65.104,-14.933,65.022,-14.979,65.008,-14.987,65.007,-15.0,65.009,-14.997,65.011,-14.99,65.012,-14.98,65.011,-14.937,65.023,-14.938,65.03,-14.928,65.034,-14.929,65.039,-14.884,65.066,-14.62,65.183,-14.581,65.219,-14.44,65.262,-14.376,65.531,-14.308,65.593,-14.237,65.606,-14.251,65.623,-14.216,65.628,-14.273,65.651,-14.373,65.602,-14.422,65.597,-14.587,65.441,-14.463,65.593,-14.315,65.665,-14.398,65.715,-14.329,65.784,-14.855,65.727,-14.763,65.79,-14.876,65.75,-14.899,65.756,-14.629,65.91,-14.614,65.992,-15.186,66.109,-14.937,66.189,-15.018,66.267,-14.529,66.378,-14.853,66.387,-15.335,66.258,-15.328,66.189,-15.366,66.149,-15.389,66.139,-15.79,66.28,-15.684,66.328,-15.699,66.397,-16.192,66.538,-16.569,66.485,-16.417,66.272,-16.684,66.135,-16.56,66.074,-16.717,66.139,-16.955,66.102,-17.001,66.194,-17.146,66.208,-17.267,66.17,-17.382,66.006,-17.581,65.984,-17.555,65.929,-17.931,66.147,-18.312,66.156,-18.279,66.016,-18.064,65.851,-18.037,65.658,-18.16,65.732,-18.192,65.853,-18.527,65.971,-18.547,66.087,-18.701,66.047,-18.607,66.118,-18.792,66.132,-18.789,66.194,-19.437,66.047,-19.465,65.941,-19.361,65.835,-19.407,65.715,-19.648,65.752,-20.097,66.12,-20.356,66.08,-20.413,66.088,-20.282,65.658,-20.388,65.547,-20.419,65.589,-20.388,65.594,-20.4,65.605,-20.42,65.597,-20.511,65.59,-20.544,65.573,-20.5,65.567,-20.524,65.574,-20.451,65.583,-20.429,65.559,-20.487,65.492,-20.57,65.572,-20.654,65.547,-20.663,65.687,-20.933,65.582,-20.991,65.481,-20.905,65.342,-21.097,65.447,-21.089,65.156,-21.194,65.434,-21.477,65.444,-21.296,65.595,-21.481,65.56,-21.385,65.622,-21.637,65.645,-21.667,65.665,-21.669,65.686,-21.695,65.692,-21.681,65.74,-21.782,65.767,-21.432,65.69,-21.337,65.745,-21.401,65.769,-21.414,65.762,-21.434,65.759,-21.489,65.774,-21.331,65.799,-21.276,65.909,-21.608,65.96,-21.315,65.997,-21.515,66.014,-21.548,66.088,-21.614,66.085,-21.604,66.036,-21.635,66.022,-21.673,66.017,-21.627,66.048,-21.728,66.065,-21.738,66.184,-22.003,66.196,-21.921,66.242,-22.078,66.293,-22.15,66.247,-22.235,66.265,-22.192,66.341,-22.404,66.455,-23.134,66.434,-29.513,68.272,-29.111,68.309,-29.263,68.441,-28.866,68.338,-28.514,68.48,-28.253,68.448,-28.4,68.535,-28.027,68.474,-28.006,68.612,-27.859,68.506,-27.649,68.496,-27.808,68.552,-27.509,68.631,-27.542,68.572,-27.265,68.559,-27.385,68.624,-27.06,68.612,-27.036,68.708,-26.861,68.672,-26.748,68.737,-26.439,68.697,-26.341,68.806,-25.726,68.871,-25.679,68.91,-25.897,68.954,-25.518,68.96,-25.762,69.005,-25.59,69.024,-25.679,69.084,-25.631,69.111,-25.348,69.009,-25.241,69.051,-25.369,69.13,-25.057,69.098,-25.154,69.133,-24.984,69.183,-25.389,69.245,-24.6,69.258,-24.748,69.273,-24.598,69.323,-24.822,69.468,-24.438,69.349,-24.348,69.448,-24.071,69.411,-24.162,69.466,-24.053,69.47,-24.085,69.528,-24.312,69.595,-23.773,69.518,-23.6,69.588,-23.701,69.612,-23.545,69.625,-23.857,69.755,-23.599,69.725,-23.48,69.77,-23.211,69.756,-23.342,69.863,-22.973,69.766,-22.881,69.818,-23.187,69.87,-22.939,69.956,-22.944,69.913,-22.844,69.886,-22.764,69.897,-22.917,69.958,-22.365,69.981,-22.048,70.146,-22.76,70.082,-23.865,70.131,-25.034,70.367,-25.315,70.272,-25.264,70.36,-25.384,70.362,-25.195,70.406,-25.273,70.411,-26.272,70.198,-26.859,70.248,-27.136,70.164,-27.337,69.956,-27.473,69.98,-27.457,70.058,-28.437,70.047,-28.481,70.105,-27.758,70.101,-26.957,70.312,-26.295,70.366,-26.67,70.474,-28.148,70.353,-28.263,70.415,-28.238,70.491,-29.087,70.386,-29.086,70.453,-28.256,70.541,-28.184,70.573,-28.268,70.601,-28.018,70.665,-27.87,70.836,-28.35,70.956,-27.603,70.921,-27.929,71.122,-27.831,71.13,-27.507,70.918,-26.505,70.951,-25.651,71.141,-25.586,71.181,-25.794,71.248,-25.52,71.208,-25.412,71.324,-25.908,71.483,-27.034,71.51,-27.246,71.453,-27.356,71.497,-27.162,71.535,-27.61,71.584,-28.028,71.525,-28.49,71.514,-28.536,71.541,-27.426,71.612,-27.386,71.68,-28.08,71.845,-27.832,71.842,-27.879,71.862,-28.535,71.893,-28.573,71.939,-28.418,71.982,-28.841,72.058,-28.638,72.099,-28.244,71.952,-27.941,71.964,-26.857,71.537,-26.581,71.531,-26.442,71.628,-26.283,71.556,-25.614,71.527,-25.109,71.291,-24.627,71.347,-24.676,71.304,-24.557,71.287,-24.597,71.212,-24.209,71.022,-24.228,70.896,-24.066,70.676,-23.383,70.461,-22.624,70.447,-22.575,70.575,-22.652,70.718,-22.499,70.869,-22.433,70.851,-22.478,70.681,-22.384,70.461,-22.047,70.517,-21.889,70.471,-21.965,70.462,-21.945,70.409,-21.543,70.493,-21.483,70.551,-21.623,70.603,-21.809,70.562,-21.79,70.659,-21.576,70.684,-21.803,70.726,-21.54,70.725,-21.835,70.748,-21.664,70.807,-21.848,70.787,-21.946,70.816,-21.735,70.816,-21.793,70.87,-21.608,70.949,-22.012,70.975,-21.687,71.019,-21.686,71.075,-22.274,71.058,-22.068,71.134,-22.104,71.082,-21.713,71.121,-21.891,71.192,-21.69,71.149,-21.733,71.251,-21.958,71.267,-21.625,71.331,-21.892,71.351,-21.696,71.448,-21.9,71.451,-21.75,71.485,-21.814,71.52,-21.94,71.456,-22.103,71.499,-22.303,71.423,-22.356,71.264,-22.44,71.244,-22.571,71.463,-22.523,71.56,-22.129,71.605,-22.256,71.651,-21.9,71.737,-22.213,71.759,-22.407,71.619,-22.681,71.612,-22.402,71.762,-22.451,71.792,-23.141,71.617,-22.792,71.794,-22.825,71.864,-22.525,71.91,-23.171,71.999,-23.104,72.059,-23.518,72.136,-23.859,72.115,-23.708,72.214,-24.617,72.425,-25.335,72.297,-25.508,72.097,-25.437,72.041,-25.53,72.011,-25.528,72.107,-25.513,72.122,-25.468,72.149,-25.505,72.234,-25.247,72.392,-25.84,72.419,-26.123,72.38,-26.328,72.388,-25.865,72.434,-24.877,72.466,-24.654,72.521,-24.746,72.638,-24.704,72.675,-25.554,72.834,-26.208,72.723,-26.423,72.574,-26.479,72.584,-26.307,72.732,-27.179,72.673,-26.415,72.78,-27.438,72.833,-26.682,72.869,-26.197,72.778,-25.708,72.9,-25.166,72.926,-24.998,72.999,-25.062,73.052,-26.426,73.188,-26.679,73.097,-27.258,73.125,-27.347,72.962,-27.463,72.923,-27.378,72.977,-27.399,73.108,-27.705,73.131,-27.257,73.188,-26.741,73.137,-26.351,73.236,-27.411,73.447,-27.313,73.503,-26.777,73.33,-26.47,73.349,-26.016,73.237,-25.458,73.349,-25.535,73.4,-25.393,73.451,-24.685,73.512,-25.089,73.61,-25.103,73.703,-25.592,73.804,-25.805,73.924,-25.754,73.958,-25.062,73.728,-24.926,73.603,-24.471,73.538,-24.396,73.553,-24.504,73.707,-24.069,73.833,-23.922,73.748,-23.526,73.726,-22.802,73.56,-22.277,73.633,-22.781,73.544,-23.609,73.708,-24.089,73.675,-23.41,73.421,-22.452,73.256,-21.584,73.389,-21.623,73.463,-21.564,73.49,-20.55,73.448,-20.377,73.531,-20.51,73.564,-20.461,73.641,-20.536,73.723,-20.436,73.731,-20.547,73.742,-20.513,73.767,-20.297,73.791,-20.275,73.876,-20.767,73.892,-21.149,73.944,-21.732,74.061,-21.942,73.995,-21.761,73.86,-21.734,73.713,-21.878,73.649,-21.858,73.901,-22.074,74.009,-22.023,74.005,-22.494,74.061,-22.179,74.105,-22.37,74.161,-22.1,74.211,-22.053,74.301,-22.327,74.287,-22.449,74.308,-21.872,74.359,-21.988,74.231,-21.085,74.093,-20.111,74.212,-20.418,74.269,-20.547,74.405,-20.992,74.448,-21.827,74.364,-21.779,74.445,-22.108,74.599,-21.661,74.446,-20.406,74.453,-20.188,74.285,-19.647,74.24,-19.372,74.265,-18.971,74.482,-19.43,74.55,-19.283,74.622,-19.407,74.684,-19.717,74.578,-20.17,74.676,-20.925,74.637,-21.091,74.664,-20.607,74.737,-20.757,74.854,-20.623,75.051,-21.13,75.084,-21.757,74.952,-22.45,75.161,-22.081,75.122,-21.763,74.976,-21.185,75.113,-20.502,75.138,-21.417,75.432,-22.117,75.465,-22.449,75.506,-22.5,75.544,-21.396,75.456,-22.219,75.646,-22.128,75.671,-20.665,75.291,-20.138,75.333,-19.965,75.286,-20.042,75.211,-19.984,75.17,-19.605,75.13,-19.443,75.207,-19.335,75.382,-19.375,75.539,-19.577,75.725,-19.353,75.721,-19.964,75.94,-20.341,75.918,-21.857,75.964,-21.94,76.03,-20.399,75.977,-20.058,76.062,-19.79,76.051,-19.741,76.123,-19.953,76.247,-20.461,76.208,-20.952,76.302,-21.124,76.289,-20.715,76.189,-21.158,76.279,-21.566,76.215,-21.835,76.214,-21.578,76.39,-22.546,76.457,-22.349,76.511,-21.9,76.424,-21.674,76.477,-22.058,76.467,-21.687,76.523,-22.201,76.557,-21.816,76.577,-22.137,76.637,-22.338,76.586,-22.298,76.531,-22.344,76.518,-22.428,76.568,-22.347,76.624,-22.726,76.699,-22.308,76.75,-22.378,76.814,-22.159,76.885,-21.659,76.627,-21.605,76.641,-21.656,76.671,-21.147,76.754,-21.288,76.779,-20.756,76.81,-21.023,76.842,-21.431,76.846,-21.646,76.859,-21.668,76.872,-20.538,76.89,-20.68,76.928,-21.025,76.915,-20.938,76.934,-21.295,76.934,-21.521,76.957,-19.865,76.924,-18.552,76.698,-18.599,76.774,-18.409,76.753,-18.185,76.896,-18.19,77.178,-18.419,77.247,-18.345,77.265,-18.916,77.324,-19.048,77.269,-18.949,77.163,-19.47,77.193,-19.401,77.259,-19.797,77.349,-20.745,77.362,-19.939,77.387,-19.986,77.403,-20.986,77.487,-20.377,77.446,-20.276,77.469,-20.828,77.517,-20.146,77.501,-20.807,77.605,-20.498,77.655,-20.602,77.693,-20.354,77.632,-19.938,77.666,-19.265,77.519,-18.879,77.568,-19.249,77.723,-20.603,77.859,-20.504,77.901,-20.777,77.961,-21.32,77.715,-21.288,77.612,-21.116,77.592,-21.979,77.551,-21.455,77.922,-21.681,77.852,-21.595,77.954,-21.097,78.069,-21.267,78.121,-21.195,78.165,-21.305,78.244,-21.054,78.456,-21.15,78.487,-20.878,78.566,-21.331,78.568,-20.914,78.632,-21.208,78.639,-21.062,78.746,-21.231,78.749,-20.71,78.78,-20.827,78.81,-19.918,79.099,-19.577,79.081,-19.528,79.175,-19.285,79.238,-19.117,79.233,-19.26,79.215,-19.202,79.177,-19.267,79.114,-18.976,79.242,-19.533,79.289,-19.858,79.114,-19.559,79.296,-19.565,79.397,-19.681,79.439,-19.305,79.55,-19.558,79.61,-19.057,79.74,-17.992,79.694,-17.183,80.007,-17.573,80.08,-18.955,80.051,-19.661,79.967,-19.992,79.805,-19.648,79.799,-20.6,79.694,-20.468,79.798,-20.521,79.851,-19.972,80.071,-20.33,80.082,-20.516,80.109,-20.515,80.116,-19.955,80.107,-19.371,80.257,-18.132,80.16,-16.798,80.169,-16.247,80.27,-16.347,80.277,-16.415,80.324,-16.19,80.276,-15.677,80.415,-16.81,80.544,-17.731,80.466,-19.366,80.615,-20.287,80.465,-20.201,80.531,-20.719,80.549,-19.428,80.651,-18.162,80.555,-17.351,80.641,-17.468,80.713,-17.244,80.741,-17.052,80.656,-15.468,80.615,-15.281,80.713,-14.14,80.772,-14.448,80.93,-14.243,80.976,-13.406,80.97,-11.477,81.354,-11.75,81.52,-13.667,81.753,-14.66,81.679,-14.994,81.745,-14.823,81.768,-15.49,81.759,-15.277,81.69,-15.868,81.706,-15.819,81.689,-15.894,81.659,-15.79,81.605,-15.827,81.599,-16.155,81.769,-16.347,81.764,-16.297,81.653,-16.096,81.579,-16.707,81.632,-16.919,81.544,-16.751,81.496,-17.206,81.487,-17.06,81.382,-17.417,81.361,-18.459,81.491,-19.356,81.367,-19.393,81.413,-19.028,81.475,-19.304,81.462,-19.202,81.538,-19.292,81.557,-18.888,81.6,-19.449,81.557,-19.163,81.609,-19.632,81.603,-19.507,81.628,-19.881,81.679,-19.996,81.665,-19.797,81.604,-20.028,81.551,-19.46,81.557,-20.585,81.379,-20.701,81.281,-22.03,80.986,-22.882,80.894,-22.847,80.812,-23.115,80.768,-23.019,80.733,-23.963,80.574,-23.402,80.713,-23.742,80.712,-23.134,80.822,-23.264,80.895,-21.99,81.248,-22.162,81.273,-22.265,81.248,-22.416,81.238,-22.725,81.25,-22.942,81.241,-23.054,81.243,-21.805,81.29,-21.312,81.452,-21.028,81.707,-20.999,81.913,-21.276,82.053,-23.156,82.004,-23.279,81.953,-23.244,81.913,-23.344,81.851,-23.289,81.752,-23.391,81.698,-24.189,81.685,-26.486,81.417,-26.549,81.447,-26.416,81.471,-26.7,81.478,-24.674,81.769,-24.573,81.851,-24.63,81.951,-24.536,81.989,-28.252,82.018,-29.189,81.959,-29.491,81.87,-29.575,81.898,-29.477,81.908,-29.524,81.947,-31.339,81.826,-32.607,81.644,-32.621,81.748,-32.784,81.802,-29.94,82.093,-30.457,82.173,-31.183,82.178,-31.294,82.19,-31.416,82.19,-31.51,82.194,-31.601,82.202,-31.857,82.197,-31.899,82.2,-31.94,82.197,-32.091,82.206,-32.316,82.196,-32.462,82.199,-32.577,82.191,-33.563,82.207,-33.363,82.219,-33.581,82.233,-35.304,82.201,-35.987,82.217,-33.685,82.249,-30.39,82.193,-29.894,82.126,-27.631,82.195,-24.416,82.15,-22.789,82.299,-21.46,82.31,-20.979,82.389,-21.108,82.434,-19.786,82.586,-20.862,82.781],"holes":[[-18.924,82.013,-19.23,82.131,-19.608,82.059,-19.049,81.821,-18.552,81.799,-18.924,82.013],[-17.915,81.719,-18.178,81.707,-17.632,81.569,-17.216,81.57,-17.6,81.706,-17.915,81.719],[-18.884,80.16,-19.33,80.209,-19.71,80.021,-18.665,80.131,-18.884,80.16],[-17.795,79.178,-17.883,79.152,-18.123,79.002,-18.061,78.941,-17.976,78.95,-18.041,78.964,-17.578,79.077,-17.795,79.178],[-19.234,78.901,-19.47,78.876,-19.753,78.729,-19.141,78.712,-19.439,78.733,-19.128,78.745,-19.395,78.751,-19.122,78.846,-19.368,78.835,-19.166,78.884,-19.234,78.901],[10.459,78.893,10.538,78.855,10.494,78.784,11.057,78.455,11.326,78.452,11.859,78.231,12.155,78.204,11.838,78.394,11.895,78.445,11.089,78.612,11.167,78.649,11.027,78.709,11.187,78.728,10.879,78.794,10.998,78.831,10.459,78.893],[-17.68,77.859,-17.831,77.831,-17.84,77.758,-18.143,77.613,-17.695,77.619,-17.445,77.786,-17.68,77.859],[-19.133,77.743,-19.295,77.845,-20.404,77.898,-19.133,77.743],[-18.853,76.674,-19.147,76.719,-19.037,76.585,-18.793,76.564,-19.075,76.569,-19.156,76.489,-18.828,76.293,-18.472,75.928,-18.463,76.087,-18.742,76.472,-18.718,76.615,-18.853,76.674],[-18.135,75.373,-18.209,75.311,-18.507,75.372,-18.921,75.334,-18.822,75.21,-18.895,75.011,-18.449,74.966,-17.845,75.028,-17.599,74.995,-17.61,74.929,-17.333,75.052,-17.356,75.155,-17.78,75.082,-18.014,75.154,-18.037,75.231,-18.211,75.233,-17.792,75.319,-18.04,75.427,-18.135,75.373],[-20.155,74.93,-19.964,74.983,-20.457,75.033,-20.588,74.986,-20.542,74.914,-20.677,74.842,-20.224,74.702,-19.734,74.855,-20.155,74.93],[-23.254,73.245,-24.797,73.432,-25.29,73.401,-25.287,73.325,-25.759,73.193,-24.415,73.022,-23.019,73.093,-22.87,73.16,-25.045,73.317,-23.254,73.245],[-23.115,73.063,-24.558,72.984,-24.587,72.954,-24.526,72.889,-22.958,72.848,-22.628,72.685,-21.88,72.716,-22.187,72.809,-21.891,72.896,-21.974,72.94,-23.115,73.063],[-22.78,72.669,-23.178,72.838,-24.525,72.835,-24.397,72.599,-24.008,72.504,-24.051,72.467,-23.199,72.342,-22.686,72.174,-22.525,72.229,-22.624,72.167,-22.452,72.132,-22.186,72.136,-22.104,72.175,-22.181,72.217,-22.05,72.263,-22.729,72.345,-22.794,72.432,-21.938,72.404,-21.973,72.49,-22.222,72.484,-22.16,72.519,-22.78,72.669],[-27.122,70.483,-26.231,70.579,-26.027,70.511,-25.267,70.686,-25.309,70.764,-25.469,70.778,-25.324,70.803,-25.411,70.853,-25.385,70.922,-25.707,70.976,-25.707,71.085,-26.461,70.9,-27.084,70.87,-27.629,70.637,-27.966,70.593,-28.005,70.556,-27.926,70.51,-28.064,70.448,-27.122,70.483]]}],"names":{"ar":"بحر جرينلاند","de":"Grönlandsee","es":"mar de Groenlandia","fr":"mer du Groenland","he":"ים גרינלנד","hu":"Grönlandi-tenger","it":"Mar di Groenlandia","nl":"Groenlandzee","pt":"mar da Gronelândia","zh":"格陵兰海"}},{"name":"Barents Sea","bbox":[16.582,65.883,68.578,81.851],"area":451.002,"polygons":[{"outer":[57.731,75.416,57.718,75.348,57.904,75.334,56.907,75.358,56.706,75.295,57.015,75.235,56.749,75.194,56.593,75.062,56.397,75.036,56.295,75.157,55.796,75.116,55.749,75.051,55.934,75.044,55.846,74.956,56.281,75.001,56.411,74.97,56.295,74.896,56.461,74.939,56.661,74.926,55.838,74.775,56.512,74.766,56.424,74.725,56.958,74.677,55.827,74.669,55.601,74.611,55.573,74.538,56.631,74.459,57.091,74.346,56.292,74.474,56.217,74.45,56.036,74.432,55.378,74.41,55.295,74.347,55.783,74.279,55.283,74.292,55.117,74.225,55.435,74.207,55.965,74.108,55.839,74.083,55.947,74.052,56.243,74.054,56.353,74.013,55.898,74.02,55.718,74.102,55.033,74.157,54.576,73.997,54.841,73.972,54.969,73.906,54.678,73.96,54.265,73.902,53.967,73.768,53.707,73.789,53.682,73.73,54.224,73.595,54.561,73.69,54.908,73.712,54.871,73.665,55.322,73.764,55.122,73.704,55.311,73.722,55.692,73.647,55.476,73.656,55.324,73.708,54.739,73.641,54.234,73.482,54.479,73.456,54.319,73.396,54.069,73.358,54.285,73.314,54.955,73.439,55.257,73.394,55.337,73.327,54.917,73.423,54.663,73.376,54.738,73.372,54.814,73.34,54.606,73.372,54.417,73.315,54.54,73.273,53.908,73.294,53.223,73.147,53.188,73.059,53.426,73.003,53.087,72.947,53.608,72.886,53.468,72.859,52.955,72.907,52.616,72.851,52.405,72.717,53.262,72.641,52.776,72.638,52.846,72.608,52.727,72.54,53.103,72.592,53.318,72.561,53.333,72.542,52.986,72.574,52.902,72.495,52.725,72.477,52.85,72.429,52.707,72.427,52.749,72.386,52.734,72.345,52.939,72.34,52.747,72.324,52.673,72.355,52.576,72.364,52.726,72.315,52.679,72.286,52.6,72.29,52.636,72.241,52.457,72.274,52.552,72.227,52.432,72.211,52.529,72.174,52.563,72.132,52.407,72.148,52.443,72.123,52.399,72.099,52.465,72.073,52.481,72.036,52.212,72.063,52.225,72.13,52.048,72.051,51.86,72.153,51.576,72.066,51.445,71.776,51.621,71.536,51.888,71.464,52.302,71.461,52.196,71.521,52.224,71.581,52.279,71.569,52.236,71.65,52.335,71.548,52.334,71.513,52.492,71.531,52.538,71.754,52.577,71.739,52.553,71.656,52.635,71.588,52.582,71.599,52.714,71.53,52.76,71.435,52.865,71.41,52.779,71.458,52.884,71.48,52.915,71.388,52.967,71.372,52.922,71.415,53.03,71.401,52.933,71.473,53.055,71.464,53.01,71.508,53.096,71.484,53.16,71.408,53.223,71.5,53.477,71.553,53.301,71.424,53.343,71.325,53.538,71.268,53.464,71.257,53.55,71.196,53.74,71.185,53.839,71.134,53.806,71.194,53.953,71.153,54.101,71.212,54.418,71.134,54.072,71.168,54.011,71.137,54.27,71.094,54.188,71.077,54.186,71.067,53.901,71.077,53.914,71.055,53.96,71.036,53.967,71.024,53.987,71.02,54.001,71.005,53.536,71.078,53.781,70.926,53.682,70.877,53.751,70.809,53.544,70.869,53.691,70.791,53.899,70.761,53.85,70.8,53.901,70.814,54.047,70.723,54.313,70.725,54.243,70.769,54.462,70.705,54.663,70.671,54.438,70.728,54.572,70.757,54.532,70.774,54.515,70.816,54.534,70.822,54.521,70.829,54.533,70.834,54.745,70.672,54.966,70.629,55.17,70.546,55.294,70.562,55.226,70.657,55.275,70.661,55.265,70.674,55.27,70.682,55.276,70.684,55.315,70.684,55.348,70.66,55.407,70.647,55.422,70.648,55.445,70.618,55.46,70.634,55.381,70.669,55.482,70.633,55.402,70.687,55.359,70.68,55.234,70.708,55.182,70.693,54.909,70.764,55.116,70.79,55.354,70.684,55.352,70.715,55.413,70.712,55.405,70.73,55.433,70.737,55.884,70.567,56.033,70.557,55.79,70.673,56.158,70.58,55.974,70.669,56.648,70.563,56.432,70.666,56.326,70.622,56.168,70.685,56.558,70.737,56.622,70.717,56.506,70.658,56.872,70.598,56.815,70.562,56.966,70.495,58.419,70.25,58.491,70.198,58.482,70.213,58.493,70.229,58.57,70.2,58.466,70.182,58.661,70.125,58.562,70.119,58.658,70.097,58.615,70.063,59.067,69.858,59.183,69.853,59.149,69.885,58.99,69.919,58.985,69.936,59.449,69.888,59.727,69.783,59.594,69.792,59.583,69.711,60.204,69.598,60.222,69.502,60.516,69.366,60.449,69.366,60.65,69.216,60.671,69.211,60.683,69.202,60.712,69.199,60.725,69.19,60.711,69.199,60.683,69.202,60.649,69.215,60.665,69.117,60.718,69.095,60.74,69.091,60.754,69.094,60.705,69.106,60.758,69.11,60.726,69.117,60.728,69.186,60.855,69.148,61.011,69.065,60.946,69.052,61.006,69.007,60.974,68.986,61.006,68.991,61.02,68.985,61.05,69.001,61.059,69.002,61.066,69.002,61.074,68.998,61.07,68.992,61.086,68.988,61.091,68.986,61.096,68.982,61.101,68.972,61.14,68.966,61.144,68.963,61.145,68.958,61.167,68.949,61.169,68.946,61.1,68.972,61.067,68.993,61.071,68.999,61.056,69.002,60.937,68.914,61.276,68.897,61.275,68.86,61.1,68.87,61.531,68.755,61.469,68.727,61.364,68.763,61.391,68.752,61.438,68.702,61.459,68.63,61.446,68.604,61.521,68.579,61.564,68.55,61.58,68.527,61.583,68.515,61.577,68.507,61.531,68.483,61.769,68.452,61.692,68.442,61.679,68.453,61.59,68.463,61.574,68.472,61.558,68.472,61.537,68.478,61.531,68.481,61.531,68.491,61.58,68.515,61.568,68.541,61.441,68.603,61.453,68.63,61.422,68.655,61.416,68.724,61.343,68.744,61.355,68.781,61.177,68.786,61.329,68.75,61.344,68.729,61.33,68.727,61.293,68.739,61.322,68.718,61.312,68.713,61.289,68.711,61.297,68.704,61.322,68.703,61.354,68.692,61.358,68.688,61.354,68.682,61.355,68.691,61.296,68.704,61.288,68.71,61.291,68.739,61.329,68.747,61.264,68.748,61.268,68.744,61.263,68.734,61.251,68.728,61.253,68.727,61.261,68.726,61.267,68.724,61.269,68.714,61.171,68.785,61.156,68.829,60.977,68.833,60.9,68.887,60.806,68.868,60.745,68.811,60.77,68.816,60.776,68.798,60.819,68.805,60.767,68.714,60.911,68.707,60.89,68.668,60.816,68.711,60.79,68.706,60.768,68.712,60.758,68.718,60.771,68.795,60.542,68.683,60.028,68.651,59.948,68.708,59.804,68.624,59.968,68.503,60.0,68.369,59.685,68.337,59.693,68.33,59.682,68.304,59.706,68.299,59.721,68.303,59.73,68.304,59.752,68.303,59.786,68.291,59.787,68.283,59.492,68.308,59.019,68.431,59.173,68.519,59.103,68.572,59.015,68.561,58.889,68.574,58.846,68.613,58.956,68.615,58.951,68.599,58.987,68.596,58.991,68.586,58.983,68.582,59.013,68.571,58.995,68.585,58.994,68.594,58.962,68.613,58.973,68.617,58.981,68.616,59.025,68.564,59.193,68.632,59.187,68.649,59.172,68.648,59.172,68.643,59.156,68.64,59.12,68.644,59.115,68.653,59.114,68.648,59.086,68.652,59.076,68.656,59.076,68.66,59.479,68.682,59.447,68.689,59.402,68.709,59.482,68.706,59.441,68.784,59.349,68.777,59.317,68.779,59.311,68.783,59.306,68.779,59.289,68.78,59.283,68.775,59.285,68.779,59.309,68.784,59.394,68.789,58.949,68.921,59.323,68.965,59.126,68.983,57.979,68.806,58.055,68.802,58.037,68.757,57.901,68.687,57.869,68.682,57.858,68.687,57.847,68.682,57.896,68.678,57.903,68.653,57.841,68.683,57.961,68.752,57.914,68.743,57.856,68.746,57.784,68.734,57.659,68.73,57.312,68.527,56.611,68.62,56.603,68.586,56.512,68.545,56.656,68.501,56.683,68.462,56.647,68.45,56.682,68.456,56.701,68.455,56.724,68.41,56.765,68.416,56.776,68.406,56.801,68.406,56.812,68.404,56.872,68.379,56.887,68.364,56.922,68.365,56.961,68.352,56.97,68.341,56.963,68.326,57.062,68.313,57.085,68.292,57.082,68.281,57.16,68.266,57.194,68.251,57.195,68.235,57.202,68.246,57.238,68.252,57.256,68.246,57.339,68.135,57.263,68.051,57.268,68.071,57.112,68.109,57.079,68.135,57.015,68.109,56.986,68.103,56.969,68.105,56.964,68.101,56.903,68.138,56.907,68.164,56.904,68.163,56.881,68.163,56.888,68.159,56.887,68.157,56.862,68.152,56.846,68.157,56.838,68.152,56.831,68.15,56.802,68.158,56.819,68.147,56.808,68.142,56.795,68.141,56.782,68.146,56.751,68.137,56.631,68.138,56.632,68.143,56.654,68.143,56.64,68.146,56.637,68.148,56.665,68.155,56.638,68.152,56.609,68.154,56.6,68.156,56.604,68.162,56.592,68.154,56.566,68.155,56.536,68.165,56.503,68.155,56.473,68.162,56.455,68.158,56.429,68.163,56.534,68.167,56.588,68.156,56.601,68.163,56.669,68.155,56.656,68.143,56.635,68.14,56.751,68.138,56.802,68.159,56.831,68.151,56.849,68.159,56.885,68.159,56.871,68.164,56.911,68.165,56.91,68.137,56.975,68.105,57.078,68.136,57.228,68.083,57.333,68.134,57.246,68.235,57.205,68.243,57.205,68.224,57.186,68.229,56.722,68.409,56.642,68.448,56.651,68.5,56.502,68.529,56.512,68.563,56.585,68.587,56.571,68.611,56.238,68.572,55.987,68.642,55.742,68.541,55.344,68.536,54.861,68.315,55.0,68.205,54.883,68.214,54.901,68.169,54.791,68.085,54.861,68.147,54.614,68.174,54.481,68.293,54.237,68.218,54.244,68.183,54.153,68.069,53.931,67.929,53.847,67.951,53.678,67.855,53.561,67.829,53.607,67.82,53.607,67.804,53.536,67.821,53.595,67.843,53.53,67.822,53.567,67.796,53.517,67.82,53.482,67.783,53.307,67.763,53.244,67.778,53.156,67.694,52.859,67.577,52.802,67.571,52.79,67.585,52.755,67.565,52.744,67.574,52.905,67.639,52.781,67.629,52.757,67.62,52.778,67.618,52.8,67.612,52.809,67.608,52.809,67.603,52.795,67.597,52.729,67.586,52.71,67.585,52.686,67.586,52.64,67.602,52.587,67.587,52.636,67.535,52.667,67.547,52.707,67.551,52.738,67.525,52.679,67.538,52.633,67.5,52.492,67.493,52.585,67.583,52.55,67.562,52.485,67.553,52.487,67.541,52.476,67.536,52.462,67.535,52.445,67.54,52.445,67.543,52.46,67.547,52.459,67.552,52.434,67.551,52.418,67.554,52.617,67.603,52.802,67.601,52.681,67.628,52.766,67.626,52.812,67.641,52.831,67.641,52.821,67.641,52.818,67.643,52.82,67.645,52.829,67.648,52.816,67.647,52.807,67.648,52.772,67.634,52.741,67.64,52.756,67.653,52.767,67.637,52.807,67.649,52.83,67.649,52.83,67.647,52.82,67.644,52.821,67.642,52.874,67.641,53.029,67.66,52.674,67.66,52.589,67.616,52.503,67.629,52.459,67.586,52.424,67.574,52.337,67.574,52.299,67.597,52.178,67.569,52.109,67.578,52.178,67.515,52.118,67.47,52.26,67.456,52.285,67.436,52.224,67.364,52.137,67.315,52.132,67.293,52.168,67.266,52.246,67.247,52.253,67.243,52.305,67.142,52.395,67.134,52.609,67.031,52.589,67.021,52.494,67.015,52.444,67.085,52.458,67.038,52.406,67.015,52.451,66.951,52.391,66.809,52.398,66.802,52.413,66.769,52.409,66.733,52.433,66.746,52.501,66.727,52.406,66.707,52.554,66.63,52.567,66.558,52.465,66.567,52.564,66.403,52.573,66.348,52.535,66.324,52.623,66.321,52.572,66.308,52.591,66.188,52.568,66.134,52.527,66.11,52.549,66.052,52.589,66.038,52.61,66.001,52.55,65.936,52.365,65.883,52.41,66.05,52.496,66.139,52.434,66.217,52.358,66.477,52.455,66.602,52.308,66.678,52.314,66.818,52.287,66.82,52.293,66.802,52.289,66.797,52.284,66.795,52.273,66.793,52.268,66.794,52.29,66.798,52.286,66.819,52.303,66.845,52.306,66.842,52.342,66.866,52.167,66.929,52.167,66.968,52.174,66.975,52.217,66.993,52.174,66.981,52.138,67.005,52.173,67.058,52.065,67.058,51.976,67.094,51.983,67.109,52.026,67.117,52.155,67.105,52.141,67.144,52.14,67.203,52.066,67.205,52.0,67.261,52.083,67.274,51.899,67.324,51.901,67.362,51.865,67.371,51.825,67.452,51.832,67.407,51.793,67.406,51.795,67.417,51.761,67.433,51.928,67.548,52.836,67.793,52.771,67.792,52.934,67.811,52.829,67.776,53.012,67.823,52.994,67.812,53.116,67.829,53.135,67.854,53.081,67.826,53.069,67.825,53.022,67.824,53.081,67.827,53.106,67.853,53.049,67.831,52.984,67.824,52.974,67.817,52.935,67.812,53.226,67.89,53.27,67.937,53.208,67.895,52.959,67.969,52.941,67.956,52.902,67.94,53.032,67.892,52.672,67.849,52.704,67.838,52.608,67.8,52.547,67.803,52.523,67.784,52.272,67.736,52.259,67.741,52.353,67.791,52.572,67.82,52.515,67.839,52.733,67.927,52.979,67.987,52.95,68.02,53.022,68.093,53.087,68.134,53.122,68.134,53.153,68.138,53.165,68.143,53.187,68.17,53.099,68.18,53.317,68.345,53.657,68.318,53.555,68.391,53.667,68.425,53.62,68.397,53.824,68.326,53.805,68.361,53.935,68.394,53.917,68.505,53.717,68.632,54.0,68.832,53.574,68.889,54.58,68.975,53.867,68.959,53.077,68.81,53.08,68.796,53.166,68.76,53.195,68.729,53.106,68.728,53.212,68.68,53.003,68.699,53.272,68.641,53.175,68.592,52.852,68.677,53.022,68.707,52.965,68.732,53.067,68.809,52.302,68.602,52.369,68.574,52.513,68.629,52.651,68.634,52.486,68.575,52.741,68.486,52.752,68.467,52.279,68.267,52.15,68.363,52.381,68.468,52.0,68.51,52.219,68.576,51.547,68.485,51.55,68.468,51.499,68.433,51.573,68.447,51.593,68.432,51.073,68.341,50.876,68.334,50.822,68.341,50.791,68.364,49.969,68.011,49.812,68.006,50.065,68.096,49.15,67.848,48.592,67.914,48.78,67.826,48.791,67.76,48.74,67.713,48.741,67.697,48.833,67.716,49.041,67.654,48.998,67.623,49.062,67.626,49.096,67.584,49.095,67.58,49.088,67.575,49.034,67.574,49.059,67.567,49.075,67.557,49.076,67.522,49.09,67.521,49.106,67.524,49.125,67.523,49.136,67.519,49.143,67.511,49.163,67.517,49.222,67.525,49.253,67.524,49.142,67.509,49.075,67.521,49.03,67.573,49.042,67.58,49.092,67.585,49.002,67.595,49.052,67.619,48.925,67.625,49.019,67.644,48.834,67.685,48.338,67.666,48.086,67.579,48.127,67.613,47.993,67.642,47.979,67.617,47.83,67.571,47.995,67.489,47.841,67.366,47.861,67.366,47.891,67.352,47.903,67.353,47.917,67.352,47.933,67.345,47.772,67.335,47.858,67.313,47.669,67.189,47.755,67.132,47.769,67.137,47.833,67.131,47.835,67.129,47.831,67.126,47.833,67.114,47.768,67.133,47.771,67.006,47.588,66.92,47.618,66.883,47.56,66.849,47.566,66.844,47.587,66.851,47.604,66.852,47.67,66.848,47.759,66.815,47.764,66.809,47.753,66.805,47.731,66.806,47.709,66.812,47.696,66.8,47.766,66.794,47.768,66.766,47.708,66.757,47.801,66.771,47.835,66.769,47.843,66.764,47.843,66.759,47.799,66.739,47.785,66.739,47.75,66.751,47.733,66.744,47.775,66.734,47.831,66.742,47.858,66.744,47.888,66.74,47.892,66.736,47.89,66.732,47.85,66.72,47.839,66.72,47.817,66.724,47.811,66.703,47.944,66.686,47.81,66.702,47.812,66.723,47.885,66.74,47.785,66.732,47.758,66.734,47.73,66.744,47.746,66.752,47.797,66.74,47.832,66.768,47.704,66.756,47.765,66.793,47.705,66.794,47.69,66.8,47.688,66.805,47.713,66.815,47.759,66.813,47.721,66.817,47.64,66.816,47.685,66.841,47.562,66.842,47.556,66.853,47.6,66.878,47.417,66.918,46.946,66.846,46.913,66.829,46.939,66.826,46.942,66.823,46.936,66.819,46.919,66.817,46.909,66.822,46.901,66.818,46.913,66.816,46.922,66.803,46.894,66.802,46.899,66.798,46.918,66.8,46.956,66.792,46.957,66.79,46.949,66.779,46.955,66.792,46.918,66.799,46.899,66.798,46.893,66.801,46.89,66.841,46.551,66.817,46.561,66.806,46.502,66.761,46.377,66.747,46.414,66.748,46.463,66.724,46.445,66.706,46.367,66.695,46.374,66.688,46.418,66.689,46.43,66.684,46.434,66.676,46.418,66.657,46.459,66.658,46.469,66.657,46.489,66.647,46.489,66.645,46.482,66.642,46.439,66.643,46.445,66.633,46.487,66.627,46.491,66.624,46.46,66.594,46.435,66.643,46.487,66.646,46.416,66.657,46.427,66.684,46.372,66.687,46.365,66.696,46.46,66.724,46.425,66.725,46.409,66.733,46.403,66.742,46.383,66.742,46.368,66.747,46.364,66.753,46.573,66.857,45.966,66.812,45.973,66.779,45.885,66.757,45.9,66.752,45.903,66.748,45.881,66.731,45.909,66.734,45.916,66.731,45.919,66.726,45.895,66.707,45.858,66.701,45.88,66.699,45.897,66.685,45.891,66.682,45.875,66.684,45.86,66.675,45.857,66.702,45.913,66.726,45.873,66.732,45.88,66.759,45.968,66.779,45.958,66.813,46.018,66.828,45.561,67.053,45.597,67.148,44.845,67.314,44.988,67.494,45.176,67.572,45.246,67.583,45.308,67.574,45.345,67.652,45.238,67.677,45.274,67.701,46.709,67.832,46.53,68.097,46.494,68.082,46.379,68.095,46.543,68.12,45.983,68.314,46.037,68.328,45.9,68.418,45.921,68.444,46.031,68.429,45.526,68.519,44.075,68.531,43.289,68.665,39.742,68.158,39.869,68.033,39.753,68.024,39.707,68.032,39.67,68.052,39.635,68.033,39.701,68.009,39.708,68.0,39.085,68.167,39.089,68.204,38.918,68.273,38.967,68.267,38.974,68.272,38.648,68.375,38.495,68.349,38.76,68.271,38.455,68.352,38.441,68.288,38.401,68.379,38.383,68.386,38.371,68.359,38.318,68.345,38.305,68.349,38.366,68.391,37.753,68.628,37.807,68.646,36.347,69.041,36.286,69.097,36.1,69.109,36.074,69.134,36.045,69.139,36.062,69.103,36.049,69.088,35.811,69.19,35.824,69.157,35.765,69.112,35.798,69.091,35.797,69.077,35.781,69.046,35.78,69.029,35.779,69.028,35.77,69.026,35.759,69.029,35.745,69.025,35.77,69.167,35.804,69.179,35.71,69.202,35.156,69.253,35.289,69.203,35.129,69.169,35.152,69.155,35.168,69.125,35.154,69.124,35.118,69.165,35.116,69.205,34.936,69.166,34.977,69.217,34.909,69.197,33.948,69.315,33.825,69.31,33.867,69.261,33.56,69.294,33.567,69.21,33.628,69.187,33.634,69.183,33.636,69.173,33.471,69.081,33.261,69.048,33.072,69.048,33.032,68.877,33.016,68.888,32.99,68.867,32.902,68.838,32.669,68.784,32.577,68.795,32.537,68.777,32.376,68.753,32.334,68.754,32.311,68.742,32.249,68.724,32.147,68.705,32.166,68.699,32.117,68.701,32.104,68.687,32.039,68.664,31.929,68.667,31.934,68.643,31.92,68.631,31.912,68.631,31.902,68.655,31.947,68.675,32.579,68.804,32.651,68.795,32.817,68.822,33.003,68.884,33.028,68.907,33.023,69.024,32.967,69.029,33.479,69.155,33.47,69.177,33.5,69.202,33.267,69.222,33.454,69.264,33.353,69.274,33.345,69.259,33.245,69.242,33.21,69.253,33.474,69.305,33.406,69.35,33.501,69.399,33.311,69.428,33.047,69.381,32.882,69.276,32.792,69.281,33.074,69.452,32.805,69.373,32.804,69.415,32.844,69.446,32.9,69.464,32.608,69.427,32.556,69.464,32.582,69.475,32.544,69.486,32.425,69.43,32.364,69.439,32.261,69.409,32.161,69.411,32.503,69.504,32.18,69.586,31.99,69.537,32.056,69.588,31.953,69.628,32.139,69.684,32.099,69.742,32.195,69.629,32.833,69.567,33.087,69.641,33.132,69.714,32.501,69.79,31.946,69.953,31.889,69.905,31.959,69.894,32.1,69.76,31.778,69.832,31.686,69.707,31.822,69.649,31.51,69.686,31.542,69.653,31.542,69.635,31.477,69.679,31.443,69.638,31.388,69.639,31.377,69.606,31.341,69.606,31.323,69.586,31.245,69.568,31.266,69.559,31.232,69.552,31.251,69.592,31.447,69.688,30.989,69.784,30.629,69.751,30.734,69.802,30.673,69.79,30.583,69.818,30.493,69.802,30.5,69.824,30.416,69.809,30.465,69.743,30.451,69.681,30.296,69.664,30.424,69.691,30.37,69.844,30.341,69.835,30.313,69.843,30.278,69.833,30.236,69.831,30.229,69.827,30.231,69.833,30.199,69.827,30.197,69.829,30.336,69.866,30.273,69.881,30.13,69.852,30.199,69.771,30.098,69.756,30.138,69.648,30.087,69.729,29.928,69.7,30.035,69.737,30.053,69.781,29.468,69.65,29.537,69.7,29.404,69.691,29.396,69.702,29.522,69.707,29.564,69.699,29.777,69.83,29.759,69.886,29.351,69.862,29.661,69.961,28.655,70.097,28.575,70.119,28.762,70.163,28.58,70.173,28.653,70.178,28.732,70.174,28.843,70.15,28.853,70.142,28.904,70.147,29.007,70.129,29.018,70.122,29.329,70.115,29.352,70.1,29.456,70.101,29.607,70.091,29.829,70.061,30.168,70.069,30.095,70.085,30.522,70.242,31.056,70.285,31.054,70.362,30.942,70.448,30.724,70.429,30.58,70.511,30.615,70.552,30.133,70.505,30.01,70.53,30.335,70.609,30.093,70.644,30.214,70.676,30.095,70.707,29.709,70.619,29.803,70.728,29.256,70.656,29.38,70.7,29.285,70.711,29.422,70.733,29.255,70.74,29.397,70.786,29.27,70.795,29.088,70.875,28.833,70.878,28.519,70.731,28.62,70.68,28.505,70.684,28.391,70.543,28.546,70.506,28.505,70.451,28.456,70.512,28.404,70.497,28.403,70.508,28.411,70.517,28.431,70.525,28.379,70.522,28.395,70.509,28.389,70.498,28.338,70.449,28.219,70.369,28.182,70.374,28.194,70.363,28.185,70.349,28.204,70.337,28.181,70.263,28.164,70.256,28.178,70.255,28.177,70.214,28.194,70.208,28.2,70.203,28.195,70.185,28.219,70.169,28.201,70.136,28.164,70.108,28.075,70.07,28.077,70.076,28.05,70.069,28.022,70.07,28.0,70.076,28.181,70.127,28.182,70.137,28.19,70.151,28.215,70.17,28.186,70.185,28.192,70.205,28.169,70.209,28.161,70.217,28.156,70.256,28.17,70.31,28.194,70.341,28.177,70.353,28.179,70.374,28.274,70.422,28.269,70.446,28.371,70.515,28.042,70.425,28.198,70.51,27.912,70.46,27.951,70.5,27.877,70.428,27.814,70.486,28.209,70.598,28.304,70.693,28.267,70.721,27.898,70.637,27.655,70.621,28.142,70.736,27.761,70.746,27.833,70.783,27.733,70.801,28.239,70.791,28.312,70.813,28.218,70.843,28.35,70.826,28.457,70.876,28.341,70.89,28.551,70.967,28.181,70.959,28.224,71.006,28.168,71.034,28.27,71.05,28.19,71.096,27.821,71.031,27.643,71.133,27.494,71.077,27.546,70.956,27.214,71.028,27.354,70.947,27.144,70.982,27.115,70.917,27.418,70.917,27.37,70.88,27.695,70.802,27.285,70.833,27.327,70.736,27.066,70.704,27.279,70.588,26.949,70.576,27.094,70.467,26.747,70.489,26.733,70.419,26.51,70.348,26.628,70.688,26.325,70.654,26.684,70.749,26.705,70.966,26.563,70.953,25.439,70.367,25.383,70.326,25.505,70.282,25.225,70.091,25.14,70.064,25.088,70.079,25.155,70.125,25.06,70.121,25.104,70.185,25.056,70.142,24.984,70.126,25.028,70.061,24.929,70.065,24.938,70.231,25.181,70.304,25.041,70.354,25.24,70.392,25.164,70.422,25.279,70.466,25.081,70.522,25.906,70.866,25.787,70.897,25.791,70.976,26.117,70.992,26.226,71.061,25.896,71.012,25.965,71.03,25.84,71.072,26.045,71.114,25.752,71.114,25.817,71.165,19.069,74.342,19.284,74.466,19.092,74.52,16.721,76.565,17.153,76.707,16.764,76.804,17.273,76.94,17.01,77.05,17.321,77.071,17.365,77.131,17.279,77.154,17.398,77.171,17.34,77.21,17.431,77.238,17.304,77.266,17.57,77.373,17.445,77.405,17.685,77.466,17.575,77.588,18.233,77.5,18.355,77.644,18.245,77.698,18.441,77.795,18.16,77.839,18.391,77.867,18.165,77.905,18.475,77.961,18.39,78.036,19.035,78.095,18.812,78.189,19.085,78.225,18.795,78.325,19.092,78.342,18.858,78.524,20.013,78.604,20.504,78.722,21.355,78.671,21.329,78.719,21.498,78.833,19.854,78.999,19.921,79.054,19.733,79.066,19.798,79.147,18.871,79.147,18.919,79.215,18.599,79.238,18.845,79.302,18.836,79.448,18.693,79.553,18.273,79.622,18.064,79.583,18.023,79.459,17.879,79.398,17.642,79.374,17.792,79.461,17.769,79.543,17.616,79.545,17.75,79.567,17.695,79.599,18.034,79.72,17.393,79.93,17.0,79.955,16.837,79.871,16.582,80.0,18.644,80.0,18.561,79.977,18.703,79.973,18.08,79.904,18.81,79.741,18.752,79.713,21.642,79.818,21.694,79.806,21.583,79.773,21.758,79.783,21.662,79.759,21.834,79.729,20.475,79.667,21.276,79.561,21.146,79.533,20.127,79.628,19.654,79.595,20.07,79.458,20.78,79.4,20.741,79.36,22.861,79.381,22.713,79.318,22.865,79.249,23.989,79.207,24.317,79.308,25.573,79.418,25.778,79.476,25.669,79.535,25.702,79.576,27.182,79.847,27.075,79.94,27.126,80.0,27.926,80.076,31.6,80.17,31.424,80.088,31.717,80.042,33.389,80.141,33.488,80.223,44.935,80.602,45.572,80.508,46.07,80.553,46.02,80.458,46.263,80.445,46.581,80.543,47.423,80.59,47.293,80.685,47.561,80.679,47.46,80.737,47.628,80.769,47.852,80.769,48.128,80.652,48.77,80.653,48.456,80.732,48.615,80.747,48.434,80.806,47.456,80.858,50.359,81.148,50.477,81.083,50.376,81.042,50.454,81.025,51.025,81.133,50.746,81.188,57.875,81.808,58.05,81.776,57.932,81.715,58.149,81.689,59.213,81.735,59.344,81.819,59.259,81.851,62.389,81.697,62.159,81.66,62.954,81.583,63.787,81.641,64.964,81.178,64.265,81.166,64.081,80.965,63.238,80.954,62.521,80.78,63.13,80.633,64.408,80.706,65.279,80.86,65.409,80.944,65.198,80.986,65.389,81.011,68.578,76.955,67.753,77.001,67.053,76.94,65.825,76.649,66.083,76.503,65.569,76.558,64.705,76.39,64.85,76.339,64.536,76.381,64.084,76.272,63.715,76.33,63.046,76.191,62.762,76.26,62.403,76.151,62.344,76.177,62.403,76.253,61.1,76.266,60.907,76.145,61.207,76.111,61.081,76.085,61.109,76.043,60.519,75.986,60.462,76.002,60.783,76.101,60.497,76.114,60.042,76.034,60.411,75.955,60.339,75.968,60.224,75.952,60.037,75.961,59.999,75.95,59.998,75.939,59.991,75.937,60.056,75.926,59.725,75.89,59.409,75.914,59.373,75.867,59.155,75.836,58.889,75.848,58.739,75.8,58.822,75.773,58.775,75.717,57.982,75.63,58.348,75.58,57.556,75.475,57.731,75.416],"holes":[[22.682,77.246,24.007,77.624,23.964,77.68,24.208,77.741,24.095,77.79,24.148,77.852,24.326,77.914,23.514,77.927,23.121,78.08,23.177,78.163,23.11,78.212,22.818,78.258,21.499,78.197,20.808,78.082,21.563,77.944,21.207,77.738,21.152,77.611,20.797,77.541,20.875,77.538,20.814,77.532,20.88,77.508,20.847,77.447,22.016,77.519,22.437,77.649,22.708,77.635,22.685,77.653,22.697,77.697,22.746,77.741,22.704,77.649,22.887,77.677,22.646,77.616,22.723,77.604,22.631,77.587,22.649,77.533,23.059,77.541,22.657,77.524,22.783,77.465,22.609,77.42,22.709,77.371,22.539,77.286,22.682,77.246],[20.988,78.218,22.055,78.259,22.206,78.376,21.952,78.58,21.589,78.545,21.208,78.638,21.031,78.555,20.155,78.482,20.6,78.434,20.643,78.347,20.807,78.33,20.597,78.318,20.784,78.293,20.698,78.226,20.988,78.218],[26.719,78.649,27.075,78.715,26.568,78.836,26.368,78.805,26.719,78.649],[28.074,78.835,28.751,78.92,29.245,78.84,29.648,78.898,28.4,78.967,28.451,78.943,27.816,78.863,28.074,78.835],[51.45,79.928,51.2,79.989,51.302,80.051,51.013,80.105,50.334,79.955,51.45,79.928],[48.025,80.074,48.38,80.079,48.516,80.125,48.481,80.192,49.098,80.17,48.841,80.214,49.005,80.235,48.893,80.283,48.5,80.309,48.804,80.331,48.789,80.378,49.575,80.35,49.796,80.385,49.675,80.481,50.933,80.538,51.814,80.713,51.253,80.799,50.211,80.749,51.005,80.857,50.859,80.95,49.919,80.884,50.436,80.925,50.193,80.944,48.998,80.778,49.679,80.676,49.266,80.628,49.383,80.6,49.163,80.548,49.211,80.523,47.94,80.531,47.425,80.456,48.288,80.419,47.607,80.396,48.14,80.336,47.88,80.328,48.025,80.303,47.272,80.315,47.308,80.368,47.189,80.38,46.722,80.292,47.15,80.16,47.847,80.246,48.167,80.196,47.66,80.092,48.025,80.074],[55.0,80.937,56.67,80.781,57.082,80.667,57.831,80.763,56.084,81.023,55.498,81.0,54.716,81.107,54.44,81.02,55.0,80.937],[56.149,81.081,56.777,81.025,57.668,80.818,58.337,80.908,57.586,81.026,56.149,81.081],[57.352,81.097,57.762,81.104,58.075,81.188,56.955,81.149,57.352,81.097],[56.566,81.158,57.26,81.26,57.961,81.295,56.619,81.405,56.354,81.391,56.483,81.343,56.415,81.302,56.625,81.254,55.711,81.329,55.456,81.264,55.598,81.195,56.566,81.158],[57.589,81.407,58.145,81.371,58.562,81.416,57.639,81.56,56.816,81.463,57.589,81.407],[58.559,81.372,58.858,81.3,59.332,81.328,59.152,81.414,58.559,81.372],[61.072,81.113,60.324,81.035,60.009,80.967,61.085,80.905,61.662,81.096,61.072,81.113],[58.406,80.929,58.736,80.999,58.164,81.087,57.873,81.038,58.406,80.929],[58.714,80.873,58.256,80.861,57.855,80.787,58.462,80.721,59.053,80.792,58.714,80.873],[61.742,80.865,60.425,80.767,60.053,80.786,60.292,80.812,60.174,80.827,59.746,80.804,59.956,80.784,59.585,80.766,59.662,80.736,59.426,80.647,59.372,80.546,59.467,80.507,59.379,80.478,59.694,80.375,60.316,80.446,61.13,80.36,62.208,80.659,62.224,80.767,62.148,80.821,61.742,80.865],[59.293,80.329,58.34,80.459,57.006,80.447,57.3,80.349,57.351,80.156,57.906,80.091,58.09,80.089,58.002,80.166,58.455,80.14,58.409,80.168,58.494,80.202,58.101,80.229,58.204,80.263,59.293,80.329],[59.373,80.087,59.035,80.053,58.8,80.008,59.374,79.908,59.899,79.987,59.787,80.067,59.373,80.087],[56.805,80.061,57.116,80.095,57.082,80.173,57.207,80.194,57.14,80.248,57.243,80.306,55.952,80.307,56.148,80.182,55.79,80.092,56.805,80.061],[50.197,80.129,50.404,80.17,50.035,80.225,49.539,80.156,49.948,80.048,50.164,80.056,50.092,80.113,50.197,80.129],[52.417,80.186,53.461,80.134,53.949,80.228,53.386,80.386,53.019,80.392,52.702,80.269,52.245,80.253,52.417,80.186],[54.477,80.476,54.115,80.544,54.29,80.583,53.848,80.582,54.003,80.52,53.853,80.453,54.304,80.415,54.477,80.476],[55.57,80.647,55.509,80.62,56.366,80.587,57.016,80.666,56.44,80.756,55.364,80.685,55.684,80.677,55.57,80.647],[55.05,80.727,55.892,80.767,55.097,80.873,54.033,80.823,55.05,80.727],[52.721,71.398,52.793,71.333,52.59,71.386,52.237,71.284,52.651,71.232,52.808,71.07,52.956,71.042,53.243,71.14,53.18,71.229,53.232,71.241,52.936,71.263,52.763,71.365,53.086,71.253,52.721,71.398],[48.981,69.497,48.357,69.283,48.221,69.038,48.242,68.787,48.434,68.717,48.27,68.798,48.25,68.881,48.594,68.716,48.441,68.715,48.725,68.692,48.729,68.749,48.805,68.701,48.745,68.691,49.113,68.668,48.903,68.693,49.731,68.867,49.953,68.997,49.886,69.021,49.97,69.081,50.214,69.142,50.126,68.979,50.312,69.106,50.043,69.288,49.34,69.49,48.981,69.497],[53.812,67.983,53.755,68.016,54.008,68.217,53.784,68.112,53.696,68.151,53.815,68.176,53.703,68.17,53.627,68.017,53.382,67.939,53.531,67.95,53.254,67.87,53.812,67.983]]}],"names":{"ar":"بحر بارنتس","de":"Barentssee","es":"Mar de Barents","fr":"mer de Barents","he":"ים ברנץ","hu":"Barents-tenger","it":"Mare di Barents","nl":"Barentszzee","pt":"Mar de Barents","zh":"巴倫支海"}},{"name":"Philippine Sea","bbox":[120.1,2.503,146.053,35.263],"area":482.2543,"polygons":[{"outer":[141.347,24.778,144.894,20.554,145.391,19.681,145.419,19.691,145.695,18.793,145.651,18.734,145.699,18.781,145.807,18.156,145.71,18.045,145.81,18.142,146.053,16.006,145.831,15.273,145.69,15.119,145.793,15.15,145.287,14.169,145.239,14.201,145.125,14.131,145.281,14.159,144.905,13.608,144.859,13.654,144.769,13.481,144.729,13.48,144.691,13.464,144.639,13.465,144.625,13.457,144.685,13.463,144.618,13.446,144.647,13.359,138.204,9.585,138.159,9.661,138.064,9.44,138.08,9.547,134.637,7.546,134.645,7.702,134.617,7.731,134.632,7.643,134.539,7.6,134.481,7.429,128.687,2.503,126.807,4.534,126.733,4.539,126.686,4.376,126.792,4.232,126.677,3.952,126.608,4.041,126.681,3.813,126.705,3.88,126.813,3.738,125.616,3.35,125.676,3.426,125.662,3.525,125.642,3.491,125.446,3.737,125.343,5.385,125.429,5.417,125.343,5.393,125.333,5.555,125.662,5.92,125.713,6.171,125.582,6.515,125.43,6.633,125.444,6.578,125.36,6.598,125.377,6.808,125.649,7.096,125.654,7.247,125.85,7.362,125.884,7.125,126.08,6.844,126.083,6.531,126.193,6.269,126.261,6.754,126.155,6.906,126.26,6.93,126.35,6.802,126.282,6.928,126.34,7.006,126.459,6.995,126.605,7.289,126.551,7.312,126.57,7.718,126.36,7.885,126.45,7.988,126.457,8.243,126.294,8.197,126.387,8.281,126.307,8.326,126.392,8.516,126.147,8.529,126.083,8.611,126.335,8.84,126.289,8.977,126.159,8.985,126.238,9.016,126.158,9.108,126.207,9.307,126.168,9.308,126.118,9.256,126.056,9.233,126.047,9.235,125.971,9.326,125.987,9.404,125.976,9.378,125.948,9.364,125.894,9.414,125.955,9.442,125.959,9.483,125.667,9.589,125.609,9.631,125.547,9.769,125.56,9.777,125.497,9.769,125.437,9.824,125.392,9.648,125.53,9.194,125.512,9.028,125.534,9.031,125.559,8.908,125.554,8.891,125.542,8.883,125.575,8.85,125.594,8.795,125.591,8.786,125.574,8.779,125.599,8.758,125.587,8.732,125.572,8.78,125.591,8.794,125.54,8.881,125.519,9.011,125.332,8.969,125.209,9.094,125.167,8.852,125.086,8.828,124.818,9.005,124.741,8.493,124.441,8.621,124.312,8.536,124.23,8.218,124.018,8.198,123.675,7.952,123.628,7.971,123.868,8.169,123.758,8.598,123.631,8.633,123.618,8.666,123.588,8.669,123.566,8.572,123.502,8.701,123.019,9.039,123.202,9.094,123.295,9.235,123.308,9.341,123.12,9.549,123.132,9.84,123.569,10.794,123.509,10.926,123.194,11.003,123.132,11.115,123.1,11.33,123.166,11.47,123.128,11.477,123.097,11.52,123.151,11.597,122.918,11.484,122.896,11.424,122.931,11.516,122.827,11.61,122.572,11.494,122.572,11.567,122.421,11.593,122.387,11.651,122.408,11.664,122.429,11.652,122.481,11.596,122.495,11.605,122.385,11.736,121.959,11.937,121.882,11.884,121.578,11.921,121.423,12.021,121.245,12.208,121.345,12.325,121.398,12.287,121.438,12.517,121.558,12.606,121.476,12.774,121.557,13.119,121.444,13.13,121.441,13.228,121.232,13.394,121.244,13.342,121.239,13.33,121.225,13.316,121.197,13.313,121.19,13.318,121.19,13.31,121.184,13.302,121.175,13.299,121.175,13.296,121.181,13.29,121.18,13.287,121.159,13.275,121.168,13.265,121.161,13.25,121.144,13.246,121.158,13.277,121.179,13.289,121.173,13.296,121.174,13.3,121.188,13.319,121.237,13.33,121.227,13.403,121.136,13.363,120.993,13.523,120.753,13.464,120.414,13.531,120.299,13.445,120.125,13.768,120.27,13.67,120.281,13.753,120.1,13.864,120.578,14.13,120.595,14.13,120.653,13.771,120.653,13.86,120.79,13.928,120.912,13.876,120.893,13.683,121.001,13.782,121.063,13.75,121.036,13.634,121.286,13.597,121.469,13.684,121.434,13.741,121.45,13.824,121.7,13.919,121.751,13.968,122.401,13.524,122.599,13.162,122.703,13.224,122.677,13.379,122.401,13.97,122.534,13.965,122.836,13.735,122.798,13.651,122.888,13.576,123.192,13.43,123.327,13.2,123.32,13.006,123.43,13.038,123.653,12.87,123.689,12.964,123.734,12.845,123.885,12.978,124.023,12.972,124.017,12.877,123.83,12.829,123.86,12.689,124.044,12.533,124.134,12.662,124.117,12.91,124.19,13.066,124.076,13.004,123.879,13.143,123.759,13.06,123.775,13.228,123.861,13.224,123.867,13.241,123.831,13.236,123.745,13.324,123.678,13.478,123.531,13.567,123.56,13.736,123.969,13.709,123.975,13.74,123.948,13.754,123.941,13.795,123.708,13.943,123.679,13.878,123.448,13.97,123.456,13.933,123.417,13.886,123.339,14.105,123.227,13.998,123.294,13.927,123.341,13.971,123.284,13.896,123.326,13.827,123.329,13.785,123.125,13.728,123.127,13.699,123.111,13.669,123.102,13.671,123.09,13.661,123.081,13.66,123.044,14.099,122.98,14.076,122.864,14.27,122.687,14.345,122.69,14.281,122.416,14.315,122.345,14.093,122.269,14.125,122.272,14.246,122.164,14.158,122.323,14.021,122.3,13.958,122.183,13.995,122.235,13.896,121.946,13.988,121.734,14.164,121.604,14.653,121.734,14.695,121.619,14.751,121.373,15.317,121.639,15.704,121.553,15.787,121.579,15.926,122.139,16.257,122.016,16.088,121.989,16.03,122.005,16.03,122.203,16.237,122.2,16.436,122.437,16.799,122.467,16.976,122.529,17.098,122.419,17.137,122.443,17.264,122.407,17.333,122.246,17.365,122.144,17.74,122.168,18.087,122.339,18.311,122.133,18.582,122.147,19.958,121.955,21.115,120.841,21.958,121.026,22.658,121.174,22.756,121.412,23.122,121.618,24.085,121.877,24.534,121.837,24.707,121.764,24.714,121.833,24.716,121.85,24.889,122.003,25.002,123.761,24.048,125.0,24.4,127.81,26.148,127.756,26.204,127.837,26.329,127.921,26.294,127.907,26.33,127.933,26.331,127.947,26.339,127.959,26.336,127.997,26.367,127.898,26.333,127.831,26.431,128.238,26.631,130.031,28.353,130.866,30.354,130.966,30.374,131.055,30.604,131.083,30.791,131.023,30.805,131.132,31.281,131.075,31.282,131.106,31.333,131.017,31.358,130.992,31.355,130.981,31.357,130.975,31.36,131.106,31.476,131.349,31.365,131.378,31.558,131.488,31.786,131.463,31.878,131.404,31.91,131.375,31.959,131.407,31.911,131.443,31.899,131.458,31.895,131.465,31.897,131.505,32.045,131.488,32.041,131.494,32.046,131.457,32.05,131.442,32.059,131.507,32.047,131.657,32.48,131.729,32.492,131.649,32.564,131.69,32.591,131.705,32.593,131.701,32.598,131.706,32.611,131.704,32.613,131.693,32.611,131.688,32.613,131.685,32.619,131.691,32.624,131.857,32.695,131.89,32.804,132.008,32.826,131.944,32.835,131.977,32.922,132.084,32.934,131.919,32.934,131.876,32.957,131.93,33.052,132.017,33.059,131.793,33.126,131.903,33.267,132.301,33.474,132.401,33.472,132.371,33.319,132.523,33.315,132.482,33.289,132.468,33.254,132.542,33.271,132.56,33.221,132.487,33.164,132.46,33.21,132.447,33.2,132.42,33.215,132.407,33.205,132.524,33.127,132.447,33.124,132.484,33.056,132.404,33.069,132.378,33.024,132.485,33.047,132.554,32.957,132.485,32.98,132.473,32.906,132.513,32.95,132.724,32.931,132.63,32.761,132.936,32.786,133.02,32.723,132.943,32.871,133.01,32.911,132.978,32.935,132.967,32.969,132.939,32.974,132.934,32.977,132.919,32.999,132.968,32.972,132.995,32.936,133.223,33.153,133.291,33.407,133.319,33.355,133.577,33.498,133.564,33.497,133.547,33.517,133.555,33.553,133.508,33.551,133.49,33.554,133.484,33.559,133.477,33.579,133.567,33.574,133.575,33.54,133.564,33.501,133.934,33.488,134.031,33.413,134.038,33.37,134.178,33.244,134.31,33.574,134.751,33.834,134.629,33.852,134.705,33.931,134.623,33.938,134.671,33.936,134.697,33.95,134.559,34.032,134.598,34.073,134.426,34.102,134.604,34.083,134.641,34.18,134.952,34.267,135.128,34.224,135.193,34.257,135.213,34.256,135.224,34.248,135.128,34.217,135.212,34.153,135.079,34.074,135.174,34.032,135.069,33.978,135.059,33.879,135.171,33.894,135.187,33.818,135.398,33.721,135.332,33.668,135.447,33.549,135.787,33.439,135.814,33.531,135.959,33.58,136.089,33.874,136.232,33.946,136.212,34.0,136.272,33.972,136.202,34.076,136.298,34.086,136.278,34.161,136.506,34.279,136.604,34.258,136.664,34.345,136.729,34.334,136.698,34.324,136.688,34.284,136.806,34.328,136.866,34.288,136.752,34.273,136.765,34.26,136.902,34.278,136.909,34.365,136.866,34.343,136.816,34.365,136.925,34.444,136.877,34.43,136.838,34.507,136.69,34.51,136.519,34.612,136.515,34.745,136.71,35.024,136.702,35.064,136.669,35.1,136.649,35.165,136.727,35.025,136.692,35.122,136.794,35.015,136.784,35.115,136.855,35.056,136.876,35.147,136.921,35.099,136.891,35.091,136.865,35.05,136.883,35.069,136.941,35.078,136.82,34.966,136.829,34.959,136.827,34.894,136.821,34.885,136.867,34.84,136.841,34.765,136.889,34.721,136.977,34.705,136.915,34.772,136.978,34.983,136.956,34.833,136.966,34.822,137.0,34.869,137.025,34.881,136.976,34.819,137.027,34.779,137.217,34.823,137.308,34.779,137.357,34.788,137.389,34.772,137.307,34.68,137.285,34.731,137.066,34.659,137.019,34.579,137.596,34.678,137.537,34.758,137.513,34.757,137.659,34.808,137.583,34.712,137.65,34.761,137.648,34.692,137.6,34.676,137.904,34.705,138.228,34.595,138.195,34.665,138.356,34.913,138.521,34.989,138.576,35.111,138.791,35.122,138.905,35.047,138.762,34.97,138.739,34.69,138.843,34.601,138.987,34.658,139.14,34.89,139.069,35.055,139.18,35.263,142.184,27.629,142.239,26.57,141.347,24.778],"holes":[[122.021,14.922,122.003,15.041,121.836,15.039,121.801,14.934,121.932,14.627,122.037,14.714,121.967,14.873,122.021,14.922],[124.205,14.099,124.126,14.06,124.136,13.796,124.023,13.662,124.208,13.517,124.295,13.605,124.345,13.549,124.426,13.796,124.205,14.099],[125.142,12.583,125.041,12.501,124.957,12.584,124.653,12.49,124.261,12.549,124.482,12.101,124.717,12.018,124.885,11.754,125.044,11.749,124.991,11.608,124.819,11.515,124.996,11.43,124.979,11.283,125.136,11.295,125.233,11.086,125.66,11.142,125.753,11.015,125.666,11.198,125.536,11.195,125.633,11.357,125.446,11.506,125.492,11.672,125.413,11.758,125.474,11.791,125.401,11.911,125.456,11.993,125.394,11.993,125.515,12.058,125.425,12.144,125.515,12.192,125.293,12.291,125.337,12.407,125.142,12.583],[125.035,10.934,124.947,11.426,124.815,11.432,124.642,11.294,124.479,11.455,124.48,11.34,124.289,11.539,124.403,11.239,124.375,10.923,124.517,10.868,124.595,11.014,124.783,10.774,124.72,10.484,124.79,10.318,124.758,10.156,125.028,10.004,125.008,10.38,125.135,10.062,125.284,9.91,125.213,10.128,125.129,10.162,125.132,10.276,125.249,10.255,125.266,10.327,125.179,10.429,125.186,10.592,125.003,10.723,125.035,10.934],[125.632,10.463,125.473,10.134,125.589,10.078,125.56,9.99,125.667,9.852,125.676,10.167,125.59,10.142,125.663,10.202,125.632,10.463],[124.557,9.828,124.547,10.041,124.283,10.173,124.153,10.154,124.026,9.968,123.79,9.864,123.783,9.758,123.872,9.627,124.257,9.598,124.486,9.761,124.573,9.733,124.557,9.828],[123.463,9.566,123.641,10.084,124.021,10.383,124.055,10.881,123.983,11.073,124.064,11.286,123.944,11.187,123.921,10.95,123.714,10.498,123.395,9.945,123.369,9.988,123.301,9.417,123.463,9.566],[124.066,11.72,123.998,12.011,124.052,11.968,123.895,12.209,123.783,12.196,123.445,12.517,123.329,12.433,123.378,12.542,123.236,12.601,123.289,12.44,123.217,12.238,123.304,12.2,123.159,11.906,123.429,12.201,123.54,12.229,123.601,12.075,123.75,11.983,123.721,11.932,123.938,11.893,124.066,11.72],[122.008,12.11,122.163,12.632,122.116,12.674,122.002,12.597,121.922,12.318,122.018,12.235,121.959,12.191,122.008,12.11],[122.046,13.226,122.127,13.456,121.866,13.57,121.82,13.345,122.046,13.226]]}],"names":{"ar":"بحر الفلبين","de":"Philippinensee","es":"mar de Filipinas","fr":"mer des Philippines","he":"ים הפיליפינים","hu":"Filippínó-tenger","it":"Mare delle Filippine","nl":"Filipijnenzee","pt":"Mar das Filipinas","zh":"菲律賓海"}},{"name":"North Atlantic Ocean","bbox":[-83.009,-0.003,6.62,68.638],"area":3419.1519,"polygons":[{"outer":[-6.264,62.346,-0.883,61.0,-0.883,60.85,-0.95,60.75,-1.126,60.726,-1.112,60.606,-1.432,60.621,-1.432,60.574,-1.497,60.537,-1.393,60.513,-1.449,60.505,-1.532,60.556,-1.631,60.487,-1.457,60.485,-1.409,60.387,-1.354,60.395,-1.262,60.351,-1.369,60.286,-1.702,60.289,-1.624,60.207,-1.527,60.243,-1.471,60.22,-1.548,60.191,-1.46,60.146,-1.359,60.194,-1.429,60.255,-1.293,60.246,-1.328,60.165,-1.296,60.211,-1.318,60.161,-1.283,60.196,-1.304,60.133,-1.266,60.14,-1.273,60.098,-1.331,59.947,-1.392,59.915,-1.318,59.848,-2.381,59.399,-3.074,59.334,-3.014,59.275,-3.358,59.104,-3.354,58.963,-3.437,58.879,-3.29,58.776,-3.414,58.647,-3.364,58.596,-4.023,58.601,-4.474,58.444,-4.408,58.522,-4.421,58.552,-4.589,58.579,-4.74,58.449,-4.751,58.458,-4.653,58.552,-4.736,58.566,-4.789,58.602,-4.767,58.579,-4.799,58.58,-4.779,58.547,-4.81,58.521,-4.824,58.597,-5.005,58.628,-6.261,58.52,-6.83,58.283,-6.789,58.277,-6.718,58.17,-6.854,58.199,-6.863,58.116,-7.027,58.244,-7.016,58.183,-7.105,58.184,-7.091,58.163,-7.517,57.604,-7.548,57.605,-7.396,57.331,-7.503,57.002,-7.661,56.822,-8.19,55.261,-8.255,55.273,-8.193,55.254,-8.299,55.043,-8.463,55.002,-8.363,54.948,-8.456,54.91,-8.31,54.889,-8.383,54.851,-8.25,54.878,-8.335,54.834,-8.569,54.825,-8.434,54.755,-8.652,54.776,-8.807,54.698,-8.68,54.618,-8.386,54.635,-8.462,54.566,-8.287,54.646,-8.299,54.601,-8.129,54.652,-8.109,54.613,-8.27,54.527,-8.201,54.5,-8.682,54.356,-8.502,54.322,-8.578,54.306,-8.519,54.301,-8.475,54.277,-8.585,54.276,-8.612,54.289,-8.626,54.255,-8.51,54.217,-9.058,54.295,-9.136,54.206,-9.113,54.2,-9.141,54.127,-9.264,54.31,-9.806,54.344,-9.864,54.303,-9.732,54.259,-9.947,54.213,-9.887,54.269,-9.998,54.309,-10.108,54.245,-10.056,54.218,-10.137,54.109,-10.059,54.099,-10.094,54.158,-10.011,54.226,-9.922,54.195,-9.986,54.18,-9.9,54.117,-9.994,54.103,-9.833,54.115,-9.926,53.995,-9.876,54.006,-9.828,53.991,-9.852,53.967,-9.802,53.972,-9.791,53.943,-9.793,53.912,-9.839,53.96,-9.939,53.931,-9.973,54.03,-10.259,53.975,-10.068,53.972,-9.961,53.874,-9.956,53.91,-9.904,53.933,-9.905,53.861,-9.765,53.9,-9.554,53.889,-9.564,53.847,-9.618,53.845,-9.602,53.815,-9.633,53.817,-9.55,53.805,-9.584,53.781,-9.912,53.752,-9.912,53.651,-9.784,53.602,-9.671,53.615,-9.672,53.608,-9.787,53.594,-9.884,53.63,-9.848,53.611,-9.932,53.598,-10.056,53.611,-9.963,53.55,-10.161,53.571,-10.151,53.562,-10.204,53.543,-10.148,53.541,-10.019,53.472,-10.086,53.488,-10.035,53.46,-10.074,53.439,-10.135,53.457,-10.177,53.439,-10.198,53.405,-10.073,53.42,-9.958,53.366,-9.802,53.418,-9.793,53.392,-9.908,53.328,-9.841,53.287,-9.653,53.386,-9.686,53.351,-9.627,53.328,-9.613,53.379,-9.552,53.381,-9.614,53.231,-9.558,53.288,-9.525,53.221,-8.947,53.273,-8.964,53.24,-9.009,53.232,-8.987,53.229,-9.052,53.22,-8.903,53.221,-8.934,53.14,-8.968,53.177,-9.283,53.145,-9.476,52.942,-9.348,52.93,-9.496,52.751,-9.939,52.558,-9.58,52.667,-9.163,52.618,-8.96,52.799,-8.942,52.735,-8.966,52.683,-8.863,52.698,-8.638,52.661,-9.298,52.571,-9.62,52.581,-9.682,52.545,-9.632,52.471,-9.948,52.412,-9.835,52.387,-9.877,52.274,-9.737,52.257,-9.956,52.232,-10.016,52.316,-10.469,52.184,-10.452,52.097,-9.935,52.106,-9.965,52.141,-9.866,52.159,-9.78,52.152,-9.762,52.159,-9.753,52.157,-10.32,51.956,-10.185,51.961,-10.253,51.904,-10.409,51.878,-10.345,51.783,-10.188,51.857,-10.228,51.782,-10.126,51.737,-9.904,51.835,-9.561,51.881,-9.786,51.765,-9.982,51.733,-10.166,51.58,-9.54,51.751,-9.444,51.692,-9.851,51.543,-9.54,51.612,-9.821,51.452,-11.5,51.0,-11.5,49.0,-5.483,46.501,-7.867,43.77,-8.323,43.564,-8.341,43.458,-8.154,43.513,-8.317,43.445,-8.134,43.424,-8.221,43.392,-8.203,43.339,-8.214,43.339,-8.202,43.322,-8.215,43.292,-8.306,43.405,-8.36,43.316,-8.4,43.39,-8.713,43.29,-8.839,43.346,-8.992,43.275,-8.931,43.22,-9.174,43.191,-9.205,43.129,-9.132,43.133,-9.3,43.057,-9.282,42.89,-9.189,42.952,-9.128,42.907,-9.091,42.737,-9.063,42.786,-8.87,42.829,-9.034,42.695,-9.048,42.517,-8.9,42.643,-8.857,42.606,-8.842,42.686,-8.795,42.637,-8.721,42.706,-8.686,42.711,-8.771,42.594,-8.83,42.575,-8.821,42.452,-8.871,42.501,-8.945,42.464,-8.839,42.384,-8.652,42.434,-8.837,42.34,-8.864,42.246,-8.661,42.291,-8.632,42.354,-8.596,42.346,-8.613,42.286,-8.85,42.157,-8.815,42.115,-8.9,42.112,-8.882,41.881,-8.76,41.934,-8.746,41.968,-8.678,41.993,-8.663,42.004,-8.659,42.03,-8.642,42.045,-8.621,42.054,-8.602,42.055,-8.756,41.927,-8.862,41.867,-8.88,41.75,-8.844,41.674,-8.847,41.684,-8.817,41.695,-8.689,41.724,-8.836,41.681,-8.754,41.513,-8.787,41.415,-8.708,41.172,-8.577,41.143,-8.566,41.126,-8.547,41.123,-8.527,41.101,-8.515,41.075,-8.467,41.075,-8.427,41.037,-8.414,41.073,-8.352,41.041,-8.292,41.084,-8.262,41.066,-8.352,41.039,-8.412,41.071,-8.42,41.038,-8.429,41.036,-8.672,41.145,-8.648,40.971,-8.763,40.644,-8.656,40.86,-8.678,40.742,-8.61,40.727,-8.674,40.64,-8.749,40.643,-8.909,40.187,-8.875,40.146,-8.826,40.146,-8.803,40.135,-8.791,40.113,-8.776,40.112,-8.79,40.111,-8.833,40.143,-8.874,40.143,-9.072,39.584,-9.228,39.436,-9.184,39.402,-9.409,39.36,-9.34,39.226,-9.5,38.779,-9.46,38.695,-9.132,38.706,-9.085,38.82,-8.991,38.95,-8.93,39.023,-8.895,39.036,-8.868,39.032,-8.994,38.933,-8.937,38.86,-8.926,38.76,-9.046,38.714,-8.97,38.7,-8.995,38.652,-9.091,38.661,-9.066,38.649,-9.051,38.604,-9.147,38.688,-9.258,38.664,-9.144,38.524,-9.222,38.413,-8.894,38.52,-8.78,38.472,-8.811,38.509,-8.764,38.527,-8.749,38.565,-8.729,38.573,-8.722,38.441,-8.654,38.409,-8.561,38.416,-8.541,38.406,-8.542,38.374,-8.559,38.412,-8.646,38.402,-8.746,38.424,-8.774,38.41,-8.803,38.443,-8.798,38.389,-8.825,38.444,-8.907,38.492,-8.775,38.253,-8.8,38.111,-8.888,37.961,-8.889,37.941,-8.805,37.923,-8.813,37.744,-8.752,37.728,-8.747,37.705,-8.998,37.022,-8.59,37.15,-8.597,37.127,-8.616,37.124,-8.619,37.121,-8.618,37.118,-8.181,37.09,-7.968,36.989,-8.013,37.018,-8.02,37.027,-7.983,37.003,-7.941,37.01,-7.874,36.983,-7.883,37.001,-7.87,37.006,-7.884,37.011,-7.56,37.152,-7.4,37.163,-7.435,37.247,-7.463,37.409,-7.461,37.414,-7.447,37.42,-7.46,37.431,-7.471,37.473,-7.469,37.48,-7.465,37.487,-7.48,37.496,-7.463,37.488,-7.445,37.42,-7.462,37.409,-7.439,37.39,-7.431,37.249,-7.381,37.172,-7.064,37.206,-7.157,37.21,-7.17,37.216,-7.177,37.277,-6.949,37.164,-7.023,37.217,-6.994,37.257,-6.971,37.243,-6.971,37.226,-6.958,37.216,-6.966,37.257,-7.002,37.257,-6.979,37.31,-6.942,37.212,-6.868,37.291,-6.855,37.303,-6.847,37.304,-6.931,37.185,-6.52,36.977,-6.39,36.803,-6.34,36.9,-6.274,36.914,-6.228,36.896,-6.341,36.894,-6.338,36.799,-6.439,36.72,-6.205,36.563,-6.246,36.509,-6.174,36.521,-6.232,36.463,-6.279,36.543,-6.309,36.533,-6.037,36.189,-5.908,35.801,-6.296,34.884,-6.25,34.859,-6.248,34.839,-6.303,34.869,-6.684,34.266,-6.651,34.27,-6.615,34.308,-6.598,34.312,-6.583,34.311,-6.577,34.307,-6.589,34.268,-6.569,34.271,-6.57,34.291,-6.553,34.337,-6.462,34.374,-6.478,34.406,-6.458,34.377,-6.485,34.34,-6.569,34.269,-6.59,34.268,-6.599,34.31,-6.647,34.271,-6.686,34.263,-6.814,34.027,-7.12,33.829,-8.298,33.367,-8.33,33.266,-8.34,33.317,-8.546,33.246,-8.746,32.977,-9.286,32.543,-9.262,32.175,-9.679,31.7,-9.687,31.604,-9.841,31.39,-9.806,30.814,-9.891,30.64,-9.598,30.4,-9.655,30.054,-10.215,29.313,-10.548,29.003,-11.075,28.741,-11.519,28.281,-12.268,28.002,-12.235,28.052,-12.935,27.946,-13.167,27.69,-13.574,26.731,-13.978,26.471,-14.183,26.424,-14.475,26.18,-14.829,25.295,-14.883,24.701,-15.84,23.889,-16.006,23.65,-15.697,23.979,-15.715,23.82,-16.205,23.096,-16.147,22.995,-16.289,22.905,-16.495,22.324,-16.767,22.219,-16.958,21.828,-17.105,20.845,-17.046,20.771,-17.026,21.043,-16.915,21.166,-16.676,20.673,-16.531,20.555,-16.488,20.723,-16.203,20.215,-16.265,20.124,-16.224,19.99,-16.311,19.903,-16.255,19.887,-16.272,19.734,-16.468,19.417,-16.288,19.529,-16.403,19.396,-16.545,19.384,-16.186,18.924,-16.02,17.981,-16.1,17.457,-16.442,16.616,-16.52,16.277,-16.511,16.216,-16.513,16.163,-16.509,16.048,-16.514,15.983,-16.512,15.97,-16.508,15.984,-16.509,16.018,-16.507,16.036,-16.505,16.041,-16.51,16.122,-16.507,16.184,-16.507,16.169,-16.504,16.171,-16.502,16.168,-16.499,16.148,-16.459,16.178,-16.504,16.206,-16.474,16.291,-16.455,16.19,-16.449,16.211,-16.416,16.22,-16.388,16.218,-16.366,16.318,-16.282,16.512,-16.273,16.519,-16.257,16.526,-16.17,16.527,-16.283,16.507,-16.336,16.292,-16.383,16.218,-16.431,16.203,-16.413,16.143,-16.453,16.069,-16.442,16.077,-16.431,16.075,-16.424,16.083,-16.41,16.088,-16.425,16.08,-16.405,16.085,-16.396,16.081,-16.394,16.079,-16.394,16.073,-16.464,16.063,-16.46,15.909,-16.505,15.937,-16.52,15.816,-16.593,15.664,-16.913,15.178,-17.128,14.91,-17.53,14.74,-17.433,14.646,-17.374,14.74,-17.188,14.654,-16.802,14.133,-16.762,13.93,-16.71,14.069,-16.663,14.073,-16.658,14.142,-16.605,14.167,-16.619,14.187,-16.601,14.201,-16.612,14.206,-16.613,14.217,-16.605,14.222,-16.597,14.201,-16.613,14.186,-16.602,14.167,-16.625,14.138,-16.621,14.117,-16.641,14.112,-16.616,14.089,-16.615,14.064,-16.718,14.009,-16.572,14.06,-16.611,14.155,-16.598,14.153,-16.581,14.202,-16.558,14.215,-16.564,14.25,-16.54,14.245,-16.534,14.229,-16.565,14.062,-16.488,14.136,-16.52,14.129,-16.54,14.16,-16.501,14.239,-16.448,14.261,-16.492,14.151,-16.466,14.19,-16.424,14.198,-16.424,14.223,-16.416,14.226,-16.389,14.202,-16.445,14.163,-16.423,14.169,-16.39,14.146,-16.377,14.161,-16.328,14.168,-16.296,14.16,-16.28,14.146,-16.278,14.174,-16.263,14.179,-16.228,14.172,-16.198,14.142,-16.187,14.161,-16.172,14.17,-16.158,14.164,-16.162,14.145,-16.144,14.147,-16.136,14.139,-16.136,14.129,-16.143,14.118,-16.076,14.131,-16.063,14.115,-16.046,14.114,-16.029,14.095,-16.143,14.115,-16.139,14.138,-16.163,14.143,-16.172,14.167,-16.199,14.138,-16.263,14.177,-16.278,14.143,-16.387,14.139,-16.432,14.165,-16.443,14.132,-16.581,14.032,-16.523,14.046,-16.582,14.003,-16.512,14.009,-16.493,13.99,-16.477,14.045,-16.456,14.065,-16.371,14.098,-16.336,14.087,-16.472,14.046,-16.472,13.964,-16.504,13.88,-16.474,13.895,-16.438,13.976,-16.428,13.99,-16.415,13.993,-16.462,13.91,-16.442,13.902,-16.44,13.892,-16.408,13.893,-16.4,13.886,-16.499,13.867,-16.461,13.867,-16.464,13.856,-16.439,13.859,-16.425,13.852,-16.412,13.833,-16.407,13.835,-16.41,13.832,-16.414,13.833,-16.434,13.855,-16.467,13.854,-16.478,13.865,-16.487,13.86,-16.462,13.841,-16.507,13.675,-16.585,13.634,-16.556,13.597,-16.547,13.629,-16.53,13.607,-16.518,13.652,-16.508,13.645,-16.507,13.362,-16.306,13.34,-16.233,13.467,-16.207,13.416,-16.19,13.477,-16.138,13.464,-16.13,13.471,-16.137,13.491,-16.136,13.496,-16.122,13.502,-16.104,13.499,-16.095,13.507,-16.088,13.526,-16.102,13.525,-16.106,13.528,-16.107,13.531,-16.1,13.569,-16.098,13.571,-16.094,13.571,-16.089,13.564,-16.085,13.568,-16.092,13.586,-16.091,13.591,-16.083,13.596,-16.087,13.608,-16.082,13.595,-16.091,13.587,-16.084,13.572,-16.085,13.566,-16.089,13.563,-16.1,13.569,-16.106,13.53,-16.089,13.529,-16.086,13.521,-16.093,13.507,-16.103,13.497,-16.136,13.492,-16.089,13.442,-15.559,13.523,-15.449,13.501,-15.364,13.442,-15.346,13.463,-15.324,13.473,-15.308,13.476,-15.304,13.489,-15.354,13.519,-15.374,13.548,-15.376,13.614,-15.364,13.642,-15.3,13.493,-15.304,13.462,-15.316,13.448,-15.35,13.435,-15.386,13.444,-15.558,13.515,-15.924,13.399,-16.147,13.435,-16.224,13.334,-16.201,13.304,-16.205,13.25,-16.139,13.295,-16.118,13.294,-16.111,13.29,-16.094,13.248,-16.089,13.275,-16.081,13.28,-16.073,13.279,-16.093,13.246,-16.144,13.29,-16.171,13.263,-16.138,13.265,-16.136,13.26,-16.139,13.251,-16.15,13.252,-16.136,13.241,-16.139,13.23,-16.209,13.246,-16.231,13.324,-16.429,13.27,-16.409,13.265,-16.404,13.26,-16.41,13.231,-16.384,13.223,-16.379,13.216,-16.408,13.215,-16.587,13.318,-16.637,13.414,-16.608,13.445,-16.573,13.441,-16.572,13.456,-16.715,13.459,-16.813,13.338,-16.736,13.031,-16.788,12.76,-16.773,12.823,-16.76,12.857,-16.753,12.869,-16.769,12.803,-16.721,12.819,-16.737,12.82,-16.736,12.83,-16.727,12.843,-16.716,12.853,-16.71,12.854,-16.698,12.85,-16.702,12.862,-16.676,12.865,-16.668,12.861,-16.667,12.858,-16.736,12.825,-16.703,12.815,-16.702,12.798,-16.687,12.807,-16.681,12.804,-16.68,12.798,-16.682,12.793,-16.767,12.801,-16.785,12.695,-16.727,12.776,-16.608,12.794,-16.634,12.821,-16.641,12.836,-16.632,12.885,-16.659,12.882,-16.667,12.883,-16.677,12.904,-16.662,12.978,-16.61,12.946,-16.596,12.911,-16.577,12.914,-16.601,12.949,-16.603,12.959,-16.602,12.965,-16.593,12.979,-16.583,12.988,-16.597,12.996,-16.601,13.031,-16.6,13.037,-16.595,13.045,-16.582,13.044,-16.583,13.052,-16.581,13.047,-16.582,13.037,-16.6,13.031,-16.582,12.987,-16.602,12.96,-16.575,12.919,-16.568,12.896,-16.558,12.905,-16.539,12.9,-16.525,12.875,-16.507,12.882,-16.522,12.893,-16.522,12.897,-16.516,12.906,-16.513,12.908,-16.491,12.905,-16.517,12.905,-16.506,12.882,-16.526,12.873,-16.571,12.885,-16.583,12.752,-16.503,12.733,-16.54,12.684,-16.548,12.682,-16.554,12.685,-16.56,12.693,-16.57,12.683,-16.49,12.62,-16.502,12.719,-16.479,12.728,-16.488,12.634,-16.367,12.561,-16.356,12.623,-16.351,12.627,-16.348,12.627,-16.34,12.618,-16.329,12.628,-16.34,12.632,-16.347,12.646,-16.054,12.627,-15.984,12.9,-15.929,12.884,-15.916,12.9,-15.935,12.924,-15.926,12.946,-15.851,12.962,-15.788,12.933,-15.766,12.978,-15.687,13.013,-15.667,13.004,-15.653,12.978,-15.647,12.996,-15.619,12.993,-15.599,13.025,-15.579,13.026,-15.579,13.035,-15.572,13.041,-15.564,13.042,-15.557,13.051,-15.547,13.054,-15.542,13.05,-15.542,13.042,-15.555,13.05,-15.609,12.993,-15.651,12.973,-15.766,12.97,-15.789,12.925,-15.925,12.941,-15.913,12.885,-15.976,12.879,-16.016,12.692,-15.9,12.588,-15.669,12.612,-15.645,12.544,-15.534,12.647,-15.558,12.748,-15.52,12.819,-15.284,12.87,-15.254,12.856,-15.24,12.832,-15.239,12.813,-15.211,12.818,-15.235,12.807,-15.279,12.861,-15.387,12.801,-15.533,12.786,-15.499,12.65,-15.549,12.523,-15.901,12.57,-16.007,12.636,-16.365,12.547,-16.525,12.595,-16.525,12.555,-16.499,12.559,-16.488,12.554,-16.448,12.496,-16.446,12.488,-16.448,12.484,-16.468,12.477,-16.456,12.444,-16.441,12.437,-16.437,12.43,-16.438,12.427,-16.44,12.422,-16.451,12.404,-16.45,12.391,-16.58,12.638,-16.679,12.553,-16.636,12.497,-16.678,12.399,-16.733,12.444,-16.73,12.451,-16.715,12.457,-16.709,12.468,-16.683,12.478,-16.674,12.5,-16.71,12.48,-16.726,12.485,-16.727,12.463,-16.743,12.46,-16.728,12.484,-16.685,12.505,-16.73,12.51,-16.749,12.545,-16.771,12.531,-16.771,12.404,-16.44,12.184,-16.429,12.19,-16.397,12.193,-16.432,12.222,-16.432,12.228,-16.416,12.237,-16.427,12.247,-16.419,12.261,-16.436,12.249,-16.448,12.26,-16.417,12.269,-16.328,12.201,-16.338,12.236,-16.307,12.264,-16.323,12.283,-16.307,12.296,-16.314,12.304,-16.335,12.294,-16.348,12.303,-16.312,12.339,-16.294,12.33,-16.282,12.306,-16.29,12.286,-16.306,12.278,-16.316,12.283,-16.294,12.264,-16.283,12.269,-16.272,12.262,-16.265,12.27,-16.271,12.26,-16.302,12.263,-16.325,12.244,-16.323,12.217,-16.211,12.283,-16.213,12.295,-16.235,12.302,-16.247,12.317,-16.202,12.353,-16.196,12.352,-16.192,12.348,-16.192,12.349,-16.192,12.353,-16.191,12.354,-16.183,12.355,-16.189,12.356,-16.195,12.363,-16.197,12.377,-16.192,12.383,-16.198,12.396,-16.19,12.4,-16.182,12.354,-16.235,12.305,-16.03,12.355,-15.925,12.321,-15.801,12.22,-15.725,12.309,-15.711,12.308,-15.7,12.291,-15.686,12.301,-15.697,12.311,-15.694,12.318,-15.687,12.323,-15.671,12.327,-15.651,12.316,-15.637,12.328,-15.625,12.324,-15.621,12.329,-15.622,12.345,-15.617,12.353,-15.575,12.383,-15.523,12.413,-15.515,12.413,-15.505,12.4,-15.491,12.407,-15.626,12.322,-15.649,12.315,-15.686,12.322,-15.684,12.3,-15.698,12.289,-15.724,12.307,-15.8,12.217,-16.086,12.335,-16.241,12.156,-16.322,12.161,-16.34,12.023,-16.281,11.997,-16.268,12.016,-16.25,12.012,-16.243,12.027,-16.228,12.032,-16.221,12.031,-16.218,12.027,-16.219,12.023,-16.233,12.011,-16.216,11.997,-16.276,11.996,-16.232,11.932,-16.101,11.875,-15.928,11.952,-16.012,11.993,-15.999,12.084,-15.917,11.968,-15.866,12.051,-15.766,12.018,-15.783,12.043,-15.773,12.06,-15.782,12.068,-15.776,12.086,-15.748,11.977,-15.668,12.032,-15.639,12.021,-15.625,11.991,-15.606,11.996,-15.607,12.022,-15.586,12.036,-15.55,12.029,-15.528,12.002,-15.523,12.02,-15.514,12.025,-15.461,12.032,-15.46,12.056,-15.452,12.065,-15.433,12.077,-15.423,12.068,-15.467,12.025,-15.52,12.02,-15.529,11.999,-15.625,11.987,-15.675,12.025,-15.72,11.925,-15.77,12.009,-15.839,11.983,-15.937,11.853,-15.951,11.734,-15.883,11.759,-15.888,11.81,-15.865,11.817,-15.88,11.836,-15.881,11.867,-15.847,11.884,-15.859,11.762,-15.794,11.878,-15.769,11.878,-15.735,11.856,-15.699,11.873,-15.674,11.858,-15.687,11.842,-15.794,11.87,-15.806,11.743,-15.406,11.963,-15.174,11.908,-15.122,11.966,-15.058,11.995,-14.957,11.962,-14.942,11.981,-14.958,12.001,-14.959,12.01,-14.956,12.019,-14.954,12.023,-14.944,12.03,-14.928,12.029,-14.956,12.016,-14.94,11.988,-14.941,11.977,-14.945,11.969,-14.962,11.959,-14.992,11.964,-15.007,11.95,-15.04,11.833,-14.955,11.753,-14.92,11.761,-14.931,11.779,-14.93,11.788,-14.905,11.794,-14.89,11.791,-14.883,11.785,-14.881,11.746,-14.866,11.759,-14.857,11.76,-14.826,11.745,-14.823,11.737,-14.839,11.717,-14.861,11.707,-14.861,11.691,-14.865,11.702,-14.855,11.72,-14.825,11.741,-14.885,11.746,-14.888,11.786,-14.927,11.787,-14.917,11.757,-14.951,11.747,-15.048,11.798,-15.046,11.957,-15.166,11.876,-15.379,11.888,-15.418,11.839,-15.513,11.779,-15.537,11.681,-15.463,11.717,-15.457,11.643,-15.425,11.684,-15.411,11.691,-15.403,11.681,-15.458,11.607,-15.425,11.542,-15.345,11.587,-15.378,11.633,-15.373,11.651,-15.321,11.607,-15.329,11.686,-15.272,11.644,-15.304,11.716,-15.226,11.664,-15.204,11.773,-15.172,11.697,-15.204,11.694,-15.206,11.655,-15.251,11.652,-15.258,11.633,-15.162,11.651,-15.134,11.671,-15.139,11.699,-15.132,11.702,-15.104,11.687,-15.153,11.648,-14.974,11.587,-15.106,11.534,-15.122,11.632,-15.198,11.627,-15.15,11.605,-15.174,11.561,-15.209,11.623,-15.278,11.621,-15.242,11.601,-15.262,11.591,-15.22,11.567,-15.224,11.557,-15.286,11.61,-15.277,11.534,-15.334,11.562,-15.368,11.483,-15.414,11.513,-15.466,11.481,-15.414,11.43,-15.502,11.329,-15.283,11.509,-15.326,11.376,-15.218,11.476,-15.302,11.373,-15.377,11.366,-15.419,11.293,-15.298,11.29,-15.301,11.248,-15.406,11.202,-15.226,11.115,-15.286,11.237,-15.148,11.225,-15.161,11.293,-15.145,11.361,-15.117,11.375,-15.145,11.222,-15.212,11.215,-15.181,11.13,-15.207,11.07,-15.051,11.133,-15.049,11.203,-15.015,11.163,-14.971,11.229,-15.013,11.256,-15.001,11.273,-14.947,11.231,-14.961,11.328,-14.938,11.238,-14.922,11.243,-14.913,11.26,-14.904,11.269,-14.902,11.276,-14.9,11.279,-14.898,11.28,-14.897,11.228,-14.981,11.193,-14.907,11.186,-14.912,11.162,-15.0,11.159,-14.94,11.118,-15.01,11.147,-15.097,11.051,-15.066,10.921,-14.979,10.985,-14.898,10.964,-14.974,11.083,-14.9,11.03,-14.848,11.109,-14.869,10.96,-14.81,10.991,-14.809,11.004,-14.821,11.017,-14.822,11.025,-14.782,11.048,-14.678,11.042,-14.707,11.083,-14.705,11.149,-14.659,11.183,-14.674,11.189,-14.705,11.174,-14.729,11.175,-14.735,11.18,-14.742,11.207,-14.739,11.221,-14.73,11.237,-14.726,11.24,-14.711,11.24,-14.721,11.268,-14.7,11.301,-14.694,11.302,-14.71,11.24,-14.74,11.207,-14.656,11.185,-14.699,11.08,-14.639,11.018,-14.687,11.027,-14.689,11.0,-14.736,10.962,-14.816,10.929,-14.804,10.82,-14.767,10.827,-14.738,10.878,-14.744,10.905,-14.732,10.905,-14.73,10.927,-14.723,10.936,-14.722,10.947,-14.716,10.955,-14.703,10.952,-14.745,10.859,-14.715,10.854,-14.7,10.845,-14.69,10.868,-14.706,10.877,-14.704,10.885,-14.661,10.898,-14.658,10.894,-14.698,10.844,-14.745,10.852,-14.772,10.717,-14.739,10.697,-14.668,10.818,-14.615,10.811,-14.638,10.847,-14.638,10.86,-14.633,10.872,-14.624,10.871,-14.612,10.88,-14.607,10.88,-14.587,10.866,-14.579,10.851,-14.572,10.862,-14.557,10.864,-14.542,10.829,-14.515,10.882,-14.385,10.904,-14.371,10.87,-14.365,10.887,-14.36,10.892,-14.37,10.869,-14.516,10.874,-14.627,10.642,-14.617,10.596,-14.589,10.635,-14.586,10.598,-14.571,10.605,-14.539,10.604,-14.517,10.592,-14.521,10.577,-14.668,10.515,-14.653,10.458,-14.604,10.506,-14.563,10.522,-14.561,10.522,-14.558,10.519,-14.637,10.469,-14.459,10.337,-14.454,10.207,-14.281,10.241,-14.225,10.193,-14.221,10.208,-14.216,10.21,-14.212,10.206,-14.204,10.208,-14.202,10.227,-14.195,10.231,-14.204,10.243,-14.199,10.272,-14.187,10.221,-14.197,10.198,-14.156,10.206,-14.135,10.155,-14.151,10.205,-14.144,10.205,-14.146,10.193,-14.125,10.197,-14.08,10.175,-14.058,10.147,-14.01,10.2,-13.996,10.2,-14.012,10.264,-13.995,10.284,-13.981,10.279,-13.993,10.197,-14.046,10.124,-14.004,10.105,-13.974,10.059,-13.954,10.062,-13.953,10.086,-13.946,10.09,-13.932,10.088,-13.916,10.074,-13.973,10.053,-13.929,10.042,-13.909,10.016,-13.917,10.009,-13.932,10.009,-13.937,9.975,-13.95,9.965,-13.802,9.838,-13.714,9.878,-13.671,10.081,-13.627,9.907,-13.659,9.883,-13.637,9.861,-13.653,9.828,-13.589,9.846,-13.597,9.765,-13.521,9.828,-13.513,9.807,-13.722,9.511,-13.595,9.569,-13.576,9.6,-13.564,9.598,-13.566,9.604,-13.524,9.496,-13.489,9.556,-13.502,9.56,-13.506,9.566,-13.507,9.581,-13.497,9.592,-13.519,9.615,-13.521,9.621,-13.518,9.63,-13.473,9.572,-13.507,9.448,-13.412,9.409,-13.415,9.501,-13.408,9.525,-13.389,9.538,-13.394,9.557,-13.393,9.561,-13.388,9.561,-13.39,9.397,-13.43,9.266,-13.26,9.449,-13.289,9.299,-13.214,9.385,-13.175,9.373,-13.181,9.39,-13.183,9.406,-13.18,9.41,-13.171,9.413,-13.144,9.387,-13.139,9.397,-13.147,9.405,-13.148,9.417,-13.133,9.43,-13.128,9.43,-13.124,9.427,-13.121,9.415,-13.115,9.427,-13.106,9.433,-13.098,9.431,-13.095,9.44,-13.096,9.431,-13.12,9.414,-13.145,9.417,-13.139,9.397,-13.145,9.386,-13.179,9.41,-13.173,9.375,-13.177,9.369,-13.214,9.383,-13.271,9.292,-13.298,9.3,-13.315,9.326,-13.325,9.32,-13.317,9.157,-13.226,9.279,-13.141,9.301,-13.258,9.167,-13.124,9.205,-13.107,9.139,-13.263,9.14,-13.293,8.964,-13.031,8.933,-12.992,8.976,-12.958,8.993,-12.977,9.013,-12.977,9.049,-12.969,9.057,-12.958,9.061,-12.966,9.077,-12.943,9.094,-12.945,9.112,-12.923,9.131,-12.958,8.991,-13.029,8.928,-13.16,8.885,-13.129,8.852,-13.0,8.858,-12.984,8.855,-12.969,8.841,-12.959,8.842,-12.947,8.84,-12.848,8.905,-12.951,8.834,-12.962,8.834,-12.975,8.843,-12.991,8.845,-13.004,8.854,-13.089,8.839,-13.143,8.841,-13.175,8.85,-13.17,8.84,-13.206,8.858,-13.243,8.662,-13.148,8.516,-13.14,8.548,-13.109,8.604,-13.164,8.647,-13.142,8.675,-13.101,8.615,-13.069,8.68,-13.015,8.655,-13.095,8.604,-13.089,8.584,-13.068,8.582,-13.061,8.57,-13.026,8.62,-13.014,8.626,-13.006,8.625,-13.006,8.628,-12.937,8.593,-12.885,8.707,-12.872,8.722,-12.858,8.721,-12.896,8.638,-12.933,8.592,-12.955,8.577,-12.761,8.594,-12.952,8.497,-13.022,8.565,-13.042,8.534,-13.02,8.528,-13.023,8.514,-13.037,8.501,-13.013,8.478,-13.019,8.467,-13.04,8.5,-13.026,8.517,-13.05,8.528,-13.082,8.457,-13.139,8.451,-13.05,8.423,-13.039,8.37,-13.209,8.494,-13.297,8.499,-13.294,8.423,-13.161,8.286,-13.162,8.17,-13.075,8.233,-13.005,8.232,-12.974,8.266,-12.965,8.265,-12.954,8.25,-12.949,8.26,-12.945,8.264,-12.94,8.266,-12.934,8.263,-12.993,8.209,-12.831,8.086,-12.9,8.087,-12.887,7.959,-12.778,7.938,-12.96,7.914,-12.81,7.789,-12.801,7.852,-12.707,7.815,-12.794,7.8,-12.711,7.701,-12.626,7.694,-12.623,7.734,-12.597,7.741,-12.588,7.684,-12.488,7.778,-12.445,7.77,-12.46,7.784,-12.452,7.802,-12.49,7.823,-12.493,7.876,-12.488,7.824,-12.452,7.808,-12.449,7.799,-12.458,7.785,-12.43,7.773,-12.431,7.769,-12.438,7.769,-12.436,7.763,-12.391,7.805,-12.399,7.763,-12.448,7.75,-12.392,7.72,-12.466,7.721,-12.49,7.745,-12.517,7.693,-12.547,7.669,-12.457,7.686,-12.462,7.553,-12.359,7.626,-12.418,7.56,-12.382,7.542,-12.365,7.565,-12.364,7.557,-12.328,7.576,-12.302,7.578,-12.294,7.574,-12.49,7.449,-12.48,7.441,-12.475,7.433,-12.463,7.423,-12.413,7.429,-12.322,7.383,-12.524,7.384,-11.924,7.177,-11.597,6.977,-11.541,7.024,-11.546,7.141,-11.514,7.207,-11.542,7.141,-11.528,7.096,-11.54,7.066,-11.533,7.053,-11.545,7.008,-11.581,6.968,-11.614,6.982,-11.492,6.928,-11.445,6.939,-11.46,6.923,-11.492,6.922,-11.505,6.928,-11.364,6.767,-11.238,6.798,-11.179,6.714,-11.232,6.681,-11.333,6.724,-11.368,6.769,-11.36,6.706,-11.059,6.577,-11.078,6.591,-11.091,6.635,-11.067,6.657,-11.043,6.66,-11.068,6.585,-10.923,6.507,-10.801,6.383,-10.658,6.493,-10.813,6.348,-10.754,6.3,-10.804,6.331,-10.818,6.311,-10.694,6.234,-10.373,6.129,-10.486,6.207,-10.439,6.198,-10.368,6.152,-10.357,6.156,-10.357,6.179,-10.34,6.205,-10.359,6.103,-10.072,5.916,-10.078,5.939,-10.074,5.926,-10.044,5.972,-10.031,5.977,-10.06,5.856,-9.582,5.45,-9.561,5.482,-9.531,5.485,-9.531,5.508,-9.529,5.483,-9.594,5.433,-9.038,4.98,-8.25,4.573,-7.943,4.524,-7.73,4.365,6.62,0.0,-49.357,0.0,-49.586,0.089,-49.874,0.0,-49.772,0.126,-49.575,0.28,-49.581,0.309,-49.545,0.297,-49.574,0.388,-49.899,0.277,-50.036,0.316,-50.088,0.242,-50.127,0.225,-50.393,0.213,-50.344,0.037,-50.1,0.035,-50.051,0.019,-50.032,0.0,-50.435,0.0,-50.552,0.069,-50.591,0.022,-50.514,0.0,-50.516,-0.003,-51.059,0.0,-50.947,0.164,-50.782,0.19,-50.826,0.29,-50.742,0.239,-50.312,0.757,-50.302,0.941,-50.342,0.925,-50.356,0.932,-50.362,0.941,-50.368,0.93,-50.386,0.923,-50.408,0.938,-50.417,0.957,-50.415,0.972,-50.301,0.945,-50.274,0.868,-49.867,1.197,-50.134,1.187,-50.162,1.209,-50.14,1.326,-50.224,1.325,-50.292,1.178,-50.357,1.154,-50.434,1.182,-50.459,1.156,-50.453,1.117,-50.465,1.085,-50.49,1.059,-50.513,1.055,-50.459,1.165,-50.435,1.187,-50.404,1.193,-50.311,1.179,-50.227,1.347,-50.129,1.338,-50.117,1.268,-50.134,1.218,-49.988,1.235,-49.87,1.406,-49.957,1.723,-50.164,1.814,-50.46,1.816,-50.675,2.133,-50.697,2.136,-50.709,2.118,-50.758,2.104,-50.748,2.027,-50.757,2.013,-50.804,1.984,-50.753,2.116,-50.831,2.166,-50.681,2.173,-50.793,2.497,-50.86,2.473,-50.866,2.476,-50.872,2.496,-50.877,2.493,-50.879,2.475,-50.885,2.469,-50.892,2.47,-50.899,2.481,-50.868,2.498,-50.859,2.476,-50.831,2.509,-50.845,2.638,-50.977,2.805,-51.101,3.442,-51.076,3.892,-51.131,3.901,-51.185,3.724,-51.179,3.525,-51.191,3.404,-51.18,3.398,-51.173,3.338,-51.175,3.29,-51.193,3.403,-51.184,3.584,-51.202,3.667,-51.179,4.049,-51.398,4.367,-51.521,4.442,-51.572,4.35,-51.533,4.145,-51.454,3.98,-51.453,3.95,-51.586,4.242,-51.648,4.046,-51.704,4.007,-51.72,4.002,-51.744,3.999,-51.762,3.981,-51.798,3.882,-51.829,3.85,-51.838,3.85,-51.799,3.89,-51.76,3.999,-51.745,4.01,-51.721,4.018,-51.702,4.036,-51.695,4.038,-51.687,4.034,-51.66,4.055,-51.658,4.121,-51.634,4.197,-51.711,4.315,-51.807,4.62,-51.908,4.674,-51.923,4.438,-51.951,4.379,-51.987,4.354,-52.044,4.326,-52.083,4.322,-52.109,4.338,-52.14,4.28,-52.204,4.268,-52.217,4.238,-52.224,4.235,-52.232,4.237,-52.236,4.235,-52.246,4.235,-52.204,4.271,-52.14,4.282,-52.114,4.338,-51.95,4.408,-52.03,4.727,-52.227,4.856,-52.333,4.712,-52.258,4.912,-52.309,4.951,-52.387,4.87,-52.492,4.893,-52.356,4.917,-52.631,5.149,-52.677,5.103,-52.626,5.16,-52.859,5.383,-52.995,5.446,-52.958,5.408,-52.947,5.35,-52.948,5.348,-52.951,5.347,-52.96,5.354,-52.961,5.337,-53.018,5.458,-53.817,5.73,-53.84,5.734,-53.805,5.717,-53.778,5.696,-53.762,5.645,-53.766,5.627,-53.773,5.622,-53.757,5.62,-53.755,5.635,-53.748,5.638,-53.719,5.619,-53.716,5.615,-53.716,5.611,-53.724,5.604,-53.717,5.595,-53.72,5.583,-53.704,5.578,-53.699,5.574,-53.697,5.566,-53.701,5.555,-53.774,5.62,-53.787,5.699,-53.949,5.745,-53.915,5.646,-53.986,5.678,-54.007,5.523,-54.055,5.428,-54.187,5.309,-54.225,5.282,-54.273,5.262,-54.345,5.144,-54.356,5.135,-54.364,5.132,-54.366,5.129,-54.369,5.11,-54.397,5.098,-54.376,5.112,-54.367,5.141,-54.354,5.146,-54.35,5.164,-54.309,5.232,-54.282,5.266,-54.029,5.528,-54.032,5.668,-53.986,5.753,-54.035,5.84,-54.681,5.978,-55.153,5.964,-55.164,5.946,-55.042,5.894,-55.015,5.863,-54.976,5.88,-54.935,5.859,-54.922,5.876,-54.889,5.887,-54.856,5.859,-54.843,5.872,-54.823,5.863,-54.821,5.874,-54.811,5.877,-54.733,5.839,-54.813,5.876,-54.82,5.872,-54.821,5.863,-54.826,5.86,-54.842,5.871,-54.853,5.858,-54.866,5.863,-54.87,5.85,-54.892,5.885,-54.938,5.857,-55.014,5.86,-55.094,5.891,-55.099,5.828,-55.158,5.805,-55.09,5.715,-55.032,5.697,-55.025,5.686,-55.027,5.675,-55.097,5.712,-55.156,5.779,-55.163,5.819,-55.098,5.867,-55.141,5.905,-55.885,5.972,-55.936,5.848,-55.683,5.866,-55.637,5.862,-55.599,5.852,-55.567,5.868,-55.547,5.862,-55.603,5.85,-55.684,5.863,-55.918,5.832,-55.874,5.732,-55.898,5.663,-55.936,5.805,-56.964,6.014,-56.972,5.945,-57.081,5.924,-57.08,5.898,-57.087,5.868,-57.133,5.773,-57.133,5.682,-57.165,5.557,-57.185,5.518,-57.245,5.484,-57.26,5.449,-57.258,5.409,-57.263,5.387,-57.335,5.323,-57.299,5.331,-57.283,5.322,-57.279,5.301,-57.285,5.245,-57.274,5.226,-57.268,5.274,-57.254,5.282,-57.229,5.273,-57.275,5.222,-57.341,5.326,-57.273,5.392,-57.264,5.482,-57.163,5.633,-57.183,5.745,-57.135,5.893,-57.177,6.11,-57.48,6.342,-57.532,6.186,-57.627,6.449,-58.022,6.799,-58.165,6.828,-58.189,6.662,-58.214,6.591,-58.243,6.537,-58.312,6.476,-58.312,6.457,-58.293,6.435,-58.291,6.409,-58.312,6.333,-58.27,6.298,-58.267,6.291,-58.272,6.259,-58.299,6.212,-58.28,6.198,-58.258,6.14,-58.259,6.132,-58.266,6.114,-58.274,6.105,-58.302,6.211,-58.268,6.29,-58.313,6.333,-58.315,6.47,-58.177,6.814,-58.31,6.882,-58.424,6.863,-58.49,6.797,-58.509,6.75,-58.521,6.739,-58.536,6.709,-58.558,6.683,-58.594,6.432,-58.589,6.405,-58.595,6.374,-58.583,6.337,-58.586,6.328,-58.615,6.345,-58.62,6.41,-58.676,6.379,-58.711,6.387,-58.616,6.47,-58.622,6.616,-58.602,6.788,-58.555,6.926,-58.485,7.018,-58.48,7.329,-59.128,8.033,-59.814,8.395,-59.535,8.232,-59.451,8.157,-59.437,8.136,-59.404,8.141,-59.385,8.134,-59.371,8.121,-59.347,8.081,-59.326,8.079,-59.302,8.069,-59.246,8.019,-59.213,7.834,-59.239,7.65,-59.247,8.017,-59.441,8.135,-59.542,8.233,-59.776,8.353,-59.74,8.316,-59.743,8.212,-60.223,8.549,-60.472,8.523,-60.895,8.597,-60.861,8.533,-60.905,8.596,-60.949,8.575,-60.987,8.569,-60.988,8.513,-61.03,8.455,-61.04,8.454,-61.064,8.461,-61.073,8.402,-61.099,8.381,-61.067,8.46,-61.059,8.465,-61.032,8.459,-61.032,8.509,-61.243,8.452,-61.337,8.46,-61.426,8.474,-61.663,8.579,-61.642,8.609,-61.626,8.613,-61.481,8.572,-61.4,8.613,-61.215,8.591,-61.198,8.693,-61.105,8.872,-61.156,8.804,-61.22,8.801,-61.1,8.938,-61.118,8.925,-61.178,8.918,-61.194,8.887,-61.209,8.879,-61.21,8.867,-61.213,8.864,-61.223,8.862,-61.231,8.864,-60.942,9.183,-61.119,9.04,-61.091,9.084,-61.039,9.141,-61.123,9.099,-61.101,9.121,-61.12,9.108,-61.144,9.118,-61.181,9.1,-61.198,9.105,-61.2,9.089,-61.213,9.077,-61.25,9.094,-61.259,9.042,-61.262,9.039,-61.266,9.038,-61.3,9.043,-61.284,9.02,-61.284,9.013,-61.299,9.006,-61.349,9.008,-61.363,8.967,-61.391,8.946,-61.477,8.931,-61.239,9.106,-61.293,9.083,-61.305,9.083,-61.244,9.111,-61.238,9.107,-61.24,9.092,-61.212,9.081,-61.199,9.106,-61.147,9.12,-61.114,9.115,-61.104,9.123,-61.107,9.139,-61.095,9.154,-61.086,9.154,-61.068,9.145,-61.037,9.145,-60.786,9.316,-60.96,9.493,-60.94,9.462,-60.93,9.429,-60.94,9.306,-60.943,9.302,-60.952,9.302,-60.955,9.298,-60.959,9.257,-60.963,9.248,-60.968,9.242,-60.996,9.226,-61.059,9.206,-60.963,9.25,-60.955,9.301,-60.951,9.304,-60.943,9.303,-60.941,9.307,-60.932,9.425,-61.0,9.532,-60.967,10.34,-61.038,10.685,-60.681,11.19,-60.534,11.255,-60.52,11.337,-60.595,11.325,-59.42,13.147,-61.004,16.345,-61.795,17.701,-63.421,18.595,-64.335,18.749,-65.65,18.834,-65.667,18.362,-65.904,18.453,-66.109,18.428,-68.323,18.609,-68.918,19.027,-69.063,18.984,-69.413,19.103,-69.454,19.07,-69.627,19.083,-69.618,19.225,-69.406,19.184,-69.221,19.183,-69.15,19.288,-69.255,19.294,-69.216,19.36,-69.328,19.284,-69.742,19.282,-69.946,19.675,-70.269,19.642,-70.448,19.783,-70.631,19.763,-70.848,19.908,-71.024,19.931,-71.249,19.829,-71.654,19.899,-71.779,19.777,-71.71,19.694,-71.843,19.712,-71.81,19.674,-71.823,19.648,-71.902,19.68,-71.852,19.717,-72.052,19.69,-72.198,19.788,-72.319,19.77,-72.318,19.707,-72.381,19.816,-72.767,19.949,-73.165,19.93,-73.41,19.842,-73.36,19.834,-73.453,19.758,-73.416,19.649,-74.294,20.066,-74.145,20.255,-74.507,20.351,-74.738,20.622,-75.23,20.72,-75.319,20.72,-75.244,20.669,-75.322,20.655,-75.338,20.686,-75.374,20.699,-75.327,20.715,-75.429,20.746,-75.447,20.686,-75.58,20.691,-75.48,20.724,-75.521,20.792,-75.757,20.703,-75.783,20.746,-75.772,20.838,-75.551,20.795,-75.758,20.882,-75.656,20.881,-75.573,21.004,-75.716,21.126,-75.922,21.114,-76.04,21.066,-76.38,21.277,-76.515,21.281,-76.468,21.223,-76.588,21.194,-76.533,21.239,-76.548,21.29,-76.651,21.248,-76.739,21.281,-76.631,21.317,-76.658,21.356,-76.804,21.392,-76.834,21.3,-76.893,21.299,-76.814,21.393,-76.946,21.458,-76.959,21.436,-76.966,21.434,-76.971,21.423,-76.982,21.418,-76.984,21.408,-77.042,21.406,-76.951,21.47,-77.09,21.605,-77.146,21.482,-77.219,21.444,-77.28,21.518,-77.207,21.544,-77.366,21.614,-77.153,21.543,-77.106,21.605,-77.141,21.663,-77.441,21.806,-77.346,21.67,-77.42,21.648,-77.463,21.776,-77.743,21.797,-77.855,21.932,-77.79,21.948,-77.786,21.96,-77.747,21.942,-77.746,21.957,-77.625,21.86,-77.616,21.899,-77.592,21.924,-77.604,21.917,-77.614,21.951,-77.628,21.899,-77.661,21.915,-77.648,22.059,-77.772,22.034,-77.897,22.176,-77.82,22.234,-77.808,22.19,-77.744,22.168,-77.846,22.305,-77.874,22.317,-77.851,22.294,-77.843,22.251,-77.829,22.245,-77.825,22.24,-77.826,22.236,-77.925,22.195,-77.992,22.238,-77.978,22.294,-78.071,22.297,-78.089,22.427,-78.098,22.403,-78.279,22.417,-78.378,22.55,-78.715,22.572,-78.685,22.524,-78.702,22.495,-78.636,22.538,-78.655,22.484,-78.41,22.449,-78.428,22.402,-78.407,22.422,-78.302,22.443,-78.225,22.306,-78.126,22.295,-78.12,22.245,-78.118,22.298,-78.09,22.296,-78.021,22.214,-78.089,22.178,-77.892,22.004,-77.813,22.017,-77.804,21.95,-77.858,21.965,-77.873,21.859,-77.988,21.963,-78.057,22.073,-78.175,22.152,-78.303,22.144,-78.699,22.383,-78.717,22.301,-78.762,22.322,-78.777,22.389,-79.249,22.373,-79.367,22.424,-79.423,22.484,-79.194,22.528,-79.202,22.566,-79.184,22.582,-79.133,22.54,-79.057,22.521,-79.129,22.551,-79.106,22.632,-79.219,22.614,-79.192,22.575,-79.228,22.548,-79.423,22.49,-79.648,22.761,-79.854,22.796,-79.861,22.865,-79.78,22.877,-79.851,22.88,-79.816,22.901,-79.847,22.955,-79.904,22.841,-80.047,22.956,-80.263,22.901,-80.344,22.956,-80.247,22.935,-80.187,22.981,-80.217,22.995,-80.213,23.015,-80.221,22.986,-80.239,22.968,-80.258,22.981,-80.227,22.988,-80.247,23.031,-80.367,22.938,-80.412,22.942,-80.533,22.987,-80.623,23.081,-80.586,23.138,-80.608,23.148,-80.636,23.142,-80.657,23.079,-80.936,23.049,-80.998,23.115,-80.99,23.092,-81.077,23.087,-81.129,23.018,-81.232,23.121,-81.281,23.118,-81.119,23.186,-81.124,23.197,-81.143,23.205,-81.557,23.035,-81.585,23.149,-82.229,23.18,-82.762,22.981,-82.952,23.019,-82.923,22.982,-83.009,22.964,-83.0,24.583,-82.583,24.583,-81.808,24.557,-81.783,24.545,-81.685,24.558,-81.647,24.59,-81.51,24.626,-81.077,24.69,-80.4,25.106,-80.257,25.333,-80.416,25.198,-80.349,25.434,-80.338,25.35,-80.305,25.386,-80.307,25.612,-80.203,25.748,-80.153,25.671,-80.2,25.748,-80.17,25.867,-80.131,25.929,-80.121,25.9,-80.12,26.099,-80.08,26.258,-80.038,26.569,-80.032,26.701,-80.035,26.772,-80.038,26.609,-80.054,26.529,-80.121,27.059,-80.079,26.947,-80.071,26.946,-80.158,27.163,-80.14,27.106,-80.323,27.24,-80.187,27.176,-80.316,27.427,-80.389,27.685,-80.745,28.397,-80.85,28.798,-80.729,28.705,-80.774,28.612,-80.681,28.587,-80.659,28.624,-80.62,28.602,-80.759,28.527,-80.729,28.525,-80.716,28.356,-80.605,28.142,-80.685,28.363,-80.657,28.3,-80.621,28.594,-80.588,28.575,-80.647,28.357,-80.627,28.211,-80.511,27.936,-80.479,27.913,-80.362,27.67,-80.328,27.504,-80.305,27.496,-80.305,27.488,-80.323,27.473,-80.29,27.473,-80.38,27.733,-80.593,28.19,-80.632,28.409,-80.525,28.456,-80.575,28.585,-80.911,29.072,-80.636,28.657,-80.685,28.67,-80.767,28.758,-80.821,28.838,-80.918,29.018,-80.919,29.029,-80.907,29.038,-80.91,29.051,-80.989,29.073,-80.977,29.094,-80.95,29.098,-80.982,29.136,-81.085,29.353,-81.1,29.352,-81.15,29.517,-81.022,29.232,-80.948,29.095,-80.921,29.079,-81.307,29.829,-81.377,30.397,-81.449,30.372,-81.439,30.355,-81.436,30.322,-81.423,30.311,-81.419,30.263,-81.429,30.254,-81.479,30.392,-81.531,30.357,-81.62,30.39,-81.6,30.321,-81.631,30.311,-81.664,30.317,-81.622,30.218,-81.675,30.146,-81.657,30.13,-81.621,30.137,-81.596,30.128,-81.669,30.037,-81.555,29.969,-81.521,29.768,-81.562,29.705,-81.632,29.695,-81.591,29.636,-81.591,29.611,-81.632,29.577,-81.669,29.58,-81.682,29.594,-81.677,29.579,-81.678,29.571,-81.702,29.542,-81.687,29.534,-81.677,29.518,-81.665,29.456,-81.68,29.435,-81.647,29.428,-81.653,29.393,-81.594,29.369,-81.53,29.3,-81.569,29.203,-81.558,29.198,-81.553,29.189,-81.554,29.185,-81.547,29.185,-81.542,29.181,-81.538,29.182,-81.523,29.169,-81.502,29.121,-81.502,29.11,-81.471,29.117,-81.409,29.114,-81.405,29.085,-81.502,29.087,-81.681,29.326,-81.684,29.395,-81.656,29.42,-81.694,29.438,-81.68,29.52,-81.705,29.538,-81.704,29.559,-81.682,29.596,-81.599,29.609,-81.652,29.699,-81.557,29.773,-81.718,30.047,-81.699,30.147,-81.738,30.092,-81.766,30.116,-81.661,30.232,-81.699,30.282,-81.708,30.23,-81.732,30.27,-81.619,30.357,-81.72,30.419,-81.586,30.437,-81.558,30.387,-81.547,30.409,-81.53,30.424,-81.509,30.405,-81.515,30.443,-81.458,30.396,-81.458,30.434,-81.448,30.45,-81.46,30.472,-81.46,30.489,-81.468,30.493,-81.472,30.505,-81.462,30.524,-81.518,30.558,-81.496,30.54,-81.481,30.496,-81.488,30.488,-81.54,30.501,-81.589,30.547,-81.584,30.565,-81.59,30.565,-81.605,30.553,-81.611,30.554,-81.615,30.562,-81.603,30.568,-81.612,30.577,-81.605,30.585,-81.616,30.579,-81.625,30.586,-81.636,30.578,-81.656,30.571,-81.625,30.587,-81.605,30.586,-81.533,30.528,-81.525,30.564,-81.515,30.569,-81.496,30.565,-81.499,30.58,-81.496,30.589,-81.482,30.592,-81.509,30.627,-81.485,30.675,-81.496,30.669,-81.521,30.665,-81.511,30.657,-81.522,30.632,-81.525,30.639,-81.523,30.665,-81.482,30.679,-81.513,30.672,-81.526,30.683,-81.488,30.717,-81.519,30.696,-81.552,30.688,-81.5,30.72,-81.538,30.707,-81.596,30.717,-81.602,30.729,-81.606,30.717,-81.61,30.715,-81.624,30.723,-81.625,30.736,-81.63,30.728,-81.65,30.727,-81.655,30.732,-81.654,30.743,-81.662,30.742,-81.664,30.746,-81.652,30.743,-81.649,30.728,-81.625,30.737,-81.61,30.717,-81.602,30.731,-81.538,30.711,-81.479,30.822,-81.48,30.73,-81.453,30.713,-81.405,30.717,-81.461,30.759,-81.403,30.959,-81.463,30.91,-81.48,30.93,-81.499,30.922,-81.504,30.925,-81.535,30.986,-81.572,30.967,-81.643,30.995,-81.644,30.977,-81.657,30.969,-81.652,30.96,-81.653,30.956,-81.671,30.952,-81.683,30.955,-81.697,30.965,-81.704,30.975,-81.742,30.968,-81.774,30.983,-81.81,30.938,-81.825,30.947,-81.774,30.985,-81.704,30.976,-81.679,30.954,-81.654,30.957,-81.678,31.043,-81.461,30.999,-81.57,31.066,-81.582,31.115,-81.56,31.129,-81.571,31.095,-81.495,31.061,-81.484,31.038,-81.464,31.043,-81.508,31.075,-81.535,31.107,-81.531,31.11,-81.492,31.103,-81.437,31.036,-81.435,31.084,-81.461,31.088,-81.573,31.156,-81.559,31.216,-81.585,31.207,-81.623,31.205,-81.631,31.207,-81.55,31.235,-81.452,31.11,-81.436,31.134,-81.447,31.142,-81.447,31.165,-81.463,31.178,-81.446,31.23,-81.387,31.28,-81.407,31.309,-81.418,31.304,-81.437,31.313,-81.437,31.331,-81.455,31.326,-81.47,31.339,-81.47,31.333,-81.474,31.332,-81.484,31.355,-81.492,31.347,-81.521,31.356,-81.553,31.377,-81.548,31.385,-81.559,31.387,-81.562,31.391,-81.546,31.386,-81.55,31.376,-81.486,31.379,-81.482,31.377,-81.482,31.363,-81.473,31.374,-81.467,31.373,-81.439,31.355,-81.436,31.341,-81.332,31.333,-81.328,31.346,-81.338,31.359,-81.338,31.372,-81.354,31.36,-81.358,31.362,-81.359,31.367,-81.374,31.358,-81.409,31.359,-81.416,31.347,-81.429,31.355,-81.434,31.363,-81.417,31.348,-81.409,31.36,-81.373,31.361,-81.355,31.388,-81.334,31.387,-81.348,31.402,-81.367,31.399,-81.378,31.407,-81.361,31.506,-81.134,31.62,-81.13,31.696,-81.206,31.682,-81.223,31.681,-81.232,31.688,-81.238,31.683,-81.235,31.689,-81.218,31.688,-81.205,31.684,-81.189,31.702,-81.271,31.73,-81.256,31.747,-81.277,31.759,-81.278,31.773,-81.274,31.789,-81.31,31.788,-81.318,31.803,-81.337,31.798,-81.316,31.815,-81.329,31.822,-81.333,31.84,-81.345,31.837,-81.335,31.837,-81.332,31.833,-81.339,31.817,-81.348,31.814,-81.355,31.814,-81.357,31.815,-81.356,31.819,-81.346,31.82,-81.359,31.831,-81.36,31.836,-81.363,31.837,-81.357,31.842,-81.345,31.822,-81.347,31.817,-81.356,31.818,-81.355,31.815,-81.34,31.818,-81.347,31.836,-81.34,31.841,-81.335,31.841,-81.331,31.84,-81.328,31.824,-81.315,31.82,-81.313,31.815,-81.305,31.812,-81.303,31.808,-81.304,31.821,-81.293,31.824,-81.16,31.728,-81.176,31.755,-81.175,31.772,-81.204,31.773,-81.202,31.791,-81.145,31.854,-81.225,31.891,-81.194,31.92,-81.215,31.913,-81.23,31.884,-81.239,31.886,-81.244,31.892,-81.236,31.909,-81.258,31.904,-81.265,31.907,-81.268,31.914,-81.259,31.925,-81.274,31.925,-81.289,31.944,-81.288,31.975,-81.311,31.981,-81.311,32.004,-81.232,31.908,-81.242,31.894,-81.238,31.888,-81.228,31.887,-81.224,31.908,-81.198,31.923,-81.189,31.918,-81.193,31.908,-81.221,31.891,-81.176,31.904,-81.158,31.892,-81.147,31.862,-81.09,31.86,-81.143,31.884,-81.154,31.914,-81.149,31.918,-81.166,31.925,-81.165,31.934,-81.157,31.937,-81.149,31.932,-81.127,31.898,-81.13,31.921,-81.1,31.895,-81.137,31.938,-81.135,31.95,-81.074,31.922,-81.099,31.941,-81.099,31.944,-81.077,31.934,-81.08,31.943,-81.056,31.981,-81.021,31.994,-81.049,32.003,-81.049,32.031,-81.029,32.063,-81.002,32.081,-81.079,32.078,-81.095,32.084,-81.12,32.104,-81.143,32.134,-81.144,32.149,-81.154,32.154,-81.156,32.158,-81.159,32.195,-81.153,32.223,-81.152,32.155,-81.143,32.177,-81.134,32.124,-81.095,32.085,-81.045,32.083,-81.095,32.1,-81.117,32.114,-81.124,32.141,-81.113,32.15,-81.119,32.152,-81.123,32.156,-81.122,32.162,-81.111,32.151,-81.115,32.117,-81.093,32.103,-81.001,32.103,-80.962,32.075,-80.897,32.104,-80.902,32.11,-80.923,32.116,-80.93,32.121,-80.927,32.126,-80.91,32.129,-80.911,32.135,-80.924,32.129,-80.942,32.136,-80.947,32.129,-80.944,32.136,-80.898,32.159,-80.877,32.152,-80.879,32.132,-80.853,32.152,-80.854,32.163,-80.864,32.163,-80.866,32.167,-80.845,32.21,-80.887,32.18,-80.872,32.228,-80.906,32.211,-80.921,32.218,-80.92,32.223,-80.906,32.212,-80.869,32.232,-80.791,32.203,-80.804,32.226,-80.788,32.255,-80.76,32.277,-80.922,32.305,-80.896,32.345,-80.797,32.328,-80.881,32.39,-80.818,32.427,-80.903,32.561,-80.84,32.538,-80.841,32.612,-80.812,32.494,-80.782,32.587,-80.565,32.504,-80.57,32.525,-80.658,32.567,-80.67,32.579,-80.673,32.585,-80.671,32.595,-80.654,32.57,-80.645,32.573,-80.629,32.576,-80.615,32.574,-80.608,32.577,-80.603,32.575,-80.578,32.548,-80.558,32.511,-80.52,32.515,-80.583,32.563,-80.585,32.571,-80.576,32.579,-80.58,32.584,-80.6,32.58,-80.61,32.59,-80.618,32.582,-80.624,32.581,-80.652,32.599,-80.677,32.627,-80.675,32.634,-80.66,32.635,-80.66,32.642,-80.682,32.642,-80.685,32.661,-80.67,32.671,-80.707,32.663,-80.717,32.676,-80.67,32.671,-80.657,32.636,-80.674,32.625,-80.579,32.585,-80.575,32.578,-80.583,32.568,-80.564,32.566,-80.567,32.552,-80.54,32.559,-80.545,32.575,-80.542,32.577,-80.533,32.574,-80.54,32.591,-80.533,32.597,-80.54,32.598,-80.54,32.605,-80.536,32.609,-80.501,32.513,-80.486,32.535,-80.446,32.532,-80.5,32.563,-80.505,32.604,-80.463,32.628,-80.468,32.628,-80.473,32.631,-80.473,32.634,-80.467,32.641,-80.474,32.638,-80.479,32.641,-80.479,32.644,-80.475,32.648,-80.476,32.65,-80.484,32.646,-80.497,32.651,-80.496,32.655,-80.488,32.66,-80.497,32.656,-80.502,32.659,-80.501,32.662,-80.496,32.667,-80.504,32.671,-80.511,32.684,-80.509,32.693,-80.528,32.703,-80.525,32.712,-80.556,32.723,-80.558,32.73,-80.532,32.719,-80.508,32.693,-80.487,32.659,-80.495,32.651,-80.477,32.651,-80.465,32.641,-80.472,32.633,-80.461,32.628,-80.467,32.605,-80.505,32.603,-80.497,32.563,-80.439,32.532,-80.388,32.615,-80.419,32.603,-80.428,32.61,-80.442,32.727,-80.419,32.605,-80.333,32.643,-80.278,32.629,-80.348,32.686,-80.256,32.653,-80.249,32.683,-80.24,32.69,-80.222,32.69,-80.231,32.704,-80.23,32.71,-80.219,32.7,-80.202,32.719,-80.181,32.717,-80.17,32.74,-80.164,32.748,-80.158,32.751,-80.145,32.748,-80.138,32.748,-80.133,32.771,-80.119,32.786,-80.088,32.793,-80.002,32.766,-80.008,32.729,-79.987,32.718,-79.985,32.694,-80.013,32.639,-79.887,32.683,-79.879,32.75,-79.986,32.765,-79.969,32.827,-80.017,32.826,-80.054,32.849,-80.072,32.872,-79.97,32.836,-79.924,32.774,-79.968,32.883,-79.949,33.022,-79.937,32.969,-79.913,32.976,-79.925,32.984,-79.926,32.993,-79.901,33.006,-79.921,33.008,-79.938,33.053,-79.935,33.057,-79.927,33.056,-79.924,33.061,-79.924,33.064,-79.931,33.063,-79.938,33.066,-79.927,33.079,-79.978,33.113,-79.996,33.149,-79.993,33.159,-79.982,33.167,-79.969,33.193,-79.969,33.186,-79.993,33.15,-79.975,33.14,-79.943,33.104,-79.941,33.093,-79.925,33.085,-79.925,33.079,-79.93,33.07,-79.857,33.076,-79.894,33.061,-79.916,33.062,-79.92,33.068,-79.924,33.056,-79.936,33.054,-79.899,33.007,-79.923,32.821,-79.891,32.901,-79.863,32.875,-79.835,32.926,-79.781,32.945,-79.738,32.914,-79.894,32.858,-79.86,32.767,-79.757,32.809,-79.795,32.816,-79.753,32.821,-79.761,32.857,-79.727,32.854,-79.715,32.879,-79.676,32.903,-79.652,32.945,-79.623,32.981,-79.582,33.022,-79.559,33.04,-79.39,33.109,-79.544,33.036,-79.423,33.016,-79.429,33.024,-79.422,33.03,-79.43,33.031,-79.406,33.04,-79.443,33.064,-79.276,33.122,-79.283,33.138,-79.351,33.152,-79.401,33.181,-79.426,33.206,-79.351,33.155,-79.333,33.157,-79.316,33.148,-79.292,33.149,-79.271,33.141,-79.265,33.135,-79.264,33.129,-79.271,33.119,-79.246,33.126,-79.265,33.163,-79.29,33.163,-79.324,33.183,-79.335,33.171,-79.348,33.172,-79.351,33.178,-79.344,33.18,-79.362,33.192,-79.361,33.2,-79.373,33.198,-79.397,33.211,-79.405,33.218,-79.406,33.225,-79.416,33.229,-79.421,33.236,-79.475,33.229,-79.475,33.232,-79.47,33.237,-79.479,33.234,-79.484,33.235,-79.48,33.245,-79.487,33.238,-79.493,33.236,-79.528,33.245,-79.561,33.261,-79.563,33.272,-79.606,33.278,-79.642,33.296,-79.419,33.236,-79.361,33.201,-79.343,33.181,-79.344,33.173,-79.325,33.184,-79.291,33.174,-79.273,33.195,-79.24,33.17,-79.24,33.135,-79.141,33.189,-79.19,33.191,-79.279,33.274,-79.285,33.358,-79.307,33.353,-79.332,33.358,-79.344,33.362,-79.348,33.367,-79.359,33.361,-79.391,33.36,-79.392,33.357,-79.399,33.354,-79.403,33.357,-79.38,33.365,-79.346,33.368,-79.305,33.355,-79.289,33.368,-79.278,33.352,-79.251,33.414,-79.236,33.422,-79.291,33.48,-79.285,33.492,-79.297,33.496,-79.299,33.508,-79.303,33.507,-79.303,33.498,-79.309,33.491,-79.317,33.488,-79.327,33.49,-79.328,33.493,-79.324,33.504,-79.334,33.507,-79.341,33.512,-79.358,33.546,-79.368,33.531,-79.377,33.529,-79.383,33.53,-79.389,33.534,-79.387,33.545,-79.359,33.548,-79.323,33.504,-79.326,33.491,-79.302,33.51,-79.298,33.507,-79.297,33.505,-79.297,33.497,-79.284,33.492,-79.286,33.476,-79.26,33.469,-79.234,33.424,-79.245,33.398,-79.223,33.427,-79.215,33.432,-79.241,33.398,-79.259,33.391,-79.262,33.361,-79.212,33.425,-79.182,33.439,-79.275,33.312,-79.196,33.296,-79.182,33.211,-79.171,33.205,-79.153,33.203,-79.188,33.332,-79.088,33.478,-78.823,33.737,-78.548,33.842,-78.608,33.871,-78.371,33.919,-78.171,33.93,-78.142,33.925,-78.173,33.928,-78.199,33.916,-78.235,33.915,-78.015,33.889,-78.112,33.922,-78.104,33.933,-78.018,33.916,-77.976,33.972,-77.954,33.975,-78.003,34.272,-77.951,34.282,-77.961,34.288,-77.954,34.295,-77.961,34.302,-77.955,34.318,-77.995,34.339,-77.988,34.371,-77.962,34.383,-77.958,34.38,-77.96,34.371,-77.94,34.37,-77.936,34.359,-77.933,34.368,-77.93,34.367,-77.93,34.362,-77.918,34.376,-77.902,34.378,-77.883,34.365,-77.822,34.427,-77.81,34.406,-77.887,34.363,-77.935,34.357,-77.959,34.368,-77.962,34.371,-77.962,34.382,-77.987,34.37,-77.953,34.317,-77.912,34.053,-77.799,34.275,-77.606,34.433,-77.553,34.43,-77.537,34.46,-77.468,34.498,-77.371,34.52,-77.453,34.604,-77.371,34.639,-77.433,34.685,-77.444,34.764,-77.351,34.747,-77.398,34.717,-77.319,34.637,-77.339,34.637,-77.338,34.626,-77.4,34.597,-77.404,34.584,-77.318,34.546,-77.216,34.651,-77.152,34.662,-77.188,34.7,-77.117,34.687,-77.15,34.769,-77.095,34.673,-76.905,34.732,-76.695,34.715,-76.768,34.756,-76.687,34.772,-76.691,34.776,-76.693,34.877,-76.685,34.899,-76.665,34.912,-76.661,34.94,-76.762,34.86,-76.812,34.939,-76.915,34.903,-77.037,35.099,-77.058,35.07,-77.142,35.062,-77.035,35.104,-77.097,35.138,-77.124,35.211,-76.804,34.964,-76.567,35.101,-76.566,35.11,-76.581,35.114,-76.582,35.12,-76.559,35.114,-76.537,35.151,-76.57,35.173,-76.613,35.121,-76.744,35.141,-76.59,35.241,-76.495,35.218,-76.482,35.311,-76.601,35.331,-76.59,35.268,-76.658,35.28,-76.624,35.341,-76.713,35.35,-76.775,35.296,-76.789,35.322,-76.693,35.362,-76.858,35.369,-76.825,35.409,-76.926,35.449,-76.964,35.402,-77.113,35.559,-76.841,35.449,-76.803,35.513,-76.806,35.439,-76.576,35.388,-76.599,35.482,-76.679,35.484,-76.72,35.5,-76.725,35.519,-76.628,35.52,-76.692,35.553,-76.65,35.577,-76.486,35.537,-76.472,35.557,-76.519,35.577,-76.519,35.585,-76.439,35.555,-76.447,35.499,-76.587,35.508,-76.498,35.46,-76.565,35.45,-76.532,35.401,-76.47,35.373,-76.487,35.402,-76.483,35.409,-76.4,35.459,-76.403,35.349,-76.352,35.413,-76.158,35.327,-75.99,35.517,-75.882,35.575,-75.891,35.644,-75.837,35.571,-75.782,35.577,-75.762,35.585,-75.728,35.825,-75.783,35.933,-75.86,35.977,-75.81,35.922,-75.908,35.932,-75.852,35.894,-75.862,35.868,-75.986,35.889,-76.034,35.646,-76.166,35.696,-76.04,35.677,-76.123,35.778,-76.055,35.769,-76.062,35.85,-76.096,35.865,-76.101,35.871,-76.101,35.885,-76.014,35.924,-76.068,35.907,-76.128,35.945,-76.07,35.922,-76.017,35.946,-76.063,35.99,-76.287,35.966,-76.315,35.946,-76.255,35.919,-76.257,35.898,-76.265,35.887,-76.274,35.881,-76.4,35.982,-76.694,35.943,-76.688,35.919,-76.717,35.92,-76.737,35.88,-76.745,35.871,-76.753,35.867,-76.774,35.862,-76.833,35.884,-76.837,35.874,-76.832,35.864,-76.833,35.852,-76.864,35.823,-76.833,35.886,-76.757,35.867,-76.725,35.918,-76.701,35.926,-76.733,35.937,-76.728,35.916,-76.738,35.902,-76.755,35.89,-76.79,35.912,-76.682,35.991,-76.755,36.15,-76.698,36.273,-76.778,36.36,-76.826,36.363,-76.907,36.388,-76.933,36.4,-76.945,36.41,-76.949,36.415,-76.948,36.467,-76.951,36.471,-76.909,36.527,-76.944,36.412,-76.776,36.363,-76.681,36.3,-76.675,36.044,-76.514,36.006,-76.409,36.067,-76.488,36.086,-76.48,36.095,-76.305,36.094,-76.38,36.122,-76.464,36.184,-76.472,36.193,-76.478,36.217,-76.237,36.099,-76.193,36.106,-76.284,36.178,-76.277,36.193,-76.29,36.212,-76.285,36.217,-76.188,36.125,-76.062,36.146,-76.218,36.298,-76.231,36.372,-76.217,36.299,-75.914,36.165,-75.989,36.202,-75.95,36.285,-75.994,36.293,-75.972,36.303,-76.003,36.306,-76.022,36.333,-76.018,36.344,-76.032,36.338,-76.036,36.354,-75.965,36.323,-75.796,36.071,-75.924,36.425,-75.945,36.362,-76.04,36.502,-76.081,36.499,-76.148,36.535,-76.154,36.54,-76.158,36.557,-76.153,36.57,-76.166,36.568,-76.151,36.571,-76.147,36.535,-76.026,36.516,-76.062,36.595,-76.078,36.704,-75.988,36.494,-75.902,36.487,-75.993,36.566,-75.943,36.722,-75.915,36.665,-75.912,36.621,-75.883,36.564,-75.899,36.465,-75.884,36.473,-75.871,36.474,-75.843,36.422,-75.849,36.375,-75.785,36.249,-75.812,36.266,-75.811,36.246,-75.804,36.25,-75.774,36.227,-75.745,36.139,-75.737,36.042,-75.698,36.059,-75.539,35.784,-75.796,36.284,-75.882,36.608,-75.995,36.922,-76.091,36.908,-76.057,36.877,-76.057,36.839,-76.325,36.963,-76.259,36.892,-76.331,36.875,-76.228,36.856,-76.197,36.827,-76.402,36.808,-76.338,36.88,-76.385,36.925,-76.562,36.788,-76.486,36.956,-76.621,36.983,-76.576,37.019,-76.668,37.052,-76.685,37.196,-76.737,37.146,-77.024,37.203,-77.009,37.303,-77.063,37.26,-77.355,37.301,-77.269,37.346,-77.305,37.358,-77.32,37.379,-77.368,37.357,-77.387,37.358,-77.391,37.361,-77.36,37.375,-77.385,37.385,-77.401,37.423,-77.42,37.421,-77.428,37.429,-77.428,37.436,-77.42,37.448,-77.424,37.496,-77.42,37.521,-77.492,37.531,-77.532,37.56,-77.56,37.556,-77.582,37.559,-77.602,37.555,-77.648,37.559,-77.695,37.584,-77.526,37.563,-77.426,37.528,-77.417,37.518,-77.418,37.451,-77.426,37.428,-77.299,37.406,-77.295,37.394,-77.306,37.371,-77.292,37.366,-77.307,37.367,-77.298,37.358,-77.274,37.355,-77.28,37.372,-77.268,37.383,-77.25,37.383,-77.243,37.367,-77.262,37.321,-77.073,37.327,-77.073,37.273,-77.016,37.312,-76.754,37.19,-76.643,37.217,-76.42,36.96,-76.296,37.017,-76.276,37.097,-76.364,37.051,-76.38,37.109,-76.294,37.126,-76.447,37.136,-76.394,37.2,-76.635,37.297,-76.82,37.55,-76.848,37.526,-76.863,37.522,-76.874,37.526,-76.879,37.536,-76.855,37.572,-76.872,37.579,-76.874,37.55,-76.894,37.544,-76.903,37.546,-76.919,37.568,-76.944,37.537,-76.948,37.535,-76.958,37.535,-76.976,37.543,-76.97,37.56,-77.002,37.554,-77.026,37.572,-77.022,37.582,-77.083,37.597,-77.092,37.606,-77.094,37.618,-76.966,37.586,-76.956,37.574,-76.952,37.556,-76.974,37.542,-76.918,37.571,-76.894,37.546,-76.876,37.555,-76.884,37.575,-76.868,37.583,-76.856,37.579,-76.853,37.569,-76.876,37.531,-76.821,37.554,-76.792,37.527,-76.779,37.551,-76.801,37.574,-76.795,37.587,-76.824,37.591,-76.84,37.616,-76.853,37.613,-76.866,37.616,-76.871,37.619,-76.87,37.624,-76.857,37.629,-76.858,37.638,-76.913,37.657,-76.917,37.694,-76.928,37.688,-76.966,37.689,-77.037,37.721,-77.051,37.733,-77.06,37.745,-77.059,37.75,-76.916,37.695,-76.912,37.657,-76.879,37.658,-76.794,37.589,-76.505,37.246,-76.376,37.282,-76.486,37.311,-76.403,37.337,-76.445,37.365,-76.445,37.353,-76.449,37.36,-76.478,37.362,-76.486,37.368,-76.409,37.37,-76.459,37.416,-76.453,37.449,-76.355,37.369,-76.367,37.429,-76.354,37.449,-76.337,37.363,-76.278,37.31,-76.252,37.436,-76.363,37.523,-76.421,37.507,-76.469,37.513,-76.464,37.526,-76.506,37.531,-76.504,37.54,-76.545,37.557,-76.548,37.567,-76.423,37.513,-76.299,37.561,-76.59,37.621,-76.6,37.721,-76.929,37.983,-76.929,38.072,-77.036,38.092,-77.057,38.11,-77.062,38.161,-77.073,38.134,-77.116,38.135,-77.253,38.188,-77.255,38.197,-77.243,38.211,-77.251,38.224,-77.252,38.229,-77.228,38.243,-77.271,38.249,-77.277,38.226,-77.28,38.224,-77.291,38.223,-77.294,38.224,-77.295,38.227,-77.293,38.241,-77.311,38.234,-77.319,38.243,-77.292,38.242,-77.293,38.224,-77.273,38.25,-77.228,38.245,-77.225,38.236,-77.251,38.229,-77.241,38.212,-77.253,38.189,-77.059,38.161,-76.928,38.076,-76.726,37.836,-76.598,37.81,-76.511,37.643,-76.527,37.754,-76.483,37.764,-76.477,37.704,-76.433,37.733,-76.429,37.727,-76.417,37.734,-76.41,37.733,-76.408,37.736,-76.406,37.731,-76.467,37.698,-76.454,37.65,-76.437,37.671,-76.427,37.67,-76.361,37.609,-76.28,37.615,-76.307,37.641,-76.32,37.627,-76.352,37.627,-76.362,37.635,-76.307,37.821,-76.334,37.847,-76.366,37.833,-76.441,37.879,-76.283,37.857,-76.278,37.812,-76.238,37.888,-76.287,37.871,-76.376,37.956,-76.381,37.92,-76.395,37.921,-76.443,37.987,-76.489,37.936,-76.464,38.005,-76.587,38.007,-76.521,38.046,-76.612,38.148,-76.648,38.094,-76.701,38.16,-76.701,38.066,-76.776,38.167,-77.004,38.204,-76.958,38.243,-77.073,38.285,-77.042,38.4,-77.237,38.331,-77.362,38.346,-77.287,38.353,-77.375,38.44,-77.311,38.397,-77.257,38.684,-77.131,38.635,-77.189,38.682,-77.047,38.714,-77.039,38.868,-77.05,38.871,-77.059,38.881,-77.064,38.887,-77.069,38.9,-77.09,38.904,-77.103,38.912,-77.147,38.964,-77.201,38.967,-77.235,38.976,-77.245,38.983,-77.256,39.002,-77.248,39.026,-77.429,39.068,-77.46,39.082,-77.485,39.11,-77.521,39.121,-77.525,39.128,-77.528,39.144,-77.516,39.171,-77.505,39.182,-77.479,39.189,-77.474,39.208,-77.458,39.225,-77.537,39.264,-77.561,39.286,-77.565,39.305,-77.616,39.303,-77.727,39.322,-77.782,39.259,-77.815,39.255,-77.728,39.323,-77.756,39.334,-77.761,39.34,-77.748,39.417,-77.758,39.427,-77.79,39.43,-77.8,39.433,-77.803,39.437,-77.798,39.479,-77.79,39.485,-77.769,39.49,-77.766,39.495,-77.802,39.489,-77.845,39.498,-77.848,39.501,-77.846,39.504,-77.825,39.517,-77.83,39.532,-77.842,39.518,-77.859,39.514,-77.866,39.517,-77.889,39.557,-77.838,39.606,-77.944,39.584,-77.997,39.601,-77.936,39.62,-77.887,39.599,-77.887,39.616,-77.837,39.608,-77.829,39.601,-77.887,39.555,-77.824,39.529,-77.824,39.516,-77.844,39.499,-77.765,39.497,-77.768,39.489,-77.797,39.478,-77.802,39.437,-77.756,39.427,-77.747,39.417,-77.736,39.399,-77.757,39.338,-77.565,39.308,-77.544,39.273,-77.529,39.269,-77.519,39.261,-77.488,39.253,-77.456,39.228,-77.512,39.122,-77.482,39.111,-77.449,39.081,-77.334,39.068,-77.317,39.059,-77.297,39.056,-77.251,39.036,-77.238,39.023,-77.244,38.983,-77.149,38.969,-77.099,38.912,-77.057,38.899,-77.023,38.857,-76.962,38.896,-77.027,38.825,-77.001,38.694,-77.101,38.685,-77.099,38.633,-77.21,38.567,-77.16,38.59,-77.132,38.597,-77.125,38.59,-77.263,38.511,-77.25,38.382,-77.023,38.499,-76.925,38.291,-76.834,38.273,-76.856,38.358,-76.927,38.387,-76.936,38.413,-76.933,38.417,-76.805,38.358,-76.778,38.228,-76.725,38.312,-76.719,38.233,-76.627,38.284,-76.687,38.234,-76.473,38.103,-76.526,38.168,-76.465,38.129,-76.442,38.191,-76.475,38.211,-76.469,38.219,-76.409,38.163,-76.441,38.15,-76.421,38.106,-76.321,38.038,-76.374,38.299,-76.453,38.287,-76.703,38.498,-76.715,38.782,-76.645,38.469,-76.507,38.389,-76.488,38.401,-76.493,38.423,-76.482,38.442,-76.421,38.319,-76.381,38.385,-76.506,38.505,-76.569,38.768,-76.49,38.889,-76.588,38.936,-76.606,38.977,-76.469,38.908,-76.451,38.941,-76.617,39.08,-76.454,38.975,-76.395,39.012,-76.533,39.087,-76.535,39.107,-76.504,39.078,-76.447,39.103,-76.424,39.087,-76.431,39.132,-76.549,39.147,-76.553,39.217,-76.573,39.219,-76.573,39.177,-76.599,39.154,-76.609,39.182,-76.559,39.234,-76.614,39.242,-76.631,39.269,-76.481,39.272,-76.441,39.195,-76.398,39.237,-76.451,39.244,-76.5,39.306,-76.401,39.248,-76.44,39.321,-76.338,39.304,-76.415,39.383,-76.348,39.396,-76.282,39.3,-76.28,39.448,-76.22,39.475,-76.254,39.377,-76.227,39.351,-76.102,39.435,-76.084,39.546,-76.484,39.959,-76.532,40.049,-76.573,40.046,-76.666,40.064,-76.911,40.267,-76.912,40.324,-76.933,40.356,-76.994,40.357,-77.02,40.364,-77.036,40.472,-77.071,40.476,-77.094,40.491,-77.109,40.471,-77.115,40.469,-77.121,40.47,-77.132,40.482,-77.135,40.49,-77.135,40.52,-77.174,40.563,-77.327,40.522,-77.334,40.523,-77.344,40.534,-77.365,40.529,-77.381,40.533,-77.403,40.556,-77.409,40.588,-77.429,40.601,-77.41,40.592,-77.402,40.583,-77.38,40.534,-77.344,40.535,-77.329,40.524,-77.206,40.568,-77.169,40.563,-77.033,40.472,-77.012,40.401,-76.983,40.445,-76.954,40.461,-76.949,40.484,-76.985,40.517,-76.992,40.568,-76.959,40.592,-76.952,40.625,-76.914,40.665,-76.867,40.7,-76.854,40.796,-76.842,40.82,-76.819,40.842,-76.81,40.848,-76.799,40.877,-76.853,40.914,-76.877,40.953,-76.898,41.134,-76.888,41.153,-76.877,41.164,-76.803,41.204,-76.992,41.242,-76.824,41.23,-76.804,41.211,-76.802,41.195,-76.807,41.188,-76.895,41.13,-76.795,40.884,-76.765,40.905,-76.679,40.94,-76.648,40.965,-76.474,40.951,-76.467,40.959,-76.469,40.972,-76.462,40.983,-76.4,41.008,-76.306,41.039,-76.256,41.04,-76.225,41.058,-76.215,41.062,-76.186,41.065,-76.149,41.067,-76.129,41.098,-76.136,41.11,-76.147,41.143,-76.146,41.155,-76.139,41.163,-76.124,41.172,-76.092,41.183,-76.078,41.197,-76.025,41.221,-75.938,41.24,-76.135,41.164,-76.146,41.147,-76.138,41.133,-76.128,41.106,-76.128,41.079,-76.145,41.066,-76.473,40.95,-76.646,40.963,-76.684,40.933,-76.766,40.899,-76.846,40.705,-76.978,40.568,-76.965,40.541,-76.972,40.517,-76.939,40.492,-76.935,40.47,-76.943,40.457,-76.977,40.437,-76.981,40.415,-76.993,40.402,-77.02,40.388,-76.927,40.362,-76.901,40.328,-76.89,40.263,-76.731,40.185,-76.653,40.071,-76.529,40.055,-76.492,40.022,-76.46,39.974,-76.461,39.952,-76.383,39.923,-76.367,39.898,-76.374,39.873,-76.255,39.782,-76.203,39.687,-76.159,39.656,-76.145,39.625,-76.082,39.575,-76.074,39.55,-75.947,39.596,-76.01,39.449,-75.85,39.585,-75.878,39.524,-75.864,39.532,-75.564,39.562,-75.614,39.62,-75.534,39.733,-75.335,39.853,-75.196,39.885,-75.215,39.897,-75.217,39.909,-75.207,39.942,-75.18,39.956,-75.206,39.983,-75.192,40.004,-75.179,39.956,-75.214,39.903,-75.144,39.89,-75.129,39.966,-74.811,40.137,-74.726,40.148,-74.725,40.162,-74.756,40.186,-74.774,40.215,-74.843,40.249,-74.883,40.308,-74.862,40.289,-74.842,40.251,-74.799,40.233,-74.771,40.216,-74.729,40.173,-74.719,40.159,-74.721,40.147,-74.74,40.133,-74.821,40.126,-74.979,40.043,-74.955,40.045,-74.945,40.043,-74.931,40.029,-74.922,40.03,-74.921,40.025,-74.889,40.009,-74.881,40.0,-75.041,40.01,-75.13,39.956,-75.129,39.879,-75.38,39.815,-75.572,39.627,-75.502,39.57,-75.48,39.642,-75.405,39.617,-75.541,39.465,-75.366,39.347,-75.367,39.357,-75.356,39.361,-75.357,39.369,-75.349,39.371,-75.356,39.378,-75.348,39.384,-75.333,39.379,-75.335,39.386,-75.333,39.388,-75.309,39.39,-75.307,39.383,-75.297,39.383,-75.294,39.375,-75.286,39.383,-75.278,39.379,-75.276,39.385,-75.273,39.387,-75.228,39.382,-75.234,39.405,-75.232,39.421,-75.227,39.381,-75.293,39.374,-75.333,39.386,-75.33,39.381,-75.333,39.377,-75.354,39.378,-75.346,39.372,-75.363,39.346,-75.326,39.342,-75.288,39.291,-75.287,39.303,-75.277,39.308,-75.282,39.314,-75.281,39.327,-75.272,39.343,-75.138,39.181,-75.105,39.216,-75.116,39.222,-75.11,39.227,-75.114,39.228,-75.115,39.231,-75.111,39.235,-75.029,39.219,-75.036,39.229,-74.995,39.276,-74.981,39.274,-74.993,39.283,-74.991,39.287,-74.98,39.297,-74.992,39.293,-74.985,39.299,-74.992,39.3,-74.992,39.306,-74.997,39.306,-75.001,39.308,-75.031,39.354,-75.038,39.366,-75.037,39.379,-74.979,39.298,-74.99,39.281,-74.977,39.28,-75.028,39.194,-74.93,39.201,-74.885,39.171,-74.972,38.937,-74.865,38.936,-74.908,38.947,-74.749,39.149,-74.711,39.122,-74.553,39.287,-74.627,39.253,-74.676,39.289,-74.648,39.301,-74.702,39.333,-74.723,39.368,-74.722,39.439,-74.651,39.309,-74.518,39.386,-74.445,39.362,-74.51,39.342,-74.537,39.303,-74.408,39.364,-74.491,39.399,-74.444,39.458,-74.392,39.443,-74.419,39.554,-74.439,39.538,-74.492,39.552,-74.541,39.575,-74.554,39.596,-74.576,39.598,-74.586,39.605,-74.591,39.612,-74.629,39.619,-74.633,39.622,-74.634,39.627,-74.554,39.601,-74.481,39.558,-74.476,39.562,-74.48,39.567,-74.486,39.579,-74.487,39.584,-74.484,39.594,-74.49,39.599,-74.5,39.623,-74.504,39.624,-74.505,39.632,-74.51,39.635,-74.504,39.637,-74.481,39.6,-74.486,39.583,-74.474,39.563,-74.476,39.555,-74.418,39.562,-74.309,39.538,-74.307,39.51,-74.298,39.538,-74.341,39.564,-74.337,39.58,-74.262,39.605,-74.255,39.647,-74.218,39.645,-74.184,39.706,-74.16,39.704,-74.106,39.931,-74.135,39.928,-74.196,39.948,-74.116,39.944,-74.134,40.03,-74.058,40.049,-74.103,40.047,-74.131,40.065,-74.049,40.066,-74.083,40.0,-74.07,39.986,-74.079,39.947,-74.082,39.942,-74.093,39.943,-74.098,39.784,-74.121,39.796,-74.097,39.765,-74.034,40.101,-74.051,40.101,-74.065,40.089,-74.077,40.09,-74.097,40.104,-74.1,40.124,-74.034,40.104,-73.972,40.327,-73.974,40.405,-73.984,40.45,-73.999,40.471,-74.019,40.472,-73.981,40.414,-73.975,40.364,-73.977,40.342,-73.997,40.336,-73.984,40.325,-74.046,40.317,-73.977,40.369,-74.085,40.342,-73.981,40.386,-74.05,40.456,-74.209,40.436,-74.292,40.505,-74.308,40.504,-74.323,40.487,-74.364,40.471,-74.433,40.488,-74.463,40.508,-74.365,40.472,-74.306,40.511,-74.267,40.499,-74.253,40.552,-74.215,40.564,-74.202,40.634,-74.12,40.705,-74.161,40.735,-74.104,40.721,-74.113,40.761,-74.064,40.806,-74.035,40.81,-74.037,40.826,-74.03,40.85,-73.994,40.874,-74.033,40.809,-74.088,40.779,-74.074,40.745,-74.145,40.645,-74.07,40.66,-73.927,40.918,-73.906,41.142,-73.976,41.229,-73.986,41.322,-73.95,41.395,-73.965,41.402,-73.986,41.438,-74.016,41.454,-73.974,41.917,-73.911,42.157,-73.846,42.237,-73.989,41.504,-73.944,41.392,-73.982,41.319,-73.927,41.305,-73.966,41.25,-73.867,41.137,-73.909,40.871,-73.934,40.834,-73.923,40.802,-73.928,40.781,-73.871,40.814,-73.848,40.804,-73.84,40.819,-73.791,40.804,-73.814,40.824,-73.812,40.855,-73.823,40.87,-73.821,40.884,-73.587,41.049,-73.189,41.186,-73.103,41.151,-73.125,41.174,-73.116,41.218,-73.088,41.278,-73.066,41.297,-73.116,41.332,-73.125,41.347,-73.065,41.298,-73.109,41.172,-72.887,41.321,-72.894,41.242,-72.828,41.265,-72.757,41.269,-72.344,41.269,-72.373,41.273,-72.386,41.38,-72.424,41.391,-72.477,41.468,-72.552,41.502,-72.559,41.518,-72.555,41.548,-72.641,41.558,-72.652,41.571,-72.668,41.771,-72.624,41.855,-72.626,41.932,-72.605,41.988,-72.609,41.995,-72.611,42.044,-72.585,42.081,-72.623,42.117,-72.63,42.17,-72.627,42.183,-72.619,42.188,-72.581,42.083,-72.646,41.566,-72.558,41.555,-72.476,41.49,-72.357,41.38,-72.375,41.358,-72.342,41.33,-72.331,41.281,-72.205,41.286,-72.194,41.368,-72.095,41.31,-72.112,41.403,-72.083,41.524,-72.065,41.315,-71.968,41.351,-71.859,41.323,-71.832,41.342,-71.839,41.358,-71.838,41.366,-71.834,41.369,-71.828,41.339,-71.886,41.306,-71.48,41.36,-71.402,41.615,-71.458,41.697,-71.356,41.718,-71.394,41.733,-71.408,41.824,-71.384,41.872,-71.36,41.746,-71.302,41.706,-71.282,41.77,-71.274,41.734,-71.289,41.737,-71.299,41.649,-71.115,41.83,-71.069,41.793,-71.208,41.653,-71.195,41.456,-71.073,41.569,-71.058,41.559,-71.031,41.503,-71.089,41.51,-71.037,41.481,-71.003,41.563,-70.954,41.514,-70.917,41.678,-70.854,41.582,-70.866,41.634,-70.765,41.641,-70.764,41.714,-70.716,41.676,-70.756,41.741,-70.742,41.759,-70.675,41.692,-70.641,41.724,-70.672,41.74,-70.659,41.754,-70.637,41.734,-70.624,41.764,-70.625,41.738,-70.555,41.775,-70.496,41.778,-70.537,41.928,-70.711,42.002,-70.648,42.056,-70.626,42.023,-70.613,42.014,-70.639,41.993,-70.599,42.005,-70.768,42.259,-70.883,42.31,-70.857,42.265,-70.962,42.243,-71.022,42.288,-70.966,42.313,-70.954,42.331,-71.046,42.276,-71.01,42.339,-71.052,42.369,-71.088,42.352,-71.111,42.352,-71.118,42.356,-71.118,42.368,-71.125,42.369,-71.129,42.374,-71.049,42.372,-71.094,42.4,-71.074,42.417,-70.953,42.344,-71.011,42.43,-70.909,42.415,-70.831,42.503,-70.888,42.498,-70.926,42.56,-70.661,42.579,-70.59,42.641,-70.823,42.682,-70.867,42.76,-70.771,42.704,-70.805,42.816,-71.016,42.794,-70.803,42.818,-70.843,42.915,-70.711,43.042,-70.858,43.122,-70.834,43.044,-70.927,43.04,-70.865,43.09,-70.915,43.132,-70.827,43.119,-70.846,43.19,-70.816,43.213,-70.82,43.126,-70.665,43.076,-70.634,43.128,-70.646,43.127,-70.652,43.136,-70.676,43.136,-70.68,43.142,-70.687,43.138,-70.691,43.139,-70.709,43.159,-70.623,43.134,-70.562,43.322,-70.333,43.447,-70.37,43.438,-70.447,43.493,-70.197,43.565,-70.31,43.659,-70.096,43.843,-69.948,43.862,-70.043,43.748,-70.024,43.763,-70.015,43.74,-69.937,43.861,-69.861,43.935,-69.855,43.704,-69.779,43.744,-69.831,43.866,-69.81,43.925,-69.833,43.935,-69.839,43.961,-69.83,43.979,-69.917,43.912,-69.933,43.911,-69.965,43.92,-69.895,44.008,-69.823,44.005,-69.79,44.288,-69.676,44.505,-69.61,44.575,-69.571,44.562,-69.787,44.286,-69.755,44.142,-69.776,44.044,-69.831,43.983,-69.76,43.888,-69.606,44.065,-69.587,44.009,-69.658,43.995,-69.649,43.993,-69.673,43.933,-69.649,43.935,-69.662,43.879,-69.682,43.871,-69.591,43.861,-69.605,43.813,-69.573,43.852,-69.602,43.909,-69.591,43.953,-69.538,44.034,-69.581,43.887,-69.558,43.862,-69.557,43.902,-69.544,43.921,-69.51,43.904,-69.515,43.832,-69.409,44.043,-69.37,44.035,-69.394,44.066,-69.377,44.094,-69.341,44.043,-69.368,43.966,-69.285,44.015,-69.307,43.954,-69.186,44.074,-69.165,44.069,-69.261,43.917,-69.191,44.002,-69.123,44.007,-69.114,44.035,-69.137,44.033,-69.126,44.051,-69.148,44.072,-69.144,44.079,-69.073,44.046,-69.108,44.11,-68.948,44.353,-69.033,44.452,-68.811,44.467,-68.802,44.574,-68.772,44.567,-68.765,44.561,-68.761,44.536,-68.746,44.568,-68.742,44.552,-68.821,44.382,-68.727,44.471,-68.665,44.37,-68.759,44.425,-68.756,44.362,-68.798,44.379,-68.788,44.371,-68.828,44.312,-68.75,44.349,-68.61,44.306,-68.524,44.228,-68.584,44.412,-68.482,44.456,-68.467,44.378,-68.458,44.453,-68.504,44.493,-68.423,44.537,-68.428,44.396,-68.354,44.495,-68.346,44.454,-68.27,44.458,-68.321,44.537,-68.224,44.466,-68.294,44.565,-68.246,44.589,-68.218,44.581,-68.252,44.54,-68.103,44.474,-68.059,44.333,-67.959,44.401,-67.991,44.503,-67.968,44.504,-67.938,44.41,-67.935,44.479,-67.905,44.492,-67.898,44.394,-67.894,44.552,-67.854,44.527,-67.848,44.564,-67.812,44.543,-67.854,44.573,-67.851,44.593,-67.784,44.604,-67.805,44.543,-67.787,44.52,-67.75,44.615,-67.727,44.62,-67.743,44.497,-67.706,44.499,-67.716,44.518,-67.659,44.571,-67.572,44.529,-67.568,44.66,-67.464,44.625,-67.478,44.608,-67.459,44.596,-67.429,44.613,-67.452,44.667,-67.431,44.666,-67.406,44.593,-67.367,44.652,-67.455,44.716,-67.384,44.72,-67.282,44.623,-67.189,44.644,-67.101,44.502,-66.194,44.09,-66.12,43.733,-66.079,43.807,-66.065,43.704,-66.061,43.744,-66.046,43.748,-65.982,43.691,-66.015,43.82,-65.975,43.88,-65.993,43.785,-65.943,43.743,-65.962,43.799,-65.93,43.836,-65.867,43.75,-65.883,43.789,-65.863,43.804,-65.82,43.711,-65.804,43.591,-65.793,43.683,-65.775,43.697,-65.731,43.492,-65.575,43.567,-65.471,43.449,-65.483,43.617,-65.34,43.535,-65.377,43.746,-65.236,43.657,-65.24,43.807,-65.127,43.653,-65.092,43.75,-65.07,43.686,-65.0,43.73,-65.048,43.83,-64.961,43.745,-64.976,43.867,-64.834,43.813,-64.782,43.864,-64.855,43.918,-64.83,43.952,-64.661,43.989,-64.664,44.022,-64.724,44.038,-64.736,44.044,-64.762,44.065,-64.553,44.069,-64.614,44.155,-64.553,44.16,-64.493,44.139,-64.351,44.287,-64.504,44.371,-64.245,44.261,-64.302,44.296,-64.256,44.323,-64.337,44.322,-64.342,44.365,-64.209,44.377,-64.254,44.397,-64.265,44.38,-64.29,44.376,-64.312,44.383,-64.33,44.397,-64.28,44.403,-64.269,44.392,-64.252,44.411,-64.331,44.406,-64.381,44.453,-64.304,44.466,-64.327,44.467,-64.329,44.478,-64.305,44.568,-64.168,44.586,-64.077,44.47,-64.048,44.523,-64.002,44.52,-64.065,44.636,-63.89,44.691,-63.936,44.635,-63.924,44.646,-63.916,44.645,-63.91,44.641,-63.938,44.505,-63.79,44.536,-63.787,44.524,-63.815,44.481,-63.765,44.525,-63.729,44.45,-63.736,44.505,-63.628,44.475,-63.648,44.432,-63.518,44.502,-63.618,44.634,-63.569,44.648,-63.658,44.682,-63.662,44.728,-63.452,44.589,-63.453,44.682,-63.367,44.689,-63.282,44.628,-63.285,44.655,-63.243,44.681,-63.262,44.72,-63.253,44.736,-63.219,44.708,-63.208,44.674,-63.155,44.78,-63.149,44.687,-63.17,44.681,-63.161,44.678,-63.133,44.692,-63.103,44.681,-63.093,44.685,-63.095,44.691,-63.141,44.693,-63.088,44.714,-63.113,44.713,-63.13,44.736,-63.135,44.791,-63.049,44.673,-63.005,44.722,-63.073,44.774,-63.038,44.776,-63.07,44.785,-63.083,44.792,-63.083,44.797,-63.008,44.778,-63.0,44.697,-62.798,44.721,-62.881,44.816,-62.838,44.811,-62.802,44.774,-62.706,44.805,-62.658,44.772,-62.675,44.807,-62.619,44.846,-62.544,44.787,-62.57,44.868,-62.489,44.891,-62.536,44.913,-62.541,44.924,-62.477,44.893,-62.486,44.844,-62.446,44.822,-62.473,44.849,-62.453,44.875,-62.255,44.971,-61.981,44.976,-61.997,44.995,-62.099,44.999,-62.103,45.011,-61.997,45.03,-61.946,45.01,-61.98,45.071,-61.885,45.026,-61.947,45.074,-61.999,45.143,-61.854,45.036,-61.843,45.107,-61.654,45.093,-61.845,45.262,-61.47,45.148,-61.547,45.231,-61.411,45.161,-61.276,45.266,-61.209,45.261,-61.212,45.226,-61.191,45.209,-61.186,45.255,-61.155,45.265,-61.165,45.272,-61.173,45.291,-61.174,45.304,-61.167,45.315,-61.114,45.282,-61.161,45.221,-61.128,45.208,-61.056,45.299,-61.006,45.294,-60.979,45.273,-60.988,45.292,-60.971,45.307,-60.985,45.307,-60.99,45.316,-60.959,45.321,-60.995,45.339,-60.746,45.579,-60.627,45.602,-60.661,45.645,-60.425,45.642,-55.543,46.942,-55.382,46.874,-55.232,46.938,-55.216,47.09,-55.174,47.007,-55.121,47.088,-55.076,47.078,-55.091,47.166,-55.224,47.112,-55.242,47.121,-55.244,47.168,-55.099,47.202,-55.037,47.161,-55.063,47.225,-54.84,47.433,-54.837,47.375,-54.785,47.433,-54.829,47.364,-54.653,47.44,-54.685,47.381,-54.631,47.388,-54.407,47.616,-54.613,47.361,-54.405,47.448,-54.478,47.493,-54.192,47.845,-54.268,47.917,-54.099,47.82,-54.081,47.88,-54.06,47.797,-53.99,47.844,-54.03,47.75,-53.972,47.773,-53.896,47.588,-53.937,47.452,-53.788,47.441,-53.93,47.383,-53.876,47.351,-53.952,47.322,-53.873,47.305,-54.006,47.316,-53.994,47.253,-53.934,47.269,-53.843,47.268,-54.016,47.238,-54.197,46.822,-53.96,46.849,-53.556,47.218,-53.553,47.127,-53.501,47.13,-53.464,47.164,-53.415,47.173,-53.656,46.987,-53.512,46.978,-53.661,46.789,-53.63,46.783,-53.568,46.88,-53.463,46.926,-53.628,46.785,-53.622,46.64,-53.532,46.613,-53.353,46.765,-53.219,46.635,-53.072,46.658,-52.89,46.939,-52.973,47.01,-52.885,47.022,-52.851,47.016,-52.911,47.055,-52.86,47.047,-52.852,47.074,-52.953,47.093,-52.868,47.1,-52.831,47.277,-52.766,47.288,-52.814,47.316,-52.746,47.31,-52.658,47.436,-52.704,47.471,-43.912,59.778,-43.784,59.88,-43.8,59.799,-43.762,59.848,-43.653,59.843,-43.793,59.953,-43.693,59.934,-43.674,59.994,-43.658,59.934,-43.553,59.912,-43.473,60.041,-43.624,60.063,-43.582,60.072,-43.12,60.003,-43.675,60.119,-43.623,60.184,-43.587,60.123,-43.085,60.088,-43.32,60.213,-43.086,60.228,-43.196,60.256,-43.111,60.317,-43.604,60.304,-43.446,60.368,-43.51,60.379,-43.502,60.387,-43.269,60.434,-43.673,60.481,-43.624,60.506,-43.71,60.52,-43.793,60.517,-43.755,60.458,-43.809,60.439,-43.834,60.534,-44.199,60.592,-43.625,60.533,-43.721,60.623,-43.703,60.715,-43.596,60.699,-43.672,60.687,-43.58,60.528,-43.226,60.453,-43.126,60.47,-43.325,60.546,-43.189,60.55,-43.159,60.507,-43.064,60.491,-43.148,60.535,-42.981,60.53,-42.831,60.567,-43.149,60.593,-42.828,60.592,-42.755,60.681,-42.913,60.608,-43.29,60.667,-42.946,60.636,-42.745,60.718,-42.791,60.746,-43.259,60.768,-43.563,60.842,-42.797,60.79,-43.479,60.911,-42.956,60.878,-42.981,60.884,-42.721,60.932,-42.812,61.009,-42.693,60.969,-42.654,61.02,-42.767,61.041,-42.733,61.053,-42.705,61.048,-42.696,61.053,-42.79,61.061,-42.82,61.038,-42.932,61.069,-43.01,61.03,-42.967,61.077,-43.641,61.121,-42.634,61.085,-43.166,61.202,-42.559,61.185,-42.651,61.25,-42.904,61.228,-42.898,61.254,-42.75,61.267,-43.233,61.328,-42.638,61.28,-42.708,61.306,-42.394,61.393,-42.478,61.426,-42.396,61.487,-42.489,61.458,-42.484,61.421,-42.522,61.431,-42.507,61.446,-42.556,61.442,-42.54,61.447,-42.593,61.457,-42.524,61.523,-42.943,61.508,-43.062,61.591,-42.442,61.544,-42.485,61.62,-42.375,61.594,-42.442,61.631,-42.313,61.624,-42.401,61.666,-42.352,61.683,-42.262,61.68,-42.324,61.696,-42.326,61.706,-42.225,61.701,-42.314,61.715,-42.409,61.674,-42.613,61.69,-42.874,61.757,-42.769,61.811,-42.595,61.726,-42.354,61.745,-42.193,61.858,-42.44,61.903,-42.219,61.902,-42.108,61.996,-42.529,61.93,-42.404,62.012,-42.541,62.032,-42.286,62.099,-42.344,62.129,-42.242,62.227,-42.325,62.238,-42.329,62.255,-42.256,62.29,-42.628,62.32,-43.019,62.504,-42.712,62.491,-42.324,62.35,-42.143,62.378,-42.282,62.373,-42.243,62.416,-42.39,62.396,-42.557,62.465,-42.451,62.458,-42.547,62.531,-42.412,62.453,-42.317,62.463,-42.36,62.508,-42.371,62.556,-43.122,62.682,-43.139,62.746,-42.684,62.667,-42.709,62.785,-42.633,62.673,-42.565,62.692,-42.602,62.758,-42.494,62.691,-42.582,62.825,-42.499,62.767,-42.412,62.822,-42.607,62.853,-42.434,62.859,-42.438,62.911,-42.274,62.942,-42.379,62.887,-42.223,62.867,-42.366,62.851,-42.347,62.801,-42.237,62.814,-42.046,62.774,-41.984,62.791,-42.043,62.869,-41.94,62.898,-41.995,62.837,-41.894,62.832,-41.834,62.806,-41.753,62.835,-41.817,62.86,-41.751,62.866,-41.775,62.926,-41.602,62.932,-41.82,63.015,-41.95,62.985,-42.18,63.017,-42.013,63.029,-42.238,63.1,-42.143,63.127,-42.169,63.21,-41.972,63.057,-41.992,63.103,-41.54,63.033,-42.115,63.26,-41.444,63.107,-41.555,63.079,-41.507,63.044,-41.315,63.056,-41.502,63.148,-41.516,63.173,-41.491,63.213,-41.638,63.246,-41.571,63.251,-41.617,63.293,-41.834,63.332,-41.693,63.335,-41.846,63.381,-41.933,63.469,-41.751,63.558,-41.562,63.427,-41.43,63.42,-41.133,63.3,-41.158,63.341,-41.598,63.486,-41.495,63.468,-41.501,63.532,-41.245,63.397,-41.451,63.555,-41.43,63.562,-41.025,63.325,-41.117,63.391,-41.015,63.419,-41.137,63.424,-41.307,63.556,-41.082,63.433,-40.988,63.447,-41.158,63.516,-40.756,63.503,-40.808,63.537,-40.742,63.582,-40.824,63.559,-40.911,63.579,-40.818,63.585,-40.844,63.585,-40.873,63.613,-40.881,63.65,-41.589,63.739,-41.668,63.811,-41.457,63.87,-41.39,63.78,-41.015,63.696,-40.51,63.693,-40.647,63.791,-40.583,63.823,-40.617,63.919,-40.69,63.901,-40.84,63.939,-40.696,63.995,-40.61,63.96,-40.649,63.996,-40.54,64.064,-40.588,64.112,-40.888,64.166,-41.0,64.073,-41.08,64.142,-41.436,64.159,-41.472,64.217,-41.394,64.211,-41.551,64.26,-41.345,64.347,-41.108,64.3,-40.694,64.311,-40.926,64.333,-40.734,64.386,-40.649,64.368,-40.635,64.395,-40.534,64.358,-40.576,64.349,-40.544,64.318,-40.469,64.363,-40.436,64.333,-40.342,64.351,-40.624,64.455,-40.553,64.492,-40.587,64.668,-40.993,64.818,-40.887,64.835,-40.869,64.866,-41.187,64.961,-40.967,64.966,-41.138,65.144,-40.826,65.063,-40.858,65.111,-40.763,65.109,-40.674,65.032,-40.641,65.188,-40.221,64.991,-40.184,65.064,-39.935,65.085,-40.044,65.142,-40.06,65.175,-39.846,65.163,-39.976,65.22,-39.685,65.245,-39.893,65.367,-39.802,65.379,-39.817,65.395,-39.794,65.407,-39.807,65.424,-39.797,65.427,-39.707,65.414,-40.093,65.488,-39.911,65.488,-39.985,65.546,-39.934,65.571,-39.705,65.501,-39.712,65.561,-39.615,65.548,-39.727,65.58,-39.528,65.611,-39.614,65.657,-39.567,65.674,-39.415,65.654,-39.455,65.611,-39.372,65.532,-39.249,65.625,-39.275,65.535,-39.226,65.604,-39.126,65.574,-39.268,65.742,-38.87,65.513,-38.906,65.566,-38.768,65.614,-38.528,65.568,-38.667,65.619,-38.682,65.697,-38.522,65.668,-38.525,65.638,-38.501,65.634,-38.508,65.628,-38.482,65.623,-38.474,65.612,-38.456,65.709,-38.359,65.664,-38.434,65.646,-38.418,65.589,-38.358,65.617,-38.4,65.644,-38.356,65.661,-38.184,65.626,-38.129,65.665,-38.197,65.692,-38.094,65.721,-38.157,65.762,-38.039,65.791,-38.261,65.805,-38.212,65.836,-38.417,65.905,-38.431,65.991,-38.243,65.887,-38.104,65.898,-38.154,65.933,-38.154,65.983,-37.962,65.925,-37.975,66.029,-37.922,66.071,-38.043,66.067,-38.104,66.088,-37.819,66.169,-37.985,66.229,-37.794,66.243,-37.78,66.196,-37.677,66.257,-38.075,66.337,-37.845,66.36,-37.84,66.421,-37.573,66.308,-36.818,66.398,-37.309,66.277,-37.613,66.053,-37.779,66.026,-37.721,66.013,-37.768,65.966,-37.637,65.983,-37.779,65.888,-37.662,65.941,-37.193,65.753,-37.296,65.835,-37.132,65.785,-37.079,65.857,-37.138,65.854,-37.425,65.945,-37.453,66.013,-37.396,65.953,-37.279,65.902,-37.141,65.874,-37.175,65.944,-37.105,65.964,-37.255,66.105,-37.097,65.972,-37.03,66.071,-37.088,65.935,-36.892,65.873,-37.016,65.853,-37.008,65.827,-36.855,65.862,-36.806,65.908,-36.901,65.922,-36.477,65.998,-36.647,66.102,-36.326,66.074,-36.403,66.017,-36.383,66.012,-36.379,65.905,-36.258,66.011,-36.272,65.943,-36.21,65.987,-36.243,65.931,-36.128,65.964,-36.096,65.922,-35.913,66.011,-35.876,66.06,-35.978,66.07,-35.927,66.094,-35.731,66.082,-36.007,66.106,-35.699,66.125,-35.78,66.207,-35.57,66.14,-35.72,66.218,-35.552,66.256,-35.865,66.264,-35.674,66.321,-35.882,66.448,-35.717,66.452,-35.802,66.42,-35.6,66.292,-35.472,66.329,-35.211,66.245,-35.059,66.266,-35.257,66.314,-35.149,66.412,-35.166,66.331,-35.019,66.289,-34.911,66.36,-34.769,66.333,-34.776,66.398,-34.686,66.366,-34.72,66.435,-34.441,66.529,-34.558,66.637,-34.445,66.672,-34.445,66.742,-34.395,66.585,-34.277,66.678,-34.331,66.57,-34.056,66.731,-34.131,66.812,-33.948,66.759,-34.058,66.816,-34.039,66.877,-34.099,66.878,-33.885,66.968,-34.097,67.01,-33.765,66.987,-33.93,67.017,-33.704,67.101,-33.56,67.087,-33.599,67.099,-33.56,67.201,-33.673,67.242,-33.368,67.254,-33.52,67.277,-33.457,67.313,-33.548,67.328,-33.454,67.332,-33.637,67.369,-33.355,67.37,-33.313,67.408,-33.434,67.444,-33.311,67.478,-33.413,67.556,-33.09,67.637,-33.346,67.732,-32.949,67.685,-32.547,67.873,-32.173,67.845,-32.213,67.933,-32.037,67.928,-32.158,68.04,-32.018,68.043,-32.069,68.131,-31.987,68.142,-32.486,68.2,-32.109,68.192,-32.248,68.353,-32.811,68.544,-32.53,68.557,-32.562,68.638,-32.418,68.58,-32.362,68.437,-32.186,68.498,-32.214,68.408,-32.048,68.277,-31.519,68.259,-31.769,68.211,-31.685,68.192,-31.765,68.145,-31.601,68.083,-31.43,68.097,-31.518,68.165,-31.333,68.168,-31.479,68.146,-30.913,68.075,-31.18,68.199,-30.999,68.216,-30.877,68.098,-30.455,68.094,-30.51,68.203,-30.859,68.279,-30.762,68.306,-30.407,68.258,-30.359,68.12,-30.044,68.147,-30.314,68.282,-29.991,68.276,-30.09,68.323,-30.036,68.414,-29.857,68.434,-29.626,68.259,-23.017,66.384,-23.194,66.357,-23.152,66.328,-22.606,66.368,-22.709,66.3,-22.506,66.333,-22.456,66.309,-22.569,66.269,-22.362,66.271,-22.977,66.226,-22.396,66.084,-22.431,66.035,-22.483,66.032,-22.338,65.909,-22.515,65.797,-22.58,65.785,-22.426,65.878,-22.414,65.907,-22.42,65.931,-22.522,65.983,-22.611,65.846,-22.662,65.825,-22.569,65.979,-22.679,66.055,-22.772,66.049,-22.843,65.89,-22.833,66.005,-22.986,65.908,-22.893,65.969,-22.863,66.038,-23.089,65.963,-22.998,66.087,-23.161,66.046,-23.149,66.141,-23.475,66.198,-23.609,66.148,-23.477,66.134,-23.368,66.084,-23.668,66.116,-23.423,66.03,-23.36,65.983,-23.758,66.072,-23.813,66.008,-23.18,65.838,-23.868,65.887,-23.706,65.78,-23.171,65.776,-23.531,65.72,-23.258,65.678,-23.547,65.605,-23.601,65.698,-24.095,65.81,-24.061,65.698,-23.816,65.626,-23.775,65.596,-24.077,65.641,-23.769,65.534,-24.316,65.644,-24.534,65.504,-23.728,65.414,-23.406,65.52,-23.21,65.489,-23.138,65.588,-22.904,65.563,-22.85,65.63,-22.771,65.602,-22.872,65.532,-22.785,65.531,-22.723,65.496,-22.769,65.634,-22.66,65.64,-22.711,65.589,-22.643,65.521,-22.619,65.624,-22.534,65.549,-22.544,65.645,-22.475,65.559,-22.471,65.505,-22.086,65.601,-22.391,65.489,-22.275,65.451,-22.276,65.439,-22.105,65.532,-21.916,65.467,-21.96,65.439,-21.681,65.453,-22.605,65.186,-22.514,65.189,-22.501,65.201,-22.445,65.188,-22.447,65.185,-22.454,65.184,-22.474,65.187,-22.47,65.182,-22.512,65.168,-22.55,65.18,-22.563,65.167,-22.475,65.161,-22.453,65.153,-22.456,65.147,-22.399,65.172,-22.065,65.109,-21.744,65.204,-21.71,65.159,-21.832,65.071,-21.78,65.059,-21.781,65.025,-22.53,65.052,-22.621,65.009,-22.584,64.971,-22.745,65.019,-22.666,65.048,-22.766,65.05,-22.729,65.079,-22.822,65.035,-22.874,65.031,-22.758,65.032,-22.778,65.016,-22.915,64.986,-23.022,65.004,-23.061,64.968,-22.989,64.951,-23.013,64.919,-23.125,64.921,-23.161,64.946,-23.082,65.004,-23.185,65.014,-23.223,64.924,-23.357,64.973,-23.629,64.885,-24.044,64.886,-23.801,64.728,-23.375,64.832,-22.642,64.77,-22.52,64.81,-22.381,64.822,-22.444,64.763,-22.309,64.749,-22.414,64.669,-22.381,64.684,-22.312,64.705,-22.323,64.652,-22.45,64.661,-22.339,64.621,-22.362,64.583,-22.352,64.562,-22.231,64.587,-22.206,64.533,-22.224,64.524,-22.234,64.533,-22.255,64.529,-22.236,64.528,-22.239,64.519,-22.236,64.518,-22.225,64.524,-22.228,64.5,-22.243,64.497,-22.243,64.483,-22.22,64.506,-22.159,64.459,-22.007,64.576,-22.023,64.52,-21.617,64.694,-21.697,64.626,-21.584,64.665,-21.48,64.667,-21.962,64.498,-21.989,64.444,-22.039,64.405,-21.859,64.376,-21.968,64.367,-21.982,64.373,-22.002,64.35,-22.096,64.311,-21.641,64.403,-21.354,64.382,-21.732,64.343,-21.908,64.233,-21.705,64.178,-21.795,64.128,-21.867,64.157,-21.916,64.146,-21.938,64.162,-21.974,64.15,-22.012,64.163,-22.035,64.156,-21.901,64.119,-22.026,64.112,-21.958,64.069,-22.013,64.05,-22.533,63.974,-22.556,64.03,-22.704,64.087,-22.757,63.964,-22.635,63.939,-22.74,63.893,-22.706,63.799,-21.382,63.844,-21.212,63.877,-21.312,63.914,-21.238,63.939,-21.258,63.928,-21.198,63.941,-21.17,63.942,-21.139,63.936,-21.12,63.94,-21.023,63.943,-21.002,63.94,-20.974,64.004,-20.884,64.015,-20.864,64.003,-20.799,64.004,-20.671,63.97,-20.649,64.041,-20.603,64.062,-20.608,64.094,-20.6,64.103,-20.584,64.109,-20.562,64.106,-20.578,64.122,-20.576,64.127,-20.565,64.131,-20.559,64.108,-20.564,64.105,-20.593,64.102,-20.6,64.078,-20.536,64.113,-20.53,64.109,-20.669,63.969,-20.933,64.001,-20.991,63.94,-21.213,63.929,-21.205,63.88,-20.805,63.774,-20.694,63.914,-20.67,63.929,-20.622,63.942,-20.574,63.946,-20.509,64.009,-20.478,64.021,-20.349,64.034,-20.269,64.015,-20.181,64.037,-20.212,64.007,-20.324,63.989,-20.273,64.014,-20.477,64.012,-20.51,63.973,-20.55,63.944,-20.616,63.941,-20.669,63.928,-20.675,63.837,-20.803,63.772,-20.55,63.684,-20.58,63.703,-20.565,63.739,-20.421,63.828,-20.472,63.779,-20.364,63.758,-20.356,63.747,-20.41,63.762,-20.494,63.774,-20.565,63.698,-20.217,63.544,-20.066,63.531,-20.055,63.581,-20.006,63.634,-20.0,63.635,-20.055,63.531,-19.131,63.401,-18.729,63.392,-18.158,63.462,-18.265,63.463,-18.488,63.639,-18.494,63.69,-18.457,63.63,-18.326,63.586,-18.157,63.463,-17.67,63.717,-16.64,63.798,-15.925,64.143,-15.186,64.229,-15.489,64.229,-15.507,64.243,-15.366,64.29,-15.39,64.41,-15.213,64.241,-15.106,64.291,-15.009,64.255,-15.194,64.236,-14.96,64.24,-14.908,64.345,-14.71,64.425,-14.497,64.438,-14.468,64.544,-14.564,64.534,-14.589,64.576,-14.406,64.601,-14.495,64.654,-14.255,64.65,-14.509,64.791,-14.083,64.7,-13.985,64.731,-14.07,64.794,-13.751,64.865,-14.043,64.935,-13.681,64.914,-14.239,65.034,-13.987,65.042,-14.041,65.079,-13.661,64.985,-13.501,65.083,-6.264,62.346],"holes":[[-6.874,62.056,-6.8,62.066,-6.943,62.099,-6.873,62.128,-7.107,62.292,-7.227,62.299,-7.186,62.248,-7.259,62.176,-6.736,61.945,-6.766,62.047,-6.874,62.056],[-41.276,63.204,-41.247,63.276,-41.096,63.204,-41.243,63.325,-41.485,63.338,-41.436,63.37,-41.696,63.483,-41.89,63.462,-41.276,63.204],[-40.199,64.446,-40.248,64.523,-40.451,64.546,-40.477,64.614,-40.331,64.596,-40.541,64.698,-40.41,64.71,-40.518,64.801,-40.464,64.807,-40.67,64.917,-40.867,64.917,-40.469,64.581,-40.527,64.584,-40.511,64.482,-40.199,64.446],[-37.691,65.57,-37.596,65.605,-37.668,65.654,-37.562,65.648,-37.569,65.59,-37.316,65.655,-37.474,65.704,-37.376,65.727,-37.481,65.813,-37.223,65.74,-37.648,65.904,-37.929,65.785,-37.951,65.621,-37.691,65.57],[-70.197,42.082,-70.246,42.063,-70.064,41.997,-70.079,41.99,-70.065,41.875,-70.061,41.936,-69.995,41.915,-70.002,41.807,-70.301,41.709,-70.558,41.772,-70.662,41.681,-70.687,41.523,-70.486,41.553,-70.406,41.649,-69.964,41.653,-69.965,41.774,-69.934,41.682,-70.007,41.984,-70.197,42.082],[-72.24,41.154,-72.354,41.14,-72.539,40.992,-73.159,40.968,-73.194,40.88,-73.486,40.947,-73.464,40.859,-73.633,40.902,-73.652,40.804,-73.73,40.865,-73.711,40.798,-73.936,40.777,-74.042,40.62,-73.991,40.57,-73.879,40.579,-73.917,40.632,-73.839,40.662,-73.742,40.631,-73.94,40.542,-73.329,40.695,-73.246,40.644,-73.241,40.723,-72.645,40.797,-72.752,40.763,-72.477,40.84,-72.59,40.812,-72.541,40.868,-72.419,40.88,-72.473,40.839,-71.857,41.071,-72.096,40.991,-72.313,41.041,-72.458,40.896,-72.643,40.916,-72.24,41.154],[-75.313,38.925,-75.416,39.07,-75.405,39.258,-75.59,39.461,-75.564,39.56,-75.916,39.503,-75.811,39.465,-75.945,39.475,-76.039,39.387,-75.825,39.377,-76.111,39.372,-76.086,39.318,-76.17,39.332,-76.273,39.17,-76.21,39.056,-76.174,39.176,-76.167,39.095,-76.076,39.137,-76.067,39.204,-76.01,39.244,-75.893,39.248,-76.057,39.208,-75.999,39.153,-76.145,39.092,-76.072,39.055,-76.151,39.079,-76.146,38.997,-76.246,38.976,-76.206,38.853,-76.162,38.961,-76.065,38.886,-76.193,38.859,-76.17,38.776,-76.102,38.81,-76.171,38.744,-76.252,38.861,-76.302,38.826,-76.343,38.75,-76.339,38.67,-76.318,38.769,-76.261,38.817,-76.298,38.719,-76.244,38.804,-76.201,38.672,-76.087,38.777,-76.177,38.675,-76.026,38.579,-75.952,38.673,-76.015,38.725,-76.016,38.73,-76.009,38.737,-75.997,38.737,-76.001,38.755,-75.83,38.9,-75.856,38.825,-75.93,38.797,-75.998,38.755,-75.993,38.737,-76.014,38.729,-75.936,38.68,-75.97,38.585,-76.024,38.558,-76.286,38.627,-76.274,38.542,-76.238,38.596,-76.13,38.528,-76.334,38.486,-76.225,38.308,-76.226,38.395,-76.035,38.219,-76.062,38.304,-75.999,38.374,-75.949,38.238,-75.826,38.48,-75.756,38.517,-75.816,38.636,-75.762,38.529,-75.621,38.626,-75.673,38.565,-75.705,38.56,-75.762,38.506,-75.822,38.48,-75.918,38.264,-75.604,38.364,-75.952,38.177,-75.745,38.165,-75.872,38.09,-75.748,38.087,-75.89,37.912,-75.657,37.954,-75.661,38.047,-75.618,38.073,-75.607,38.076,-75.598,38.069,-75.661,38.045,-75.642,37.936,-75.758,37.901,-75.655,37.904,-75.703,37.786,-75.817,37.789,-75.752,37.701,-75.838,37.713,-75.799,37.666,-75.941,37.565,-75.829,37.567,-75.935,37.547,-75.963,37.475,-75.893,37.482,-76.017,37.323,-75.97,37.116,-75.895,37.351,-75.79,37.403,-75.821,37.429,-75.806,37.514,-75.746,37.562,-75.806,37.472,-75.684,37.457,-75.755,37.519,-75.663,37.488,-75.695,37.591,-75.625,37.569,-75.663,37.632,-75.417,37.888,-75.428,37.986,-75.096,38.329,-75.19,38.428,-75.101,38.389,-75.081,38.46,-75.072,38.358,-75.061,38.605,-75.287,38.594,-75.097,38.62,-75.165,38.706,-75.079,38.695,-75.063,38.609,-75.089,38.797,-75.313,38.925],[-25.79,37.904,-25.841,37.831,-25.685,37.732,-25.206,37.735,-25.134,37.807,-25.318,37.866,-25.586,37.813,-25.79,37.904],[-76.69,34.714,-76.616,34.708,-76.621,34.815,-76.58,34.723,-76.5,34.73,-76.512,34.79,-76.28,34.942,-76.297,35.009,-76.367,35.036,-76.317,34.957,-76.443,34.914,-76.47,35.075,-76.489,34.961,-76.575,34.992,-76.523,34.887,-76.677,34.969,-76.69,34.714],[-17.216,32.856,-17.266,32.815,-17.213,32.737,-16.946,32.633,-16.679,32.738,-16.903,32.838,-17.216,32.856],[-13.474,29.232,-13.546,29.119,-13.817,29.033,-13.874,28.855,-13.485,28.992,-13.419,29.185,-13.474,29.232],[-17.788,28.844,-17.92,28.858,-18.009,28.779,-17.843,28.453,-17.724,28.738,-17.788,28.844],[-13.866,28.738,-14.018,28.715,-14.224,28.214,-14.511,28.067,-14.332,28.045,-14.219,28.165,-13.916,28.252,-13.824,28.553,-13.866,28.738],[-15.413,28.045,-15.407,28.178,-15.709,28.166,-15.832,27.91,-15.574,27.735,-15.385,27.857,-15.413,28.045],[-16.686,27.998,-16.428,28.146,-16.36,28.38,-16.134,28.581,-16.329,28.577,-16.513,28.419,-16.923,28.355,-16.686,27.998],[-77.587,26.921,-77.827,26.914,-77.548,26.893,-77.423,26.688,-77.314,26.665,-77.344,26.61,-77.132,26.553,-77.227,26.529,-77.171,26.44,-77.237,26.403,-77.157,26.355,-77.254,26.316,-77.205,26.15,-77.406,26.005,-77.29,25.987,-77.204,25.846,-77.178,26.205,-76.992,26.331,-77.059,26.438,-77.021,26.557,-77.267,26.624,-77.296,26.646,-77.315,26.691,-77.294,26.659,-77.264,26.681,-77.3,26.686,-77.436,26.814,-77.527,26.889,-77.587,26.921],[-78.703,26.488,-78.437,26.595,-77.874,26.673,-77.917,26.778,-78.29,26.684,-78.547,26.72,-78.576,26.799,-78.633,26.721,-78.574,26.684,-78.746,26.533,-78.995,26.704,-78.703,26.488],[-76.738,25.555,-76.793,25.404,-76.607,25.439,-76.492,25.345,-76.361,25.323,-76.152,25.123,-76.216,24.885,-76.158,24.838,-76.229,24.782,-76.346,24.83,-76.144,24.612,-76.194,24.747,-76.138,24.842,-76.114,25.13,-76.368,25.349,-76.738,25.555],[-77.519,23.923,-77.599,24.212,-77.697,24.036,-77.84,23.986,-77.803,23.883,-77.682,23.859,-77.803,23.84,-77.692,23.843,-77.645,23.874,-77.645,23.801,-77.531,23.795,-77.519,23.923],[-78.039,24.269,-77.884,24.357,-77.891,24.426,-77.733,24.459,-77.739,24.648,-77.783,24.726,-77.854,24.673,-77.946,24.652,-77.783,24.726,-77.988,24.977,-78.023,25.179,-78.217,25.205,-78.182,24.913,-78.294,24.79,-78.221,24.746,-78.305,24.775,-78.331,24.72,-78.28,24.647,-78.292,24.75,-78.221,24.703,-78.235,24.634,-78.287,24.594,-78.371,24.711,-78.461,24.6,-78.208,24.434,-78.121,24.519,-78.179,24.405,-78.039,24.269],[-73.011,21.283,-73.066,21.321,-73.26,21.136,-73.39,21.236,-73.668,21.112,-73.674,20.932,-73.163,20.964,-73.011,21.283],[-25.102,17.195,-25.345,17.084,-25.299,16.906,-24.986,17.055,-24.966,17.114,-25.102,17.195],[-23.745,15.335,-23.779,15.063,-23.625,14.91,-23.481,14.906,-23.442,15.041,-23.745,15.335],[-12.576,7.622,-12.796,7.63,-12.957,7.571,-12.54,7.39,-12.496,7.507,-12.632,7.587,-12.576,7.622],[-50.403,0.239,-50.299,0.299,-50.306,0.637,-50.404,0.571,-50.477,0.255,-50.549,0.262,-50.427,0.16,-50.403,0.239]]}],"names":{"ar":"المحيط الأطلسي الشمالي","de":"Nordatlantik","es":"Océano Atlántico Norte","fr":"océan Atlantique Nord","he":"צפון האוקיינוס האטלנטי","it":"Atlantico settentrionale","nl":"Noordelijke Atlantische Oceaan","pt":"Oceano Atlântico Norte","zh":"北大西洋"}},{"name":"Arctic Ocean","bbox":[-180.0,71.388,180.0,90.0],"area":3936.5248,"polygons":[{"outer":[180.0,90.0,180.0,71.711,156.604,77.115,138.713,76.181,95.73,81.278,65.389,81.011,65.256,81.131,63.602,81.688,58.817,81.859,50.498,81.178,47.146,80.84,47.245,80.795,46.956,80.745,44.913,80.599,33.488,80.223,32.833,80.315,31.6,80.17,28.263,80.09,27.977,80.143,27.869,80.112,27.926,80.076,27.126,80.0,27.235,80.054,26.781,80.149,25.744,80.158,25.882,80.197,25.608,80.185,25.251,80.248,25.369,80.267,25.313,80.274,24.81,80.244,24.888,80.278,24.85,80.347,24.494,80.248,24.426,80.287,24.619,80.316,24.144,80.351,24.434,80.309,24.183,80.297,24.223,80.247,23.77,80.284,23.851,80.233,23.735,80.217,23.862,80.179,23.583,80.115,23.497,80.195,23.442,80.2,23.323,80.191,23.499,80.133,23.227,80.177,23.268,80.154,23.092,80.114,23.011,80.156,23.053,80.237,23.332,80.33,23.099,80.373,23.338,80.425,22.792,80.509,22.855,80.406,22.695,80.343,22.792,80.314,22.647,80.34,22.623,80.421,22.349,80.385,22.404,80.326,22.58,80.304,22.61,80.286,22.466,80.269,22.573,80.211,22.352,80.104,22.512,80.074,22.404,80.041,22.5,80.004,22.229,79.985,22.066,80.117,21.581,80.118,21.858,80.195,21.81,80.27,21.696,80.223,20.863,80.202,20.797,80.228,20.82,80.302,20.598,80.272,20.425,80.393,19.639,80.506,19.753,80.419,19.514,80.467,19.454,80.416,19.494,80.388,19.95,80.375,19.719,80.323,19.86,80.305,19.705,80.253,19.841,80.217,19.02,80.342,19.218,80.187,19.617,80.144,19.392,80.144,19.491,80.137,19.393,80.125,19.436,80.095,18.711,80.206,18.785,80.183,17.955,80.195,17.735,80.131,18.292,80.025,18.95,80.049,18.755,80.017,18.808,79.971,16.582,80.0,-22.191,82.899,-22.699,82.891,-22.971,82.806,-22.896,82.865,-23.118,82.927,-22.887,82.957,-23.125,82.98,-25.002,82.806,-25.41,82.813,-24.794,82.848,-24.5,82.933,-24.879,82.923,-24.105,83.071,-24.425,83.145,-24.481,83.177,-24.3,83.195,-24.497,83.244,-26.597,83.124,-30.59,83.162,-32.115,83.004,-32.036,82.941,-32.311,83.012,-34.051,82.957,-34.214,82.909,-34.093,82.848,-34.194,82.822,-34.181,82.872,-34.372,82.914,-35.402,82.905,-35.769,82.844,-35.677,82.814,-35.806,82.733,-35.95,82.77,-35.706,82.891,-36.172,82.904,-34.522,82.945,-32.998,83.083,-34.121,83.17,-34.034,83.178,-32.332,83.08,-30.732,83.213,-26.853,83.223,-25.428,83.39,-28.124,83.585,-29.038,83.483,-29.506,83.549,-28.833,83.572,-33.368,83.661,-34.782,83.578,-34.47,83.52,-34.522,83.491,-35.601,83.627,-70.645,83.106,-71.409,83.017,-70.77,82.885,-71.762,83.011,-71.532,83.099,-72.602,83.096,-73.615,82.928,-72.44,82.714,-72.629,82.713,-72.58,82.695,-73.738,82.843,-73.973,82.96,-74.494,83.032,-77.418,82.994,-76.397,82.786,-75.9,82.784,-76.31,82.764,-76.222,82.692,-75.415,82.617,-76.085,82.523,-75.92,82.604,-76.569,82.668,-77.16,82.868,-77.818,82.931,-78.082,82.884,-77.927,82.852,-78.154,82.871,-78.059,82.815,-78.385,82.882,-78.509,82.838,-78.47,82.921,-78.678,82.946,-79.015,82.867,-78.964,82.889,-79.197,82.941,-79.07,82.951,-80.108,82.93,-80.364,82.896,-79.61,82.825,-79.98,82.808,-79.786,82.739,-78.473,82.679,-79.953,82.686,-79.766,82.649,-79.86,82.646,-80.368,82.789,-81.532,82.811,-80.673,82.642,-80.75,82.622,-80.51,82.542,-80.777,82.486,-81.394,82.631,-82.195,82.666,-82.359,82.606,-81.435,82.497,-82.51,82.498,-82.679,82.395,-81.827,82.227,-80.795,82.153,-80.939,82.132,-80.88,82.093,-79.93,82.018,-79.435,81.883,-82.739,82.287,-82.966,82.286,-82.989,82.219,-82.616,82.092,-82.002,82.083,-81.824,82.038,-83.084,82.054,-82.888,82.124,-83.664,82.351,-84.347,82.356,-84.923,82.429,-84.553,82.461,-85.154,82.482,-85.857,82.43,-85.481,82.405,-85.542,82.343,-85.315,82.277,-86.861,82.209,-86.63,82.12,-84.814,82.019,-84.505,81.884,-84.864,81.996,-85.027,81.985,-84.716,81.88,-85.151,81.999,-85.713,81.984,-85.325,81.86,-85.387,81.854,-86.332,82.048,-86.87,82.049,-86.988,82.024,-86.803,81.989,-87.168,81.959,-86.712,81.896,-86.775,81.89,-87.239,81.953,-87.11,82.003,-87.336,82.067,-88.033,82.103,-89.039,81.979,-88.784,81.861,-88.871,81.818,-89.242,81.941,-89.371,81.906,-89.111,81.897,-89.318,81.822,-89.232,81.785,-89.77,81.915,-91.117,81.801,-90.961,81.757,-91.423,81.768,-93.552,81.376,-93.446,81.318,-94.256,81.342,-94.174,81.323,-94.363,81.264,-94.266,81.231,-99.895,80.114,-105.475,79.332,-110.462,78.751,-110.707,78.764,-111.399,78.596,-111.806,78.549,-112.286,78.549,-113.129,78.409,-113.219,78.411,-113.173,78.401,-113.151,78.39,-114.349,78.079,-114.738,78.02,-114.941,77.941,-115.092,77.963,-116.593,77.548,-116.905,77.535,-116.705,77.526,-116.865,77.478,-117.129,77.461,-116.607,77.399,-116.735,77.326,-117.142,77.368,-116.96,77.308,-117.181,77.29,-117.939,77.364,-117.863,77.393,-118.695,77.375,-119.199,77.319,-119.352,77.192,-120.392,76.886,-120.331,76.833,-120.382,76.804,-120.746,76.75,-120.935,76.682,-121.194,76.693,-121.175,76.624,-121.399,76.569,-121.464,76.461,-121.983,76.45,-121.948,76.437,-122.023,76.411,-122.386,76.397,-122.367,76.394,-122.344,76.383,-122.333,76.375,-122.46,76.391,-122.453,76.365,-122.637,76.342,-122.613,76.276,-122.646,76.26,-122.676,76.256,-122.628,76.248,-122.736,76.249,-122.677,76.218,-122.822,76.236,-122.656,76.36,-122.627,76.359,-122.632,76.366,-156.482,71.388,-179.495,71.595,-180.0,71.711,-180.0,90.0,180.0,90.0]}],"names":{"ar":"المحيط المتجمد الشمالي","de":"Arktischer Ozean","es":"océano Ártico","fr":"océan Arctique","he":"אוקיינוס הקרח הצפוני","hu":"Jeges-tenger","it":"Mar Glaciale Artico","nl":"Noordelijke IJszee","pt":"Oceano Ártico","zh":"北冰洋"}},{"name":"South Atlantic Ocean","bbox":[-69.601,-60.0,20.009,0.075],"area":4102.0766,"polygons":[{"outer":[8.974,-0.936,9.019,-1.037,9.08,-1.032,9.066,-1.019,9.064,-1.005,9.055,-1.002,9.051,-0.997,9.045,-0.984,9.044,-0.973,9.035,-0.969,9.028,-0.963,9.01,-0.932,9.045,-0.973,9.052,-0.998,9.065,-1.003,9.069,-1.02,9.203,-1.045,9.229,-1.073,9.381,-0.993,9.492,-0.996,9.507,-0.984,9.599,-0.95,9.606,-0.939,9.64,-0.932,9.638,-0.923,9.683,-0.916,9.724,-0.87,9.736,-0.876,9.548,-1.028,9.486,-1.007,9.464,-1.022,9.426,-1.035,9.425,-1.06,9.415,-1.08,9.403,-1.086,9.378,-1.09,9.373,-1.096,9.559,-1.118,9.517,-1.187,9.358,-1.093,9.327,-1.102,9.291,-1.093,9.278,-1.101,9.269,-1.102,9.206,-1.086,9.153,-1.122,9.156,-1.128,9.152,-1.137,9.153,-1.155,9.148,-1.187,9.153,-1.208,9.157,-1.215,9.176,-1.216,9.18,-1.218,9.188,-1.225,9.19,-1.23,9.19,-1.257,9.193,-1.273,9.189,-1.284,9.177,-1.306,9.172,-1.32,9.162,-1.332,9.16,-1.348,9.153,-1.358,9.144,-1.367,9.124,-1.376,9.124,-1.387,9.119,-1.395,9.164,-1.367,9.177,-1.388,9.166,-1.414,9.173,-1.418,9.183,-1.439,9.18,-1.451,9.204,-1.444,9.222,-1.451,9.226,-1.46,9.269,-1.393,9.332,-1.412,9.338,-1.318,9.341,-1.439,9.232,-1.479,9.296,-1.607,9.456,-1.497,9.586,-1.651,9.416,-1.633,9.433,-1.646,9.421,-1.689,9.435,-1.703,9.427,-1.729,9.402,-1.641,9.296,-1.709,9.265,-1.681,9.272,-1.576,9.234,-1.575,9.192,-1.539,9.016,-1.336,9.013,-1.322,8.996,-1.298,8.983,-1.265,8.997,-1.305,9.019,-1.347,9.238,-1.646,9.268,-1.871,9.322,-1.85,9.334,-1.917,9.374,-1.846,9.429,-1.915,9.477,-1.874,9.563,-2.084,9.463,-1.933,9.382,-1.962,9.286,-1.906,9.356,-1.976,9.573,-2.229,9.628,-2.406,9.732,-2.45,9.757,-2.519,9.879,-2.419,9.873,-2.493,9.887,-2.465,9.898,-2.474,9.899,-2.46,9.911,-2.457,9.913,-2.462,9.89,-2.496,9.896,-2.515,9.915,-2.485,10.015,-2.536,10.016,-2.567,9.993,-2.543,9.979,-2.562,10.015,-2.629,10.056,-2.558,10.086,-2.573,10.087,-2.609,10.148,-2.624,10.078,-2.541,10.08,-2.507,10.225,-2.592,10.22,-2.632,9.996,-2.73,10.027,-2.651,9.979,-2.668,9.826,-2.554,9.845,-2.502,9.827,-2.519,9.821,-2.509,9.824,-2.507,9.826,-2.494,9.829,-2.488,9.827,-2.466,9.819,-2.521,9.787,-2.521,9.808,-2.531,9.795,-2.543,9.787,-2.522,9.754,-2.523,9.736,-2.496,9.648,-2.424,10.012,-2.789,10.28,-2.983,10.313,-2.982,10.306,-2.973,10.307,-2.968,10.329,-2.939,10.341,-2.935,10.354,-2.943,10.359,-2.938,10.362,-2.936,10.37,-2.938,10.372,-2.943,10.36,-2.953,10.359,-2.96,10.369,-2.964,10.362,-2.956,10.364,-2.952,10.369,-2.952,10.399,-2.974,10.396,-2.98,10.38,-2.985,10.378,-2.996,10.399,-2.988,10.423,-2.989,10.426,-2.992,10.427,-2.996,10.42,-3.006,10.428,-3.011,10.435,-3.008,10.436,-3.0,10.439,-2.997,10.459,-3.008,10.465,-2.985,10.487,-2.967,10.477,-2.957,10.492,-2.911,10.502,-2.896,10.51,-2.89,10.508,-2.883,10.51,-2.873,10.525,-2.849,10.532,-2.847,10.537,-2.842,10.547,-2.838,10.511,-2.873,10.511,-2.891,10.492,-2.919,10.497,-2.953,10.462,-3.008,10.425,-3.013,10.418,-3.003,10.423,-2.991,10.377,-2.998,10.378,-2.984,10.397,-2.974,10.359,-2.961,10.359,-2.953,10.369,-2.945,10.363,-2.938,10.315,-2.983,10.279,-2.985,10.652,-3.331,10.661,-3.344,10.676,-3.394,10.666,-3.434,10.713,-3.448,10.714,-3.458,10.705,-3.475,10.711,-3.476,10.728,-3.484,10.849,-3.571,10.853,-3.58,10.873,-3.582,10.884,-3.599,10.944,-3.623,10.995,-3.691,11.09,-3.691,11.181,-3.795,10.962,-3.696,10.872,-3.589,10.849,-3.584,10.704,-3.476,10.713,-3.452,10.662,-3.441,10.656,-3.427,10.665,-3.388,10.659,-3.344,10.64,-3.435,10.943,-3.689,11.022,-3.859,11.103,-3.925,11.162,-3.953,11.167,-3.959,11.151,-3.958,11.196,-3.98,11.195,-3.986,11.231,-4.007,11.239,-4.016,11.297,-3.946,11.352,-3.936,11.344,-3.995,11.319,-3.973,11.268,-4.013,11.24,-4.018,11.228,-4.01,11.396,-4.214,11.716,-4.473,11.723,-4.468,11.723,-4.46,11.729,-4.449,11.741,-4.427,11.748,-4.419,11.776,-4.413,11.794,-4.388,11.817,-4.377,11.835,-4.344,11.863,-4.33,11.893,-4.245,11.953,-4.193,11.869,-4.329,11.835,-4.347,11.828,-4.369,11.821,-4.38,11.78,-4.414,11.741,-4.435,11.729,-4.465,11.714,-4.479,11.709,-4.477,11.826,-4.611,11.838,-4.824,12.14,-5.171,12.233,-5.518,12.155,-5.692,12.404,-6.025,12.401,-5.987,12.39,-5.978,12.393,-5.974,12.534,-5.95,12.532,-5.976,12.468,-6.029,12.46,-6.033,12.459,-6.029,12.455,-6.032,12.452,-6.028,12.437,-6.036,12.439,-6.057,12.605,-5.983,12.703,-5.997,12.768,-5.893,12.902,-5.838,13.087,-5.863,13.118,-5.899,13.22,-5.857,13.289,-5.871,13.31,-5.888,13.333,-5.887,13.359,-5.903,13.416,-5.826,13.511,-5.787,13.516,-5.757,13.544,-5.719,13.585,-5.701,13.552,-5.544,13.604,-5.527,13.637,-5.528,13.65,-5.536,13.566,-5.548,13.587,-5.713,13.557,-5.716,13.517,-5.798,13.357,-5.918,13.224,-5.866,13.13,-5.909,13.003,-5.898,12.847,-6.033,12.816,-6.029,12.792,-6.048,12.745,-6.049,12.668,-6.03,12.663,-6.044,12.639,-6.072,12.608,-6.053,12.573,-6.061,12.603,-6.095,12.502,-6.086,12.521,-6.125,12.54,-6.119,12.567,-6.138,12.587,-6.13,12.6,-6.13,12.609,-6.139,12.569,-6.141,12.541,-6.123,12.519,-6.128,12.484,-6.092,12.365,-6.115,12.39,-6.128,12.395,-6.148,12.392,-6.171,12.388,-6.176,12.288,-6.148,12.332,-6.077,12.275,-6.109,12.278,-6.154,12.562,-6.663,12.829,-6.993,12.854,-7.27,13.394,-8.393,13.398,-8.748,13.233,-8.783,13.199,-8.866,13.158,-8.909,13.148,-8.957,12.993,-9.085,13.153,-9.345,13.165,-9.321,13.186,-9.327,13.192,-9.293,13.197,-9.29,13.205,-9.29,13.21,-9.273,13.22,-9.266,13.218,-9.263,13.217,-9.258,13.227,-9.21,13.248,-9.188,13.254,-9.185,13.281,-9.179,13.301,-9.155,13.318,-9.149,13.323,-9.149,13.349,-9.159,13.359,-9.165,13.249,-9.188,13.228,-9.211,13.221,-9.266,13.187,-9.327,13.15,-9.349,13.196,-9.688,13.53,-10.389,13.782,-10.684,13.719,-10.756,13.865,-11.02,13.794,-11.812,13.652,-12.244,13.392,-12.583,13.192,-12.602,12.94,-12.834,12.979,-12.983,12.532,-13.417,12.529,-13.847,12.421,-13.878,12.361,-14.059,12.354,-14.411,12.116,-15.117,12.154,-15.182,12.055,-15.234,11.995,-15.631,11.733,-15.872,11.827,-16.456,11.771,-16.777,11.735,-17.586,11.808,-18.0,12.035,-18.501,12.325,-18.739,12.599,-19.136,13.055,-20.072,13.198,-20.216,13.414,-20.876,13.861,-21.507,13.95,-21.775,14.392,-22.282,14.509,-22.554,14.539,-22.898,14.46,-23.023,14.438,-22.874,14.403,-23.009,14.5,-23.332,14.433,-23.395,14.511,-23.628,14.46,-24.088,14.594,-24.554,14.767,-24.764,14.876,-25.083,14.802,-25.288,14.882,-25.544,14.827,-25.749,14.98,-26.074,14.951,-26.319,15.095,-26.408,15.175,-26.586,15.15,-26.693,15.143,-26.689,15.131,-26.664,15.128,-26.626,15.079,-26.69,15.149,-26.901,15.228,-26.929,15.294,-27.322,15.717,-28.0,16.444,-28.629,16.452,-28.631,16.459,-28.599,16.47,-28.585,16.493,-28.567,16.668,-28.475,16.682,-28.47,16.689,-28.471,16.694,-28.475,16.697,-28.478,16.698,-28.494,16.709,-28.501,16.737,-28.495,16.769,-28.398,16.809,-28.362,16.789,-28.346,16.779,-28.321,16.765,-28.313,16.756,-28.303,16.752,-28.292,16.756,-28.271,16.759,-28.264,16.762,-28.261,16.777,-28.26,16.818,-28.276,16.826,-28.241,16.819,-28.278,16.757,-28.272,16.811,-28.363,16.737,-28.496,16.453,-28.632,16.601,-28.878,16.817,-29.096,16.968,-29.437,17.272,-30.334,17.604,-30.916,18.187,-31.703,18.193,-31.651,18.207,-31.641,18.346,-32.281,18.32,-32.548,18.145,-32.769,18.209,-32.795,18.197,-32.812,17.967,-32.699,17.843,-32.826,17.893,-33.042,18.029,-33.023,18.124,-33.189,18.025,-33.144,17.995,-33.07,17.97,-33.137,18.103,-33.237,18.146,-33.355,18.44,-33.699,18.485,-33.886,18.377,-33.921,18.319,-34.148,18.4,-34.309,18.497,-34.358,18.439,-34.133,18.787,-34.087,18.868,-34.158,18.829,-34.39,19.139,-34.315,19.118,-34.419,19.219,-34.439,19.271,-34.409,19.387,-34.414,19.305,-34.424,19.378,-34.543,19.291,-34.633,19.428,-34.599,19.639,-34.791,19.875,-34.767,20.009,-34.832,20.003,-60.0,-67.267,-60.0,-67.267,-55.979,-67.212,-55.966,-67.267,-55.888,-67.195,-55.881,-67.267,-55.829,-67.267,-55.776,-67.256,-55.758,-67.243,-55.758,-67.316,-55.727,-67.254,-55.723,-67.267,-55.331,-67.044,-55.132,-67.267,-54.884,-66.445,-55.058,-65.971,-54.97,-65.981,-54.903,-65.655,-54.974,-65.431,-54.885,-65.382,-54.942,-65.117,-54.656,-65.804,-54.652,-66.465,-54.479,-66.736,-54.26,-67.535,-53.928,-67.547,-53.84,-67.779,-53.834,-67.681,-53.797,-67.988,-53.587,-68.105,-53.356,-68.519,-53.24,-68.28,-53.048,-68.201,-53.143,-68.287,-52.953,-68.605,-52.66,-68.353,-52.321,-68.969,-51.619,-69.013,-51.607,-69.145,-51.68,-69.244,-51.696,-69.15,-51.644,-69.601,-51.621,-68.945,-51.552,-69.167,-50.979,-69.206,-50.975,-69.293,-51.017,-69.386,-51.083,-69.435,-51.091,-69.132,-50.897,-69.078,-50.564,-68.88,-50.335,-68.354,-50.15,-68.49,-50.085,-68.58,-49.964,-68.903,-49.974,-68.569,-49.937,-68.621,-49.831,-68.628,-49.79,-68.342,-50.122,-67.914,-50.004,-67.729,-49.776,-67.612,-49.25,-67.67,-49.254,-67.674,-49.298,-67.702,-49.308,-67.684,-49.344,-67.694,-49.366,-67.707,-49.382,-67.831,-49.39,-67.769,-49.383,-67.794,-49.364,-67.804,-49.34,-67.796,-49.328,-67.777,-49.374,-67.746,-49.361,-67.782,-49.322,-67.721,-49.328,-67.741,-49.275,-67.609,-49.18,-67.555,-49.018,-66.751,-48.473,-66.334,-48.362,-66.337,-48.278,-66.122,-48.125,-65.94,-48.122,-65.892,-48.102,-65.974,-48.048,-65.924,-47.94,-65.764,-47.95,-65.845,-47.893,-65.895,-47.763,-66.265,-47.862,-66.033,-47.745,-65.842,-47.744,-65.739,-47.505,-65.737,-47.202,-65.944,-47.077,-66.784,-47.004,-67.128,-46.702,-67.43,-46.56,-67.592,-46.294,-67.625,-46.052,-67.368,-45.787,-67.347,-45.627,-66.965,-45.274,-66.84,-45.231,-66.519,-45.224,-66.599,-45.141,-66.473,-45.167,-66.533,-45.141,-66.513,-45.094,-66.195,-44.983,-66.177,-44.989,-66.173,-45.004,-66.004,-45.007,-65.936,-45.051,-65.842,-45.004,-65.829,-45.041,-65.742,-45.025,-65.691,-45.064,-65.602,-45.029,-65.594,-44.957,-65.519,-44.93,-65.729,-44.815,-65.636,-44.666,-65.278,-44.519,-65.333,-44.455,-65.214,-44.367,-65.311,-44.202,-65.187,-44.036,-65.299,-43.934,-65.333,-43.66,-64.984,-43.261,-64.308,-42.98,-65.011,-42.783,-64.985,-42.654,-64.566,-42.492,-64.213,-42.624,-64.259,-42.755,-64.146,-42.875,-63.633,-42.766,-63.597,-42.33,-63.759,-42.076,-64.379,-42.237,-64.094,-42.253,-64.045,-42.358,-64.129,-42.427,-64.44,-42.439,-64.617,-42.411,-64.463,-42.239,-64.855,-42.189,-65.039,-42.064,-64.987,-41.523,-65.144,-41.175,-65.124,-40.854,-64.862,-40.727,-64.776,-40.791,-64.877,-40.832,-64.539,-40.866,-63.776,-41.161,-63.119,-41.155,-62.797,-41.026,-62.957,-40.819,-62.982,-40.816,-62.997,-40.8,-63.018,-40.802,-63.027,-40.798,-63.05,-40.773,-63.101,-40.763,-63.126,-40.745,-63.141,-40.746,-63.168,-40.734,-63.182,-40.723,-63.197,-40.726,-63.228,-40.724,-63.245,-40.728,-63.271,-40.724,-63.279,-40.729,-63.285,-40.731,-63.298,-40.724,-63.298,-40.713,-63.314,-40.707,-63.327,-40.715,-63.362,-40.723,-63.367,-40.72,-63.367,-40.715,-63.357,-40.708,-63.367,-40.702,-63.186,-40.721,-63.049,-40.771,-62.958,-40.817,-62.758,-41.033,-62.311,-40.871,-62.195,-40.633,-62.235,-40.654,-62.293,-40.646,-62.284,-40.656,-62.34,-40.635,-62.249,-40.645,-62.274,-40.564,-62.26,-40.557,-62.317,-40.537,-62.406,-40.466,-62.486,-40.306,-62.316,-40.149,-62.341,-40.061,-62.329,-39.914,-62.309,-39.874,-62.284,-39.845,-62.117,-39.873,-62.12,-39.85,-62.127,-39.859,-62.144,-39.859,-62.173,-39.833,-62.117,-39.846,-62.147,-39.723,-62.045,-39.449,-62.132,-39.399,-62.16,-39.361,-62.271,-39.309,-62.042,-39.415,-62.007,-39.381,-62.106,-39.294,-62.319,-39.267,-62.237,-39.252,-62.328,-39.092,-62.244,-39.006,-62.227,-39.005,-62.221,-39.0,-62.253,-39.004,-62.262,-38.99,-62.268,-39.018,-62.262,-38.975,-62.318,-38.989,-62.345,-38.954,-62.354,-38.865,-62.333,-38.856,-62.323,-38.867,-62.312,-38.851,-62.283,-38.861,-62.279,-38.853,-62.292,-38.852,-62.301,-38.84,-62.311,-38.846,-62.32,-38.845,-62.324,-38.84,-62.328,-38.842,-62.304,-38.82,-62.347,-38.842,-62.387,-38.826,-62.346,-38.83,-62.302,-38.789,-62.467,-38.768,-62.44,-38.742,-62.353,-38.715,-62.135,-38.818,-62.073,-38.925,-61.814,-38.987,-60.851,-38.98,-59.012,-38.679,-58.277,-38.465,-57.635,-38.172,-57.535,-38.082,-57.406,-37.616,-57.352,-37.57,-57.323,-37.562,-57.422,-37.742,-57.126,-37.494,-56.668,-36.88,-56.765,-36.299,-54.865,-34.919,-54.869,-34.912,-54.885,-34.913,-54.901,-34.903,-54.902,-34.887,-54.917,-34.878,-54.922,-34.859,-54.852,-34.92,-54.676,-34.844,-54.743,-34.824,-54.705,-34.798,-54.672,-34.842,-54.629,-34.847,-54.559,-34.74,-54.567,-34.802,-54.281,-34.684,-54.365,-34.631,-54.275,-34.559,-54.221,-34.66,-54.275,-34.683,-54.151,-34.67,-54.15,-34.623,-54.084,-34.553,-53.777,-34.406,-53.778,-34.277,-53.533,-34.044,-53.453,-33.821,-52.704,-33.208,-52.333,-32.409,-52.072,-32.051,-52.239,-32.081,-52.22,-31.96,-52.113,-31.943,-52.248,-31.877,-52.226,-31.79,-52.38,-31.805,-52.383,-31.903,-52.388,-31.808,-52.34,-31.784,-52.302,-31.772,-52.222,-31.787,-52.071,-31.681,-52.111,-31.554,-52.038,-31.561,-52.032,-31.695,-51.951,-31.342,-51.752,-31.235,-51.617,-31.267,-51.633,-31.259,-51.641,-31.216,-51.619,-31.14,-51.442,-31.087,-51.499,-30.923,-51.371,-30.874,-51.386,-30.651,-51.319,-30.646,-51.286,-30.816,-51.274,-30.497,-51.095,-30.372,-51.296,-30.302,-51.3,-30.006,-51.25,-30.033,-51.309,-29.951,-51.402,-29.988,-51.418,-29.989,-51.444,-29.98,-51.507,-29.929,-51.744,-29.967,-51.905,-29.961,-51.945,-29.951,-51.957,-29.918,-52.031,-29.958,-52.038,-29.956,-52.048,-29.942,-52.067,-29.932,-52.08,-29.945,-52.103,-29.948,-52.14,-29.926,-52.178,-29.986,-52.198,-29.997,-52.211,-29.998,-52.238,-29.994,-52.269,-29.979,-52.335,-29.936,-52.341,-29.945,-52.336,-29.957,-52.335,-29.981,-52.34,-29.992,-52.357,-29.999,-52.371,-29.999,-52.398,-29.996,-52.416,-30.014,-52.419,-30.083,-52.437,-30.092,-52.443,-30.091,-52.45,-30.088,-52.468,-30.093,-52.48,-30.093,-52.489,-30.091,-52.49,-30.097,-52.477,-30.1,-52.475,-30.105,-52.496,-30.113,-52.503,-30.112,-52.509,-30.105,-52.519,-30.059,-52.552,-30.044,-52.573,-30.071,-52.59,-30.071,-52.605,-30.061,-52.608,-30.056,-52.608,-30.024,-52.643,-29.995,-52.809,-30.059,-52.789,-30.01,-52.642,-29.994,-52.523,-30.047,-52.498,-30.112,-52.489,-30.09,-52.42,-30.082,-52.418,-30.015,-52.402,-29.996,-52.341,-29.991,-52.342,-29.94,-52.333,-29.934,-52.209,-29.996,-52.17,-29.929,-52.079,-29.913,-52.033,-29.956,-51.957,-29.916,-51.75,-29.958,-51.749,-29.883,-51.719,-29.948,-51.687,-29.945,-51.656,-29.933,-51.606,-29.924,-51.589,-29.932,-51.565,-29.919,-51.553,-29.929,-51.518,-29.915,-51.5,-29.912,-51.486,-29.914,-51.464,-29.931,-51.446,-29.96,-51.403,-29.971,-51.331,-29.946,-51.295,-29.946,-51.251,-29.928,-51.228,-29.939,-51.203,-29.982,-51.248,-30.187,-51.026,-30.278,-51.057,-30.354,-51.055,-30.392,-50.904,-30.442,-50.919,-30.329,-50.67,-30.297,-50.644,-30.194,-50.54,-30.262,-50.583,-30.488,-50.729,-30.366,-50.678,-30.605,-50.739,-30.806,-50.965,-30.901,-50.986,-31.042,-51.17,-31.066,-51.159,-31.289,-51.243,-31.463,-51.36,-31.531,-51.426,-31.49,-51.451,-31.595,-51.848,-31.865,-52.098,-31.836,-52.005,-31.944,-52.053,-31.979,-52.039,-32.038,-52.092,-32.128,-52.082,-32.159,-51.255,-31.57,-50.796,-31.142,-50.314,-30.463,-50.12,-29.98,-50.166,-30.003,-50.181,-29.952,-50.119,-29.976,-49.679,-29.277,-49.364,-28.926,-48.76,-28.538,-48.86,-28.484,-48.883,-28.342,-48.766,-28.348,-48.702,-28.237,-48.739,-28.347,-48.83,-28.439,-48.79,-28.469,-48.787,-28.496,-48.748,-28.495,-48.645,-28.234,-48.687,-28.138,-48.573,-27.886,-48.659,-27.647,-48.567,-27.595,-48.647,-27.485,-48.523,-27.334,-48.624,-27.244,-48.465,-27.144,-48.6,-27.129,-48.569,-27.009,-48.626,-26.996,-48.621,-26.925,-48.722,-26.878,-48.728,-26.868,-48.725,-26.84,-48.766,-26.891,-48.888,-26.927,-48.96,-26.929,-48.976,-26.918,-48.989,-26.902,-48.729,-26.837,-48.722,-26.84,-48.717,-26.847,-48.721,-26.876,-48.644,-26.91,-48.584,-26.796,-48.672,-26.762,-48.664,-26.58,-48.684,-26.628,-48.671,-26.579,-48.595,-26.453,-48.592,-26.405,-48.493,-26.218,-48.557,-26.171,-48.713,-26.307,-48.631,-26.371,-48.615,-26.401,-48.618,-26.452,-48.8,-26.3,-48.81,-26.089,-48.706,-26.249,-48.585,-26.173,-48.564,-25.865,-48.628,-25.914,-48.755,-25.867,-48.603,-25.8,-48.535,-25.853,-48.348,-25.581,-48.709,-25.499,-48.738,-25.478,-48.718,-25.482,-48.693,-25.499,-48.678,-25.456,-48.733,-25.413,-48.742,-25.368,-48.664,-25.384,-48.635,-25.468,-48.473,-25.48,-48.413,-25.422,-48.502,-25.33,-48.428,-25.324,-48.445,-25.261,-48.425,-25.27,-48.433,-25.252,-48.425,-25.247,-48.431,-25.237,-48.431,-25.231,-48.427,-25.227,-48.392,-25.31,-48.35,-25.321,-48.379,-25.281,-48.327,-25.235,-48.317,-25.365,-48.287,-25.293,-48.13,-25.269,-48.226,-25.338,-48.213,-25.461,-48.103,-25.299,-48.004,-25.194,-47.996,-25.207,-48.0,-25.188,-47.993,-25.175,-48.026,-25.181,-48.033,-25.157,-48.037,-25.115,-48.015,-25.078,-48.062,-25.065,-48.037,-25.015,-47.982,-25.02,-47.868,-24.888,-47.562,-24.719,-47.57,-24.697,-47.57,-24.69,-47.564,-24.684,-47.57,-24.678,-47.64,-24.697,-47.737,-24.651,-47.738,-24.641,-47.73,-24.621,-47.708,-24.599,-47.695,-24.601,-47.707,-24.548,-47.691,-24.529,-47.704,-24.532,-47.731,-24.503,-47.731,-24.512,-47.733,-24.514,-47.792,-24.512,-47.805,-24.509,-47.812,-24.487,-47.832,-24.491,-47.84,-24.487,-47.851,-24.463,-47.826,-24.404,-47.838,-24.487,-47.811,-24.486,-47.792,-24.51,-47.731,-24.502,-47.69,-24.529,-47.692,-24.601,-47.736,-24.644,-47.715,-24.661,-47.483,-24.618,-47.394,-24.632,-47.388,-24.64,-47.392,-24.658,-47.001,-24.41,-47.001,-24.332,-46.802,-24.171,-46.398,-24.034,-46.456,-23.929,-46.376,-23.872,-46.357,-23.916,-46.328,-23.891,-46.314,-23.91,-46.321,-23.922,-46.311,-23.922,-46.312,-23.915,-46.308,-23.906,-46.297,-23.9,-46.311,-23.918,-46.297,-23.916,-46.29,-23.923,-46.278,-23.916,-46.248,-23.92,-46.245,-23.923,-46.158,-23.827,-45.895,-23.757,-45.412,-23.825,-45.416,-23.632,-45.31,-23.575,-45.211,-23.583,-45.17,-23.493,-45.082,-23.521,-45.018,-23.461,-45.061,-23.42,-44.907,-23.334,-44.828,-23.389,-44.506,-23.3,-44.561,-23.227,-44.647,-23.301,-44.613,-23.234,-44.685,-23.251,-44.644,-23.185,-44.659,-23.208,-44.713,-23.233,-44.69,-23.084,-44.669,-23.055,-44.478,-23.006,-44.449,-23.035,-44.435,-22.964,-44.359,-22.923,-44.333,-22.968,-44.303,-22.96,-44.359,-23.023,-44.251,-22.998,-44.238,-23.057,-43.841,-22.902,-43.839,-22.938,-43.778,-22.922,-43.7,-22.986,-43.672,-22.982,-43.6,-23.023,-43.603,-23.039,-43.59,-23.041,-43.574,-23.047,-43.563,-23.055,-43.563,-23.059,-43.567,-23.061,-43.568,-23.053,-43.603,-23.039,-43.614,-23.031,-43.892,-23.032,-44.007,-23.096,-43.552,-23.077,-43.184,-22.986,-43.158,-22.913,-43.211,-22.897,-43.215,-22.887,-43.204,-22.871,-43.217,-22.875,-43.232,-22.868,-43.269,-22.82,-43.278,-22.78,-43.085,-22.677,-43.005,-22.715,-43.135,-22.939,-43.053,-22.963,-43.051,-22.982,-42.36,-22.935,-42.013,-22.998,-42.028,-22.898,-41.864,-22.756,-41.988,-22.713,-41.964,-22.536,-41.677,-22.293,-40.992,-22.011,-41.013,-21.62,-41.016,-21.622,-41.042,-21.622,-41.055,-21.643,-41.071,-21.643,-41.078,-21.649,-41.089,-21.649,-41.089,-21.661,-41.097,-21.675,-41.108,-21.68,-41.131,-21.677,-41.143,-21.696,-41.181,-21.725,-41.224,-21.748,-41.241,-21.755,-41.246,-21.754,-41.265,-21.765,-41.269,-21.766,-41.324,-21.754,-41.481,-21.684,-41.554,-21.614,-41.695,-21.654,-41.731,-21.653,-41.759,-21.641,-41.763,-21.626,-41.557,-21.608,-41.272,-21.762,-41.133,-21.673,-41.101,-21.673,-41.071,-21.504,-40.758,-20.864,-40.646,-20.786,-40.627,-20.841,-40.528,-20.666,-40.426,-20.635,-40.266,-20.325,-40.31,-20.33,-40.356,-20.326,-40.379,-20.278,-40.23,-20.295,-40.132,-19.966,-40.141,-19.954,-40.168,-19.956,-40.175,-19.95,-40.192,-19.951,-40.221,-19.938,-40.233,-19.91,-40.102,-19.922,-39.805,-19.603,-40.066,-19.413,-40.192,-19.473,-40.388,-19.545,-40.466,-19.543,-40.486,-19.535,-40.502,-19.517,-40.516,-19.515,-40.684,-19.543,-40.729,-19.524,-40.756,-19.501,-40.773,-19.507,-40.788,-19.527,-40.802,-19.533,-40.819,-19.532,-40.857,-19.512,-40.752,-19.499,-40.693,-19.537,-40.613,-19.512,-40.534,-19.508,-40.401,-19.538,-40.139,-19.427,-40.071,-19.407,-40.056,-19.408,-39.957,-19.424,-39.947,-19.431,-39.796,-19.578,-39.794,-19.596,-39.81,-19.625,-39.811,-19.65,-39.689,-19.31,-39.736,-18.597,-39.759,-18.601,-39.748,-18.623,-39.752,-18.627,-39.767,-18.638,-39.761,-18.645,-39.763,-18.666,-39.767,-18.671,-39.775,-18.67,-39.761,-18.6,-39.737,-18.592,-39.728,-18.6,-39.713,-18.457,-39.566,-18.088,-39.361,-17.903,-39.417,-17.864,-39.36,-17.876,-39.349,-17.846,-39.319,-17.853,-39.354,-17.822,-39.335,-17.807,-39.324,-17.809,-39.331,-17.791,-39.325,-17.78,-39.318,-17.778,-39.315,-17.852,-39.358,-17.887,-39.322,-17.893,-39.206,-17.772,-39.279,-17.732,-39.273,-17.755,-39.288,-17.764,-39.278,-17.766,-39.278,-17.772,-39.287,-17.784,-39.307,-17.782,-39.298,-17.803,-39.313,-17.818,-39.299,-17.803,-39.308,-17.782,-39.298,-17.779,-39.287,-17.783,-39.279,-17.768,-39.291,-17.765,-39.286,-17.749,-39.281,-17.758,-39.275,-17.754,-39.283,-17.741,-39.296,-17.745,-39.306,-17.742,-39.31,-17.738,-39.312,-17.752,-39.319,-17.754,-39.314,-17.739,-39.28,-17.726,-39.361,-17.738,-39.35,-17.717,-39.289,-17.658,-39.228,-17.751,-39.137,-17.691,-39.214,-17.17,-39.113,-16.896,-39.088,-16.445,-39.009,-16.375,-39.031,-16.259,-38.856,-15.857,-38.906,-15.865,-38.984,-15.838,-38.986,-15.833,-38.977,-15.819,-38.998,-15.816,-38.998,-15.793,-39.02,-15.813,-39.079,-15.818,-39.0,-15.79,-38.904,-15.863,-38.858,-15.833,-38.988,-15.69,-38.925,-15.702,-39.023,-14.806,-39.047,-14.827,-39.043,-14.817,-39.066,-14.821,-39.074,-14.814,-38.983,-14.274,-39.08,-14.305,-38.991,-14.273,-38.928,-13.908,-38.988,-13.908,-38.971,-14.065,-39.049,-14.177,-38.998,-13.972,-39.057,-13.939,-39.076,-14.006,-39.084,-14.009,-39.124,-13.887,-39.004,-13.854,-39.151,-13.754,-39.138,-13.713,-39.121,-13.701,-39.066,-13.753,-39.063,-13.74,-39.075,-13.734,-39.076,-13.731,-39.063,-13.701,-38.982,-13.834,-39.021,-13.576,-38.994,-13.523,-39.068,-13.49,-39.092,-13.535,-39.087,-13.556,-39.08,-13.561,-39.078,-13.573,-39.082,-13.586,-39.086,-13.588,-39.096,-13.538,-39.072,-13.442,-39.041,-13.336,-38.955,-13.379,-38.928,-13.21,-38.804,-13.142,-38.958,-13.131,-38.869,-13.114,-38.943,-13.104,-38.959,-13.088,-38.963,-13.04,-38.818,-13.118,-38.845,-13.049,-38.815,-13.056,-38.814,-13.042,-38.808,-13.031,-38.792,-13.025,-38.786,-12.991,-38.772,-12.988,-38.755,-12.901,-38.72,-12.876,-38.853,-12.883,-38.883,-12.777,-38.91,-12.792,-38.908,-12.813,-38.914,-12.833,-38.921,-12.841,-38.917,-12.846,-38.93,-12.861,-38.918,-12.846,-38.93,-12.846,-38.926,-12.809,-38.916,-12.805,-38.92,-12.786,-38.892,-12.767,-38.933,-12.723,-38.976,-12.601,-38.923,-12.728,-38.855,-12.655,-38.816,-12.842,-38.749,-12.76,-38.755,-12.709,-38.733,-12.683,-38.739,-12.68,-38.72,-12.609,-38.712,-12.61,-38.71,-12.593,-38.696,-12.58,-38.694,-12.591,-38.7,-12.597,-38.699,-12.611,-38.705,-12.621,-38.65,-12.63,-38.649,-12.693,-38.633,-12.671,-38.64,-12.693,-38.619,-12.692,-38.621,-12.726,-38.497,-12.73,-38.488,-12.79,-38.453,-12.752,-38.434,-12.761,-38.456,-12.816,-38.499,-12.789,-38.488,-13.017,-38.047,-12.635,-37.349,-11.459,-37.473,-11.579,-37.473,-11.524,-37.52,-11.55,-37.548,-11.548,-37.388,-11.467,-37.446,-11.409,-37.404,-11.306,-37.376,-11.335,-37.366,-11.309,-37.317,-11.269,-37.33,-11.237,-37.292,-11.244,-37.407,-11.41,-37.32,-11.427,-37.166,-11.183,-37.167,-11.114,-37.204,-11.112,-37.267,-11.059,-37.266,-11.03,-37.216,-11.026,-37.194,-11.055,-37.199,-11.084,-37.166,-11.099,-37.177,-11.077,-37.164,-11.056,-37.119,-11.035,-37.148,-11.142,-37.037,-10.978,-37.046,-10.941,-37.046,-10.966,-37.049,-10.966,-37.059,-10.954,-37.037,-10.869,-37.067,-10.878,-37.081,-10.863,-37.092,-10.884,-37.096,-10.888,-37.105,-10.887,-37.082,-10.862,-37.037,-10.864,-37.032,-10.847,-37.102,-10.82,-37.108,-10.828,-37.117,-10.833,-37.133,-10.836,-37.144,-10.832,-37.11,-10.817,-37.071,-10.82,-37.088,-10.799,-37.092,-10.779,-37.128,-10.794,-37.123,-10.788,-37.134,-10.781,-37.133,-10.769,-37.141,-10.766,-37.146,-10.771,-37.153,-10.769,-37.161,-10.75,-37.079,-10.77,-37.021,-10.849,-37.032,-10.883,-37.015,-10.862,-37.005,-10.858,-37.0,-10.861,-37.028,-10.948,-36.797,-10.697,-36.402,-10.509,-36.453,-10.415,-36.471,-10.429,-36.504,-10.435,-36.56,-10.422,-36.577,-10.386,-36.568,-10.331,-36.604,-10.291,-36.624,-10.258,-36.674,-10.281,-36.706,-10.277,-36.747,-10.232,-36.809,-10.227,-36.838,-10.207,-36.847,-10.195,-36.615,-10.238,-36.585,-10.281,-36.587,-10.294,-36.579,-10.299,-36.573,-10.31,-36.56,-10.321,-36.55,-10.336,-36.551,-10.408,-36.521,-10.416,-36.492,-10.399,-36.467,-10.396,-36.443,-10.4,-36.386,-10.501,-35.907,-9.874,-35.99,-9.82,-35.951,-9.844,-35.918,-9.834,-35.899,-9.846,-35.801,-9.719,-35.804,-9.717,-35.806,-9.722,-35.812,-9.729,-35.825,-9.735,-35.86,-9.735,-35.857,-9.756,-35.87,-9.755,-35.957,-9.614,-35.871,-9.75,-35.861,-9.748,-35.864,-9.736,-35.845,-9.726,-35.815,-9.729,-35.768,-9.668,-35.798,-9.59,-35.746,-9.653,-35.772,-9.65,-35.765,-9.666,-35.769,-9.683,-35.8,-9.714,-35.722,-9.684,-35.305,-9.191,-35.316,-9.147,-35.086,-8.747,-35.125,-8.67,-35.007,-8.562,-34.939,-8.351,-34.954,-8.236,-34.824,-7.946,-34.844,-7.822,-34.895,-7.831,-34.898,-7.822,-34.892,-7.827,-34.864,-7.817,-34.888,-7.809,-34.893,-7.793,-34.866,-7.715,-34.905,-7.725,-34.879,-7.608,-34.845,-7.685,-34.807,-7.624,-34.898,-7.546,-34.804,-7.471,-34.829,-6.969,-34.86,-7.062,-34.908,-7.119,-34.855,-6.903,-34.92,-6.771,-34.949,-6.767,-34.954,-6.784,-34.969,-6.793,-34.975,-6.794,-34.982,-6.785,-34.997,-6.798,-35.005,-6.797,-34.929,-6.74,-34.981,-6.399,-35.037,-6.317,-35.069,-6.351,-35.061,-6.362,-35.067,-6.366,-35.071,-6.349,-35.032,-6.307,-35.042,-6.228,-35.143,-6.214,-35.096,-6.057,-35.194,-5.752,-35.213,-5.787,-35.231,-5.791,-35.246,-5.784,-35.249,-5.811,-35.296,-5.84,-35.314,-5.841,-35.193,-5.7,-35.487,-5.159,-35.979,-5.041,-36.293,-5.091,-36.17,-5.114,-36.216,-5.15,-36.448,-5.067,-36.604,-5.08,-36.533,-5.106,-36.553,-5.13,-36.687,-5.089,-36.629,-5.12,-36.579,-5.197,-36.614,-5.226,-36.67,-5.193,-36.663,-5.141,-36.662,-5.151,-36.654,-5.154,-36.644,-5.152,-36.642,-5.149,-36.645,-5.145,-36.626,-5.132,-36.624,-5.129,-36.626,-5.126,-36.765,-5.053,-36.787,-5.111,-36.879,-4.953,-37.157,-4.946,-37.325,-4.701,-37.586,-4.629,-37.762,-4.428,-37.775,-4.564,-37.806,-4.635,-37.815,-4.638,-37.826,-4.638,-37.777,-4.564,-37.794,-4.459,-37.77,-4.4,-38.159,-4.112,-38.464,-3.707,-38.649,-3.685,-38.98,-3.406,-39.088,-3.412,-39.253,-3.222,-39.993,-2.84,-40.143,-2.869,-40.511,-2.787,-40.842,-2.883,-40.828,-2.897,-40.816,-2.892,-40.81,-2.896,-40.816,-2.892,-40.825,-2.898,-40.833,-2.897,-40.802,-2.932,-40.798,-2.986,-40.807,-3.029,-40.86,-2.862,-41.261,-2.886,-41.31,-2.915,-41.222,-2.947,-41.234,-3.03,-41.286,-2.928,-41.32,-2.951,-41.271,-2.968,-41.249,-3.018,-41.253,-3.025,-41.332,-2.99,-41.329,-2.921,-41.711,-2.878,-41.649,-2.862,-41.835,-2.725,-42.018,-2.72,-41.929,-2.767,-41.997,-2.779,-41.946,-2.804,-41.982,-2.802,-41.932,-2.841,-41.932,-2.847,-41.942,-2.855,-41.94,-2.86,-41.926,-2.855,-41.909,-2.866,-41.906,-2.854,-41.903,-2.851,-41.901,-2.851,-41.899,-2.867,-41.907,-2.872,-41.901,-2.852,-41.909,-2.867,-41.941,-2.861,-42.014,-2.785,-42.043,-2.81,-42.074,-2.807,-42.068,-2.815,-42.081,-2.828,-42.223,-2.8,-42.228,-2.827,-42.25,-2.856,-42.282,-2.862,-42.266,-2.757,-42.368,-2.714,-42.442,-2.718,-42.398,-2.736,-42.391,-2.753,-42.432,-2.735,-42.436,-2.75,-42.473,-2.741,-42.696,-2.576,-42.685,-2.659,-42.706,-2.668,-42.704,-2.678,-42.708,-2.689,-42.712,-2.695,-42.722,-2.7,-42.738,-2.567,-42.794,-2.562,-43.241,-2.359,-43.424,-2.337,-43.483,-2.385,-43.448,-2.377,-43.429,-2.427,-43.416,-2.417,-43.398,-2.424,-43.39,-2.411,-43.378,-2.409,-43.348,-2.442,-43.41,-2.455,-43.464,-2.422,-43.393,-2.544,-43.456,-2.597,-43.458,-2.511,-43.469,-2.501,-43.468,-2.524,-43.485,-2.529,-43.48,-2.536,-43.484,-2.55,-43.477,-2.552,-43.493,-2.552,-43.524,-2.419,-43.555,-2.514,-43.604,-2.505,-43.614,-2.559,-43.627,-2.564,-43.65,-2.482,-43.675,-2.547,-43.705,-2.56,-43.713,-2.525,-43.695,-2.485,-43.716,-2.476,-43.746,-2.497,-43.741,-2.54,-43.745,-2.545,-43.823,-2.576,-43.857,-2.52,-43.865,-2.546,-43.873,-2.515,-43.858,-2.577,-43.839,-2.556,-43.865,-2.656,-43.928,-2.559,-44.233,-2.956,-44.245,-2.904,-44.21,-2.846,-44.222,-2.859,-44.231,-2.862,-44.245,-2.861,-44.248,-2.865,-44.251,-2.867,-44.256,-2.868,-44.259,-2.867,-44.304,-2.82,-44.287,-2.78,-44.341,-2.841,-44.338,-2.786,-44.037,-2.557,-44.054,-2.471,-44.124,-2.476,-44.075,-2.437,-44.024,-2.448,-44.075,-2.405,-44.305,-2.489,-44.282,-2.522,-44.308,-2.529,-44.286,-2.553,-44.285,-2.57,-44.294,-2.585,-44.379,-2.565,-44.425,-2.944,-44.648,-3.2,-44.76,-3.228,-44.733,-3.31,-44.792,-3.303,-44.8,-3.376,-44.83,-3.386,-44.814,-3.384,-44.791,-3.417,-44.803,-3.434,-44.788,-3.422,-44.781,-3.421,-44.781,-3.428,-44.793,-3.444,-44.8,-3.448,-44.8,-3.453,-44.789,-3.445,-44.799,-3.455,-44.833,-3.386,-44.804,-3.273,-44.851,-3.302,-44.893,-3.278,-44.81,-3.26,-44.758,-3.298,-44.801,-3.211,-44.726,-3.178,-44.722,-3.15,-44.739,-3.153,-44.755,-3.134,-44.755,-3.127,-44.614,-3.053,-44.68,-2.92,-44.54,-2.54,-44.567,-2.537,-44.584,-2.578,-44.611,-2.583,-44.622,-2.598,-44.651,-2.604,-44.571,-2.53,-44.521,-2.522,-44.51,-2.44,-44.49,-2.416,-44.561,-2.411,-44.402,-2.415,-44.357,-2.34,-44.464,-2.146,-44.56,-2.205,-44.56,-2.218,-44.538,-2.216,-44.529,-2.235,-44.511,-2.236,-44.498,-2.251,-44.499,-2.258,-44.511,-2.271,-44.542,-2.217,-44.623,-2.267,-44.656,-2.435,-44.665,-2.361,-44.705,-2.385,-44.709,-2.41,-44.714,-2.416,-44.722,-2.416,-44.659,-2.288,-44.756,-2.268,-44.779,-2.295,-44.8,-2.306,-44.817,-2.299,-44.846,-2.331,-44.867,-2.333,-44.82,-2.298,-44.775,-2.288,-44.756,-2.259,-44.688,-2.28,-44.482,-2.043,-44.607,-2.037,-44.485,-1.99,-44.575,-1.775,-44.605,-1.767,-44.609,-1.829,-44.656,-1.838,-44.663,-1.862,-44.71,-1.884,-44.603,-1.739,-44.669,-1.799,-44.702,-1.769,-44.716,-1.795,-44.788,-1.792,-44.803,-1.813,-44.811,-1.815,-44.819,-1.813,-44.823,-1.82,-44.833,-1.822,-44.837,-1.821,-44.7,-1.732,-44.771,-1.704,-44.779,-1.711,-44.758,-1.725,-44.759,-1.737,-44.792,-1.758,-44.805,-1.756,-44.795,-1.675,-44.686,-1.621,-44.662,-1.662,-44.641,-1.614,-44.686,-1.564,-44.729,-1.62,-44.74,-1.619,-44.763,-1.587,-44.757,-1.614,-44.765,-1.623,-44.776,-1.607,-44.767,-1.566,-44.73,-1.55,-44.751,-1.535,-44.796,-1.585,-44.78,-1.622,-44.782,-1.639,-44.793,-1.65,-44.835,-1.573,-44.853,-1.618,-44.82,-1.642,-44.818,-1.647,-44.831,-1.661,-44.872,-1.601,-44.923,-1.607,-44.902,-1.641,-44.912,-1.653,-44.929,-1.655,-45.031,-1.596,-44.961,-1.552,-45.035,-1.537,-45.005,-1.487,-45.096,-1.455,-45.127,-1.547,-45.198,-1.487,-45.255,-1.615,-45.164,-1.683,-45.273,-1.703,-45.142,-1.901,-45.154,-1.953,-45.173,-1.955,-45.175,-1.997,-45.179,-2.0,-45.19,-1.996,-45.176,-1.997,-45.178,-1.956,-45.145,-1.9,-45.276,-1.734,-45.338,-1.768,-45.36,-1.677,-45.404,-1.707,-45.342,-1.621,-45.398,-1.547,-45.351,-1.473,-45.387,-1.51,-45.455,-1.512,-45.455,-1.522,-45.463,-1.536,-45.478,-1.481,-45.487,-1.497,-45.523,-1.514,-45.536,-1.504,-45.541,-1.513,-45.549,-1.519,-45.412,-1.298,-45.454,-1.296,-45.429,-1.306,-45.476,-1.326,-45.472,-1.347,-45.476,-1.357,-45.482,-1.339,-45.506,-1.392,-45.531,-1.419,-45.549,-1.416,-45.548,-1.425,-45.574,-1.4,-45.511,-1.305,-45.577,-1.376,-45.616,-1.276,-45.623,-1.342,-45.639,-1.372,-45.649,-1.376,-45.704,-1.375,-45.69,-1.405,-45.695,-1.446,-45.71,-1.362,-45.726,-1.382,-45.746,-1.387,-45.753,-1.377,-45.687,-1.349,-45.737,-1.348,-45.755,-1.242,-45.691,-1.153,-45.714,-1.12,-45.826,-1.307,-45.848,-1.282,-45.804,-1.226,-45.814,-1.205,-45.802,-1.162,-45.868,-1.282,-45.872,-1.263,-45.887,-1.29,-45.898,-1.288,-45.855,-1.214,-45.913,-1.256,-45.871,-1.2,-45.883,-1.149,-45.911,-1.196,-45.923,-1.202,-45.859,-1.052,-45.934,-1.222,-45.98,-1.259,-45.951,-1.143,-46.021,-1.175,-46.032,-1.109,-46.016,-1.08,-46.053,-1.132,-46.046,-1.181,-46.05,-1.185,-46.069,-1.181,-46.071,-1.185,-46.051,-1.214,-46.038,-1.197,-46.034,-1.196,-46.027,-1.201,-46.015,-1.181,-46.006,-1.188,-46.05,-1.216,-46.06,-1.21,-46.073,-1.187,-46.072,-1.183,-46.069,-1.18,-46.052,-1.182,-46.048,-1.179,-46.065,-1.142,-46.092,-1.206,-46.148,-1.229,-46.102,-1.339,-46.14,-1.448,-46.175,-1.475,-46.164,-1.487,-46.163,-1.498,-46.151,-1.508,-46.144,-1.517,-46.133,-1.582,-46.156,-1.682,-46.171,-1.7,-46.189,-1.707,-46.177,-1.474,-46.106,-1.337,-46.163,-1.286,-46.157,-1.23,-46.062,-1.104,-46.089,-1.019,-46.201,-1.142,-46.151,-1.052,-46.176,-1.003,-46.189,-1.074,-46.251,-1.137,-46.275,-1.24,-46.298,-1.298,-46.328,-1.303,-46.318,-1.312,-46.321,-1.321,-46.33,-1.303,-46.277,-1.239,-46.263,-1.134,-46.206,-1.064,-46.242,-1.046,-46.203,-0.891,-46.344,-1.103,-46.348,-1.008,-46.431,-1.086,-46.417,-1.025,-46.479,-1.073,-46.536,-1.017,-46.467,-1.007,-46.427,-0.868,-46.529,-0.968,-46.557,-0.92,-46.576,-0.978,-46.558,-0.993,-46.544,-1.027,-46.548,-1.035,-46.62,-1.009,-46.601,-0.956,-46.758,-1.016,-46.628,-0.935,-46.638,-0.792,-46.717,-0.843,-46.711,-0.898,-46.735,-0.935,-46.75,-0.944,-46.739,-0.818,-46.807,-0.916,-46.857,-0.739,-46.918,-0.882,-46.982,-0.919,-46.96,-0.71,-47.076,-0.866,-47.103,-0.845,-47.092,-0.678,-47.125,-0.683,-47.139,-0.8,-47.149,-0.802,-47.149,-0.807,-47.158,-0.815,-47.168,-0.82,-47.216,-0.638,-47.381,-0.606,-47.419,-0.689,-47.444,-0.587,-47.472,-0.693,-47.449,-0.7,-47.398,-0.756,-47.373,-0.827,-47.383,-0.838,-47.396,-0.827,-47.401,-0.851,-47.379,-0.856,-47.379,-0.868,-47.422,-0.9,-47.408,-0.905,-47.403,-0.927,-47.403,-0.936,-47.42,-0.938,-47.418,-0.949,-47.404,-0.952,-47.393,-0.96,-47.408,-0.999,-47.395,-0.959,-47.418,-0.953,-47.424,-0.944,-47.425,-0.899,-47.4,-0.825,-47.378,-0.825,-47.481,-0.759,-47.497,-0.775,-47.494,-0.8,-47.51,-0.821,-47.502,-0.837,-47.509,-0.851,-47.502,-0.852,-47.5,-0.854,-47.503,-0.859,-47.509,-0.859,-47.502,-0.866,-47.508,-0.867,-47.543,-0.638,-47.586,-0.765,-47.615,-0.7,-47.693,-0.766,-47.727,-0.752,-47.72,-0.803,-47.73,-0.819,-47.707,-0.833,-47.712,-0.862,-47.701,-0.86,-47.694,-0.869,-47.697,-0.846,-47.695,-0.842,-47.691,-0.842,-47.68,-0.851,-47.68,-0.856,-47.683,-0.866,-47.674,-0.862,-47.666,-0.865,-47.666,-0.874,-47.673,-0.871,-47.668,-0.879,-47.714,-0.863,-47.732,-0.82,-47.731,-0.753,-47.693,-0.714,-47.634,-0.703,-47.653,-0.583,-47.704,-0.702,-47.827,-0.606,-47.784,-0.698,-47.955,-0.752,-47.89,-0.851,-47.921,-0.862,-47.986,-0.762,-47.993,-0.705,-48.003,-0.775,-48.028,-0.815,-48.062,-0.665,-48.163,-0.79,-48.142,-0.844,-48.157,-0.879,-48.136,-0.9,-48.198,-1.006,-48.225,-1.022,-48.309,-1.023,-48.292,-1.103,-48.24,-1.089,-48.233,-1.092,-48.233,-1.099,-48.292,-1.108,-48.297,-1.157,-48.327,-1.197,-48.332,-1.255,-48.36,-1.269,-48.363,-1.292,-48.348,-1.297,-48.346,-1.299,-48.34,-1.329,-48.491,-1.296,-48.508,-1.459,-48.255,-1.456,-48.117,-1.54,-48.062,-1.534,-48.053,-1.48,-48.02,-1.47,-47.982,-1.501,-47.989,-1.582,-47.939,-1.582,-47.996,-1.584,-48.02,-1.477,-48.058,-1.541,-48.091,-1.56,-48.352,-1.476,-48.373,-1.491,-48.39,-1.496,-48.405,-1.509,-48.455,-1.524,-48.476,-1.522,-48.498,-1.507,-48.45,-1.618,-48.469,-1.624,-48.48,-1.622,-48.484,-1.618,-48.5,-1.616,-48.507,-1.612,-48.512,-1.579,-48.529,-1.58,-48.537,-1.573,-48.598,-1.465,-48.613,-1.465,-48.625,-1.502,-48.629,-1.507,-48.636,-1.508,-48.641,-1.515,-48.639,-1.493,-48.647,-1.491,-48.655,-1.495,-48.668,-1.493,-48.692,-1.471,-48.967,-1.818,-48.99,-1.802,-49.016,-1.85,-49.047,-1.862,-49.072,-1.855,-49.051,-1.877,-49.05,-1.891,-49.072,-1.862,-49.075,-1.846,-49.119,-1.898,-49.162,-1.87,-49.137,-1.923,-49.135,-1.94,-49.142,-1.955,-49.195,-1.9,-49.514,-2.608,-49.529,-2.626,-49.588,-2.647,-49.675,-2.652,-49.658,-2.674,-49.679,-2.78,-49.639,-2.984,-49.645,-3.048,-49.57,-3.165,-49.561,-3.271,-49.586,-3.525,-49.656,-3.682,-49.636,-3.832,-49.664,-3.647,-49.603,-3.517,-49.618,-3.487,-49.625,-3.461,-49.63,-3.393,-49.659,-3.348,-49.674,-3.301,-49.681,-3.159,-49.658,-3.052,-49.748,-3.021,-49.764,-2.761,-49.668,-2.621,-49.664,-2.619,-49.654,-2.619,-49.643,-2.614,-49.604,-2.611,-49.566,-2.575,-49.551,-2.573,-49.553,-2.405,-49.503,-2.256,-49.427,-2.106,-49.388,-2.086,-49.431,-2.056,-49.381,-1.889,-49.273,-1.726,-49.501,-1.773,-49.53,-1.869,-49.634,-1.858,-49.652,-1.919,-49.627,-1.932,-49.597,-1.969,-49.587,-1.988,-49.585,-2.012,-49.589,-2.025,-49.632,-2.054,-49.624,-2.08,-49.626,-2.089,-49.633,-2.053,-49.587,-2.019,-49.63,-1.935,-49.855,-1.912,-49.811,-2.129,-49.82,-2.19,-49.846,-2.041,-49.913,-2.119,-49.919,-2.121,-49.942,-2.118,-49.951,-2.122,-49.852,-2.038,-49.943,-1.837,-50.164,-1.877,-50.135,-1.979,-50.186,-2.032,-50.183,-2.057,-50.193,-2.089,-50.225,-2.137,-50.186,-2.025,-50.139,-1.978,-50.282,-1.891,-50.377,-1.96,-50.374,-1.97,-50.377,-1.98,-50.366,-1.987,-50.359,-2.015,-50.365,-2.018,-50.371,-2.028,-50.377,-2.031,-50.393,-2.016,-50.431,-2.064,-50.334,-2.238,-50.43,-2.135,-50.444,-2.107,-50.46,-1.979,-50.442,-1.934,-50.455,-1.933,-50.458,-1.923,-50.525,-1.925,-50.539,-1.911,-50.518,-1.924,-50.445,-1.908,-50.499,-1.913,-50.539,-1.908,-50.667,-1.801,-50.682,-1.958,-50.825,-1.931,-50.773,-2.065,-50.763,-2.119,-50.776,-2.162,-50.675,-2.18,-50.659,-2.192,-50.641,-2.241,-50.628,-2.237,-50.62,-2.237,-50.584,-2.253,-50.575,-2.245,-50.566,-2.245,-50.521,-2.277,-50.445,-2.378,-50.431,-2.461,-50.521,-2.279,-50.628,-2.293,-50.678,-2.187,-50.761,-2.192,-50.779,-2.168,-50.801,-2.18,-50.803,-2.179,-50.804,-2.173,-50.808,-2.169,-50.839,-2.178,-50.846,-2.175,-50.787,-2.098,-50.952,-1.992,-50.951,-2.013,-50.956,-2.028,-50.987,-2.042,-50.976,-2.13,-50.993,-2.197,-51.016,-2.21,-50.997,-2.291,-50.949,-2.316,-50.944,-2.328,-50.946,-2.333,-50.936,-2.337,-50.935,-2.346,-50.998,-2.296,-50.986,-2.397,-50.792,-2.522,-50.785,-2.562,-50.643,-2.577,-50.57,-2.624,-50.533,-2.672,-50.513,-2.795,-50.461,-2.771,-50.395,-2.821,-50.515,-2.799,-50.642,-2.58,-50.75,-2.589,-50.856,-2.515,-50.869,-2.473,-50.92,-2.521,-51.014,-2.362,-51.027,-2.207,-51.05,-2.215,-51.055,-2.198,-51.089,-2.183,-51.092,-2.17,-51.103,-2.165,-51.11,-2.153,-51.134,-2.152,-51.151,-2.143,-51.16,-2.133,-51.172,-2.138,-51.182,-2.132,-51.187,-2.138,-51.194,-2.13,-51.203,-2.137,-51.207,-2.136,-51.211,-2.13,-51.193,-2.129,-51.187,-2.137,-51.183,-2.131,-51.161,-2.131,-51.111,-2.151,-51.141,-2.124,-51.135,-2.106,-51.033,-2.186,-50.987,-2.139,-50.997,-2.053,-50.993,-2.031,-50.979,-2.006,-51.122,-1.955,-51.156,-1.837,-51.251,-1.831,-51.298,-1.759,-51.33,-1.861,-51.347,-1.811,-51.378,-1.917,-51.483,-2.058,-51.45,-2.255,-51.393,-2.303,-51.359,-2.349,-51.358,-2.377,-51.339,-2.372,-51.328,-2.394,-51.356,-2.382,-51.36,-2.378,-51.363,-2.347,-51.457,-2.26,-51.451,-2.28,-51.474,-2.312,-51.524,-2.043,-51.414,-1.823,-51.473,-1.802,-51.494,-1.76,-51.506,-1.774,-51.52,-1.777,-51.521,-1.786,-51.556,-1.799,-51.597,-1.795,-51.609,-1.787,-51.608,-1.778,-51.623,-1.771,-51.456,-1.733,-51.426,-1.78,-51.32,-1.643,-51.249,-1.762,-51.208,-1.69,-51.214,-1.742,-51.206,-1.717,-51.166,-1.678,-51.222,-1.77,-51.098,-1.729,-51.123,-1.818,-51.083,-1.813,-51.054,-1.816,-50.941,-1.853,-50.95,-1.843,-50.941,-1.815,-50.918,-1.884,-50.879,-1.853,-50.765,-1.884,-50.687,-1.775,-50.741,-1.73,-50.742,-1.722,-50.742,-1.71,-50.728,-1.678,-50.727,-1.666,-50.724,-1.655,-50.697,-1.593,-50.74,-1.559,-50.757,-1.534,-50.808,-1.415,-50.823,-1.332,-50.769,-1.182,-50.833,-1.056,-50.826,-0.997,-50.885,-1.009,-50.836,-0.914,-51.013,-0.926,-51.0,-0.972,-51.02,-1.048,-50.938,-1.121,-51.13,-1.168,-51.139,-1.165,-51.15,-1.152,-51.164,-1.148,-51.181,-1.122,-51.2,-1.116,-51.24,-1.194,-51.403,-1.312,-51.313,-1.234,-51.383,-1.248,-51.456,-1.327,-51.934,-1.588,-52.137,-1.614,-52.187,-1.688,-52.251,-1.711,-52.173,-2.096,-52.123,-2.097,-52.108,-2.123,-52.103,-2.143,-52.097,-2.148,-52.148,-2.099,-52.16,-2.172,-51.951,-2.522,-52.002,-2.735,-51.899,-2.816,-51.808,-3.081,-51.653,-3.118,-51.654,-3.154,-51.601,-3.138,-51.613,-3.251,-51.643,-3.296,-51.694,-3.353,-51.705,-3.351,-51.697,-3.318,-51.708,-3.328,-51.758,-3.335,-51.694,-3.358,-51.674,-3.472,-51.687,-3.494,-51.711,-3.507,-51.703,-3.525,-51.705,-3.537,-51.696,-3.535,-51.68,-3.549,-51.68,-3.554,-51.712,-3.537,-51.714,-3.505,-51.733,-3.517,-51.738,-3.516,-51.741,-3.512,-51.765,-3.576,-51.813,-3.617,-51.894,-3.614,-51.924,-3.573,-51.94,-3.572,-51.964,-3.544,-51.979,-3.484,-51.957,-3.424,-51.964,-3.418,-51.968,-3.405,-51.979,-3.395,-51.995,-3.396,-51.999,-3.393,-52.002,-3.389,-52.014,-3.388,-52.029,-3.381,-52.031,-3.377,-52.031,-3.27,-52.019,-3.277,-52.02,-3.299,-52.013,-3.301,-52.002,-3.313,-51.988,-3.343,-51.989,-3.355,-51.934,-3.383,-51.915,-3.41,-51.897,-3.449,-51.895,-3.475,-51.897,-3.504,-51.91,-3.532,-51.853,-3.553,-51.796,-3.488,-51.759,-3.482,-51.721,-3.488,-51.752,-3.422,-51.747,-3.414,-51.752,-3.394,-51.749,-3.381,-51.73,-3.358,-51.766,-3.333,-51.748,-3.325,-51.74,-3.326,-51.729,-3.307,-51.717,-3.299,-51.71,-3.301,-51.712,-3.264,-51.692,-3.265,-51.679,-3.259,-51.701,-3.247,-51.618,-3.176,-51.821,-3.093,-51.905,-2.926,-52.003,-2.869,-52.058,-2.784,-52.049,-2.765,-52.061,-2.776,-52.068,-2.736,-52.023,-2.63,-52.038,-2.554,-52.14,-2.361,-52.21,-2.404,-52.152,-2.346,-52.261,-2.046,-52.371,-2.158,-52.483,-2.145,-52.478,-2.25,-52.484,-2.267,-52.479,-2.277,-52.481,-2.299,-52.486,-2.308,-52.532,-2.125,-52.402,-2.132,-52.373,-2.059,-52.255,-2.027,-52.305,-1.934,-52.249,-1.825,-52.269,-1.833,-52.294,-1.833,-52.383,-1.798,-52.248,-1.819,-52.328,-1.685,-52.303,-1.634,-52.214,-1.641,-52.243,-1.53,-52.898,-1.661,-52.99,-1.773,-53.255,-1.821,-53.341,-1.866,-53.434,-1.901,-53.475,-1.907,-53.498,-1.989,-53.635,-2.139,-53.665,-2.081,-53.837,-2.102,-53.679,-2.078,-53.701,-2.015,-53.68,-2.001,-53.624,-2.096,-53.516,-1.93,-53.477,-1.907,-53.375,-1.874,-53.881,-2.012,-54.012,-2.17,-54.089,-2.513,-54.078,-2.376,-54.26,-2.416,-54.364,-2.57,-54.317,-2.488,-54.363,-2.52,-54.463,-2.539,-54.554,-2.514,-54.582,-2.499,-54.574,-2.468,-54.553,-2.446,-54.112,-2.306,-54.033,-2.015,-54.093,-2.018,-54.089,-2.123,-54.101,-2.136,-54.111,-2.14,-54.109,-2.15,-54.116,-2.158,-54.11,-2.171,-54.12,-2.182,-54.113,-2.187,-54.111,-2.192,-54.125,-2.205,-54.123,-2.214,-54.109,-2.219,-54.107,-2.225,-54.101,-2.229,-54.1,-2.233,-54.102,-2.235,-54.108,-2.233,-54.111,-2.237,-54.133,-2.25,-54.156,-2.255,-54.094,-2.016,-54.027,-1.999,-53.996,-1.954,-53.961,-1.933,-53.522,-1.81,-53.671,-1.792,-53.634,-1.758,-53.518,-1.81,-53.341,-1.746,-53.089,-1.752,-53.085,-1.748,-53.094,-1.744,-53.098,-1.729,-53.096,-1.725,-52.925,-1.602,-52.893,-1.586,-52.637,-1.539,-52.8,-1.441,-52.837,-1.373,-52.771,-1.455,-52.754,-1.467,-52.761,-1.457,-52.713,-1.471,-52.706,-1.475,-52.691,-1.495,-52.622,-1.536,-52.19,-1.349,-52.091,-1.429,-51.987,-1.404,-51.895,-1.16,-51.957,-1.168,-51.966,-1.159,-51.963,-1.128,-51.981,-1.119,-51.992,-1.13,-51.978,-1.152,-51.98,-1.161,-51.986,-1.168,-52.035,-1.164,-51.98,-1.115,-51.819,-1.15,-51.701,-1.04,-51.695,-0.746,-51.222,-0.09,-51.268,-0.096,-51.395,-0.069,-51.403,-0.063,-51.406,-0.051,-51.418,-0.049,-51.434,-0.035,-51.446,0.0,-51.211,-0.077,-51.25,0.034,-51.244,0.049,-51.237,0.057,-51.22,0.064,-51.222,0.075,-51.166,-0.063,-51.059,0.0,-50.911,0.0,-50.962,-0.044,-50.771,0.0,-50.74,0.0,-50.71,0.0,-50.68,0.0,-50.643,0.0,-50.589,-0.03,-50.522,0.0,-50.493,-0.022,-50.515,0.0,-50.512,0.025,-50.032,0.0,-50.009,-0.055,-49.739,0.0,-49.829,-0.102,-49.68,-0.141,-49.446,-0.117,-49.357,0.0,6.62,0.0,8.702,-0.634,8.883,-1.018,8.974,-0.936],"holes":[[-50.68,0.0,-50.979,-0.057,-51.037,-0.225,-50.897,-0.258,-50.72,-0.176,-50.622,-0.089,-50.68,0.0],[-50.605,-0.224,-50.709,-0.428,-50.587,-0.499,-50.647,-0.534,-50.534,-0.522,-50.588,-0.543,-50.542,-0.584,-50.363,-0.525,-50.329,-0.375,-50.428,-0.288,-50.377,-0.215,-50.605,-0.224],[-50.336,-0.143,-50.401,-0.285,-50.319,-0.422,-50.361,-0.526,-50.544,-0.587,-50.645,-0.495,-50.717,-0.521,-50.663,-0.631,-50.333,-0.712,-50.397,-0.788,-50.368,-0.969,-50.654,-1.093,-50.578,-1.139,-50.56,-1.167,-50.574,-1.15,-50.73,-1.096,-50.652,-1.238,-50.802,-1.092,-50.809,-1.354,-50.664,-1.403,-50.591,-1.553,-50.475,-1.347,-50.359,-1.311,-50.574,-1.16,-50.358,-1.307,-50.358,-1.313,-50.374,-1.343,-50.474,-1.348,-50.584,-1.551,-50.44,-1.768,-50.281,-1.775,-50.373,-1.606,-50.271,-1.758,-50.307,-1.615,-50.291,-1.458,-50.292,-1.625,-50.225,-1.666,-50.21,-1.519,-50.087,-1.742,-50.043,-1.581,-50.017,-1.645,-49.97,-1.576,-50.058,-1.721,-49.809,-1.818,-49.733,-1.746,-49.75,-1.554,-49.692,-1.786,-49.545,-1.721,-49.616,-1.624,-49.506,-1.614,-49.537,-1.491,-49.487,-1.579,-49.354,-1.587,-49.379,-1.519,-49.264,-1.63,-49.17,-1.62,-49.164,-1.598,-49.202,-1.602,-49.2,-1.579,-49.231,-1.593,-49.263,-1.535,-49.23,-1.592,-49.202,-1.576,-49.193,-1.583,-49.199,-1.599,-49.164,-1.581,-49.194,-1.563,-49.184,-1.547,-49.217,-1.526,-49.188,-1.501,-49.215,-1.526,-49.183,-1.545,-49.187,-1.566,-49.082,-1.559,-49.194,-1.405,-49.219,-1.333,-49.059,-1.545,-48.826,-1.448,-48.983,-1.346,-48.867,-1.305,-48.813,-1.41,-48.785,-1.264,-48.869,-1.201,-48.622,-1.072,-48.63,-0.934,-48.508,-0.881,-48.511,-0.753,-48.635,-0.661,-48.551,-0.587,-48.567,-0.681,-48.488,-0.738,-48.405,-0.252,-48.926,-0.256,-49.195,-0.133,-49.613,-0.253,-50.336,-0.143],[-51.48,-1.229,-51.429,-1.196,-51.416,-1.175,-51.432,-1.192,-51.478,-1.212,-51.534,-1.175,-51.431,-1.19,-51.325,-0.993,-51.25,-0.987,-51.23,-0.831,-51.252,-0.877,-51.384,-0.869,-51.252,-0.876,-51.178,-0.695,-51.385,-0.652,-51.332,-0.52,-51.588,-0.666,-51.652,-1.079,-51.862,-1.245,-51.949,-1.431,-51.803,-1.471,-51.647,-1.337,-51.88,-1.428,-51.48,-1.229],[-50.593,-0.734,-50.653,-0.73,-50.599,-0.834,-50.682,-0.922,-50.567,-1.07,-50.363,-0.926,-50.401,-0.783,-50.541,-0.677,-50.593,-0.734],[-48.419,-27.382,-48.546,-27.458,-48.57,-27.837,-48.475,-27.771,-48.36,-27.447,-48.419,-27.382],[-58.97,-51.233,-58.867,-51.377,-59.144,-51.464,-58.851,-51.521,-59.046,-51.505,-59.04,-51.601,-59.128,-51.496,-59.179,-51.584,-58.977,-51.671,-59.049,-51.653,-58.965,-51.78,-59.049,-51.828,-59.057,-51.689,-59.233,-51.722,-59.387,-51.852,-59.498,-51.836,-59.445,-51.889,-59.539,-51.882,-59.469,-51.928,-59.61,-51.916,-59.522,-51.964,-59.65,-51.965,-59.549,-52.006,-59.651,-51.999,-59.557,-52.067,-59.641,-52.085,-59.604,-52.122,-59.732,-52.097,-59.499,-52.312,-59.296,-52.333,-59.413,-52.284,-59.33,-52.211,-59.459,-52.223,-59.41,-52.115,-59.012,-52.256,-59.104,-52.198,-59.025,-52.144,-59.157,-52.162,-59.068,-52.103,-59.277,-51.991,-59.017,-52.02,-58.928,-52.118,-58.888,-52.042,-58.747,-52.046,-58.659,-52.142,-58.679,-52.027,-58.586,-52.017,-59.111,-51.837,-58.986,-51.857,-58.947,-51.788,-58.783,-51.896,-58.511,-51.893,-58.552,-51.851,-58.401,-51.923,-58.163,-51.817,-58.378,-51.825,-58.109,-51.789,-58.296,-51.748,-58.066,-51.772,-57.712,-51.682,-57.92,-51.686,-57.83,-51.682,-57.962,-51.653,-57.722,-51.645,-58.165,-51.548,-58.019,-51.536,-58.072,-51.494,-57.763,-51.541,-57.895,-51.498,-57.843,-51.475,-57.718,-51.518,-57.944,-51.361,-58.281,-51.39,-58.329,-51.474,-58.387,-51.485,-58.201,-51.526,-58.264,-51.553,-58.15,-51.61,-58.252,-51.585,-58.178,-51.643,-58.261,-51.655,-58.277,-51.554,-58.45,-51.547,-58.425,-51.469,-58.61,-51.465,-58.504,-51.381,-58.35,-51.463,-58.377,-51.332,-58.571,-51.297,-58.617,-51.358,-58.97,-51.233],[-60.641,-52.26,-60.488,-52.179,-60.585,-52.126,-60.329,-52.152,-60.267,-52.093,-60.242,-52.02,-60.276,-52.083,-60.33,-51.987,-59.857,-51.964,-59.537,-51.659,-59.592,-51.663,-59.339,-51.529,-59.442,-51.518,-59.335,-51.525,-59.19,-51.4,-59.417,-51.337,-59.477,-51.404,-59.386,-51.439,-59.523,-51.477,-59.6,-51.443,-59.49,-51.396,-59.793,-51.46,-59.731,-51.42,-59.995,-51.379,-60.101,-51.439,-59.981,-51.473,-60.152,-51.503,-60.664,-51.34,-60.648,-51.416,-60.381,-51.488,-60.492,-51.542,-60.31,-51.545,-60.003,-51.731,-60.235,-51.656,-60.65,-51.667,-60.54,-51.809,-60.317,-51.697,-60.183,-51.752,-60.326,-51.762,-60.358,-51.874,-60.414,-51.864,-60.373,-51.766,-60.448,-51.777,-60.506,-51.936,-60.648,-51.924,-60.544,-51.955,-60.402,-51.927,-60.57,-51.984,-60.508,-52.024,-60.847,-51.953,-60.746,-52.018,-60.898,-52.028,-60.862,-52.079,-61.079,-52.01,-60.8,-52.19,-60.718,-52.177,-60.834,-52.099,-60.677,-52.129,-60.748,-52.144,-60.635,-52.18,-60.698,-52.211,-60.641,-52.26],[-37.717,-53.976,-38.013,-54.011,-38.022,-54.064,-37.611,-54.045,-37.733,-54.109,-37.63,-54.191,-37.24,-54.15,-37.416,-54.186,-37.308,-54.231,-37.42,-54.278,-37.217,-54.241,-36.778,-54.418,-36.834,-54.456,-36.506,-54.511,-36.106,-54.901,-35.939,-54.857,-36.107,-54.772,-35.794,-54.777,-35.963,-54.705,-35.895,-54.546,-36.09,-54.575,-36.011,-54.504,-36.176,-54.451,-36.159,-54.374,-36.266,-54.391,-36.205,-54.347,-36.247,-54.289,-36.402,-54.245,-36.349,-54.373,-36.52,-54.357,-36.489,-54.239,-36.699,-54.301,-36.689,-54.237,-36.491,-54.183,-36.704,-54.187,-36.626,-54.131,-36.805,-54.154,-36.896,-54.079,-37.05,-54.136,-36.991,-54.056,-37.156,-54.133,-37.135,-54.042,-37.25,-54.093,-37.498,-54.062,-37.367,-53.992,-37.717,-53.976],[-64.582,-54.899,-64.306,-54.788,-64.234,-54.782,-64.222,-54.858,-63.841,-54.79,-63.794,-54.726,-64.048,-54.778,-64.339,-54.703,-64.371,-54.811,-64.545,-54.725,-64.508,-54.797,-64.757,-54.837,-64.659,-54.847,-64.702,-54.915,-64.582,-54.899]]}],"names":{"ar":"جنوب المحيط الأطلسي","de":"Südatlantik","es":"Océano Atlántico Sur","fr":"océan Atlantique Sud","he":"דרום האוקיינוס האטלנטי","it":"Atlantico meridionale","nl":"Zuidelijke Atlantische Oceaan","pt":"Oceano Atlântico Sul","zh":"南大西洋"}},{"name":"Southern Ocean","bbox":[-180.0,-85.562,180.0,-60.0],"area":4728.4828,"polygons":[{"outer":[180.0,-60.0,179.999,-84.36,176.576,-84.037,175.141,-84.001,174.843,-83.901,172.919,-83.937,173.492,-83.828,172.776,-83.751,172.163,-83.756,171.634,-83.824,171.589,-83.892,171.27,-83.847,171.629,-83.793,171.935,-83.633,171.62,-83.566,170.079,-83.523,169.494,-83.425,169.183,-83.491,169.217,-83.441,169.091,-83.401,168.124,-83.526,167.699,-83.507,167.572,-83.455,167.755,-83.321,168.272,-83.307,168.001,-83.279,168.569,-83.209,168.123,-83.06,166.552,-82.96,166.486,-82.924,167.148,-82.923,166.356,-82.825,166.312,-82.747,164.789,-82.689,165.337,-82.52,164.699,-82.42,163.154,-82.488,163.348,-82.583,163.066,-82.639,163.178,-82.685,162.457,-82.546,161.665,-82.55,161.216,-82.401,163.563,-82.312,163.753,-82.245,162.728,-81.985,162.774,-81.932,162.692,-81.892,162.294,-81.905,162.17,-81.854,162.179,-81.747,160.901,-81.568,161.275,-81.5,160.453,-81.41,160.507,-81.308,160.785,-81.277,160.43,-81.253,160.553,-81.231,160.441,-81.183,160.597,-81.147,160.33,-81.026,160.658,-80.976,160.075,-80.905,160.968,-80.708,159.663,-80.689,160.775,-80.548,159.987,-80.185,160.389,-80.017,160.158,-79.933,160.227,-79.818,159.995,-79.735,160.101,-79.677,160.014,-79.603,160.276,-79.565,160.184,-79.502,160.339,-79.465,160.242,-79.44,160.491,-79.372,160.491,-79.308,160.267,-79.247,160.887,-79.109,162.494,-79.114,162.727,-78.94,162.616,-78.883,163.331,-78.759,163.854,-78.757,164.119,-78.644,166.109,-78.584,166.875,-78.684,166.802,-78.735,167.133,-78.708,166.937,-78.556,165.64,-78.462,165.569,-78.43,165.61,-78.337,165.227,-78.245,165.482,-78.173,165.337,-78.058,165.052,-78.196,165.09,-78.239,165.005,-78.291,164.575,-78.375,164.233,-78.337,163.728,-78.418,163.299,-78.328,164.27,-78.14,164.461,-77.987,164.375,-77.841,164.423,-77.793,164.175,-77.705,163.432,-77.746,163.357,-77.713,163.587,-77.665,163.383,-77.607,163.725,-77.553,163.651,-77.494,163.723,-77.456,163.442,-77.419,163.475,-77.359,163.028,-77.14,163.052,-77.086,162.271,-77.069,162.38,-77.021,162.202,-77.0,162.453,-76.975,162.526,-76.867,162.83,-76.865,162.876,-76.768,162.524,-76.695,162.771,-76.627,162.612,-76.6,162.773,-76.481,162.609,-76.4,162.688,-76.294,162.398,-76.274,162.232,-76.161,162.62,-76.134,162.589,-75.982,162.979,-75.973,162.653,-75.938,162.762,-75.879,162.721,-75.822,162.967,-75.781,162.497,-75.718,162.851,-75.594,162.692,-75.479,164.586,-75.581,164.668,-75.433,164.286,-75.439,164.366,-75.398,164.162,-75.313,164.203,-75.25,162.559,-75.258,162.574,-75.195,162.41,-75.155,162.738,-75.04,162.414,-74.945,162.824,-74.975,162.56,-74.872,162.847,-74.942,162.584,-74.8,162.847,-74.803,162.722,-74.768,162.782,-74.73,163.214,-74.737,163.085,-74.654,163.397,-74.524,163.63,-74.585,163.582,-74.664,163.943,-74.645,163.622,-74.775,163.864,-74.881,163.812,-74.947,164.07,-74.768,163.984,-74.69,164.162,-74.671,164.254,-74.574,164.765,-74.629,165.113,-74.574,165.333,-74.694,165.399,-74.555,164.928,-74.357,164.998,-74.314,164.829,-74.286,164.745,-74.158,164.847,-74.133,165.028,-74.243,165.073,-74.121,165.266,-74.074,166.229,-74.131,166.165,-74.106,166.231,-74.038,165.587,-73.957,165.96,-73.936,165.805,-73.879,166.677,-73.737,166.465,-73.627,166.864,-73.695,166.931,-73.664,166.748,-73.61,167.258,-73.625,167.202,-73.56,167.906,-73.526,167.942,-73.42,168.042,-73.454,167.997,-73.561,168.38,-73.615,168.591,-73.444,168.831,-73.513,169.014,-73.474,168.954,-73.41,169.066,-73.37,168.937,-73.306,169.16,-73.303,169.149,-73.23,168.994,-73.2,169.613,-73.099,169.604,-73.004,169.873,-72.739,169.827,-72.711,170.114,-72.72,169.962,-72.644,170.248,-72.621,170.244,-72.351,169.81,-72.419,169.971,-72.331,169.759,-72.26,169.97,-72.203,169.975,-72.079,170.395,-72.053,170.924,-71.874,170.656,-71.753,170.416,-71.455,170.235,-71.357,170.133,-71.335,170.286,-71.543,170.18,-71.649,170.238,-71.677,170.137,-71.708,169.716,-71.534,169.377,-71.542,169.241,-71.416,168.93,-71.394,168.803,-71.294,168.068,-71.188,167.754,-71.033,167.891,-70.931,167.697,-70.826,166.546,-70.815,166.332,-70.734,166.838,-70.735,166.653,-70.655,165.784,-70.737,165.26,-70.612,163.498,-70.517,163.436,-70.543,163.699,-70.662,163.506,-70.715,163.086,-70.655,162.589,-70.318,162.267,-70.316,161.895,-70.466,161.953,-70.269,161.9,-70.255,161.728,-70.458,161.651,-70.369,161.699,-70.349,161.537,-70.25,160.978,-70.336,160.758,-70.143,160.297,-70.062,160.559,-70.01,160.488,-69.914,160.175,-69.916,160.253,-69.858,160.143,-69.793,159.73,-69.781,159.76,-69.723,159.632,-69.729,159.55,-69.629,159.35,-69.603,159.418,-69.562,158.895,-69.339,158.875,-69.428,158.71,-69.362,158.414,-69.406,157.812,-69.187,157.378,-69.239,157.079,-69.002,157.156,-69.092,157.026,-69.07,157.104,-69.201,156.817,-69.155,156.782,-69.226,156.43,-69.093,155.436,-68.918,155.148,-68.917,155.01,-69.04,154.866,-69.035,154.451,-68.811,154.635,-68.738,154.424,-68.64,154.77,-68.673,154.693,-68.585,154.299,-68.55,153.821,-68.34,153.577,-68.339,153.294,-68.477,153.137,-68.411,152.641,-68.424,152.042,-68.704,151.375,-68.74,151.053,-68.644,151.008,-68.541,151.083,-68.455,151.003,-68.31,149.802,-68.468,149.114,-68.335,148.399,-68.444,147.867,-68.407,147.761,-68.327,148.605,-68.284,148.488,-67.846,147.812,-67.805,147.057,-67.927,146.735,-67.825,146.507,-67.855,146.566,-67.723,146.025,-67.605,145.243,-67.606,145.53,-67.422,146.616,-67.241,146.811,-67.1,146.83,-67.022,146.459,-66.85,145.548,-66.822,145.431,-66.846,145.537,-66.922,145.365,-67.019,145.272,-66.9,144.577,-67.175,144.404,-67.017,143.851,-67.095,143.731,-67.025,143.78,-66.942,143.385,-66.863,142.523,-67.034,141.889,-66.794,141.332,-66.843,141.078,-66.743,140.092,-66.735,139.584,-66.579,138.348,-66.536,137.036,-66.329,136.437,-66.394,136.009,-66.229,136.065,-66.136,135.823,-66.18,135.492,-66.096,135.316,-65.963,135.146,-66.003,135.343,-65.889,135.479,-65.652,135.437,-65.564,134.867,-65.275,134.546,-65.326,134.56,-65.513,134.463,-65.563,134.762,-65.794,134.552,-65.9,134.672,-65.975,134.138,-66.212,133.82,-66.111,133.406,-66.097,132.809,-66.203,132.46,-66.144,131.664,-66.211,129.495,-65.945,129.525,-66.03,130.006,-66.236,129.624,-66.579,129.744,-66.752,129.612,-66.926,129.304,-67.024,127.919,-67.02,127.902,-66.936,127.438,-66.687,127.432,-66.598,126.148,-66.27,124.684,-66.657,121.126,-67.104,121.101,-66.781,120.703,-66.891,120.371,-66.833,119.517,-66.944,118.505,-66.911,118.25,-67.023,117.714,-66.989,116.988,-66.816,116.693,-66.57,116.103,-66.367,115.456,-66.373,114.89,-66.492,114.687,-66.381,114.519,-66.446,114.473,-66.404,114.579,-66.294,114.44,-66.264,114.531,-66.183,113.928,-66.04,113.419,-65.733,113.201,-65.7,112.479,-65.87,111.549,-65.914,110.93,-66.038,110.521,-66.241,110.493,-66.334,110.659,-66.38,110.508,-66.516,110.349,-66.458,110.204,-66.56,110.3,-66.619,109.475,-66.6,109.549,-66.647,109.274,-66.706,109.326,-66.742,109.234,-66.797,109.309,-66.872,109.174,-66.825,109.211,-66.896,108.9,-66.922,108.176,-66.608,106.977,-66.461,106.561,-66.382,106.582,-66.307,106.467,-66.355,105.113,-66.073,103.638,-65.992,102.982,-65.838,101.592,-65.99,101.369,-65.942,101.26,-66.021,101.547,-66.019,101.012,-66.247,100.681,-66.199,101.051,-66.116,100.667,-66.122,100.918,-65.997,100.84,-65.971,100.773,-66.055,100.633,-66.038,100.546,-66.145,100.328,-66.194,100.345,-66.124,100.135,-66.092,100.207,-66.057,100.122,-65.822,100.248,-65.661,100.036,-65.654,99.502,-65.727,99.607,-65.813,99.531,-65.88,99.296,-65.766,98.736,-65.747,98.858,-66.002,99.056,-66.15,98.954,-66.269,99.038,-66.329,98.729,-66.368,98.99,-66.42,98.732,-66.477,98.632,-66.38,97.978,-66.551,97.791,-66.52,97.721,-66.599,97.091,-66.494,96.57,-66.621,95.917,-66.605,95.56,-66.453,95.294,-66.546,94.613,-66.502,94.342,-66.638,94.212,-66.523,93.969,-66.518,93.751,-66.573,93.752,-66.635,93.028,-66.559,92.792,-66.635,91.947,-66.47,89.894,-66.813,88.105,-66.693,87.08,-66.961,86.274,-67.063,85.96,-67.194,84.6,-67.068,84.05,-67.341,82.974,-67.609,82.509,-67.621,81.888,-67.78,80.42,-67.943,79.363,-68.151,78.648,-68.487,78.562,-68.433,78.307,-68.499,78.792,-68.516,78.17,-68.603,78.461,-68.598,78.144,-68.634,78.36,-68.658,78.089,-68.705,78.306,-68.78,78.191,-68.878,78.248,-68.982,78.113,-69.116,77.023,-69.251,76.797,-69.405,76.361,-69.399,75.933,-69.719,75.171,-69.821,74.994,-69.721,74.263,-69.73,73.637,-69.834,73.259,-69.98,73.328,-70.002,72.869,-70.081,72.864,-70.193,73.259,-70.356,73.282,-70.442,72.5,-70.653,72.363,-70.914,71.747,-71.175,71.459,-71.442,69.284,-70.792,69.279,-70.515,69.158,-70.333,68.373,-70.41,68.086,-70.266,68.187,-69.988,68.361,-69.896,68.908,-69.926,69.235,-69.845,69.071,-69.701,68.451,-69.62,68.486,-69.558,68.806,-69.571,68.663,-69.488,68.969,-69.413,68.692,-69.297,69.729,-69.315,69.88,-69.25,69.7,-69.138,69.073,-69.117,70.054,-68.92,69.151,-68.954,70.136,-68.692,70.148,-68.62,69.983,-68.534,70.14,-68.436,70.151,-68.343,69.857,-68.097,69.92,-67.896,69.858,-67.773,69.473,-67.76,69.308,-67.883,69.054,-67.919,68.459,-67.919,63.661,-67.506,62.767,-67.658,62.074,-67.544,61.527,-67.568,61.458,-67.554,61.541,-67.471,61.159,-67.497,60.602,-67.35,59.671,-67.454,59.491,-67.411,59.563,-67.374,59.336,-67.379,59.171,-67.45,58.643,-67.33,59.133,-67.306,58.908,-67.182,58.482,-67.216,58.125,-67.071,58.021,-67.114,57.607,-66.998,57.385,-67.052,57.45,-67.1,57.134,-67.027,56.985,-67.076,56.657,-66.966,56.828,-66.911,56.628,-66.902,56.808,-66.888,56.777,-66.853,56.485,-66.851,56.671,-66.812,56.324,-66.839,56.875,-66.787,56.982,-66.7,57.382,-66.71,57.398,-66.607,56.843,-66.396,56.378,-66.384,55.715,-66.002,55.222,-65.896,53.843,-65.843,52.127,-65.963,51.411,-66.215,50.678,-66.303,50.396,-66.43,50.292,-66.543,50.309,-66.737,50.859,-66.784,50.514,-66.936,50.591,-67.017,50.517,-67.073,51.063,-67.103,50.917,-67.186,50.652,-67.219,50.157,-67.125,50.073,-67.218,49.802,-67.27,50.003,-67.084,49.494,-66.974,49.299,-66.812,49.132,-66.901,48.372,-67.001,48.376,-67.141,48.846,-67.247,48.766,-67.307,49.342,-67.29,48.467,-67.593,48.253,-67.558,48.223,-67.646,47.379,-67.673,47.252,-67.638,47.367,-67.582,47.275,-67.489,47.482,-67.412,46.928,-67.276,46.492,-67.299,46.285,-67.361,46.268,-67.45,46.408,-67.61,46.321,-67.666,45.854,-67.674,45.789,-67.7,45.815,-67.777,45.494,-67.71,45.102,-67.746,44.538,-67.976,42.819,-68.079,42.868,-68.125,42.672,-68.127,42.282,-68.346,41.047,-68.566,40.954,-68.689,40.55,-68.727,39.811,-68.976,39.76,-69.029,39.91,-69.191,39.743,-69.202,39.75,-69.254,39.953,-69.409,39.842,-69.478,39.623,-69.453,39.819,-69.542,39.808,-69.637,39.232,-69.742,39.238,-69.871,39.072,-69.838,38.908,-70.023,38.772,-69.934,38.563,-69.97,38.659,-70.073,38.329,-69.796,37.932,-69.794,37.81,-69.667,37.158,-69.844,37.126,-69.688,36.902,-69.566,35.8,-69.358,35.421,-69.066,34.656,-68.908,34.429,-68.671,34.04,-68.595,33.542,-68.643,33.262,-68.756,33.216,-69.045,33.595,-69.166,33.373,-69.294,33.517,-69.587,33.277,-69.665,31.729,-69.628,31.011,-69.772,29.09,-69.896,26.77,-70.213,25.432,-70.283,24.296,-70.477,23.162,-70.518,21.236,-70.364,20.013,-70.405,18.308,-70.235,16.212,-70.19,14.452,-70.313,12.395,-70.313,12.126,-70.404,11.988,-70.603,11.78,-70.627,10.928,-70.56,10.267,-70.333,9.494,-70.196,7.833,-70.18,5.671,-70.461,4.437,-70.251,3.015,-70.498,2.306,-70.898,0.53,-71.258,-0.457,-71.678,-0.969,-71.591,-0.836,-71.399,-1.02,-71.297,-1.297,-71.302,-1.598,-71.444,-2.208,-71.473,-2.257,-71.44,-2.204,-71.377,-2.518,-71.187,-2.568,-70.998,-2.001,-70.827,-3.175,-70.658,-2.692,-70.425,-2.76,-70.322,-2.988,-70.297,-3.428,-70.424,-3.546,-70.665,-3.384,-70.868,-3.735,-71.106,-3.037,-71.273,-3.655,-71.342,-3.904,-71.25,-4.316,-71.33,-4.746,-71.285,-5.956,-71.423,-6.211,-71.338,-5.84,-71.15,-5.821,-71.033,-5.688,-70.972,-5.837,-70.817,-5.819,-70.712,-6.963,-70.693,-7.683,-70.862,-7.296,-71.085,-7.769,-71.444,-7.396,-71.69,-7.919,-71.705,-8.451,-71.823,-8.728,-71.729,-8.78,-71.412,-9.013,-71.202,-9.69,-71.03,-9.517,-70.931,-10.216,-70.922,-10.481,-70.977,-10.099,-71.12,-10.665,-71.39,-10.876,-71.689,-10.99,-71.728,-11.689,-71.434,-11.733,-71.377,-11.645,-71.268,-12.323,-71.358,-12.241,-71.373,-12.184,-71.566,-12.007,-71.665,-11.651,-71.783,-11.192,-71.813,-11.083,-71.905,-11.42,-72.304,-12.084,-72.579,-13.75,-72.9,-15.109,-72.903,-18.407,-73.536,-21.557,-74.435,-22.721,-74.627,-22.942,-74.729,-23.453,-75.225,-24.767,-75.758,-26.436,-76.088,-27.013,-76.002,-27.078,-76.03,-26.969,-76.092,-28.856,-76.322,-28.923,-76.34,-28.431,-76.429,-31.037,-77.087,-31.464,-77.027,-31.362,-77.159,-31.773,-77.085,-31.459,-77.219,-32.952,-77.385,-34.257,-77.635,-34.633,-77.796,-35.401,-77.824,-35.642,-77.98,-35.611,-78.104,-36.379,-78.347,-36.446,-78.448,-36.373,-78.527,-36.526,-78.655,-36.343,-78.831,-35.948,-78.934,-33.855,-79.166,-30.85,-79.197,-29.961,-79.281,-29.889,-79.363,-30.769,-79.537,-30.394,-79.701,-30.416,-79.8,-30.08,-80.041,-30.253,-80.198,-30.811,-80.324,-30.667,-80.426,-32.058,-80.398,-31.848,-80.139,-31.98,-80.087,-34.582,-80.207,-35.867,-80.351,-37.927,-80.839,-42.938,-81.534,-45.479,-81.716,-50.496,-81.765,-51.645,-81.839,-55.705,-82.355,-58.43,-83.01,-59.277,-82.981,-59.344,-82.929,-59.004,-82.646,-60.184,-82.422,-65.004,-82.512,-67.833,-82.41,-71.041,-82.03,-71.53,-81.925,-72.051,-81.66,-71.725,-81.44,-70.589,-81.296,-63.185,-81.155,-60.772,-81.033,-58.435,-80.454,-54.792,-79.979,-54.003,-80.077,-52.766,-80.46,-51.452,-80.706,-48.872,-80.725,-45.878,-80.523,-44.995,-80.41,-44.261,-80.157,-44.444,-80.057,-44.929,-79.992,-44.992,-79.931,-44.907,-79.879,-43.55,-79.976,-42.971,-79.906,-43.133,-79.796,-43.132,-79.709,-42.877,-79.448,-42.93,-79.368,-43.113,-79.34,-44.059,-79.34,-45.997,-79.483,-46.349,-79.436,-46.517,-79.3,-44.063,-79.067,-44.228,-78.846,-44.5,-78.797,-45.38,-78.769,-48.448,-78.919,-48.499,-78.861,-48.159,-78.778,-45.365,-78.622,-44.888,-78.519,-45.882,-78.08,-46.403,-77.953,-48.868,-77.71,-51.213,-78.313,-53.148,-78.664,-53.55,-78.968,-54.203,-79.23,-56.043,-79.449,-59.039,-79.639,-59.455,-79.81,-59.5,-79.985,-59.69,-80.102,-60.03,-80.175,-69.626,-80.717,-72.326,-80.775,-74.501,-80.696,-76.228,-80.464,-77.001,-80.275,-76.929,-80.172,-78.122,-80.162,-80.027,-79.976,-77.99,-80.037,-77.33,-79.984,-79.026,-79.518,-78.693,-79.454,-79.452,-79.312,-80.023,-79.248,-80.351,-79.301,-79.994,-79.571,-80.018,-79.652,-80.336,-79.745,-81.146,-79.451,-81.277,-79.271,-81.557,-79.182,-81.032,-79.075,-81.422,-78.876,-81.075,-78.655,-82.042,-78.385,-82.053,-78.312,-81.873,-78.194,-82.388,-78.127,-81.936,-78.034,-81.99,-77.896,-82.461,-77.488,-82.711,-77.392,-82.719,-77.266,-83.216,-77.151,-81.955,-77.084,-81.974,-77.179,-81.36,-77.47,-80.855,-77.17,-81.058,-77.018,-80.993,-76.965,-75.757,-76.645,-75.787,-77.132,-74.11,-77.771,-71.524,-78.067,-69.059,-78.086,-68.47,-77.976,-68.302,-77.829,-67.642,-77.628,-67.554,-77.525,-67.55,-77.343,-67.691,-77.014,-67.638,-76.886,-68.977,-76.684,-69.88,-76.433,-65.755,-75.924,-65.25,-75.779,-64.045,-75.625,-63.144,-75.341,-64.468,-75.313,-64.129,-75.22,-62.765,-75.119,-61.972,-74.862,-61.919,-74.802,-62.103,-74.664,-61.937,-74.584,-61.047,-74.522,-60.96,-74.484,-61.105,-74.419,-60.746,-74.377,-60.669,-74.297,-61.681,-74.308,-61.332,-74.25,-60.997,-74.09,-60.75,-74.1,-60.841,-73.972,-61.043,-73.945,-60.865,-73.888,-60.927,-73.814,-60.681,-73.735,-60.786,-73.547,-60.634,-73.424,-60.57,-73.188,-60.435,-73.175,-60.262,-73.314,-59.903,-73.266,-59.944,-73.126,-59.788,-72.924,-59.912,-72.869,-60.105,-72.906,-60.153,-72.994,-60.48,-72.954,-60.956,-73.067,-60.51,-72.87,-60.526,-72.694,-60.69,-72.639,-60.972,-72.709,-61.56,-72.677,-61.361,-72.615,-61.571,-72.51,-61.438,-72.417,-60.933,-72.483,-60.687,-72.425,-60.641,-72.4,-60.716,-72.364,-61.053,-72.357,-60.939,-72.287,-60.681,-72.317,-60.82,-72.18,-61.061,-72.169,-60.691,-72.108,-60.645,-72.021,-60.776,-71.993,-60.883,-72.096,-61.026,-72.03,-61.517,-72.095,-61.853,-72.05,-61.957,-71.967,-61.804,-71.881,-60.782,-71.766,-60.835,-71.656,-61.178,-71.531,-61.445,-71.575,-61.568,-71.469,-61.186,-71.419,-61.318,-71.338,-60.995,-71.362,-61.062,-71.265,-61.401,-71.233,-60.939,-71.188,-61.446,-71.047,-61.304,-70.915,-61.363,-70.838,-62.101,-70.916,-62.051,-70.801,-61.664,-70.698,-61.661,-70.607,-61.404,-70.603,-61.421,-70.511,-62.205,-70.54,-62.41,-70.465,-62.464,-70.372,-61.886,-70.19,-61.975,-70.065,-62.35,-69.994,-62.457,-69.93,-62.394,-69.892,-62.674,-69.814,-62.421,-69.741,-62.428,-69.507,-62.605,-69.473,-62.722,-69.354,-63.023,-69.375,-63.226,-69.163,-63.408,-69.243,-63.581,-69.2,-63.343,-69.043,-63.824,-69.026,-63.386,-68.93,-63.484,-68.904,-63.387,-68.9,-63.409,-68.836,-63.224,-68.831,-63.881,-68.674,-63.551,-68.531,-62.941,-68.471,-63.793,-68.459,-64.038,-68.634,-64.25,-68.589,-64.224,-68.677,-64.016,-68.75,-64.148,-68.792,-64.483,-68.761,-64.653,-68.664,-65.181,-68.65,-65.351,-68.512,-64.982,-68.404,-65.529,-68.322,-64.813,-68.175,-64.786,-68.11,-64.96,-68.077,-64.942,-68.02,-65.311,-68.18,-65.721,-68.136,-65.615,-68.03,-65.337,-67.976,-65.744,-67.886,-65.607,-67.84,-65.626,-67.726,-65.381,-67.656,-65.625,-67.492,-65.533,-67.355,-65.038,-67.34,-65.088,-67.314,-64.958,-67.233,-65.021,-67.191,-64.734,-67.083,-64.964,-67.036,-64.869,-66.957,-63.723,-66.884,-63.791,-66.77,-64.184,-66.769,-64.06,-66.71,-64.155,-66.68,-64.102,-66.573,-63.814,-66.592,-63.822,-66.521,-63.974,-66.474,-63.757,-66.438,-63.917,-66.388,-63.585,-66.377,-63.794,-66.323,-63.563,-66.158,-62.889,-66.484,-62.617,-66.741,-62.418,-66.644,-62.699,-66.549,-62.425,-66.439,-62.917,-66.269,-62.861,-66.229,-61.92,-66.192,-61.929,-66.268,-61.732,-66.471,-61.639,-66.403,-61.707,-66.255,-61.495,-66.227,-61.427,-66.107,-61.149,-66.335,-60.932,-66.256,-60.635,-66.048,-60.611,-65.942,-60.998,-65.917,-61.048,-66.048,-61.34,-65.928,-61.335,-66.07,-61.607,-66.023,-61.897,-66.173,-62.375,-65.949,-62.368,-65.782,-62.123,-65.719,-62.089,-65.593,-61.699,-65.534,-62.098,-65.446,-62.054,-65.413,-62.126,-65.384,-61.953,-65.376,-62.205,-65.324,-61.917,-65.186,-61.617,-65.235,-61.594,-65.095,-61.49,-65.048,-61.194,-64.974,-61.06,-65.019,-61.095,-64.943,-60.769,-64.831,-60.682,-64.694,-59.983,-64.403,-59.743,-64.408,-59.674,-64.329,-59.481,-64.312,-59.192,-64.477,-58.982,-64.413,-59.024,-64.513,-58.831,-64.526,-58.791,-64.413,-58.953,-64.392,-58.755,-64.298,-58.854,-64.237,-58.657,-64.103,-58.682,-64.008,-58.134,-63.683,-57.613,-63.628,-57.719,-63.591,-57.405,-63.555,-57.468,-63.517,-57.391,-63.473,-57.157,-63.476,-57.139,-63.649,-56.774,-63.592,-56.988,-63.47,-56.948,-63.397,-57.053,-63.401,-56.975,-63.361,-57.074,-63.246,-57.319,-63.221,-57.903,-63.305,-58.039,-63.415,-58.212,-63.382,-58.149,-63.456,-58.969,-63.548,-58.964,-63.64,-59.095,-63.714,-59.414,-63.716,-59.344,-63.765,-59.481,-63.814,-59.499,-63.903,-59.824,-63.873,-59.92,-63.78,-59.913,-63.866,-60.03,-63.859,-60.014,-63.952,-60.311,-63.919,-60.728,-64.011,-60.76,-64.077,-60.975,-64.043,-61.012,-64.081,-60.845,-64.143,-60.997,-64.208,-60.938,-64.292,-61.107,-64.293,-61.126,-64.378,-61.339,-64.422,-61.488,-64.354,-61.488,-64.401,-61.606,-64.428,-61.468,-64.484,-61.628,-64.535,-61.53,-64.592,-61.565,-64.637,-61.711,-64.625,-61.802,-64.484,-61.825,-64.574,-61.981,-64.6,-61.975,-64.676,-62.17,-64.677,-62.296,-64.767,-62.36,-64.747,-62.308,-64.696,-62.402,-64.708,-62.322,-64.647,-62.553,-64.588,-62.561,-64.74,-62.709,-64.76,-62.392,-64.868,-62.641,-64.92,-62.832,-64.79,-62.901,-64.908,-63.266,-64.948,-63.013,-65.024,-63.108,-65.148,-63.259,-65.182,-63.263,-65.076,-63.412,-65.115,-63.848,-65.028,-64.085,-65.154,-64.044,-65.266,-64.122,-65.267,-63.984,-65.318,-64.09,-65.406,-63.666,-65.501,-63.871,-65.593,-64.008,-65.504,-64.24,-65.537,-64.1,-65.602,-64.16,-65.613,-64.119,-65.696,-64.459,-65.619,-64.361,-65.706,-64.396,-65.79,-64.698,-65.73,-64.699,-65.844,-64.48,-65.939,-64.538,-65.998,-64.999,-65.921,-65.107,-65.979,-65.344,-65.984,-65.162,-66.174,-65.59,-66.094,-65.72,-66.138,-65.704,-66.241,-65.898,-66.327,-65.613,-66.396,-65.773,-66.509,-65.719,-66.514,-65.779,-66.685,-66.204,-66.591,-66.597,-66.646,-66.551,-66.773,-66.605,-66.795,-66.466,-66.873,-66.626,-67.019,-66.454,-67.072,-66.533,-67.099,-66.488,-67.307,-66.672,-67.186,-66.875,-67.186,-66.956,-67.096,-66.931,-66.93,-67.51,-67.049,-67.521,-67.117,-67.619,-67.169,-67.496,-67.208,-67.585,-67.271,-67.511,-67.326,-67.647,-67.439,-67.574,-67.488,-67.606,-67.554,-67.027,-67.467,-66.525,-67.495,-66.554,-67.565,-66.99,-67.676,-66.779,-67.718,-67.136,-67.758,-66.818,-67.776,-66.923,-67.816,-66.876,-67.908,-67.336,-67.962,-66.946,-68.228,-66.634,-68.224,-66.762,-68.308,-67.189,-68.285,-66.972,-68.339,-66.966,-68.43,-67.203,-68.526,-66.938,-68.778,-67.513,-68.827,-66.769,-69.044,-67.35,-69.347,-67.902,-69.356,-68.284,-69.277,-68.336,-69.386,-68.865,-69.415,-68.36,-69.674,-68.376,-69.87,-68.493,-69.924,-68.43,-70.127,-67.984,-70.273,-67.87,-70.488,-67.732,-70.557,-67.788,-70.645,-67.478,-70.933,-67.514,-70.996,-67.404,-71.003,-67.663,-71.414,-66.916,-71.883,-66.996,-72.218,-66.863,-72.412,-67.346,-72.588,-67.616,-72.854,-69.335,-73.182,-73.153,-73.487,-73.308,-73.579,-73.63,-73.553,-73.982,-73.719,-74.319,-73.678,-74.615,-73.767,-75.213,-73.654,-76.18,-73.833,-76.493,-73.785,-76.993,-73.883,-77.128,-73.831,-76.591,-73.592,-76.782,-73.521,-77.412,-73.524,-78.572,-73.644,-78.623,-73.622,-78.509,-73.466,-78.606,-73.379,-79.396,-73.112,-80.242,-73.006,-80.348,-73.136,-80.148,-73.244,-80.089,-73.385,-81.027,-73.281,-81.105,-73.329,-81.094,-73.494,-80.793,-73.646,-80.879,-73.672,-80.811,-73.759,-81.981,-73.937,-83.232,-73.646,-83.373,-73.528,-84.477,-73.539,-85.389,-73.422,-85.751,-73.137,-85.799,-72.995,-86.062,-73.211,-86.763,-73.328,-87.516,-73.131,-88.6,-73.059,-88.118,-72.772,-88.408,-72.662,-89.142,-72.599,-89.599,-72.636,-89.383,-72.683,-89.31,-72.782,-89.292,-72.893,-89.452,-73.067,-90.94,-73.298,-92.52,-73.156,-93.666,-73.221,-93.922,-73.182,-93.993,-73.027,-94.401,-72.899,-94.479,-72.925,-94.382,-73.078,-94.422,-73.15,-94.819,-73.232,-95.786,-73.24,-95.904,-73.348,-97.512,-73.132,-97.97,-72.911,-99.974,-73.179,-100.711,-72.989,-101.735,-73.08,-102.171,-73.026,-102.058,-72.93,-102.252,-72.826,-103.102,-72.788,-103.473,-72.874,-103.506,-72.954,-103.189,-73.227,-102.661,-73.383,-101.512,-73.297,-100.972,-73.112,-99.99,-73.331,-100.015,-73.51,-100.386,-73.605,-102.285,-73.565,-102.095,-73.658,-102.16,-73.75,-102.025,-73.847,-101.002,-73.87,-100.467,-74.159,-100.528,-74.322,-100.926,-74.26,-101.718,-74.487,-100.617,-74.942,-100.403,-75.111,-101.993,-75.262,-101.979,-75.144,-102.073,-75.037,-103.31,-74.806,-103.058,-74.76,-104.954,-74.687,-105.303,-74.948,-105.278,-75.151,-106.151,-75.281,-106.814,-75.275,-106.558,-74.802,-107.192,-74.75,-107.33,-74.564,-108.223,-74.597,-108.309,-74.617,-108.197,-74.683,-108.307,-74.681,-108.26,-74.769,-108.588,-74.721,-108.762,-74.809,-109.308,-74.727,-109.043,-74.666,-109.224,-74.593,-109.925,-74.618,-109.959,-74.479,-110.13,-74.434,-110.171,-74.4,-110.052,-74.386,-110.256,-74.253,-111.332,-74.194,-111.642,-74.243,-111.348,-74.45,-111.74,-74.575,-111.459,-74.629,-111.545,-74.708,-111.175,-74.68,-111.225,-74.716,-112.788,-74.875,-112.653,-74.674,-112.953,-74.665,-112.816,-74.528,-112.987,-74.415,-113.606,-74.375,-113.406,-74.308,-113.48,-74.227,-113.108,-74.119,-113.222,-74.025,-113.564,-74.013,-114.209,-74.179,-113.605,-73.93,-113.95,-73.843,-114.46,-73.908,-114.888,-74.079,-114.892,-74.194,-114.621,-74.359,-114.682,-74.488,-115.55,-74.497,-115.75,-74.374,-115.908,-74.411,-115.874,-74.482,-116.088,-74.511,-117.041,-74.517,-117.622,-74.372,-117.81,-74.226,-118.681,-74.521,-120.24,-74.591,-120.612,-74.528,-121.059,-74.625,-125.063,-74.683,-126.09,-74.769,-126.852,-74.642,-129.729,-74.868,-132.739,-74.731,-134.02,-74.859,-134.244,-74.829,-134.155,-74.702,-134.401,-74.604,-134.287,-74.53,-134.725,-74.585,-135.531,-74.49,-136.043,-74.666,-137.16,-74.723,-137.139,-74.844,-136.89,-74.847,-136.718,-74.958,-136.963,-74.931,-136.955,-75.032,-137.389,-75.144,-137.797,-75.085,-138.344,-75.178,-138.518,-75.125,-138.738,-75.198,-139.863,-75.192,-140.32,-75.447,-140.6,-75.489,-140.546,-75.557,-141.518,-75.568,-141.594,-75.593,-141.527,-75.664,-141.839,-75.706,-142.092,-75.589,-142.583,-75.52,-142.732,-75.565,-142.719,-75.657,-143.314,-75.609,-144.247,-75.682,-144.256,-75.782,-144.14,-75.86,-145.987,-75.934,-146.4,-76.086,-146.006,-76.274,-145.435,-76.38,-145.434,-76.452,-145.628,-76.492,-146.583,-76.409,-148.281,-76.123,-148.661,-76.221,-148.464,-76.268,-149.528,-76.369,-149.454,-76.461,-148.447,-76.547,-147.932,-76.467,-146.984,-76.499,-145.495,-76.801,-146.195,-76.886,-145.392,-76.983,-145.33,-77.084,-146.353,-77.033,-145.987,-77.113,-145.937,-77.232,-146.191,-77.197,-146.022,-77.311,-145.827,-77.322,-146.017,-77.416,-146.421,-77.435,-146.931,-77.265,-147.069,-77.291,-147.099,-77.381,-147.486,-77.329,-147.462,-77.436,-147.702,-77.509,-148.6,-77.557,-148.54,-77.646,-148.713,-77.694,-149.491,-77.626,-149.584,-77.655,-148.987,-77.738,-149.627,-77.823,-150.98,-77.529,-151.317,-77.533,-151.169,-77.476,-151.275,-77.433,-152.062,-77.341,-152.288,-77.362,-152.233,-77.439,-152.42,-77.519,-153.477,-77.493,-153.726,-77.461,-153.064,-77.405,-153.984,-77.223,-154.702,-77.175,-155.112,-77.227,-155.854,-77.115,-156.273,-77.162,-156.348,-77.215,-156.252,-77.303,-156.695,-77.413,-157.795,-77.121,-157.984,-77.168,-157.829,-77.244,-157.991,-77.364,-157.693,-77.513,-157.749,-77.619,-158.209,-77.908,-158.024,-78.025,-158.474,-78.163,-158.36,-78.226,-157.921,-78.299,-157.822,-78.29,-158.342,-78.175,-157.872,-78.063,-156.767,-78.147,-157.008,-78.27,-156.806,-78.42,-157.577,-78.508,-157.719,-78.568,-157.732,-78.671,-157.329,-78.833,-155.22,-79.254,-153.975,-79.408,-153.207,-79.762,-152.038,-79.859,-151.89,-80.017,-151.146,-80.256,-149.718,-80.219,-147.872,-80.293,-145.203,-80.704,-145.447,-80.813,-145.933,-80.86,-150.74,-80.995,-153.393,-81.21,-154.531,-81.456,-154.941,-81.64,-155.03,-81.776,-154.852,-81.942,-154.154,-82.224,-154.066,-82.354,-154.19,-82.628,-154.162,-82.755,-152.865,-83.355,-152.845,-83.717,-153.216,-83.862,-152.978,-84.005,-152.124,-84.273,-151.127,-84.454,-146.075,-84.884,-141.938,-85.044,-137.446,-85.068,-138.462,-85.254,-140.697,-85.294,-142.681,-85.203,-147.949,-85.145,-148.929,-85.202,-149.131,-85.308,-150.269,-85.526,-151.454,-85.465,-151.366,-85.528,-151.436,-85.562,-157.64,-85.485,-158.393,-85.408,-158.61,-85.417,-158.228,-85.53,-161.026,-85.495,-160.496,-85.394,-161.487,-85.358,-161.812,-85.45,-162.429,-85.391,-162.145,-85.3,-162.941,-85.166,-164.967,-85.11,-166.873,-84.928,-166.939,-84.878,-166.9,-84.829,-167.043,-84.792,-167.748,-84.839,-169.081,-84.678,-175.072,-84.48,-178.89,-84.248,-179.093,-84.26,-178.296,-84.351,-179.999,-84.429,-180.0,-60.0,180.0,-60.0],"holes":[[-45.684,-60.516,-45.937,-60.517,-46.036,-60.609,-45.784,-60.584,-45.71,-60.655,-45.191,-60.732,-45.131,-60.68,-45.684,-60.516],[-55.399,-61.069,-55.155,-61.224,-54.967,-61.111,-54.594,-61.057,-55.282,-61.0,-55.399,-61.069],[-58.065,-61.928,-58.721,-62.017,-59.017,-62.203,-58.543,-62.244,-58.459,-62.161,-58.628,-62.171,-58.436,-62.057,-58.309,-62.075,-58.383,-62.117,-58.256,-62.182,-58.135,-62.146,-58.182,-62.081,-58.111,-62.054,-57.605,-62.023,-57.694,-61.908,-58.065,-61.928],[-60.152,-62.46,-60.496,-62.575,-60.809,-62.464,-60.886,-62.594,-61.243,-62.591,-61.167,-62.682,-60.379,-62.613,-60.451,-62.713,-60.324,-62.684,-60.299,-62.757,-59.822,-62.607,-60.112,-62.579,-60.051,-62.527,-60.152,-62.46],[-56.552,-63.01,-56.631,-63.05,-56.332,-63.184,-56.016,-63.038,-56.552,-63.01],[-55.667,-63.116,-56.13,-63.149,-56.576,-63.371,-56.338,-63.436,-55.768,-63.284,-55.332,-63.371,-55.021,-63.309,-55.155,-63.18,-55.667,-63.116],[-56.053,-63.393,-56.242,-63.485,-55.967,-63.575,-55.684,-63.449,-56.053,-63.393],[-62.288,-64.077,-62.334,-64.01,-62.561,-64.046,-62.511,-64.096,-62.591,-64.142,-62.484,-64.231,-62.705,-64.408,-62.627,-64.446,-62.825,-64.494,-62.477,-64.487,-62.217,-64.356,-62.236,-64.294,-62.097,-64.211,-62.25,-64.164,-62.046,-64.139,-62.288,-64.077],[-62.939,-64.508,-63.299,-64.577,-63.139,-64.48,-63.273,-64.457,-63.171,-64.404,-63.392,-64.435,-63.205,-64.293,-63.548,-64.266,-64.311,-64.682,-63.712,-64.841,-63.578,-64.731,-63.391,-64.756,-62.847,-64.556,-62.939,-64.508],[-65.687,-65.555,-66.005,-65.545,-66.225,-65.861,-65.844,-65.821,-65.931,-65.673,-65.699,-65.659,-65.687,-65.555],[-67.779,-66.62,-68.475,-66.828,-68.78,-67.267,-69.162,-67.447,-69.202,-67.59,-68.963,-67.738,-68.618,-67.754,-68.446,-67.654,-68.462,-67.565,-68.133,-67.565,-67.973,-67.393,-68.205,-67.345,-67.688,-67.149,-67.997,-66.933,-67.652,-66.688,-67.779,-66.62],[-67.271,-67.58,-67.618,-67.6,-67.847,-67.683,-67.702,-67.702,-67.787,-67.727,-67.683,-67.788,-67.385,-67.774,-67.158,-67.642,-67.271,-67.58],[-70.938,-68.84,-71.602,-68.909,-72.192,-69.118,-71.68,-69.274,-71.951,-69.505,-71.848,-69.62,-71.913,-69.644,-71.908,-69.799,-70.763,-70.156,-70.345,-70.132,-70.02,-70.218,-71.278,-70.26,-71.562,-70.375,-71.559,-70.543,-71.172,-70.63,-70.653,-70.878,-70.728,-70.96,-70.931,-70.988,-70.975,-70.864,-71.329,-70.794,-71.835,-70.954,-72.817,-70.948,-73.257,-71.032,-72.922,-71.211,-72.357,-71.25,-72.954,-71.344,-73.565,-71.142,-74.161,-71.225,-74.084,-71.319,-73.379,-71.482,-73.52,-71.556,-74.772,-71.215,-75.094,-71.305,-74.668,-71.586,-74.764,-71.636,-75.09,-71.574,-75.458,-71.344,-75.713,-71.451,-75.5,-71.682,-75.153,-71.823,-74.192,-72.079,-73.682,-72.079,-73.6,-72.164,-72.97,-71.91,-73.375,-71.824,-73.388,-71.766,-72.809,-71.737,-72.374,-71.863,-72.52,-71.698,-72.447,-71.583,-72.33,-71.559,-71.789,-71.635,-71.495,-71.741,-71.472,-71.839,-71.194,-71.819,-71.01,-71.99,-70.678,-71.995,-71.047,-72.125,-70.606,-72.218,-70.657,-72.272,-72.122,-72.239,-72.684,-72.456,-71.973,-72.694,-69.643,-72.667,-68.891,-72.452,-69.119,-72.455,-69.086,-72.35,-68.948,-72.283,-68.449,-72.264,-68.361,-72.066,-68.5,-71.978,-68.233,-71.9,-68.112,-71.646,-68.289,-71.377,-68.215,-71.165,-68.353,-70.974,-68.356,-70.827,-68.46,-70.805,-68.375,-70.784,-68.515,-70.505,-68.691,-70.338,-69.094,-70.189,-69.105,-70.036,-69.327,-69.819,-69.341,-69.635,-69.715,-69.278,-70.109,-69.256,-70.105,-69.108,-70.016,-69.082,-70.171,-68.847,-70.58,-68.761,-70.938,-68.84],[-77.24,-72.71,-78.143,-72.654,-78.197,-72.691,-78.154,-72.811,-78.27,-72.892,-78.503,-72.968,-78.922,-72.937,-79.075,-73.026,-78.868,-73.153,-78.469,-73.179,-78.067,-73.011,-77.395,-72.943,-77.401,-72.861,-77.24,-72.71],[-75.811,-72.988,-75.439,-73.037,-75.788,-73.118,-76.078,-73.088,-76.196,-73.161,-76.116,-73.297,-74.619,-73.658,-74.376,-73.539,-74.619,-73.328,-74.274,-73.052,-74.584,-72.901,-75.275,-72.814,-75.583,-72.833,-75.811,-72.988],[-77.769,-73.217,-78.061,-73.254,-78.091,-73.385,-77.779,-73.415,-77.419,-73.334,-77.769,-73.217],[-91.016,-72.532,-91.676,-72.609,-91.385,-72.746,-91.298,-72.919,-91.503,-73.135,-91.181,-73.129,-90.809,-72.854,-90.992,-72.81,-90.817,-72.685,-91.016,-72.532],[-90.0,-72.871,-90.356,-72.909,-90.4,-72.997,-90.288,-73.094,-89.781,-73.103,-89.506,-72.911,-89.929,-72.801,-90.0,-72.871],[-99.405,-72.615,-99.316,-72.556,-99.925,-72.462,-100.391,-72.486,-99.973,-72.597,-99.405,-72.615],[-98.911,-72.458,-98.977,-72.481,-96.131,-72.538,-95.981,-72.517,-95.94,-72.437,-96.442,-72.42,-95.417,-72.299,-96.465,-72.228,-95.497,-72.112,-95.666,-71.994,-96.675,-72.18,-97.086,-72.134,-96.111,-71.918,-96.11,-71.8,-96.35,-71.757,-97.088,-71.772,-97.445,-72.162,-97.72,-72.036,-97.456,-71.945,-97.873,-71.787,-98.022,-71.819,-97.901,-72.035,-97.97,-72.092,-98.242,-71.99,-98.475,-72.088,-98.386,-71.958,-98.495,-71.866,-98.248,-71.852,-98.129,-71.768,-98.422,-71.796,-98.304,-71.736,-98.789,-71.632,-99.231,-71.779,-98.771,-72.033,-99.425,-71.899,-99.549,-71.996,-99.749,-71.843,-100.315,-71.993,-100.621,-71.973,-100.168,-71.862,-100.423,-71.777,-100.849,-71.92,-100.971,-71.913,-100.919,-71.848,-102.172,-71.988,-102.413,-72.111,-101.99,-72.229,-100.483,-72.32,-100.101,-72.407,-99.743,-72.35,-98.793,-72.391,-98.911,-72.458],[-94.986,-72.536,-95.289,-72.642,-94.437,-72.601,-94.481,-72.461,-94.986,-72.536],[-75.259,-70.873,-74.41,-70.824,-73.742,-70.637,-74.179,-70.469,-74.301,-70.59,-74.61,-70.562,-74.61,-70.629,-74.732,-70.665,-75.043,-70.559,-75.202,-70.709,-75.549,-70.712,-75.259,-70.873],[-74.647,-69.97,-74.451,-69.792,-74.667,-69.7,-74.689,-69.573,-74.848,-69.535,-76.091,-69.656,-75.898,-69.81,-75.936,-69.925,-74.647,-69.97],[-72.615,-69.533,-72.201,-69.56,-72.099,-69.53,-72.42,-69.313,-72.687,-69.329,-72.85,-69.423,-72.615,-69.533],[-125.835,-73.083,-126.768,-73.149,-126.919,-73.37,-126.602,-73.442,-126.712,-73.56,-126.534,-73.595,-126.186,-73.448,-125.716,-73.812,-124.771,-73.956,-124.381,-74.092,-123.547,-74.127,-123.382,-73.974,-123.396,-73.905,-123.665,-73.847,-123.595,-73.793,-123.95,-73.706,-123.98,-73.602,-124.39,-73.743,-124.656,-73.672,-124.97,-73.74,-125.108,-73.68,-124.648,-73.531,-124.92,-73.412,-124.829,-73.322,-125.409,-73.123,-125.835,-73.083],[-122.662,-73.593,-122.874,-73.737,-122.27,-73.852,-122.606,-74.004,-122.462,-74.107,-122.639,-74.124,-122.613,-74.236,-121.609,-74.334,-120.162,-74.3,-118.83,-73.929,-118.63,-73.797,-118.9,-73.722,-119.609,-73.762,-120.305,-73.654,-122.662,-73.593],[-117.049,-74.017,-117.475,-74.043,-117.722,-74.173,-116.575,-74.157,-115.881,-73.883,-116.135,-73.822,-117.049,-74.017],[-127.196,-74.338,-127.455,-74.266,-127.874,-74.255,-127.847,-74.429,-127.558,-74.552,-127.206,-74.606,-126.918,-74.539,-126.885,-74.388,-127.196,-74.338],[-130.812,-74.326,-130.896,-74.382,-131.695,-74.252,-131.952,-74.371,-131.903,-74.463,-131.029,-74.552,-130.795,-74.493,-130.735,-74.413,-130.812,-74.326],[-132.473,-74.497,-132.253,-74.434,-132.534,-74.318,-132.776,-74.412,-132.697,-74.488,-132.473,-74.497],[-145.723,-75.559,-145.961,-75.597,-145.569,-75.744,-145.153,-75.75,-145.25,-75.663,-145.723,-75.559],[-147.345,-76.119,-147.399,-76.148,-146.863,-76.33,-146.602,-76.289,-146.886,-76.115,-147.345,-76.119],[-147.23,-76.577,-147.338,-76.599,-147.14,-76.725,-146.774,-76.73,-146.859,-76.597,-147.23,-76.577],[-148.297,-76.75,-148.892,-76.708,-149.223,-76.774,-148.878,-76.868,-148.17,-76.799,-148.297,-76.75],[-146.957,-76.864,-146.975,-76.946,-146.873,-77.018,-146.379,-76.98,-146.292,-76.903,-146.957,-76.864],[-148.575,-76.933,-149.156,-76.95,-149.219,-76.965,-148.959,-77.051,-148.289,-77.01,-148.575,-76.933],[-149.829,-76.712,-150.467,-76.701,-150.76,-76.767,-150.256,-76.814,-149.829,-76.712],[-148.481,-77.12,-148.766,-77.157,-148.871,-77.225,-148.769,-77.264,-148.881,-77.31,-148.196,-77.47,-147.528,-77.408,-147.708,-77.239,-148.481,-77.12],[-150.383,-77.409,-150.162,-77.377,-150.811,-77.242,-151.314,-77.306,-150.383,-77.409],[-160.907,-79.904,-159.543,-79.814,-159.148,-79.708,-159.532,-79.588,-159.754,-79.403,-161.66,-78.821,-163.377,-78.774,-163.604,-78.838,-163.699,-78.999,-164.364,-79.163,-164.132,-79.376,-163.466,-79.577,-160.907,-79.904],[-57.835,-63.78,-58.284,-63.903,-58.307,-63.959,-58.199,-63.982,-58.464,-64.06,-58.437,-64.14,-58.19,-64.074,-58.075,-64.122,-58.192,-64.197,-58.074,-64.238,-58.287,-64.305,-58.236,-64.401,-57.959,-64.448,-57.731,-64.366,-57.317,-64.383,-57.384,-64.306,-57.061,-64.171,-57.183,-64.159,-57.174,-64.075,-57.506,-64.019,-57.492,-63.917,-57.84,-64.09,-57.873,-63.97,-57.785,-63.884,-57.835,-63.78],[-57.033,-64.362,-57.485,-64.436,-57.515,-64.489,-57.194,-64.535,-56.904,-64.343,-57.033,-64.362],[100.686,-65.389,100.389,-65.485,100.26,-65.568,100.265,-65.645,100.569,-65.7,101.209,-65.529,101.051,-65.416,100.686,-65.389],[-62.491,-69.151,-62.61,-69.214,-62.193,-69.53,-62.233,-69.666,-62.063,-69.73,-61.956,-69.672,-61.897,-69.522,-61.944,-69.389,-62.23,-69.161,-62.491,-69.151],[-61.033,-70.61,-60.797,-70.718,-60.604,-70.648,-60.632,-70.557,-60.779,-70.504,-61.033,-70.61],[-60.691,-71.074,-60.478,-71.062,-60.404,-70.982,-60.695,-70.898,-60.978,-70.96,-60.691,-71.074],[-70.627,-79.164,-69.299,-79.199,-67.103,-78.69,-65.468,-78.404,-66.497,-78.367,-67.872,-78.464,-69.234,-78.705,-70.126,-79.009,-70.833,-79.148,-70.627,-79.164],[96.257,-66.182,96.662,-66.232,96.995,-66.151,96.886,-66.065,96.496,-66.034,96.343,-66.065,96.257,-66.182],[85.677,-66.721,85.56,-66.753,86.053,-67.017,86.206,-67.023,86.32,-66.936,86.016,-66.767,85.677,-66.721],[164.561,-67.587,164.719,-67.631,164.826,-67.571,164.6,-67.346,164.492,-67.415,164.561,-67.587],[169.515,-73.513,169.382,-73.564,169.625,-73.657,169.847,-73.574,169.72,-73.356,169.595,-73.377,169.614,-73.45,169.515,-73.513],[166.512,-77.421,165.998,-77.58,166.794,-77.749,166.541,-77.869,166.59,-77.888,167.494,-77.673,168.442,-77.737,169.28,-77.556,168.304,-77.409,167.368,-77.447,166.998,-77.388,166.998,-77.293,166.778,-77.222,166.233,-77.313,166.512,-77.421],[167.328,-78.054,167.047,-78.188,166.744,-78.265,167.299,-78.305,167.539,-78.167,167.477,-78.077,167.328,-78.054],[166.41,-78.311,166.658,-78.262,165.915,-78.176,166.055,-78.217,165.96,-78.317,166.41,-78.311]]}],"names":{"ar":"المحيط الجنوبي","de":"Südlicher Ozean","es":"Océano Austral","fr":"océan Austral","he":"האוקיינוס הדרומי","hu":"Déli-óceán","it":"Oceano Antartico","nl":"Zuidelijke Oceaan","pt":"Oceano Antártico","zh":"南冰洋"}},{"name":"Indian Ocean","bbox":[20.003,-60.0,146.898,10.445],"area":5873.8545,"polygons":[{"outer":[51.415,10.445,73.154,-0.703,80.589,5.919,95.047,5.736,95.229,5.495,95.212,5.276,95.306,5.17,95.412,4.82,95.494,4.748,95.539,4.653,95.849,4.44,96.495,3.751,96.805,3.728,97.002,3.547,97.372,2.986,97.597,2.873,97.663,2.4,97.777,2.241,97.779,2.273,97.758,2.29,97.747,2.303,97.745,2.308,97.746,2.316,97.759,2.317,97.77,2.299,97.772,2.326,97.798,2.346,97.803,2.347,97.81,2.343,97.809,2.333,97.83,2.329,97.857,2.348,97.841,2.344,97.836,2.348,97.812,2.572,97.814,2.574,97.824,2.575,97.827,2.589,97.837,2.595,97.833,2.602,97.835,2.615,97.83,2.619,97.83,2.623,97.845,2.647,97.856,2.649,97.865,2.676,97.856,2.675,97.841,2.678,97.841,2.682,97.847,2.692,97.819,2.708,97.817,2.711,97.817,2.72,97.809,2.711,97.802,2.71,97.791,2.741,97.812,2.767,97.839,2.773,97.792,2.741,97.803,2.711,97.817,2.722,97.866,2.676,97.838,2.595,97.825,2.575,97.814,2.571,97.859,2.348,97.83,2.326,97.809,2.332,97.802,2.345,97.772,2.298,97.763,2.3,97.758,2.314,97.747,2.313,97.783,2.272,97.782,2.234,97.972,2.262,98.232,2.091,98.263,2.027,98.464,1.987,98.682,1.736,98.742,1.782,98.796,1.724,98.84,1.628,98.767,1.529,98.713,1.559,99.059,0.742,99.138,0.253,99.353,0.232,99.692,0.042,99.817,-0.299,100.301,-0.805,100.412,-1.043,100.349,-1.171,100.43,-1.198,100.401,-1.271,100.565,-1.331,100.644,-1.622,100.87,-1.939,100.82,-2.16,100.89,-2.326,101.322,-2.738,101.594,-3.215,102.216,-3.653,102.28,-3.964,102.898,-4.488,103.913,-5.13,103.906,-5.232,104.56,-5.86,104.556,-5.926,105.241,-6.841,105.983,-6.813,106.309,-6.996,106.512,-6.965,106.544,-7.056,106.373,-7.237,106.405,-7.383,107.399,-7.494,107.847,-7.737,108.441,-7.821,108.517,-7.699,108.67,-7.732,108.822,-7.621,108.985,-7.73,108.993,-7.665,109.028,-7.655,108.992,-7.699,109.02,-7.752,109.117,-7.675,109.115,-7.638,109.128,-7.62,109.12,-7.612,109.118,-7.605,109.125,-7.596,109.13,-7.596,109.14,-7.607,109.143,-7.583,109.148,-7.579,109.164,-7.576,109.17,-7.569,109.168,-7.542,109.171,-7.54,109.179,-7.54,109.183,-7.543,109.203,-7.519,109.214,-7.513,109.211,-7.506,109.211,-7.5,109.225,-7.49,109.243,-7.493,109.26,-7.503,109.264,-7.513,109.277,-7.507,109.296,-7.508,109.318,-7.513,109.32,-7.514,109.32,-7.518,109.315,-7.526,109.321,-7.52,109.314,-7.527,109.296,-7.509,109.266,-7.514,109.223,-7.492,109.212,-7.517,109.188,-7.542,109.182,-7.543,109.177,-7.541,109.169,-7.544,109.172,-7.562,109.17,-7.573,109.141,-7.609,109.123,-7.6,109.13,-7.62,109.117,-7.641,109.128,-7.661,109.126,-7.667,109.113,-7.687,109.913,-7.847,110.204,-7.98,110.23,-7.958,110.235,-7.957,110.234,-7.954,110.243,-7.934,110.246,-7.928,110.251,-7.924,110.236,-7.959,110.209,-7.984,110.709,-8.201,111.425,-8.257,111.455,-8.274,111.455,-8.321,111.624,-8.322,111.679,-8.385,111.713,-8.291,111.761,-8.345,111.839,-8.259,112.331,-8.332,112.652,-8.448,112.832,-8.369,112.912,-8.399,113.078,-8.287,113.236,-8.284,114.049,-8.647,114.257,-8.587,114.369,-8.751,115.145,-8.848,115.845,-8.813,115.934,-8.834,116.004,-8.904,116.055,-8.851,116.377,-8.903,116.377,-8.956,116.469,-8.823,116.436,-8.92,116.594,-8.862,116.787,-9.035,117.013,-9.11,117.202,-9.011,117.412,-9.05,117.691,-8.918,118.129,-8.878,118.404,-8.677,118.38,-8.626,118.399,-8.587,118.433,-8.677,118.382,-8.836,118.503,-8.88,119.059,-9.699,119.389,-9.798,119.461,-9.74,119.672,-9.782,120.165,-10.24,120.382,-10.264,121.276,-10.829,122.832,-10.927,126.938,-13.758,126.743,-13.789,126.805,-13.929,126.932,-13.975,126.939,-14.024,126.706,-13.973,126.758,-14.05,126.696,-14.145,126.644,-14.079,126.651,-14.174,126.611,-14.264,126.57,-14.246,126.577,-14.235,126.565,-14.219,126.569,-14.14,126.513,-14.162,126.524,-14.082,126.454,-14.092,126.572,-13.941,126.335,-14.05,126.297,-14.14,126.319,-14.142,126.363,-14.197,126.228,-14.259,126.214,-14.077,126.149,-14.069,126.225,-14.027,126.144,-14.015,126.216,-13.967,126.11,-14.028,126.158,-13.915,126.01,-13.926,126.108,-13.977,126.103,-14.041,126.039,-13.982,126.01,-14.066,125.983,-13.99,125.994,-14.069,126.167,-14.15,126.053,-14.254,126.088,-14.352,125.977,-14.394,126.018,-14.412,126.05,-14.529,125.918,-14.537,125.931,-14.692,125.861,-14.69,125.853,-14.458,125.741,-14.485,125.736,-14.409,125.724,-14.417,125.727,-14.45,125.72,-14.481,125.644,-14.674,125.652,-14.387,125.732,-14.283,125.629,-14.234,125.574,-14.262,125.623,-14.39,125.601,-14.564,125.494,-14.513,125.445,-14.652,125.413,-14.617,125.409,-14.545,125.348,-14.612,125.366,-14.505,125.346,-14.563,125.248,-14.604,125.324,-14.651,125.146,-14.767,125.21,-14.865,125.332,-14.882,125.295,-14.937,125.262,-14.924,125.265,-14.911,125.261,-14.9,125.256,-14.9,125.187,-14.963,125.304,-14.963,125.286,-15.0,125.367,-14.995,125.387,-15.033,125.412,-15.022,125.408,-14.982,125.399,-14.969,125.399,-14.967,125.401,-14.965,125.486,-14.989,125.357,-15.109,125.481,-15.137,125.542,-15.177,125.546,-15.188,125.54,-15.196,125.547,-15.207,125.547,-15.211,125.46,-15.15,125.34,-15.143,125.332,-15.147,125.349,-15.184,125.35,-15.192,125.344,-15.193,125.28,-15.098,125.181,-15.187,125.074,-14.976,125.012,-14.985,125.031,-15.113,124.91,-15.107,124.834,-15.18,124.893,-15.175,124.876,-15.262,124.971,-15.165,125.051,-15.152,124.878,-15.31,124.92,-15.357,125.032,-15.278,125.085,-15.313,125.077,-15.249,125.113,-15.262,125.089,-15.339,125.157,-15.318,125.07,-15.441,125.196,-15.469,125.158,-15.506,125.347,-15.646,125.169,-15.531,125.116,-15.48,125.075,-15.459,125.014,-15.538,125.018,-15.471,125.005,-15.476,125.001,-15.5,124.988,-15.506,124.951,-15.477,125.014,-15.43,124.83,-15.362,124.859,-15.346,124.838,-15.343,124.848,-15.337,124.836,-15.318,124.846,-15.299,124.82,-15.326,124.782,-15.294,124.774,-15.336,124.737,-15.346,124.704,-15.254,124.661,-15.355,124.744,-15.362,124.678,-15.488,124.645,-15.425,124.628,-15.526,124.516,-15.469,124.449,-15.498,124.492,-15.487,124.508,-15.523,124.419,-15.55,124.455,-15.654,124.369,-15.675,124.413,-15.878,124.462,-15.796,124.509,-15.934,124.499,-16.0,124.633,-15.787,124.695,-15.74,124.752,-15.809,124.746,-15.691,124.736,-15.667,124.738,-15.652,124.729,-15.643,124.734,-15.633,124.726,-15.625,124.734,-15.632,124.755,-15.692,124.763,-15.696,124.783,-15.668,124.818,-15.655,124.826,-15.656,124.829,-15.648,124.837,-15.646,124.828,-15.656,124.786,-15.67,124.787,-15.703,124.749,-15.73,124.761,-15.814,124.695,-15.887,124.605,-15.897,124.692,-16.009,124.588,-15.973,124.588,-16.112,124.648,-16.087,124.527,-16.164,124.579,-16.198,124.515,-16.224,124.445,-16.065,124.395,-16.339,124.606,-16.319,124.59,-16.333,124.694,-16.345,124.815,-16.417,124.959,-16.37,124.911,-16.42,124.452,-16.41,124.388,-16.346,124.336,-16.419,124.395,-16.419,124.403,-16.47,124.475,-16.497,124.489,-16.531,124.429,-16.498,124.409,-16.573,124.391,-16.445,124.309,-16.469,124.236,-16.461,124.335,-16.429,124.219,-16.413,124.125,-16.28,124.043,-16.278,123.909,-16.212,124.03,-16.289,123.934,-16.297,124.013,-16.366,123.993,-16.385,123.884,-16.304,123.913,-16.35,123.871,-16.443,123.865,-16.328,123.818,-16.331,123.729,-16.254,123.811,-16.19,123.706,-16.129,123.703,-16.185,123.585,-16.164,123.631,-16.229,123.556,-16.178,123.612,-16.26,123.566,-16.276,123.539,-16.254,123.526,-16.265,123.504,-16.256,123.514,-16.299,123.612,-16.287,123.696,-16.325,123.716,-16.377,123.63,-16.357,123.709,-16.421,123.676,-16.434,123.505,-16.379,123.582,-16.427,123.494,-16.404,123.471,-16.407,123.663,-16.49,123.429,-16.498,123.492,-16.547,123.511,-16.598,123.529,-16.545,123.634,-16.564,123.563,-16.581,123.568,-16.626,123.502,-16.659,123.714,-16.725,123.796,-16.912,123.97,-16.769,123.826,-16.957,123.918,-17.038,123.913,-17.074,123.926,-17.069,123.93,-17.072,123.934,-17.112,123.911,-17.072,123.911,-17.036,123.874,-17.036,123.853,-17.009,123.826,-16.999,123.794,-17.0,123.87,-17.083,123.829,-17.135,123.921,-17.205,123.92,-17.255,123.94,-17.268,123.948,-17.281,123.961,-17.286,123.961,-17.293,123.916,-17.258,123.913,-17.217,123.861,-17.212,123.779,-17.134,123.759,-17.085,123.703,-17.043,123.689,-17.07,123.75,-17.135,123.753,-17.144,123.75,-17.156,123.757,-17.164,123.758,-17.171,123.666,-17.038,123.683,-17.077,123.677,-17.091,123.688,-17.103,123.693,-17.114,123.69,-17.11,123.672,-17.089,123.681,-17.077,123.66,-17.058,123.652,-17.002,123.583,-17.032,123.597,-17.126,123.686,-17.218,123.703,-17.271,123.685,-17.267,123.676,-17.218,123.642,-17.193,123.654,-17.214,123.65,-17.288,123.664,-17.291,123.668,-17.304,123.65,-17.289,123.624,-17.194,123.563,-17.369,123.612,-17.55,123.591,-17.604,123.565,-17.603,123.574,-17.653,123.566,-17.664,123.562,-17.603,123.583,-17.532,123.534,-17.56,123.539,-17.556,123.527,-17.541,123.53,-17.531,123.57,-17.513,123.513,-17.494,123.528,-17.444,123.52,-17.391,123.375,-17.331,123.387,-17.244,123.282,-17.031,123.142,-16.931,123.139,-16.689,122.958,-16.587,122.987,-16.462,123.077,-16.438,123.019,-16.413,123.039,-16.359,122.924,-16.389,122.742,-16.682,122.87,-16.809,122.578,-16.763,122.536,-16.852,122.611,-16.965,122.479,-16.895,122.472,-16.987,122.366,-16.996,122.255,-17.113,122.315,-17.157,122.172,-17.264,122.146,-17.561,122.237,-17.761,122.176,-17.974,122.362,-17.982,122.371,-18.117,121.946,-18.441,121.817,-18.449,121.726,-18.574,121.78,-18.663,121.618,-18.706,121.652,-18.767,121.654,-18.835,121.489,-19.131,121.053,-19.578,120.255,-19.899,119.764,-19.957,119.573,-20.078,119.182,-19.953,119.168,-19.998,119.22,-20.02,119.227,-20.059,119.098,-19.961,118.855,-20.292,118.673,-20.339,118.598,-20.287,118.607,-20.35,118.563,-20.295,118.489,-20.353,118.198,-20.391,118.181,-20.335,117.94,-20.48,117.968,-20.55,117.798,-20.677,117.636,-20.663,117.383,-20.784,117.184,-20.699,117.183,-20.592,117.124,-20.654,116.832,-20.71,116.868,-20.525,116.809,-20.53,116.764,-20.633,116.642,-20.704,116.688,-20.703,116.714,-20.743,116.686,-20.787,116.631,-20.711,116.449,-20.836,116.296,-20.881,116.205,-20.833,116.181,-20.977,115.916,-21.08,115.828,-21.254,115.618,-21.344,115.514,-21.517,114.648,-21.846,114.607,-21.997,114.499,-22.011,114.526,-22.128,114.508,-22.179,114.455,-22.161,114.481,-22.188,114.496,-22.217,114.369,-22.499,114.307,-22.435,114.179,-22.523,114.122,-22.472,114.179,-22.307,114.116,-22.329,114.08,-22.156,114.171,-21.791,113.934,-21.973,113.654,-22.579,113.825,-22.953,113.76,-23.122,113.78,-23.472,113.47,-23.899,113.399,-24.408,113.627,-24.753,113.673,-25.044,113.815,-25.174,113.935,-25.476,114.043,-25.646,113.998,-25.602,113.997,-25.612,114.265,-25.894,114.26,-25.986,114.195,-25.983,114.237,-26.316,114.095,-26.455,113.979,-26.352,113.877,-25.944,113.829,-26.156,113.78,-26.214,113.715,-26.198,113.762,-25.879,113.51,-25.504,113.414,-25.725,113.47,-25.785,113.445,-25.721,113.461,-25.706,113.472,-25.71,113.579,-26.096,113.881,-26.336,113.871,-26.401,113.88,-26.514,113.665,-26.673,113.626,-26.572,113.63,-26.463,113.559,-26.574,113.538,-26.281,113.493,-26.31,113.528,-26.36,113.506,-26.475,113.476,-26.474,113.508,-26.364,113.363,-26.016,113.393,-26.401,113.3,-26.017,113.274,-26.236,113.155,-26.152,113.779,-26.926,114.159,-27.708,114.172,-27.69,114.166,-27.669,114.178,-27.645,114.177,-27.692,114.104,-27.849,114.176,-28.112,114.557,-28.536,114.578,-28.784,114.871,-29.116,114.958,-29.362,114.955,-30.049,115.042,-30.271,115.058,-30.509,115.686,-31.653,115.729,-32.054,115.774,-31.989,115.808,-32.002,115.842,-31.963,115.88,-31.965,115.882,-31.942,115.886,-31.937,115.927,-31.931,115.882,-31.97,115.845,-31.965,115.859,-32.032,115.895,-32.017,115.903,-32.023,115.904,-32.031,115.735,-32.056,115.727,-32.276,115.661,-32.157,115.777,-32.593,115.667,-32.616,115.722,-32.78,115.637,-32.605,115.607,-32.68,115.712,-33.288,115.378,-33.636,115.205,-33.651,115.004,-33.53,115.023,-34.253,115.135,-34.377,115.145,-34.29,115.199,-34.261,115.169,-34.321,115.587,-34.427,116.002,-34.843,116.372,-34.936,116.415,-34.867,116.526,-34.952,116.406,-34.931,116.383,-34.949,116.631,-35.061,116.747,-35.037,116.699,-35.013,116.725,-34.975,116.921,-35.065,116.948,-34.995,116.972,-34.973,117.151,-35.061,117.328,-35.043,117.337,-34.996,117.37,-34.97,117.459,-34.968,117.465,-35.02,117.393,-34.999,117.332,-35.023,146.03,-43.573,146.234,-43.491,146.586,-43.539,146.552,-43.511,146.562,-43.466,146.704,-43.634,146.86,-43.633,146.898,-60.0,20.003,-60.0,20.056,-34.759,20.499,-34.482,20.87,-34.467,20.664,-34.349,21.304,-34.441,21.416,-34.359,21.813,-34.382,21.956,-34.229,22.161,-34.188,22.136,-34.088,22.553,-33.995,23.406,-34.111,23.371,-34.087,23.4,-33.997,23.649,-33.981,24.833,-34.217,24.792,-34.121,24.886,-34.111,25.01,-33.947,25.703,-34.036,25.594,-33.849,25.84,-33.705,26.546,-33.751,27.112,-33.524,27.902,-33.044,28.119,-32.802,28.374,-32.704,28.385,-32.681,28.361,-32.667,28.354,-32.642,28.353,-32.631,28.514,-32.602,28.885,-32.281,29.41,-31.693,29.545,-31.643,29.529,-31.598,29.599,-31.604,29.735,-31.462,29.999,-31.307,30.389,-30.85,31.06,-29.88,30.996,-29.886,31.122,-29.648,31.496,-29.227,31.447,-29.22,31.429,-29.211,31.423,-29.201,31.424,-29.198,31.445,-29.185,31.439,-29.174,31.377,-29.171,31.374,-29.163,31.391,-29.148,31.384,-29.14,31.351,-29.14,31.35,-29.167,31.345,-29.173,31.339,-29.175,31.335,-29.173,31.337,-29.156,31.33,-29.151,31.306,-29.175,31.3,-29.173,31.295,-29.166,31.284,-29.164,31.289,-29.114,31.268,-29.142,31.258,-29.148,31.248,-29.147,31.132,-29.031,31.116,-29.001,31.124,-28.959,31.107,-28.961,31.084,-28.957,31.1,-28.986,31.097,-28.992,31.09,-28.995,31.078,-28.992,31.031,-28.936,31.026,-28.897,31.028,-28.89,31.036,-28.88,31.03,-28.865,30.986,-28.858,30.969,-28.839,30.966,-28.828,31.033,-28.865,31.078,-28.99,31.083,-28.956,31.09,-28.955,31.123,-28.957,31.129,-28.961,31.249,-29.146,31.29,-29.114,31.285,-29.163,31.33,-29.15,31.338,-29.156,31.341,-29.174,31.351,-29.139,31.388,-29.138,31.393,-29.149,31.376,-29.169,31.439,-29.174,31.501,-29.227,32.37,-28.567,32.405,-28.273,32.41,-28.389,32.555,-28.148,32.889,-26.842,45.506,-25.576,46.0,-25.304,46.434,-25.176,46.228,-24.934,46.229,-24.871,46.198,-24.866,46.145,-24.802,46.08,-24.804,46.06,-24.791,46.061,-24.746,46.074,-24.736,46.106,-24.733,46.09,-24.721,46.078,-24.661,46.079,-24.626,46.089,-24.618,46.112,-24.605,46.098,-24.55,46.13,-24.488,46.16,-24.469,46.176,-24.471,46.186,-24.479,46.188,-24.44,46.202,-24.426,46.189,-24.478,46.118,-24.512,46.107,-24.735,46.062,-24.784,46.138,-24.795,46.225,-24.862,46.441,-25.175,46.642,-25.199,46.746,-25.163,46.718,-25.143,46.701,-25.104,46.763,-25.16,46.907,-25.028,46.961,-25.077,47.088,-24.972,47.044,-24.973,47.039,-24.958,47.085,-24.966,47.099,-24.995,47.096,-24.91,47.2,-24.785,47.347,-24.318,47.255,-24.262,47.368,-24.269,47.401,-24.184,47.386,-24.17,47.355,-24.167,47.344,-24.162,47.462,-24.055,47.447,-24.061,47.457,-24.054,47.44,-24.054,47.429,-24.041,47.427,-24.021,47.404,-24.015,47.4,-24.007,47.481,-24.036,47.577,-23.811,47.545,-23.815,47.501,-23.783,47.487,-23.761,47.587,-23.798,47.621,-23.599,47.592,-23.615,47.56,-23.599,47.627,-23.589,47.814,-22.741,47.836,-22.812,48.129,-21.902,48.105,-21.948,48.058,-21.923,48.166,-21.801,48.151,-21.806,48.133,-21.803,48.088,-21.778,48.055,-21.745,48.043,-21.753,48.005,-21.747,47.991,-21.738,47.995,-21.728,47.961,-21.786,47.953,-21.788,47.944,-21.787,47.916,-21.768,47.914,-21.747,47.906,-21.769,47.856,-21.801,47.845,-21.798,47.845,-21.805,47.842,-21.809,47.826,-21.81,47.82,-21.8,47.82,-21.797,47.914,-21.746,47.961,-21.784,47.995,-21.726,47.999,-21.743,48.055,-21.744,48.151,-21.804,48.195,-21.734,48.443,-20.993,48.544,-20.579,48.53,-20.582,48.513,-20.574,48.508,-20.538,48.542,-20.577,48.579,-20.413,48.647,-20.293,48.636,-20.28,48.599,-20.266,48.587,-20.255,48.651,-20.271,48.694,-20.163,48.731,-20.113,48.7,-20.166,48.681,-20.218,48.731,-20.125,48.779,-20.0,48.824,-19.912,48.867,-19.615,48.833,-19.657,48.822,-19.662,48.814,-19.651,48.935,-19.448,48.861,-19.446,48.938,-19.434,49.102,-18.985,49.048,-18.925,49.048,-18.876,49.058,-18.858,49.054,-18.849,49.055,-18.842,49.074,-18.819,48.997,-18.782,48.98,-18.729,48.984,-18.689,49.077,-18.817,49.104,-18.974,49.338,-18.408,49.36,-18.247,49.356,-18.244,49.351,-18.251,49.344,-18.251,49.322,-18.232,49.302,-18.241,49.279,-18.235,49.322,-18.231,49.359,-18.244,49.362,-18.246,49.365,-18.259,49.365,-18.275,49.43,-18.162,49.394,-18.064,49.521,-17.696,49.444,-17.651,49.483,-17.535,49.41,-17.37,49.47,-17.202,49.436,-17.22,49.406,-17.222,49.354,-17.208,49.352,-17.225,49.335,-17.238,49.318,-17.232,49.308,-17.214,49.282,-17.257,49.279,-17.228,49.31,-17.213,49.336,-17.236,49.354,-17.206,49.471,-17.2,49.648,-16.845,49.667,-16.873,49.805,-16.85,49.841,-16.838,49.713,-16.743,49.77,-16.61,49.84,-16.562,49.814,-16.348,49.841,-16.244,49.859,-16.232,49.863,-16.216,49.863,-16.199,49.732,-16.172,49.677,-16.106,49.731,-15.906,49.601,-15.568,49.761,-15.422,49.746,-15.414,49.739,-15.404,49.686,-15.424,49.679,-15.423,49.67,-15.417,49.668,-15.406,49.647,-15.395,49.638,-15.383,49.742,-15.404,49.762,-15.422,49.754,-15.457,49.9,-15.439,50.016,-15.872,50.159,-15.998,50.34,-15.815,50.462,-15.456,50.446,-15.239,50.471,-15.211,50.211,-14.853,50.264,-14.87,50.183,-14.584,50.188,-14.517,50.176,-14.469,50.177,-14.43,50.166,-14.435,50.152,-14.433,50.146,-14.423,50.15,-14.413,50.142,-14.406,50.115,-14.411,50.139,-14.405,50.145,-14.406,50.15,-14.411,50.149,-14.426,50.16,-14.434,50.178,-14.416,50.178,-14.438,50.201,-14.333,50.154,-14.15,50.015,-14.222,50.007,-14.221,50.003,-14.212,50.003,-14.186,49.961,-14.213,49.894,-14.195,49.884,-14.202,49.891,-14.211,49.89,-14.219,49.886,-14.223,49.891,-14.232,49.887,-14.244,49.835,-14.164,50.005,-14.182,50.018,-14.218,50.166,-14.066,50.127,-13.754,49.994,-13.535,50.017,-13.356,49.932,-13.204,49.925,-13.203,49.904,-13.225,49.895,-13.229,49.877,-13.229,49.861,-13.225,49.852,-13.248,49.818,-13.256,49.798,-13.248,49.851,-13.245,49.86,-13.224,49.92,-13.204,49.86,-12.901,49.802,-12.89,49.711,-12.739,49.643,-12.814,49.633,-12.695,49.543,-12.654,49.563,-12.439,49.475,-12.425,49.537,-12.423,49.514,-12.358,49.44,-12.382,49.357,-12.229,49.341,-12.311,49.195,-12.303,49.283,-12.259,49.216,-12.234,49.241,-12.202,49.248,-12.148,49.302,-12.214,49.313,-12.163,49.356,-12.21,49.369,-12.212,49.263,-11.952,43.382,-11.37,40.449,-10.474,40.39,-10.527,40.376,-10.56,40.363,-10.573,40.349,-10.579,40.309,-10.573,40.306,-10.58,40.232,-10.608,40.229,-10.617,40.201,-10.649,40.177,-10.655,40.177,-10.669,40.129,-10.716,40.11,-10.719,40.078,-10.752,40.058,-10.758,40.029,-10.804,40.006,-10.819,39.976,-10.829,39.957,-10.82,39.945,-10.826,39.906,-10.832,39.897,-10.847,39.872,-10.867,39.846,-10.873,39.833,-10.892,39.803,-10.909,39.786,-10.911,39.79,-10.898,39.91,-10.829,39.96,-10.817,40.0,-10.818,40.056,-10.755,40.108,-10.714,40.136,-10.706,40.18,-10.649,40.259,-10.587,40.318,-10.568,40.337,-10.573,40.347,-10.571,40.356,-10.574,40.371,-10.531,40.43,-10.466,40.366,-10.449,40.382,-10.444,40.369,-10.427,40.385,-10.4,40.354,-10.412,40.445,-10.351,40.402,-10.296,40.405,-10.358,40.328,-10.355,40.239,-10.204,40.23,-10.304,40.114,-10.279,40.137,-10.187,39.987,-10.121,39.988,-10.211,39.968,-10.204,39.959,-10.093,39.819,-9.983,39.662,-10.073,39.806,-9.832,39.716,-9.737,39.682,-9.492,39.562,-9.428,39.539,-9.376,39.658,-9.361,39.629,-9.065,39.548,-9.029,39.556,-9.085,39.573,-9.085,39.6,-9.105,39.603,-9.151,39.547,-9.092,39.5,-9.19,39.468,-9.047,39.494,-8.932,39.443,-8.965,39.429,-8.962,39.43,-8.901,39.373,-8.957,39.358,-8.947,39.398,-8.865,39.474,-8.883,39.511,-8.948,39.559,-8.929,39.362,-8.722,39.236,-8.209,39.343,-8.297,39.309,-8.199,39.315,-8.19,39.322,-8.188,39.318,-8.18,39.325,-8.176,39.359,-8.214,39.412,-8.097,39.357,-8.069,39.419,-8.086,39.447,-8.012,39.383,-7.919,39.447,-7.815,39.438,-7.819,39.426,-7.85,39.412,-7.863,39.41,-7.801,39.401,-7.837,39.368,-7.866,39.335,-7.889,39.314,-7.889,39.295,-7.907,39.264,-7.92,39.267,-7.932,39.257,-7.954,39.251,-7.957,39.239,-7.951,39.236,-7.958,39.253,-7.988,39.239,-8.019,39.257,-8.03,39.257,-8.041,39.252,-8.049,39.244,-8.05,39.219,-8.038,39.213,-8.045,39.191,-8.056,39.18,-8.054,39.153,-8.031,39.126,-8.045,39.084,-8.03,39.071,-8.047,39.063,-8.051,38.706,-7.986,38.643,-7.965,38.538,-7.871,38.647,-7.964,38.703,-7.982,38.739,-7.971,38.956,-8.02,38.996,-8.008,39.065,-8.048,39.078,-8.03,39.093,-8.025,39.157,-8.031,39.19,-8.054,39.221,-8.035,39.253,-8.046,39.238,-8.019,39.234,-7.954,39.238,-7.95,39.256,-7.953,39.261,-7.92,39.275,-7.909,39.215,-7.856,39.309,-7.754,39.275,-7.596,39.351,-7.322,39.556,-7.131,39.477,-6.866,39.313,-6.815,39.295,-6.826,39.306,-6.852,39.286,-6.87,39.286,-6.741,39.152,-6.581,38.853,-6.407,38.841,-6.316,38.864,-6.175,38.778,-6.025,38.996,-5.488,39.001,-5.448,38.952,-5.428,38.946,-5.408,38.957,-5.395,38.944,-5.388,39.022,-5.409,39.052,-5.233,39.141,-5.122,39.132,-5.055,39.084,-5.028,39.154,-5.004,39.108,-4.918,39.166,-4.841,39.176,-4.942,39.214,-4.862,39.187,-4.687,39.26,-4.589,39.314,-4.58,39.386,-4.65,39.395,-4.578,39.449,-4.526,39.453,-4.584,39.675,-4.094,39.576,-4.087,39.566,-4.026,39.73,-4.024,39.764,-3.962,39.717,-3.955,39.704,-3.947,39.698,-3.937,39.7,-3.923,39.775,-3.944,39.871,-3.698,39.795,-3.609,39.872,-3.631,39.969,-3.326,39.976,-3.39,40.122,-3.289,40.242,-2.989,40.169,-2.993,40.185,-2.727,40.351,-2.573,40.494,-2.515,40.61,-2.561,40.772,-2.453,40.826,-2.375,40.724,-2.298,40.798,-2.329,40.761,-2.295,40.778,-2.266,40.761,-2.267,40.76,-2.263,40.751,-2.259,40.747,-2.252,40.782,-2.264,40.808,-2.31,40.793,-2.28,40.809,-2.249,40.806,-2.291,40.886,-2.216,40.909,-2.22,40.958,-2.31,40.99,-2.262,40.906,-2.011,40.898,-2.005,40.891,-2.017,40.883,-2.015,40.891,-2.008,40.824,-1.973,40.809,-1.963,40.808,-1.957,40.921,-1.999,40.948,-2.085,40.981,-2.067,40.96,-2.018,40.974,-1.939,41.003,-1.908,41.018,-2.056,41.125,-1.981,41.192,-1.996,41.167,-1.949,41.22,-1.915,41.23,-1.972,41.261,-1.955,41.281,-1.987,41.404,-1.859,41.429,-1.852,41.83,-1.265,41.783,-1.156,41.693,-1.214,41.759,-1.154,41.795,-1.155,41.782,-1.126,41.778,-1.127,41.769,-1.123,41.759,-1.107,41.788,-1.128,41.8,-1.15,41.797,-1.161,41.836,-1.177,41.845,-1.213,41.873,-1.227,41.977,-1.035,41.963,-1.012,41.972,-0.999,41.961,-0.967,41.97,-0.912,41.921,-0.89,41.972,-0.91,41.982,-1.022,42.083,-0.9,42.103,-0.858,42.056,-0.864,42.045,-0.844,42.044,-0.809,42.0,-0.821,41.987,-0.8,41.986,-0.795,41.992,-0.771,42.056,-0.811,42.058,-0.863,42.178,-0.81,42.611,-0.274,43.537,0.694,44.599,1.599,46.005,2.424,47.938,4.448,48.912,5.921,49.216,6.752,49.824,7.738,49.818,7.92,50.13,8.197,50.42,8.855,50.829,9.419,50.899,10.312,51.183,10.422,51.375,10.363,51.415,10.445],"holes":[[98.896,-0.906,98.663,-0.98,98.602,-1.226,98.878,-1.68,99.107,-1.812,99.241,-1.797,99.2,-1.641,99.28,-1.752,99.293,-1.636,99.116,-1.443,99.171,-1.47,98.896,-0.906],[100.223,-2.785,100.188,-2.991,100.465,-3.347,100.365,-3.17,100.472,-3.144,100.47,-3.021,100.223,-2.785],[39.678,-4.884,39.671,-5.045,39.737,-5.077,39.685,-5.136,39.747,-5.178,39.648,-5.203,39.756,-5.256,39.654,-5.288,39.63,-5.406,39.726,-5.474,39.839,-5.273,39.875,-4.909,39.826,-4.987,39.678,-4.884],[39.289,-5.747,39.274,-5.865,39.183,-5.912,39.198,-6.223,39.283,-6.321,39.311,-6.242,39.418,-6.413,39.411,-6.312,39.515,-6.477,39.582,-6.386,39.535,-6.188,39.495,-6.123,39.506,-6.197,39.415,-6.191,39.432,-6.065,39.289,-5.747],[57.622,-19.975,57.4,-20.201,57.306,-20.482,57.586,-20.517,57.824,-20.35,57.722,-20.034,57.622,-19.975],[55.574,-20.893,55.286,-20.924,55.22,-21.077,55.425,-21.325,55.782,-21.362,55.836,-21.161,55.574,-20.893],[112.971,-25.48,112.967,-25.783,113.216,-26.162,113.22,-26.037,112.971,-25.48],[69.011,-48.659,68.933,-48.671,68.889,-48.823,68.781,-48.846,68.887,-48.993,68.804,-48.962,68.821,-49.055,68.725,-49.086,68.927,-49.144,68.752,-49.204,68.85,-49.274,68.807,-49.306,68.923,-49.327,68.643,-49.347,68.889,-49.369,68.835,-49.403,68.913,-49.461,68.816,-49.483,68.729,-49.664,68.772,-49.729,69.086,-49.691,69.065,-49.541,69.114,-49.646,69.182,-49.59,69.114,-49.518,69.217,-49.584,69.115,-49.474,69.306,-49.583,69.291,-49.461,69.427,-49.644,69.676,-49.667,69.667,-49.483,69.857,-49.712,70.221,-49.702,70.139,-49.635,70.29,-49.629,70.298,-49.551,70.008,-49.542,70.123,-49.639,69.94,-49.597,69.607,-49.384,69.814,-49.488,69.75,-49.392,69.897,-49.454,69.923,-49.4,69.806,-49.338,70.169,-49.345,70.385,-49.452,70.444,-49.439,70.322,-49.355,70.445,-49.382,70.537,-49.304,70.519,-49.097,70.319,-49.05,70.193,-49.165,69.982,-49.139,69.797,-49.295,69.4,-49.276,69.543,-49.243,69.356,-49.187,69.432,-49.131,69.299,-49.189,69.335,-49.109,69.653,-49.12,69.561,-49.116,69.656,-49.102,69.571,-49.097,69.664,-49.066,69.549,-49.008,69.629,-48.97,69.22,-49.133,69.117,-48.987,69.037,-49.041,69.08,-49.127,68.99,-49.088,68.995,-48.985,69.118,-48.96,69.035,-48.938,69.15,-48.846,69.099,-48.806,69.18,-48.782,69.095,-48.767,68.894,-48.902,69.124,-48.731,68.968,-48.769,69.076,-48.72,68.972,-48.733,69.073,-48.691,69.011,-48.659],[73.277,-52.976,73.494,-53.195,73.832,-53.115,73.573,-53.019,73.277,-52.976],[97.824,0.544,97.901,0.625,97.9,1.052,97.69,1.178,97.416,1.522,97.339,1.536,97.271,1.416,97.061,1.415,97.263,1.243,97.406,0.939,97.632,0.8,97.685,0.583,97.824,0.544],[96.444,2.331,96.499,2.364,96.455,2.475,96.386,2.456,96.174,2.674,96.192,2.618,96.108,2.636,96.132,2.73,95.786,2.925,95.791,2.831,95.692,2.773,95.796,2.63,96.075,2.569,96.444,2.331]]}],"names":{"ar":"المحيط الهندي","de":"Indischer Ozean","es":"océano Índico","fr":"océan Indien","he":"האוקיינוס ההודי","hu":"Indiai-óceán","it":"Oceano Indiano","nl":"Indische Oceaan","pt":"Oceano Índico","zh":"印度洋"}},{"name":"North Pacific Ocean","bbox":[-180.0,0.0,180.0,58.212],"area":5945.2706,"polygons":[{"outer":[163.019,56.537,163.099,56.475,162.629,56.229,162.55,56.232,162.502,56.213,162.632,56.224,162.887,56.032,166.67,54.679,173.355,52.403,179.236,51.349,180.0,51.394,180.0,0.0,174.256,0.0,173.006,3.396,172.68,3.276,172.589,0.0,130.09,0.004,129.882,0.323,128.687,2.503,134.293,7.277,134.481,7.429,134.504,7.415,134.505,7.363,134.533,7.346,134.53,7.342,134.534,7.326,134.587,7.351,134.619,7.541,138.049,9.495,138.032,9.424,138.043,9.42,138.126,9.539,138.142,9.525,138.131,9.512,138.133,9.489,138.185,9.511,138.134,9.512,138.198,9.561,138.176,9.563,138.158,9.635,138.14,9.624,138.161,9.652,138.2,9.568,138.204,9.585,144.647,13.359,144.727,13.246,145.793,15.15,146.066,16.024,145.697,18.791,145.417,19.7,144.901,20.551,141.347,24.778,142.239,26.568,142.188,27.622,139.413,34.681,139.448,34.746,139.185,35.261,139.368,35.361,139.57,35.294,139.607,35.143,139.677,35.139,139.746,35.257,139.634,35.295,139.622,35.332,139.656,35.373,139.621,35.396,139.669,35.403,139.69,35.439,139.624,35.464,139.765,35.512,139.723,35.544,139.809,35.538,139.753,35.553,139.737,35.588,139.763,35.657,139.81,35.733,139.792,35.653,139.776,35.642,139.794,35.614,139.804,35.653,139.838,35.656,139.841,35.65,139.854,35.694,139.81,35.755,139.765,35.758,139.742,35.78,139.81,35.758,139.854,35.709,139.858,35.694,139.849,35.676,139.848,35.66,139.85,35.677,139.858,35.69,139.86,35.699,139.857,35.707,139.843,35.722,139.842,35.726,139.862,35.749,139.872,35.626,139.925,35.702,139.987,35.689,140.117,35.596,140.117,35.541,139.784,35.313,139.87,35.24,139.816,35.172,139.859,35.001,139.753,34.964,139.888,34.899,140.126,35.119,140.385,35.188,140.406,35.454,140.531,35.611,140.866,35.693,140.557,35.902,140.514,35.898,140.459,35.917,140.32,35.863,140.249,35.861,140.4,35.911,140.462,35.92,140.558,35.907,140.808,35.749,140.839,35.741,140.847,35.742,140.849,35.744,140.713,35.955,140.675,35.898,140.563,36.222,140.559,36.291,140.596,36.335,140.583,36.337,140.577,36.344,140.562,36.35,140.552,36.359,140.525,36.365,140.519,36.369,140.622,36.362,140.606,36.482,140.794,36.912,140.97,36.973,141.028,37.237,141.012,37.74,140.949,37.819,140.975,37.827,140.923,37.913,140.907,38.075,140.815,38.066,140.814,37.998,140.796,37.933,140.81,38.06,140.826,38.083,140.885,38.096,140.908,38.079,140.922,38.051,140.924,38.198,141.088,38.295,141.024,38.319,141.047,38.326,141.038,38.342,141.062,38.369,141.131,38.378,141.138,38.321,141.148,38.334,141.178,38.319,141.153,38.404,141.311,38.413,141.309,38.428,141.316,38.439,141.326,38.444,141.306,38.449,141.294,38.446,141.287,38.449,141.296,38.491,141.276,38.488,141.239,38.503,141.225,38.529,141.232,38.557,141.524,38.272,141.482,38.375,141.539,38.4,141.448,38.444,141.551,38.525,141.458,38.573,141.421,38.541,141.314,38.509,141.307,38.513,141.295,38.562,141.302,38.593,141.295,38.604,141.283,38.648,141.285,38.709,141.292,38.718,141.275,38.704,141.268,38.709,141.263,38.786,141.294,38.719,141.312,38.512,141.525,38.609,141.445,38.672,141.562,38.691,141.513,38.766,141.604,38.828,141.576,38.907,141.637,38.902,141.675,38.858,141.623,38.999,141.705,38.938,141.722,39.075,141.838,39.024,141.812,39.112,141.922,39.105,141.837,39.146,141.971,39.247,141.891,39.307,141.97,39.313,141.997,39.35,141.898,39.337,142.04,39.419,142.051,39.485,141.979,39.439,141.955,39.473,142.072,39.547,142.026,39.657,141.946,39.587,142.005,39.75,141.964,39.953,141.833,40.075,141.877,40.144,141.782,40.193,141.834,40.221,141.769,40.35,141.484,40.56,141.392,40.886,141.353,40.725,141.277,40.741,141.308,40.863,141.393,40.888,141.389,41.166,141.465,41.429,141.145,41.856,140.96,41.915,140.716,42.137,140.548,42.113,140.292,42.247,140.324,42.434,140.471,42.587,140.7,42.586,141.003,42.301,141.373,42.554,141.733,42.655,142.491,42.267,142.965,42.118,143.249,41.924,143.323,42.308,143.659,42.691,143.615,42.698,143.598,42.71,143.592,42.728,143.595,42.744,143.569,42.75,143.565,42.763,143.539,42.78,143.532,42.791,143.514,42.801,143.443,42.873,143.425,42.899,143.65,42.694,143.998,42.928,144.385,43.037,144.362,42.971,144.449,42.944,144.774,42.933,144.725,42.991,144.871,43.098,144.95,43.047,144.832,43.014,144.977,42.982,145.032,43.01,144.987,43.058,145.172,43.076,145.145,43.144,145.517,43.163,145.615,43.314,146.101,43.497,146.118,43.453,146.197,43.535,146.83,43.805,146.898,43.805,150.022,45.827,152.027,46.9,156.661,50.867,157.474,51.36,157.566,51.503,157.906,51.626,158.046,51.794,158.009,51.788,157.969,51.816,157.968,51.824,158.105,51.808,158.274,51.962,158.327,52.069,158.298,52.103,158.319,52.161,158.376,52.128,158.367,52.214,158.561,52.316,158.483,52.344,158.559,52.417,158.398,52.407,158.526,52.532,158.39,52.651,158.55,52.627,158.602,52.694,158.481,52.773,158.646,52.843,158.635,52.917,158.435,52.893,158.421,53.016,158.506,53.034,158.498,53.042,158.502,53.045,158.476,53.051,158.471,53.055,158.469,53.062,158.467,53.054,158.45,53.053,158.416,53.075,158.404,53.101,158.419,53.119,158.381,53.137,158.376,53.144,158.378,53.15,158.409,53.137,158.423,53.113,158.407,53.099,158.433,53.09,158.416,53.077,158.442,53.065,158.452,53.054,158.47,53.064,158.518,53.045,158.521,53.08,158.613,53.05,158.704,52.885,159.034,53.086,159.578,53.252,159.846,53.275,159.744,53.205,160.036,53.098,160.03,53.267,159.946,53.211,159.903,53.256,159.974,53.327,159.845,53.404,159.881,53.487,159.773,53.51,159.947,53.476,159.914,53.67,159.846,53.668,159.9,53.634,159.905,53.577,159.824,53.688,159.804,53.693,159.784,53.711,159.76,53.715,159.758,53.736,159.723,53.736,159.691,53.745,159.689,53.75,159.696,53.755,159.693,53.759,159.669,53.756,159.661,53.762,159.652,53.76,159.639,53.766,159.64,53.749,159.634,53.748,159.603,53.753,159.583,53.752,159.581,53.74,159.564,53.734,159.549,53.733,159.545,53.725,159.536,53.718,159.509,53.712,159.489,53.715,159.476,53.725,159.474,53.722,159.423,53.719,159.36,53.722,159.334,53.709,159.324,53.709,159.308,53.716,159.336,53.711,159.338,53.715,159.356,53.724,159.467,53.728,159.506,53.725,159.516,53.72,159.533,53.721,159.538,53.722,159.542,53.731,159.563,53.75,159.57,53.753,159.597,53.755,159.638,53.749,159.628,53.759,159.628,53.764,159.629,53.765,159.635,53.769,159.644,53.769,159.695,53.76,159.722,53.738,159.732,53.74,159.739,53.738,159.746,53.741,159.755,53.739,159.866,53.675,159.848,53.825,159.994,54.16,160.688,54.509,161.077,54.594,161.288,54.482,161.716,54.505,162.138,54.752,162.154,54.853,161.859,55.066,161.738,55.396,161.618,55.39,161.599,55.393,161.564,55.405,161.566,55.402,161.555,55.412,161.616,55.392,161.736,55.397,161.743,55.552,162.083,56.086,162.481,56.209,162.28,56.17,162.37,56.2,162.443,56.213,162.351,56.197,162.37,56.205,162.493,56.226,161.911,56.213,161.815,56.256,161.725,56.249,161.556,56.299,161.479,56.283,161.37,56.32,161.48,56.286,161.548,56.303,161.629,56.291,161.717,56.253,161.812,56.263,161.925,56.221,162.086,56.219,162.131,56.262,162.198,56.273,162.435,56.255,162.484,56.246,162.505,56.238,162.52,56.225,162.602,56.262,162.408,56.389,162.71,56.485,162.91,56.42,163.019,56.537]},{"outer":[-95.194,16.166,-94.748,16.209,-95.105,16.236,-95.074,16.266,-94.989,16.244,-94.948,16.241,-94.886,16.246,-94.849,16.256,-94.842,16.27,-94.829,16.264,-94.861,16.283,-94.959,16.244,-95.066,16.282,-94.849,16.432,-94.772,16.331,-94.801,16.273,-94.676,16.365,-94.58,16.336,-94.623,16.267,-94.561,16.3,-94.568,16.222,-94.614,16.214,-94.678,16.23,-94.625,16.265,-94.725,16.211,-93.969,15.993,-94.206,16.128,-94.222,16.167,-94.253,16.161,-94.351,16.179,-94.297,16.158,-94.292,16.147,-94.396,16.177,-94.443,16.216,-94.379,16.297,-94.213,16.187,-94.118,16.22,-94.085,16.124,-93.874,16.057,-93.86,15.996,-93.938,15.989,-92.773,15.154,-92.854,15.157,-92.226,14.531,-91.783,14.189,-91.314,13.954,-90.535,13.905,-89.936,13.661,-89.807,13.524,-89.3,13.485,-88.828,13.257,-88.784,13.292,-88.768,13.294,-88.77,13.289,-88.811,13.267,-88.805,13.247,-88.477,13.172,-88.594,13.262,-88.65,13.238,-88.704,13.247,-88.716,13.264,-88.641,13.302,-88.476,13.263,-88.404,13.196,-88.424,13.166,-88.363,13.231,-88.349,13.174,-87.899,13.153,-87.91,13.202,-87.934,13.181,-87.927,13.198,-87.779,13.292,-87.865,13.355,-87.886,13.396,-87.839,13.447,-87.745,13.353,-87.638,13.471,-87.534,13.343,-87.476,13.408,-87.467,13.404,-87.461,13.416,-87.447,13.415,-87.429,13.396,-87.402,13.418,-87.389,13.368,-87.458,13.353,-87.453,13.337,-87.444,13.341,-87.434,13.334,-87.428,13.341,-87.423,13.342,-87.409,13.339,-87.408,13.331,-87.411,13.339,-87.433,13.333,-87.447,13.339,-87.425,13.31,-87.465,13.275,-87.5,13.3,-87.514,13.27,-87.427,13.109,-87.244,13.046,-87.384,12.938,-87.253,12.953,-87.274,12.895,-87.222,12.907,-87.181,12.889,-87.161,12.842,-87.473,12.94,-87.577,13.087,-87.69,12.91,-87.484,12.766,-87.487,12.781,-87.523,12.809,-87.543,12.841,-87.504,12.85,-87.315,12.646,-87.349,12.606,-87.164,12.52,-87.149,12.463,-87.125,12.461,-87.125,12.452,-87.109,12.447,-87.09,12.426,-87.088,12.418,-87.167,12.468,-87.182,12.46,-86.759,12.225,-86.518,11.776,-85.67,11.06,-85.757,11.024,-85.714,10.925,-85.793,10.909,-85.884,10.95,-85.951,10.89,-85.657,10.763,-85.7,10.61,-85.654,10.642,-85.626,10.623,-85.812,10.517,-85.772,10.449,-85.876,10.356,-85.668,9.902,-85.298,9.807,-85.11,9.554,-85.009,9.741,-84.863,9.825,-84.931,9.837,-84.906,9.853,-84.922,9.925,-85.009,9.939,-85.116,9.98,-85.168,10.033,-85.183,10.023,-85.27,10.139,-85.248,10.245,-85.289,10.269,-85.313,10.32,-85.356,10.314,-85.359,10.316,-85.359,10.325,-85.357,10.316,-85.309,10.322,-85.248,10.253,-85.227,10.266,-85.214,10.181,-84.742,9.97,-84.632,9.786,-84.677,9.626,-84.533,9.518,-84.252,9.5,-83.78,9.193,-83.518,8.944,-83.619,8.99,-83.527,8.874,-83.605,8.891,-83.609,8.874,-83.499,8.857,-83.636,8.818,-83.546,8.813,-83.735,8.625,-83.567,8.44,-83.279,8.386,-83.284,8.541,-83.479,8.72,-83.347,8.735,-83.125,8.601,-83.178,8.607,-83.137,8.55,-83.112,8.557,-83.09,8.55,-83.083,8.543,-83.151,8.547,-83.092,8.45,-83.144,8.366,-82.974,8.241,-82.905,8.04,-82.856,8.276,-82.708,8.344,-82.482,8.271,-82.537,8.307,-82.434,8.364,-82.417,8.285,-82.377,8.319,-82.35,8.289,-82.337,8.315,-82.367,8.327,-82.376,8.338,-82.37,8.345,-82.273,8.342,-82.269,8.29,-82.244,8.301,-82.227,8.36,-82.22,8.36,-82.22,8.372,-82.216,8.376,-82.21,8.378,-82.146,8.343,-82.224,8.287,-82.157,8.163,-82.114,8.217,-82.126,8.235,-82.138,8.238,-82.146,8.249,-82.139,8.25,-82.143,8.257,-82.14,8.263,-81.926,8.244,-81.961,8.193,-81.774,8.13,-81.762,8.225,-81.674,8.074,-81.74,8.08,-81.717,8.03,-81.632,7.94,-81.641,7.987,-81.626,8.0,-81.633,8.002,-81.635,8.006,-81.605,7.994,-81.626,7.905,-81.584,7.769,-81.441,7.678,-81.308,7.666,-81.221,7.611,-81.25,7.752,-81.17,7.809,-81.223,7.874,-81.226,7.884,-81.199,7.896,-81.222,7.902,-81.233,7.917,-81.237,7.93,-81.244,7.932,-81.248,7.928,-81.253,7.928,-81.245,7.935,-81.235,7.931,-81.199,7.901,-81.197,7.887,-81.216,7.871,-81.203,7.869,-81.198,7.877,-81.196,7.878,-81.191,7.878,-81.102,7.843,-81.09,7.849,-81.119,7.873,-81.123,7.917,-81.106,7.919,-81.096,7.902,-81.089,7.858,-81.08,7.92,-81.04,7.947,-81.062,7.826,-81.025,7.844,-81.017,7.826,-81.09,7.774,-80.916,7.441,-80.941,7.257,-80.716,7.206,-80.431,7.244,-80.276,7.434,-79.999,7.466,-80.046,7.635,-80.511,8.101,-80.498,8.242,-80.387,8.33,-80.187,8.325,-79.867,8.553,-79.784,8.576,-79.698,8.648,-79.756,8.61,-79.859,8.65,-79.753,8.692,-79.74,8.849,-79.662,8.894,-79.565,8.903,-79.663,9.06,-79.544,8.93,-79.437,9.018,-79.119,9.0,-79.125,9.091,-79.114,9.097,-79.087,9.095,-79.081,9.113,-79.071,9.121,-79.059,9.126,-79.061,9.135,-79.056,9.141,-79.053,9.141,-79.104,8.993,-78.911,8.925,-78.839,8.813,-78.807,8.87,-78.809,8.796,-78.786,8.81,-78.784,8.834,-78.773,8.835,-78.664,8.706,-78.618,8.754,-78.418,8.346,-78.367,8.395,-78.417,8.53,-78.402,8.568,-78.382,8.545,-78.369,8.422,-78.344,8.506,-78.292,8.439,-78.264,8.498,-78.232,8.437,-78.235,8.543,-78.207,8.435,-78.253,8.437,-78.249,8.378,-78.163,8.47,-78.142,8.465,-78.101,8.411,-78.122,8.485,-78.106,8.513,-78.136,8.513,-78.151,8.599,-78.021,8.403,-78.078,8.419,-78.025,8.27,-77.883,8.222,-77.791,8.123,-77.792,8.114,-77.802,8.103,-77.777,8.101,-77.783,8.082,-77.777,8.09,-77.765,8.091,-77.767,8.105,-77.76,8.12,-77.742,8.129,-77.755,8.145,-77.754,8.151,-77.752,8.161,-77.741,8.164,-77.74,8.171,-77.734,8.185,-77.741,8.13,-77.762,8.09,-77.783,8.08,-77.804,8.103,-77.794,8.124,-77.889,8.215,-77.974,8.236,-77.905,8.191,-77.854,8.108,-77.879,8.104,-78.156,8.418,-78.189,8.28,-78.308,8.257,-78.275,8.073,-78.441,8.086,-78.4,7.916,-78.182,7.516,-77.68,7.038,-77.702,6.842,-77.586,6.816,-77.527,6.647,-77.405,6.686,-77.326,6.547,-77.399,6.228,-77.473,6.297,-77.483,6.192,-77.24,5.765,-77.255,5.782,-77.344,5.616,-77.489,5.609,-77.539,5.488,-77.381,5.446,-77.375,4.96,-77.338,4.971,-77.315,4.969,-77.311,4.958,-77.37,4.944,-77.347,4.778,-77.257,4.734,-77.229,4.667,-77.2,4.697,-77.188,4.7,-77.178,4.699,-77.175,4.693,-77.229,4.666,-77.324,4.745,-77.352,4.429,-77.318,4.439,-77.316,4.435,-77.44,4.31,-77.378,4.277,-77.366,4.261,-77.352,4.221,-77.294,4.232,-77.249,4.234,-77.221,4.157,-77.207,4.173,-77.165,4.185,-77.102,4.176,-77.104,4.212,-77.094,4.23,-77.085,4.229,-77.076,4.212,-77.058,4.214,-77.038,4.206,-77.022,4.18,-77.039,4.308,-77.034,4.313,-77.023,4.311,-77.032,4.321,-77.032,4.327,-77.013,4.341,-77.001,4.372,-76.995,4.376,-77.006,4.39,-77.008,4.405,-77.006,4.411,-76.993,4.419,-77.007,4.428,-77.01,4.437,-77.005,4.443,-76.988,4.443,-77.01,4.454,-77.015,4.463,-76.992,4.483,-76.985,4.444,-77.018,4.179,-77.024,4.178,-77.053,4.21,-77.077,4.209,-77.083,4.213,-77.09,4.229,-77.097,4.175,-77.224,4.156,-77.262,4.232,-77.278,4.22,-77.286,4.182,-77.304,4.166,-77.31,4.166,-77.316,4.169,-77.337,4.191,-77.352,4.195,-77.389,4.147,-77.436,4.141,-77.424,4.048,-77.357,4.053,-77.439,4.027,-77.359,3.927,-77.27,4.102,-77.167,4.052,-77.197,4.047,-77.197,4.027,-77.211,4.008,-77.25,3.987,-77.215,3.982,-77.204,3.972,-77.198,3.947,-77.317,3.951,-77.249,3.828,-77.121,3.932,-77.014,3.919,-77.021,3.856,-77.148,3.809,-77.119,3.769,-77.139,3.775,-77.176,3.745,-77.125,3.72,-77.197,3.744,-77.188,3.681,-77.182,3.699,-77.146,3.713,-77.114,3.708,-77.165,3.651,-77.133,3.666,-77.109,3.659,-77.131,3.634,-77.188,3.653,-77.175,3.623,-77.225,3.569,-77.33,3.531,-77.324,3.518,-77.322,3.528,-77.277,3.528,-77.248,3.516,-77.276,3.396,-77.335,3.512,-77.348,3.328,-77.365,3.45,-77.395,3.333,-77.481,3.317,-77.482,3.31,-77.472,3.317,-77.458,3.315,-77.41,3.292,-77.384,3.204,-77.455,3.308,-77.543,3.239,-77.468,3.188,-77.48,3.111,-77.544,3.085,-77.527,3.062,-77.528,3.051,-77.519,3.045,-77.519,3.009,-77.497,3.003,-77.487,2.991,-77.481,3.0,-77.45,3.002,-77.444,2.996,-77.443,3.003,-77.43,3.008,-77.429,3.008,-77.427,3.007,-77.407,3.003,-77.404,3.004,-77.397,3.001,-77.395,2.998,-77.402,2.987,-77.411,2.985,-77.404,2.978,-77.403,2.969,-77.389,2.967,-77.381,2.956,-77.366,2.955,-77.348,2.947,-77.351,2.935,-77.333,2.936,-77.32,2.919,-77.271,2.911,-77.269,2.907,-77.277,2.898,-77.265,2.892,-77.271,2.876,-77.258,2.881,-77.23,2.874,-77.225,2.87,-77.23,2.851,-77.22,2.85,-77.22,2.842,-77.273,2.876,-77.278,2.897,-77.272,2.911,-77.303,2.91,-77.353,2.935,-77.349,2.946,-77.385,2.956,-77.391,2.966,-77.404,2.966,-77.416,2.983,-77.397,3.0,-77.488,2.989,-77.522,3.008,-77.546,3.085,-77.515,3.127,-77.514,3.134,-77.526,3.128,-77.538,3.143,-77.535,3.169,-77.515,3.184,-77.541,3.21,-77.657,3.057,-77.647,3.029,-77.634,3.031,-77.623,3.047,-77.616,3.05,-77.611,3.047,-77.61,3.038,-77.6,3.035,-77.643,3.027,-77.652,3.03,-77.665,3.048,-77.705,2.97,-77.702,2.969,-77.688,2.988,-77.672,3.0,-77.609,2.988,-77.672,2.966,-77.605,2.931,-77.627,2.897,-77.664,2.887,-77.63,2.887,-77.606,2.847,-77.705,2.863,-77.673,2.816,-77.765,2.793,-77.727,2.778,-77.714,2.791,-77.702,2.791,-77.696,2.787,-77.8,2.744,-77.787,2.693,-77.781,2.689,-77.77,2.703,-77.749,2.706,-77.739,2.69,-77.79,2.683,-77.788,2.65,-77.769,2.66,-77.765,2.659,-77.743,2.628,-77.744,2.609,-77.806,2.638,-77.75,2.508,-77.751,2.498,-77.759,2.491,-77.879,2.565,-77.891,2.585,-77.873,2.629,-77.899,2.609,-77.972,2.64,-77.975,2.616,-77.983,2.611,-77.977,2.608,-77.972,2.593,-77.978,2.585,-78.012,2.651,-78.015,2.618,-78.03,2.591,-78.025,2.581,-78.028,2.57,-78.092,2.49,-78.057,2.477,-78.047,2.453,-78.088,2.479,-78.094,2.488,-78.093,2.506,-78.113,2.481,-78.105,2.473,-78.094,2.445,-78.117,2.393,-78.114,2.485,-78.096,2.51,-78.078,2.515,-78.134,2.644,-78.124,2.475,-78.154,2.508,-78.156,2.52,-78.145,2.526,-78.158,2.527,-78.152,2.631,-78.134,2.661,-78.147,2.683,-78.209,2.668,-78.198,2.648,-78.187,2.659,-78.188,2.652,-78.177,2.649,-78.181,2.647,-78.168,2.641,-78.166,2.636,-78.193,2.624,-78.169,2.545,-78.17,2.54,-78.176,2.535,-78.172,2.531,-78.175,2.521,-78.167,2.515,-78.163,2.496,-78.166,2.48,-78.235,2.509,-78.195,2.47,-78.195,2.468,-78.206,2.461,-78.197,2.448,-78.199,2.445,-78.203,2.447,-78.206,2.442,-78.191,2.427,-78.188,2.414,-78.195,2.402,-78.191,2.399,-78.196,2.4,-78.192,2.426,-78.205,2.435,-78.207,2.442,-78.205,2.447,-78.203,2.449,-78.199,2.447,-78.208,2.46,-78.215,2.451,-78.239,2.449,-78.245,2.457,-78.272,2.393,-78.324,2.379,-78.341,2.395,-78.334,2.427,-78.359,2.428,-78.343,2.51,-78.37,2.521,-78.378,2.499,-78.395,2.485,-78.385,2.479,-78.381,2.461,-78.382,2.403,-78.396,2.401,-78.489,2.544,-78.45,2.402,-78.465,2.431,-78.491,2.416,-78.495,2.422,-78.492,2.426,-78.5,2.419,-78.508,2.425,-78.509,2.442,-78.532,2.447,-78.524,2.435,-78.532,2.416,-78.524,2.398,-78.511,2.397,-78.51,2.39,-78.567,2.414,-78.572,2.448,-78.67,2.179,-78.712,2.17,-78.631,2.052,-78.672,1.976,-78.629,2.011,-78.545,1.901,-78.546,1.755,-78.595,1.768,-78.598,1.727,-78.602,1.721,-78.607,1.72,-78.619,1.73,-78.62,1.718,-78.63,1.716,-78.627,1.709,-78.628,1.704,-78.639,1.702,-78.64,1.699,-78.633,1.695,-78.632,1.69,-78.636,1.678,-78.644,1.675,-78.641,1.702,-78.621,1.73,-78.616,1.731,-78.604,1.722,-78.601,1.728,-78.604,1.795,-78.764,1.734,-78.738,1.797,-78.812,1.778,-78.835,1.814,-79.008,1.657,-78.831,1.688,-78.586,1.377,-78.828,1.683,-78.942,1.623,-79.009,1.636,-78.923,1.58,-78.904,1.541,-78.867,1.565,-78.861,1.554,-78.833,1.561,-78.82,1.55,-78.871,1.502,-78.762,1.393,-78.837,1.406,-78.832,1.335,-78.878,1.315,-78.804,1.312,-78.788,1.299,-78.961,1.216,-78.946,1.21,-78.935,1.196,-78.939,1.191,-78.957,1.188,-78.947,1.178,-78.945,1.17,-78.958,1.133,-78.982,1.13,-78.976,1.106,-78.985,1.085,-78.914,1.086,-78.847,1.05,-78.989,1.084,-78.995,1.079,-78.983,1.07,-78.983,1.052,-79.024,1.023,-78.98,1.115,-78.996,1.129,-79.008,1.185,-79.052,1.222,-79.176,1.091,-79.392,1.084,-79.626,0.994,-79.634,0.95,-79.656,0.929,-79.656,0.902,-79.657,0.933,-79.633,0.967,-79.65,0.997,-80.051,0.825,-80.101,0.678,-80.087,0.649,-80.069,0.655,-80.051,0.634,-80.043,0.636,-80.029,0.628,-80.006,0.606,-80.056,0.465,-79.987,0.39,-79.981,0.283,-79.947,0.268,-79.934,0.255,-79.946,0.205,-79.99,0.17,-80.039,0.367,-80.111,0.0,-89.322,0.0,-91.822,1.399,-91.861,0.0,-180.0,0.0,-180.0,51.394,-176.939,51.584,-173.157,52.055,-169.105,52.819,-166.748,53.441,-164.954,54.074,-163.757,54.633,-163.422,54.655,-163.377,54.748,-162.663,54.298,-136.647,58.212,-136.523,58.099,-136.567,58.043,-136.573,57.919,-136.5,57.927,-136.299,57.742,-135.861,56.999,-134.913,56.389,-134.877,56.301,-134.807,56.382,-134.807,56.235,-133.617,55.23,-132.864,54.707,-133.08,54.249,-133.055,54.031,-133.156,53.89,-133.083,53.826,-133.123,53.776,-132.994,53.771,-132.924,53.698,-133.012,53.669,-132.954,53.663,-132.912,53.604,-132.818,53.602,-132.85,53.587,-133.006,53.592,-132.838,53.515,-132.9,53.468,-132.735,53.536,-132.736,53.483,-132.777,53.462,-132.671,53.499,-132.676,53.432,-132.42,53.34,-132.721,53.377,-132.775,53.315,-132.555,53.332,-132.555,53.301,-132.719,53.258,-132.592,53.122,-132.392,53.105,-132.575,53.098,-132.534,53.046,-132.264,53.06,-132.361,53.026,-132.353,53.018,-132.258,53.018,-132.202,52.994,-132.142,52.998,-132.101,52.983,-132.367,52.927,-132.262,52.917,-132.329,52.898,-132.139,52.744,-132.08,52.855,-131.971,52.794,-131.918,52.717,-132.015,52.783,-132.025,52.769,-131.984,52.738,-131.985,52.721,-132.111,52.734,-131.881,52.653,-131.942,52.642,-131.86,52.613,-131.906,52.587,-131.784,52.582,-131.84,52.554,-131.611,52.414,-131.548,52.438,-131.507,52.394,-131.607,52.38,-131.56,52.326,-131.365,52.182,-131.373,52.272,-131.051,51.952,-129.095,50.853,-128.352,50.781,-128.376,50.672,-128.275,50.672,-128.329,50.629,-128.048,50.443,-128.091,50.508,-128.012,50.537,-127.94,50.545,-127.904,50.527,-127.896,50.52,-128.01,50.528,-127.991,50.466,-127.559,50.552,-127.617,50.581,-127.757,50.586,-128.006,50.652,-127.422,50.596,-127.609,50.524,-127.503,50.445,-127.443,50.379,-127.435,50.342,-127.585,50.49,-127.937,50.464,-127.979,50.324,-127.877,50.344,-127.858,50.312,-127.794,50.325,-127.724,50.301,-127.718,50.295,-127.863,50.29,-127.729,50.241,-127.917,50.112,-127.795,50.078,-127.617,50.19,-127.645,50.145,-127.593,50.109,-127.477,50.181,-127.44,50.187,-127.516,50.122,-127.421,50.133,-127.485,50.092,-127.344,50.02,-127.267,50.058,-127.266,50.119,-127.306,50.127,-127.309,50.19,-127.21,50.093,-127.096,50.147,-127.196,50.023,-127.081,50.026,-127.259,49.966,-127.224,49.951,-127.154,49.86,-126.997,49.852,-127.045,49.941,-126.941,49.86,-126.94,49.987,-126.912,49.868,-126.822,49.882,-126.801,49.913,-126.86,49.957,-126.848,49.982,-126.751,49.873,-126.651,49.92,-126.587,49.694,-126.469,49.731,-126.496,49.802,-126.394,49.759,-126.499,49.689,-126.44,49.653,-126.118,49.681,-126.05,49.623,-126.104,49.658,-126.348,49.63,-126.479,49.644,-126.5,49.606,-126.57,49.578,-126.545,49.378,-126.426,49.477,-126.257,49.348,-126.297,49.512,-126.214,49.401,-126.033,49.454,-126.034,49.432,-126.083,49.394,-126.015,49.319,-125.901,49.42,-126.031,49.286,-126.018,49.241,-125.884,49.283,-125.834,49.262,-125.808,49.34,-125.772,49.36,-125.812,49.246,-125.73,49.256,-125.791,49.14,-125.597,49.234,-125.601,49.205,-125.74,49.083,-125.907,49.156,-125.902,49.109,-125.545,48.92,-125.527,48.93,-125.586,48.976,-125.48,48.921,-125.348,49.04,-125.203,49.039,-125.324,49.01,-125.221,48.951,-125.146,49.054,-125.198,49.097,-125.153,49.074,-125.138,49.046,-125.185,48.99,-124.91,48.985,-124.816,49.121,-124.84,49.248,-124.812,49.256,-124.789,49.144,-124.86,49.004,-125.22,48.784,-124.7,48.59,-124.744,48.391,-124.66,48.327,-124.734,48.164,-124.678,47.966,-124.425,47.738,-124.181,47.138,-124.181,46.927,-124.105,46.934,-124.109,47.042,-123.946,46.968,-123.785,46.979,-123.692,46.958,-123.983,46.923,-124.025,46.838,-124.111,46.913,-124.159,46.906,-124.087,46.738,-123.887,46.751,-123.761,46.68,-123.926,46.672,-123.898,46.596,-123.961,46.636,-123.895,46.548,-123.944,46.475,-123.851,46.42,-123.842,46.406,-123.84,46.394,-123.935,46.458,-123.922,46.422,-123.961,46.361,-124.015,46.381,-124.065,46.649,-124.088,46.264,-124.038,46.265,-124.052,46.282,-124.051,46.295,-123.998,46.313,-123.874,46.241,-123.708,46.319,-123.452,46.266,-123.44,46.259,-123.427,46.242,-123.413,46.238,-123.394,46.222,-123.383,46.198,-123.306,46.156,-123.165,46.192,-123.117,46.19,-123.057,46.172,-122.924,46.096,-122.893,46.107,-122.919,46.12,-122.922,46.129,-122.915,46.214,-122.906,46.237,-122.893,46.238,-122.914,46.212,-122.891,46.107,-122.907,46.097,-122.872,46.081,-122.786,45.947,-122.804,45.907,-122.78,45.855,-122.739,45.872,-122.718,45.869,-122.789,45.814,-122.759,45.766,-122.746,45.649,-122.269,45.555,-122.016,45.626,-121.977,45.628,-121.954,45.646,-121.943,45.651,-121.936,45.649,-121.996,45.616,-122.197,45.55,-122.311,45.54,-122.528,45.567,-122.701,45.612,-122.762,45.649,-122.794,45.615,-122.815,45.626,-122.868,45.674,-122.869,45.691,-122.868,45.717,-122.803,45.791,-122.802,45.8,-122.823,45.804,-122.828,45.814,-122.795,45.864,-122.914,46.084,-123.039,46.128,-123.098,46.167,-123.147,46.169,-123.172,46.182,-123.23,46.138,-123.279,46.133,-123.375,46.144,-123.382,46.138,-123.533,46.236,-123.595,46.185,-123.612,46.187,-123.695,46.171,-123.726,46.171,-123.75,46.175,-123.758,46.213,-123.866,46.189,-123.812,46.17,-123.802,46.151,-123.811,46.144,-123.802,46.123,-123.804,46.118,-123.786,46.106,-123.785,46.098,-123.813,46.114,-123.823,46.168,-123.876,46.122,-124.005,46.24,-124.076,46.234,-123.917,46.014,-123.997,45.945,-123.984,45.767,-123.945,45.656,-123.917,45.707,-123.888,45.701,-123.893,45.715,-123.879,45.732,-123.883,45.69,-123.945,45.655,-123.966,45.57,-123.897,45.56,-123.875,45.457,-123.958,45.502,-123.934,45.557,-123.948,45.565,-123.965,45.567,-123.98,45.486,-123.932,45.403,-124.01,45.338,-123.934,45.172,-124.012,45.077,-124.083,44.611,-124.048,44.632,-124.011,44.627,-123.971,44.574,-123.948,44.623,-123.938,44.619,-123.916,44.596,-123.984,44.566,-124.047,44.625,-124.083,44.608,-124.083,44.427,-124.048,44.449,-123.961,44.411,-124.084,44.414,-124.148,44.019,-124.134,44.017,-124.126,44.01,-124.111,43.967,-124.072,43.988,-124.06,43.977,-124.101,43.961,-124.116,43.965,-124.131,44.011,-124.138,44.017,-124.148,44.017,-124.219,43.671,-124.139,43.751,-124.067,43.738,-124.082,43.703,-123.971,43.695,-123.93,43.681,-123.901,43.652,-123.798,43.675,-123.713,43.657,-123.687,43.673,-123.667,43.683,-123.659,43.683,-123.653,43.679,-123.653,43.664,-123.662,43.651,-123.673,43.642,-123.674,43.638,-123.669,43.636,-123.657,43.644,-123.627,43.651,-123.575,43.639,-123.566,43.631,-123.576,43.617,-123.583,43.612,-123.611,43.613,-123.619,43.596,-123.547,43.594,-123.62,43.596,-123.621,43.604,-123.61,43.615,-123.579,43.615,-123.574,43.637,-123.675,43.636,-123.659,43.682,-123.713,43.656,-123.9,43.65,-123.943,43.683,-124.066,43.692,-124.109,43.709,-124.138,43.736,-124.348,43.359,-124.194,43.467,-124.154,43.365,-124.221,43.285,-124.221,43.424,-124.319,43.288,-124.378,43.344,-124.427,43.124,-124.41,43.144,-124.359,43.167,-124.35,43.164,-124.342,43.156,-124.34,43.146,-124.436,43.115,-124.566,42.842,-124.401,42.626,-124.43,42.423,-124.382,42.463,-124.327,42.483,-124.315,42.481,-124.309,42.495,-124.301,42.503,-124.297,42.504,-124.313,42.48,-124.37,42.465,-124.372,42.445,-124.39,42.432,-124.429,42.421,-124.433,42.322,-124.352,42.1,-124.201,42.075,-124.27,42.044,-124.123,41.87,-124.204,41.941,-124.254,41.77,-124.16,41.735,-124.094,41.554,-124.057,41.546,-124.038,41.519,-124.003,41.524,-123.992,41.504,-123.974,41.513,-123.966,41.512,-123.963,41.506,-123.964,41.49,-123.947,41.479,-123.944,41.473,-123.948,41.467,-123.935,41.461,-123.927,41.439,-123.93,41.396,-123.858,41.346,-123.854,41.34,-123.858,41.327,-123.85,41.317,-123.854,41.279,-123.875,41.302,-123.856,41.341,-123.931,41.394,-123.928,41.437,-123.966,41.49,-123.966,41.51,-123.996,41.502,-124.005,41.523,-124.036,41.517,-124.059,41.544,-124.076,41.538,-124.166,41.139,-124.115,41.001,-124.24,40.769,-124.149,40.873,-124.084,40.832,-124.183,40.8,-124.216,40.676,-124.268,40.699,-124.228,40.745,-124.235,40.758,-124.245,40.765,-124.309,40.653,-124.164,40.596,-124.153,40.572,-124.159,40.518,-124.093,40.506,-124.089,40.501,-124.09,40.498,-124.11,40.479,-124.102,40.468,-123.988,40.452,-123.935,40.422,-123.919,40.379,-123.913,40.344,-123.89,40.347,-123.854,40.344,-123.823,40.321,-123.914,40.342,-123.941,40.425,-124.096,40.462,-124.16,40.515,-124.166,40.587,-124.31,40.649,-124.41,40.444,-124.363,40.265,-123.85,39.829,-123.759,39.546,-123.827,39.348,-123.692,39.05,-123.728,38.918,-123.334,38.565,-123.08,38.443,-123.106,38.434,-123.13,38.45,-123.062,38.299,-123.058,38.336,-122.97,38.31,-122.827,38.09,-122.995,38.24,-122.939,38.126,-123.024,37.995,-122.933,38.083,-122.529,37.815,-122.472,37.836,-122.478,37.859,-122.52,37.884,-122.525,37.896,-122.438,37.88,-122.512,38.13,-122.252,38.069,-122.325,38.186,-122.315,38.195,-122.134,38.043,-122.066,38.11,-122.064,38.118,-122.081,38.123,-122.083,38.137,-122.071,38.157,-122.058,38.158,-122.07,38.169,-122.03,38.225,-122.037,38.19,-122.068,38.17,-122.056,38.158,-122.08,38.126,-122.037,38.172,-121.978,38.188,-121.964,38.183,-121.961,38.172,-121.951,38.176,-121.936,38.168,-121.91,38.185,-121.935,38.166,-122.037,38.17,-122.06,38.14,-121.898,38.05,-121.842,38.077,-121.797,38.064,-121.702,38.123,-121.658,38.201,-121.725,38.289,-121.676,38.234,-121.649,38.333,-121.641,38.351,-121.619,38.194,-121.663,38.178,-121.603,38.165,-121.556,38.241,-121.512,38.245,-121.566,38.295,-121.578,38.311,-121.579,38.32,-121.512,38.254,-121.51,38.247,-121.517,38.239,-121.557,38.238,-121.598,38.166,-121.617,38.163,-121.661,38.175,-121.671,38.171,-121.7,38.107,-121.696,38.106,-121.691,38.113,-121.682,38.116,-121.684,38.087,-121.66,38.099,-121.637,38.09,-121.629,38.102,-121.607,38.109,-121.567,38.097,-121.587,38.115,-121.577,38.13,-121.567,38.135,-121.511,38.122,-121.578,38.127,-121.519,38.088,-121.555,38.061,-121.495,38.093,-121.335,37.951,-121.461,38.003,-121.517,38.053,-121.525,38.037,-121.508,38.009,-121.51,37.974,-121.534,37.947,-121.539,37.973,-121.533,37.976,-121.535,37.986,-121.525,38.002,-121.581,37.984,-121.572,37.98,-121.573,37.976,-121.579,37.976,-121.573,37.973,-121.564,37.96,-121.554,37.922,-121.559,37.828,-121.594,37.828,-121.584,38.029,-121.659,38.056,-121.712,38.007,-122.148,38.022,-122.24,38.058,-122.43,37.965,-122.311,37.895,-122.341,37.804,-122.327,37.807,-122.248,37.787,-122.238,37.773,-122.279,37.792,-122.332,37.782,-122.218,37.76,-122.107,37.5,-121.979,37.46,-122.018,37.422,-122.378,37.606,-122.41,37.811,-122.515,37.781,-122.518,37.522,-122.337,37.117,-122.107,36.956,-121.909,36.97,-121.79,36.807,-121.739,36.832,-121.858,36.611,-121.977,36.579,-121.904,36.306,-121.503,36.0,-121.289,35.663,-120.887,35.434,-120.862,35.21,-120.638,35.133,-120.672,34.903,-120.584,34.691,-120.636,34.558,-120.453,34.442,-119.562,34.415,-119.129,34.101,-118.807,34.001,-118.543,34.039,-118.447,33.98,-118.412,33.741,-118.268,33.703,-118.25,33.709,-118.283,33.711,-118.276,33.766,-118.247,33.715,-118.129,33.768,-117.931,33.606,-117.882,33.595,-117.928,33.619,-117.907,33.613,-117.89,33.646,-117.868,33.651,-117.899,33.605,-117.782,33.54,-117.715,33.46,-117.51,33.337,-117.272,33.091,-117.245,32.666,-117.209,32.741,-117.118,32.671,-117.105,32.588,-117.166,32.697,-117.229,32.696,-117.142,32.631,-117.04,32.285,-116.923,32.227,-116.88,32.017,-116.618,31.853,-116.61,31.771,-116.63,31.695,-116.619,31.774,-116.745,31.743,-116.65,31.664,-116.692,31.553,-116.36,31.241,-116.333,30.961,-116.06,30.8,-116.049,30.472,-116.002,30.36,-116.026,30.453,-115.957,30.447,-115.999,30.496,-115.992,30.508,-115.935,30.458,-115.986,30.39,-115.813,30.303,-115.812,29.95,-115.702,29.901,-115.699,29.755,-115.19,29.428,-114.967,29.374,-114.627,29.09,-114.587,28.964,-114.414,28.876,-114.347,28.73,-114.164,28.642,-114.045,28.462,-114.125,28.256,-114.066,28.256,-114.064,28.236,-114.053,28.223,-114.046,28.192,-114.058,28.095,-114.059,28.2,-114.114,28.118,-114.035,28.042,-114.081,27.978,-114.093,28.034,-114.12,28.037,-114.085,27.971,-114.112,27.926,-114.152,28.006,-114.134,28.084,-114.273,27.896,-114.263,27.888,-114.178,27.949,-114.165,27.954,-114.149,27.952,-114.185,27.752,-114.117,27.706,-114.039,27.731,-114.068,27.804,-114.029,27.74,-113.966,27.748,-113.999,27.789,-113.961,27.867,-113.845,27.595,-113.857,27.56,-113.9,27.551,-113.944,27.623,-113.909,27.711,-113.942,27.758,-113.956,27.66,-114.061,27.692,-114.039,27.671,-114.101,27.604,-114.301,27.75,-114.318,27.878,-114.499,27.773,-115.055,27.862,-115.027,27.741,-114.513,27.416,-114.435,27.176,-113.85,26.969,-113.734,26.799,-113.609,26.714,-113.548,26.72,-113.509,26.791,-113.465,26.806,-113.486,26.816,-113.458,26.831,-113.417,26.832,-113.392,26.815,-113.457,26.807,-113.265,26.752,-113.164,26.993,-113.139,26.858,-113.244,26.786,-113.186,26.756,-113.168,26.796,-113.153,26.79,-113.15,26.797,-113.145,26.801,-113.127,26.796,-113.152,26.749,-113.054,26.641,-113.067,26.604,-112.68,26.314,-112.409,26.245,-112.295,26.057,-112.273,26.102,-112.265,26.103,-112.245,26.087,-112.076,25.727,-112.094,25.692,-112.074,25.676,-112.067,25.652,-112.064,25.493,-112.091,25.474,-112.025,25.474,-112.088,25.462,-112.067,25.431,-112.076,25.279,-112.107,25.256,-112.1,25.254,-112.094,25.262,-112.086,25.261,-112.078,25.242,-112.179,24.819,-112.144,24.804,-112.148,24.865,-112.142,24.904,-112.131,24.92,-112.127,24.922,-112.139,24.949,-112.11,24.978,-112.11,25.0,-112.105,25.007,-112.102,25.007,-112.077,24.933,-112.122,24.788,-112.077,24.725,-112.043,24.754,-112.056,24.83,-112.028,24.839,-112.031,24.739,-111.965,24.736,-111.954,24.752,-111.971,24.752,-111.987,24.802,-111.991,24.848,-111.984,24.843,-111.819,24.51,-111.758,24.516,-111.804,24.543,-111.814,24.558,-111.815,24.598,-111.656,24.569,-111.584,24.415,-111.561,24.461,-110.322,23.555,-110.165,23.31,-110.086,22.989,-109.963,22.871,-106.77,23.644,-106.489,23.339,-106.43,23.177,-106.405,23.214,-106.387,23.22,-106.328,23.173,-106.38,23.21,-106.403,23.208,-106.419,23.178,-105.866,22.706,-105.698,22.472,-105.735,22.71,-105.662,22.708,-105.718,22.678,-105.674,22.646,-105.658,22.617,-105.614,22.214,-105.47,22.129,-105.45,22.026,-105.635,22.182,-105.637,21.976,-105.45,21.633,-105.441,21.644,-105.431,21.64,-105.444,21.665,-105.443,21.672,-105.44,21.676,-105.401,21.673,-105.44,21.674,-105.427,21.639,-105.446,21.625,-105.198,21.494,-105.236,21.059,-105.539,20.769,-105.346,20.759,-105.245,20.582,-105.695,20.409,-105.492,19.988,-104.938,19.307,-104.803,19.301,-104.81,19.233,-104.733,19.239,-104.442,19.083,-104.356,19.115,-104.29,19.077,-104.332,19.017,-103.958,18.87,-103.738,18.687,-103.739,18.698,-103.701,18.754,-103.678,18.774,-103.625,18.795,-103.737,18.684,-103.494,18.328,-102.192,17.913,-102.138,17.939,-102.206,18.014,-102.18,18.042,-102.179,18.0,-102.138,17.94,-101.964,17.968,-101.786,17.867,-101.653,17.662,-101.201,17.427,-101.061,17.265,-99.99,16.909,-99.653,16.695,-98.73,16.504,-98.715,16.518,-98.696,16.523,-98.699,16.526,-98.679,16.521,-98.664,16.536,-98.595,16.549,-98.588,16.546,-98.577,16.554,-98.544,16.559,-98.539,16.573,-98.491,16.578,-98.488,16.591,-98.483,16.596,-98.503,16.615,-98.502,16.622,-98.495,16.627,-98.506,16.652,-98.504,16.666,-98.443,16.565,-98.415,16.625,-98.379,16.668,-98.324,16.688,-98.443,16.563,-98.727,16.5,-98.549,16.313,-98.182,16.255,-97.791,15.985,-97.783,15.994,-97.793,16.001,-97.789,16.017,-97.734,16.065,-97.788,15.982,-97.235,15.931,-96.557,15.657,-96.238,15.682,-96.203,15.712,-96.127,15.737,-96.122,15.76,-95.963,15.827,-95.435,15.976,-95.194,16.166],"holes":[[-155.677,18.91,-155.504,19.136,-154.975,19.347,-154.806,19.516,-155.002,19.736,-155.086,19.726,-155.084,19.853,-155.24,19.995,-155.853,20.268,-155.904,20.188,-155.829,19.98,-156.062,19.728,-155.886,19.344,-155.907,19.079,-155.677,18.91],[-156.427,20.594,-156.047,20.651,-155.979,20.765,-156.239,20.935,-156.474,20.891,-156.64,21.026,-156.686,20.881,-156.462,20.78,-156.427,20.594],[-156.873,21.047,-156.71,21.158,-157.254,21.224,-157.311,21.102,-156.873,21.047],[-157.791,21.259,-157.649,21.31,-157.723,21.459,-157.84,21.456,-157.966,21.71,-158.118,21.58,-158.283,21.575,-158.107,21.296,-157.979,21.315,-158.02,21.373,-157.959,21.387,-157.95,21.304,-157.791,21.259],[-159.773,22.006,-159.443,21.868,-159.331,21.959,-159.292,22.142,-159.403,22.235,-159.581,22.223,-159.724,22.151,-159.773,22.006],[-126.671,49.579,-126.612,49.665,-126.669,49.855,-126.965,49.834,-126.823,49.797,-126.967,49.73,-126.671,49.579]]}],"names":{"ar":"شمال المحيط الهادئ","de":"Nordpazifik","es":"Océano Pacífico Norte","fr":"océan Pacifique nord","he":"האוקיינוס הצפון פסיפי","it":"Nord dell'oceano pacifico","nl":"Noordelijke Grote Oceaan","pt":"Oceano Pacífico Norte","zh":"北太平洋"}},{"name":"South Pacific Ocean","bbox":[-180.0,-60.0,180.0,3.391],"area":7514.5326,"polygons":[{"outer":[167.583,-22.733,167.586,-22.737,167.595,-22.735,167.583,-22.716,169.878,-20.242,169.903,-20.193,169.507,-19.521,169.341,-18.913,169.199,-18.787,169.281,-18.767,168.594,-17.697,168.598,-16.883,168.491,-16.795,168.312,-16.306,168.069,-14.447,167.194,-9.88,167.166,-9.873,167.107,-9.809,167.107,-9.797,167.097,-9.797,167.069,-9.763,162.377,-10.829,162.264,-10.822,162.254,-10.801,162.224,-10.81,162.264,-10.689,162.085,-10.474,161.842,-10.457,161.301,-10.208,161.572,-9.727,161.557,-9.57,161.393,-9.361,161.332,-9.332,161.397,-9.4,161.376,-9.404,161.406,-9.43,161.382,-9.476,161.356,-9.465,161.217,-9.286,161.267,-9.303,161.266,-9.194,161.166,-8.984,160.937,-8.813,161.001,-8.642,160.738,-8.317,159.896,-8.562,159.742,-8.402,159.855,-8.343,159.375,-7.981,159.066,-7.9,158.967,-7.816,158.994,-7.792,158.734,-7.58,158.434,-7.513,158.426,-7.594,158.278,-7.498,158.403,-7.564,158.419,-7.513,158.244,-7.475,158.279,-7.441,158.279,-7.404,158.254,-7.444,158.191,-7.412,158.229,-7.466,157.825,-7.472,157.533,-7.289,157.395,-7.325,157.139,-7.125,157.098,-6.958,156.497,-6.591,155.952,-6.756,155.924,-6.514,155.819,-6.363,155.634,-6.217,155.628,-6.166,155.583,-6.222,155.455,-6.147,155.404,-5.988,155.192,-5.856,155.171,-5.726,155.052,-5.576,155.067,-5.542,154.882,-5.548,154.683,-5.425,154.713,-5.184,154.647,-5.014,152.891,-4.835,153.06,-4.599,153.025,-4.478,153.116,-4.272,152.934,-3.978,152.851,-3.977,152.822,-3.864,152.553,-3.812,152.492,-3.641,152.417,-3.672,152.352,-3.535,152.048,-3.242,151.801,-3.191,151.414,-2.901,151.214,-2.873,150.806,-2.555,150.424,-2.481,150.196,-2.359,150.087,-2.417,147.367,-1.973,147.395,-2.0,147.144,-2.009,146.84,-1.951,146.6,-2.025,145.079,-1.506,144.398,-1.074,143.048,-1.456,141.993,-2.966,141.884,-2.973,141.205,-2.633,140.705,-2.628,140.74,-2.516,140.613,-2.439,140.348,-2.395,140.371,-2.457,140.336,-2.465,140.112,-2.32,139.751,-2.36,138.861,-1.965,138.653,-1.787,137.981,-1.529,138.128,-1.646,137.954,-1.655,137.934,-1.458,137.886,-1.488,137.871,-1.642,137.812,-1.694,137.894,-1.464,137.837,-1.462,137.741,-1.481,137.794,-1.548,137.727,-1.493,137.591,-1.554,137.596,-1.56,137.606,-1.553,137.636,-1.56,137.637,-1.565,137.626,-1.585,137.635,-1.59,137.633,-1.594,137.478,-1.598,137.52,-1.672,137.472,-1.607,137.331,-1.691,137.329,-1.696,137.34,-1.692,137.348,-1.699,137.354,-1.708,137.361,-1.739,137.36,-1.76,137.388,-1.76,137.395,-1.764,137.403,-1.784,137.402,-1.81,137.428,-1.808,137.477,-1.837,137.349,-1.841,137.377,-1.872,137.394,-1.87,137.413,-1.878,137.419,-1.884,137.443,-2.011,137.418,-1.883,137.376,-1.872,137.35,-1.849,137.344,-1.829,137.299,-1.827,137.208,-1.746,137.153,-1.772,137.185,-1.809,137.186,-1.827,137.205,-1.842,137.204,-1.852,137.193,-1.859,137.237,-1.873,137.258,-1.904,137.249,-1.921,137.305,-1.916,137.31,-1.922,137.319,-1.952,137.335,-1.931,137.321,-1.953,137.183,-1.937,137.267,-1.988,137.189,-1.991,137.258,-2.115,137.058,-2.098,137.005,-2.129,137.062,-2.168,137.036,-2.205,136.942,-2.12,136.749,-2.268,136.396,-2.216,136.226,-2.613,135.946,-2.782,135.964,-2.954,135.734,-3.064,135.729,-3.075,135.767,-3.088,135.767,-3.1,135.785,-3.103,135.786,-3.11,135.611,-3.181,135.502,-3.358,135.118,-3.382,134.972,-3.315,134.961,-3.226,134.861,-3.249,134.801,-3.131,134.853,-2.886,134.768,-2.991,134.689,-2.967,134.631,-2.475,134.532,-2.45,134.569,-2.484,134.461,-2.55,134.524,-2.793,134.461,-2.863,134.287,-2.518,134.32,-2.507,134.298,-2.503,134.279,-2.481,134.258,-2.429,134.214,-2.419,134.176,-2.355,134.068,-1.674,134.279,-1.343,134.01,-0.959,134.053,-0.869,134.122,-0.88,134.137,-0.863,134.014,-0.782,133.975,-0.721,133.396,-0.741,132.954,-0.453,132.42,-0.343,132.171,-0.425,131.848,-0.706,131.231,-0.818,131.301,-0.918,131.249,-1.083,131.177,-1.22,131.17,-1.225,131.111,-1.244,131.051,-1.255,131.0,-1.381,130.932,-1.426,130.863,-1.346,130.867,-1.334,130.909,-1.322,130.961,-1.36,130.991,-1.337,131.08,-0.967,130.922,-0.889,130.635,-0.962,130.393,-0.923,130.822,-0.868,130.927,-0.784,130.805,-0.752,130.796,-0.771,130.815,-0.779,130.809,-0.786,130.822,-0.817,130.815,-0.826,130.533,-0.839,130.486,-0.829,130.484,-0.789,130.388,-0.923,130.111,-0.005,172.589,0.0,172.68,3.276,173.008,3.391,174.256,0.0,180.0,0.0,180.0,-60.0,146.898,-60.0,146.86,-43.633,166.106,-50.927,166.208,-50.882,166.055,-50.865,166.072,-50.808,166.246,-50.831,166.08,-50.717,166.201,-50.716,166.129,-50.705,166.211,-50.658,166.164,-50.601,166.285,-50.584,166.277,-50.524,166.119,-50.513,166.571,-48.039,167.662,-47.23,167.62,-47.245,167.565,-47.242,167.556,-47.23,167.698,-47.151,167.82,-47.192,168.141,-47.079,168.221,-47.098,168.153,-47.067,168.229,-47.015,168.194,-46.92,168.124,-46.992,168.014,-46.925,167.91,-46.974,167.997,-46.868,168.044,-46.92,168.142,-46.912,168.128,-46.855,168.846,-46.66,169.585,-46.579,169.612,-46.542,169.723,-46.49,169.616,-46.48,169.82,-46.449,169.781,-46.397,169.804,-46.321,170.171,-46.154,170.168,-46.007,170.211,-46.052,170.399,-45.915,170.741,-45.879,170.682,-45.846,170.731,-45.844,170.729,-45.772,170.722,-45.797,170.669,-45.815,170.671,-45.833,170.574,-45.879,170.506,-45.88,170.718,-45.787,170.692,-45.755,170.625,-45.762,170.57,-45.726,170.833,-45.474,170.863,-45.241,171.147,-44.943,171.164,-44.6,171.275,-44.344,172.296,-43.872,172.704,-43.827,172.944,-43.9,172.904,-43.823,172.934,-43.751,172.968,-43.888,173.079,-43.853,173.13,-43.747,173.047,-43.651,172.648,-43.635,172.809,-43.588,172.703,-43.555,172.711,-43.395,172.66,-43.407,172.808,-43.143,173.319,-42.885,173.529,-42.479,173.723,-42.425,173.697,-42.356,174.276,-41.734,175.322,-41.609,175.963,-41.245,176.343,-40.679,176.624,-40.49,176.677,-40.268,176.878,-40.121,177.096,-39.644,176.968,-39.625,176.872,-39.452,177.045,-39.196,177.416,-39.031,177.842,-39.06,177.82,-39.16,177.864,-39.268,178.002,-39.115,177.888,-39.041,177.973,-38.757,177.924,-38.701,178.015,-38.671,178.07,-38.708,178.082,-38.678,178.294,-38.529,178.337,-38.012,178.541,-37.675,178.315,-37.555,177.983,-37.536,177.953,-37.615,177.727,-37.678,177.488,-37.949,177.157,-37.988,177.15,-38.042,176.174,-37.624,176.233,-37.711,175.968,-37.629,175.92,-37.534,175.991,-37.464,175.857,-37.235,175.886,-37.03,175.862,-37.006,175.837,-37.052,175.827,-37.054,175.861,-36.93,175.784,-36.822,175.704,-36.894,175.676,-36.869,175.822,-36.717,175.61,-36.755,175.535,-36.54,175.414,-36.469,175.326,-36.521,175.471,-36.621,175.43,-36.716,175.504,-36.797,175.406,-36.854,175.499,-36.975,175.516,-37.111,175.618,-37.241,175.618,-37.249,175.604,-37.266,175.527,-37.166,175.337,-37.2,175.288,-36.995,175.192,-36.929,175.093,-36.945,175.038,-36.874,174.967,-36.936,174.9,-36.845,174.884,-36.927,174.853,-36.955,174.869,-36.844,174.664,-36.892,174.673,-36.789,174.642,-36.792,174.597,-36.768,174.676,-36.751,174.699,-36.824,174.814,-36.829,174.709,-36.62,174.742,-36.648,174.842,-36.601,174.701,-36.596,174.695,-36.426,174.736,-36.505,174.781,-36.437,174.732,-36.423,174.725,-36.376,174.743,-36.398,174.869,-36.366,174.773,-36.361,174.821,-36.276,174.467,-35.99,174.502,-35.836,174.322,-35.836,174.346,-35.734,174.592,-35.854,174.549,-35.787,174.569,-35.717,174.499,-35.732,174.514,-35.631,174.5,-35.642,174.467,-35.646,174.464,-35.644,174.538,-35.58,174.309,-35.333,174.355,-35.348,174.357,-35.373,174.378,-35.377,174.35,-35.334,174.374,-35.333,174.378,-35.318,174.29,-35.275,174.335,-35.172,174.209,-35.283,174.116,-35.241,174.224,-35.332,174.113,-35.355,174.07,-35.211,174.042,-35.219,174.035,-35.206,174.012,-35.212,174.002,-35.207,173.986,-35.211,173.975,-35.208,174.05,-35.193,173.983,-35.156,174.008,-35.14,174.068,-35.194,174.088,-35.172,174.123,-35.172,174.135,-35.154,173.933,-35.106,173.854,-34.988,173.721,-35.074,173.757,-34.999,173.558,-34.912,173.557,-35.013,173.47,-35.019,173.372,-34.915,173.47,-34.849,173.397,-34.781,173.285,-34.862,173.307,-35.003,173.304,-35.006,173.299,-35.006,173.292,-35.002,173.292,-35.008,173.286,-34.999,173.271,-35.005,173.28,-35.0,173.267,-34.998,173.263,-34.974,173.252,-34.986,173.262,-35.002,173.26,-35.009,173.235,-35.005,173.28,-34.89,173.121,-34.817,173.104,-34.77,173.175,-34.803,172.986,-34.528,172.955,-34.535,172.987,-34.574,172.975,-34.616,172.946,-34.528,172.874,-34.543,172.906,-34.476,173.009,-34.51,173.031,-34.398,172.154,-34.144,159.3,-31.783,159.033,-30.0,159.119,-29.472,167.587,-22.754,167.583,-22.733],"holes":[[130.218,-0.208,130.437,-0.29,130.527,-0.247,130.554,-0.431,130.578,-0.321,130.679,-0.292,130.727,-0.44,130.945,-0.355,130.731,-0.294,130.621,-0.08,130.958,-0.351,131.1,-0.308,131.241,-0.381,131.341,-0.291,131.298,-0.159,131.049,-0.021,130.57,-0.039,130.37,-0.076,130.362,-0.135,130.349,-0.079,130.301,-0.173,130.424,-0.129,130.377,-0.182,130.451,-0.198,130.218,-0.208],[177.895,-18.276,178.15,-18.264,178.389,-18.104,178.524,-18.165,178.518,-18.135,178.566,-18.061,178.44,-17.985,178.569,-18.061,178.524,-18.144,178.682,-18.056,178.578,-17.914,178.583,-17.644,178.318,-17.445,178.236,-17.467,178.208,-17.319,177.658,-17.445,177.645,-17.474,177.666,-17.483,177.662,-17.506,177.672,-17.498,177.68,-17.503,177.685,-17.564,177.678,-17.502,177.666,-17.508,177.66,-17.505,177.664,-17.483,177.644,-17.477,177.639,-17.445,177.517,-17.508,177.382,-17.64,177.433,-17.759,177.25,-17.888,177.333,-18.122,177.895,-18.276],[178.681,-16.98,178.784,-17.004,178.905,-16.842,179.016,-16.938,179.047,-16.806,179.198,-16.7,179.359,-16.738,179.304,-16.821,179.568,-16.811,179.715,-16.717,179.927,-16.764,179.822,-16.673,179.92,-16.467,179.558,-16.768,179.471,-16.708,180.0,-16.14,179.836,-16.24,179.533,-16.236,179.356,-16.408,178.942,-16.461,178.861,-16.547,178.927,-16.589,178.853,-16.548,178.691,-16.675,178.531,-16.641,178.491,-16.817,178.616,-16.819,178.568,-16.875,178.681,-16.98],[136.362,-1.063,136.17,-1.042,135.855,-0.701,135.749,-0.74,135.361,-0.635,135.541,-0.876,135.467,-0.744,135.642,-0.887,135.73,-0.776,135.873,-1.173,136.149,-1.2,136.362,-1.063],[135.471,-1.599,135.41,-1.602,135.474,-1.681,135.731,-1.688,136.23,-1.909,136.904,-1.793,135.471,-1.599]]},{"outer":[-142.178,-18.171,-142.113,-18.264,-142.159,-18.288,-142.165,-18.218,-142.202,-18.166,-142.191,-18.163,-142.185,-18.167,-142.177,-18.171,-142.178,-18.171]},{"outer":[-142.245,-17.996,-142.24,-17.992,-142.238,-17.989,-142.238,-17.988,-142.237,-17.988,-142.237,-17.987,-142.233,-17.98,-142.233,-17.976,-142.231,-17.975,-142.232,-17.973,-142.23,-17.973,-142.23,-17.97,-142.229,-17.968,-142.2,-17.96,-142.23,-18.145,-142.272,-18.129,-142.332,-18.081,-142.36,-18.076,-142.389,-18.031,-142.383,-18.015,-142.379,-18.012,-142.371,-18.009,-142.303,-18.019,-142.288,-18.017,-142.271,-18.012,-142.245,-17.996]},{"outer":[-140.784,-17.88,-140.872,-17.904,-140.849,-17.857,-140.852,-17.839,-140.84,-17.832,-140.835,-17.821,-140.831,-17.823,-140.824,-17.813,-140.821,-17.807,-140.82,-17.807,-140.807,-17.784,-140.797,-17.775,-140.786,-17.771,-140.761,-17.762,-140.756,-17.76,-140.751,-17.756,-140.749,-17.754,-140.747,-17.753,-140.748,-17.753,-140.746,-17.751,-140.737,-17.744,-140.737,-17.742,-140.732,-17.738,-140.732,-17.739,-140.728,-17.736,-140.732,-17.737,-140.697,-17.711,-140.658,-17.716,-140.682,-17.785,-140.753,-17.841,-140.774,-17.873,-140.784,-17.881,-140.784,-17.88]},{"outer":[-145.509,-17.421,-145.508,-17.417,-145.513,-17.419,-145.525,-17.409,-145.525,-17.406,-145.525,-17.403,-145.528,-17.404,-145.527,-17.401,-145.53,-17.402,-145.528,-17.376,-145.578,-17.373,-145.591,-17.339,-145.52,-17.346,-145.482,-17.402,-145.43,-17.431,-145.422,-17.452,-145.396,-17.483,-145.509,-17.421]},{"outer":[-70.932,-54.756,-70.441,-54.635,-70.851,-54.828,-70.576,-54.773,-70.347,-54.855,-70.311,-54.752,-70.191,-54.688,-70.327,-54.823,-70.196,-54.877,-70.231,-54.84,-70.072,-54.794,-70.095,-54.863,-69.95,-54.85,-69.987,-54.726,-69.937,-54.682,-69.921,-54.847,-69.768,-54.834,-69.784,-54.728,-69.765,-54.835,-69.689,-54.837,-69.706,-54.704,-69.664,-54.801,-69.62,-54.733,-69.591,-54.77,-69.65,-54.803,-69.67,-54.845,-69.044,-54.967,-68.706,-54.913,-68.818,-54.859,-68.808,-54.84,-68.582,-54.894,-68.264,-54.842,-68.319,-54.812,-68.25,-54.795,-67.693,-54.888,-67.504,-54.854,-67.494,-54.868,-67.452,-54.866,-67.409,-54.889,-67.352,-54.867,-67.267,-54.884,-67.267,-54.944,-67.978,-54.893,-68.379,-54.948,-68.173,-54.993,-68.146,-55.052,-68.159,-55.149,-68.057,-55.176,-68.116,-55.224,-67.865,-55.189,-67.732,-55.239,-67.857,-55.257,-67.614,-55.279,-67.502,-55.177,-67.419,-55.201,-67.436,-55.317,-67.267,-55.331,-67.267,-55.71,-67.384,-55.706,-67.391,-55.689,-67.375,-55.668,-67.322,-55.64,-67.382,-55.566,-67.498,-55.622,-67.416,-55.647,-67.4,-55.691,-67.418,-55.719,-67.537,-55.645,-67.51,-55.711,-67.583,-55.727,-67.352,-55.797,-67.267,-55.788,-67.424,-55.826,-67.358,-55.823,-67.267,-55.888,-67.35,-55.93,-67.267,-55.979,-67.267,-60.0,-180.0,-60.0,-180.0,-16.967,-179.826,-16.785,-179.881,-16.686,-180.0,-16.793,-180.0,-16.505,-179.937,-16.526,-179.911,-16.441,-180.0,-16.481,-180.0,0.0,-91.861,0.0,-91.82,1.397,-89.322,0.0,-80.111,0.0,-80.489,-0.365,-80.395,-0.632,-80.298,-0.654,-80.385,-0.663,-80.453,-0.611,-80.583,-0.927,-80.83,-0.956,-80.913,-1.059,-80.737,-1.357,-80.855,-1.6,-80.736,-2.056,-80.898,-2.218,-80.943,-2.219,-80.987,-2.185,-81.013,-2.189,-80.891,-2.343,-80.684,-2.398,-80.256,-2.744,-80.286,-2.606,-80.173,-2.596,-80.22,-2.535,-80.198,-2.498,-80.15,-2.572,-80.106,-2.528,-80.092,-2.406,-79.975,-2.34,-79.981,-2.331,-79.981,-2.31,-80.0,-2.297,-79.998,-2.289,-79.964,-2.322,-79.976,-2.334,-79.96,-2.324,-79.977,-2.306,-79.966,-2.285,-79.953,-2.315,-79.941,-2.293,-79.918,-2.306,-79.918,-2.285,-79.893,-2.288,-79.917,-2.389,-79.931,-2.385,-79.925,-2.395,-79.927,-2.399,-79.941,-2.398,-79.879,-2.451,-79.937,-2.514,-79.959,-2.516,-79.935,-2.524,-79.915,-2.5,-79.891,-2.507,-79.93,-2.544,-79.982,-2.52,-79.899,-2.574,-79.846,-2.322,-79.886,-2.23,-79.87,-2.096,-79.843,-2.076,-79.836,-2.076,-79.812,-2.089,-79.796,-2.091,-79.81,-2.057,-79.803,-2.038,-79.664,-1.861,-79.659,-1.858,-79.656,-1.858,-79.649,-1.875,-79.646,-1.863,-79.62,-1.84,-79.599,-1.829,-79.584,-1.833,-79.573,-1.845,-79.573,-1.834,-79.569,-1.844,-79.574,-1.846,-79.619,-1.84,-79.646,-1.865,-79.649,-1.876,-79.66,-1.859,-79.739,-2.028,-79.796,-2.097,-79.843,-2.095,-79.86,-2.182,-79.839,-2.193,-79.828,-2.211,-79.796,-2.484,-79.75,-2.518,-79.742,-2.512,-79.735,-2.513,-79.735,-2.491,-79.717,-2.488,-79.72,-2.474,-79.701,-2.449,-79.698,-2.448,-79.695,-2.448,-79.692,-2.571,-79.724,-2.573,-79.717,-2.606,-79.734,-2.643,-79.781,-2.667,-79.909,-3.162,-80.003,-3.239,-79.997,-3.411,-79.999,-3.388,-80.018,-3.372,-80.027,-3.358,-80.027,-3.411,-80.182,-3.442,-80.28,-3.439,-80.31,-3.403,-80.506,-3.505,-81.255,-4.277,-81.328,-4.684,-81.14,-4.905,-81.136,-4.889,-81.13,-4.884,-81.039,-4.855,-81.133,-4.89,-81.062,-5.048,-81.169,-5.081,-81.196,-5.212,-80.898,-5.502,-80.839,-5.857,-80.709,-5.849,-80.707,-5.903,-80.838,-5.865,-80.874,-5.774,-80.947,-5.846,-81.072,-5.783,-81.143,-6.011,-80.791,-6.297,-79.974,-6.761,-79.687,-7.124,-79.466,-7.717,-78.988,-8.208,-78.935,-8.439,-78.763,-8.599,-78.762,-8.773,-78.559,-9.132,-78.61,-9.177,-78.555,-9.247,-78.564,-9.184,-78.497,-9.189,-78.524,-9.277,-78.409,-9.363,-78.435,-9.388,-78.369,-9.628,-78.227,-9.797,-78.186,-10.11,-77.662,-10.95,-77.594,-11.184,-77.657,-11.289,-77.348,-11.473,-77.211,-11.648,-77.171,-12.074,-76.799,-12.361,-76.804,-12.509,-76.212,-13.378,-76.251,-13.853,-76.367,-13.802,-76.396,-13.909,-76.283,-13.91,-76.294,-14.163,-76.139,-14.234,-76.138,-14.35,-75.975,-14.47,-75.918,-14.661,-75.519,-14.905,-75.371,-15.153,-75.231,-15.206,-75.142,-15.412,-74.054,-15.953,-73.703,-16.245,-73.452,-16.281,-72.749,-16.64,-72.457,-16.7,-72.109,-17.021,-71.509,-17.268,-71.386,-17.409,-71.382,-17.709,-70.913,-17.945,-70.303,-18.449,-70.361,-18.794,-70.125,-19.99,-70.212,-20.812,-70.139,-20.884,-70.179,-21.024,-70.058,-21.442,-70.222,-22.096,-70.29,-22.914,-70.4,-23.08,-70.484,-23.099,-70.515,-23.024,-70.581,-23.082,-70.605,-23.252,-70.629,-23.517,-70.481,-23.467,-70.392,-23.613,-70.527,-23.862,-70.581,-24.552,-70.447,-25.367,-70.652,-25.537,-70.738,-25.817,-70.628,-25.995,-70.63,-26.348,-70.834,-26.877,-70.808,-27.056,-70.975,-27.172,-70.887,-27.485,-70.899,-27.621,-71.04,-27.664,-71.035,-27.732,-71.143,-27.951,-71.144,-28.082,-71.186,-28.119,-71.162,-28.359,-71.316,-28.566,-71.297,-28.68,-71.521,-28.918,-71.493,-29.203,-71.309,-29.421,-71.346,-29.756,-71.275,-29.916,-71.419,-30.003,-71.391,-30.179,-71.527,-30.293,-71.654,-30.273,-71.719,-30.653,-71.507,-31.76,-71.543,-32.198,-71.535,-32.2,-71.534,-32.209,-71.526,-32.208,-71.526,-32.241,-71.474,-32.274,-71.471,-32.345,-71.409,-32.395,-71.477,-32.522,-71.432,-32.641,-71.541,-32.773,-71.553,-33.01,-71.746,-33.1,-71.651,-33.326,-71.71,-33.425,-71.605,-33.543,-72.01,-34.132,-71.974,-34.345,-72.181,-34.899,-72.148,-35.059,-72.189,-34.966,-72.207,-35.113,-72.404,-35.256,-72.319,-35.375,-72.436,-35.328,-72.617,-35.557,-72.569,-35.8,-72.787,-35.977,-72.871,-36.386,-72.818,-36.381,-72.733,-36.404,-72.69,-36.432,-72.676,-36.493,-72.512,-36.607,-72.676,-36.501,-72.736,-36.407,-72.871,-36.391,-73.003,-36.577,-72.994,-36.735,-73.104,-36.724,-73.091,-36.624,-73.122,-36.613,-73.13,-36.744,-73.219,-36.773,-73.08,-36.818,-72.946,-36.974,-72.923,-37.159,-72.774,-37.201,-72.738,-37.238,-72.728,-37.268,-72.705,-37.251,-72.624,-37.228,-72.591,-37.225,-72.564,-37.233,-72.556,-37.232,-72.545,-37.235,-72.727,-37.273,-72.663,-37.379,-72.663,-37.414,-72.678,-37.475,-72.601,-37.502,-72.593,-37.512,-72.596,-37.519,-72.603,-37.524,-72.595,-37.529,-72.589,-37.555,-72.684,-37.478,-72.746,-37.238,-72.779,-37.207,-72.859,-37.202,-72.976,-37.141,-72.956,-36.979,-73.125,-36.823,-73.192,-37.169,-73.446,-37.256,-73.587,-37.145,-73.686,-37.337,-73.591,-37.46,-73.682,-37.628,-73.652,-37.727,-73.437,-38.133,-73.531,-38.387,-73.489,-38.657,-73.436,-38.752,-73.417,-38.774,-73.438,-38.734,-73.347,-38.696,-73.315,-38.693,-73.194,-38.703,-73.183,-38.694,-73.176,-38.693,-73.174,-38.695,-73.171,-38.706,-73.42,-38.752,-73.227,-39.251,-73.235,-39.207,-73.172,-39.173,-73.244,-39.391,-73.172,-39.448,-73.265,-39.454,-73.254,-39.538,-73.405,-39.694,-73.404,-39.87,-73.341,-39.811,-73.285,-39.856,-73.276,-39.787,-73.273,-39.782,-73.255,-39.778,-73.264,-39.743,-73.249,-39.725,-73.236,-39.72,-73.261,-39.827,-73.254,-39.821,-73.249,-39.81,-73.245,-39.807,-73.218,-39.819,-73.211,-39.793,-73.19,-39.783,-73.154,-39.805,-73.123,-39.786,-73.11,-39.788,-73.104,-39.793,-73.121,-39.787,-73.144,-39.805,-73.161,-39.806,-73.193,-39.785,-73.218,-39.823,-73.248,-39.811,-73.283,-39.875,-73.379,-39.878,-73.34,-39.897,-73.316,-39.943,-73.295,-39.943,-73.287,-39.946,-73.26,-39.87,-73.176,-39.886,-73.258,-39.872,-73.251,-39.898,-73.284,-39.948,-73.326,-39.95,-73.376,-39.895,-73.39,-39.945,-73.467,-39.851,-73.706,-40.003,-73.642,-40.087,-73.715,-40.243,-73.691,-40.23,-73.598,-40.301,-73.579,-40.283,-73.456,-40.269,-73.323,-40.336,-73.598,-40.302,-73.689,-40.239,-73.75,-40.286,-73.777,-40.459,-73.707,-40.561,-73.834,-40.666,-73.847,-40.919,-73.946,-40.986,-73.836,-41.273,-73.861,-41.462,-73.79,-41.587,-73.674,-41.603,-73.71,-41.493,-73.672,-41.497,-73.648,-41.557,-73.606,-41.551,-73.663,-41.572,-73.603,-41.606,-73.529,-41.589,-73.501,-41.532,-73.486,-41.518,-73.477,-41.514,-73.415,-41.496,-73.375,-41.504,-73.36,-41.517,-73.586,-41.626,-73.532,-41.64,-73.515,-41.66,-73.648,-41.602,-73.76,-41.747,-73.347,-41.814,-73.357,-41.794,-73.407,-41.777,-73.406,-41.767,-73.256,-41.788,-73.24,-41.776,-73.262,-41.731,-73.246,-41.714,-73.134,-41.766,-73.158,-41.798,-73.191,-41.796,-73.157,-41.809,-73.124,-41.778,-73.127,-41.762,-73.172,-41.741,-73.152,-41.703,-73.136,-41.755,-73.037,-41.705,-73.097,-41.592,-72.981,-41.488,-72.92,-41.476,-72.851,-41.509,-72.821,-41.492,-72.739,-41.533,-72.57,-41.716,-72.386,-41.66,-72.332,-41.394,-72.277,-41.392,-72.318,-41.653,-72.464,-41.724,-72.648,-41.73,-72.878,-41.914,-72.612,-42.043,-72.599,-42.007,-72.428,-41.975,-72.465,-42.144,-72.348,-42.171,-72.461,-42.202,-72.376,-42.254,-72.471,-42.3,-72.388,-42.488,-72.581,-42.188,-72.838,-42.294,-72.623,-42.52,-72.49,-42.553,-72.813,-42.508,-72.837,-42.834,-72.714,-42.914,-72.755,-42.956,-72.724,-42.971,-72.715,-42.987,-72.66,-42.991,-72.653,-42.996,-72.651,-43.001,-72.658,-43.014,-72.657,-42.994,-72.716,-42.988,-72.811,-42.933,-72.72,-43.086,-72.859,-43.029,-72.872,-43.233,-73.055,-43.32,-73.093,-43.435,-73.012,-43.58,-72.875,-43.611,-73.049,-43.737,-72.837,-43.77,-72.818,-43.654,-72.833,-43.763,-72.754,-43.835,-72.829,-43.822,-72.851,-43.789,-72.971,-43.778,-72.958,-43.794,-72.944,-43.852,-72.864,-43.873,-72.869,-43.897,-72.853,-43.883,-72.846,-43.881,-72.834,-43.883,-72.774,-43.937,-72.836,-43.884,-72.854,-43.901,-72.874,-43.897,-72.948,-43.853,-72.979,-43.774,-73.138,-44.019,-73.124,-44.015,-73.087,-44.083,-73.184,-44.055,-73.19,-44.153,-73.26,-44.113,-73.26,-44.102,-73.283,-44.163,-73.172,-44.201,-73.103,-44.126,-73.107,-44.229,-73.183,-44.25,-72.857,-44.292,-72.909,-44.376,-72.763,-44.381,-72.675,-44.512,-72.644,-44.413,-72.557,-44.327,-72.607,-44.473,-72.542,-44.531,-72.656,-44.541,-72.689,-44.776,-73.106,-44.953,-73.357,-44.956,-73.275,-45.151,-73.425,-45.188,-73.439,-45.261,-73.484,-45.196,-73.513,-45.225,-73.421,-45.276,-73.327,-45.294,-73.274,-45.248,-73.206,-45.24,-72.913,-45.426,-72.82,-45.378,-72.693,-45.41,-72.834,-45.485,-72.984,-45.454,-73.199,-45.292,-73.328,-45.347,-73.328,-45.355,-73.312,-45.359,-73.302,-45.378,-73.301,-45.412,-73.317,-45.36,-73.331,-45.354,-73.329,-45.349,-73.38,-45.387,-73.371,-45.435,-73.386,-45.487,-73.393,-45.407,-73.477,-45.446,-73.563,-45.781,-73.277,-45.593,-73.279,-45.647,-73.215,-45.643,-73.138,-45.657,-73.375,-45.697,-73.531,-45.826,-73.532,-45.837,-73.503,-45.856,-73.5,-45.871,-73.636,-45.975,-73.612,-46.221,-73.674,-46.33,-73.405,-46.013,-73.336,-46.035,-73.557,-46.273,-73.534,-46.296,-73.506,-46.283,-73.503,-46.291,-73.473,-46.289,-73.456,-46.3,-73.753,-46.405,-73.72,-46.489,-73.807,-46.576,-73.922,-46.55,-73.959,-46.61,-73.845,-46.68,-73.917,-46.738,-74.007,-46.634,-73.894,-46.477,-73.844,-46.528,-73.725,-46.233,-73.809,-46.201,-73.862,-46.135,-73.962,-46.157,-73.991,-46.227,-73.955,-46.219,-73.938,-46.225,-73.828,-46.349,-73.976,-46.282,-73.942,-46.226,-74.04,-46.235,-73.999,-46.183,-74.291,-46.261,-74.442,-46.199,-74.155,-46.223,-73.938,-46.079,-74.017,-45.988,-74.052,-46.123,-74.137,-46.15,-74.178,-46.137,-74.28,-46.149,-74.318,-46.14,-74.33,-46.121,-74.125,-46.141,-74.047,-46.051,-74.064,-46.015,-74.245,-46.042,-74.27,-46.035,-74.303,-46.009,-74.003,-45.963,-74.255,-45.903,-74.061,-45.861,-74.05,-45.845,-74.076,-45.812,-74.267,-45.874,-74.34,-45.795,-74.41,-45.822,-74.442,-45.815,-74.381,-45.867,-74.461,-45.846,-74.339,-45.963,-74.426,-46.047,-74.418,-45.941,-74.481,-45.958,-74.524,-45.839,-74.699,-45.901,-74.684,-45.817,-74.841,-45.901,-74.885,-45.887,-74.913,-45.861,-74.938,-45.861,-74.785,-45.953,-75.077,-45.877,-75.058,-45.972,-74.972,-45.953,-74.911,-45.969,-75.033,-45.99,-75.004,-46.006,-74.811,-45.999,-74.865,-46.027,-75.059,-46.039,-75.015,-46.113,-74.866,-46.11,-74.669,-45.905,-74.707,-46.033,-74.575,-46.029,-74.561,-46.05,-74.75,-46.055,-74.754,-46.167,-74.632,-46.171,-74.735,-46.227,-74.83,-46.154,-74.901,-46.182,-74.855,-46.3,-75.002,-46.215,-75.073,-46.267,-74.997,-46.291,-75.104,-46.29,-74.872,-46.372,-75.175,-46.304,-75.17,-46.383,-75.351,-46.432,-75.32,-46.5,-75.504,-46.525,-75.458,-46.618,-75.597,-46.588,-75.651,-46.719,-75.491,-46.955,-75.312,-46.936,-75.253,-46.903,-75.254,-46.854,-75.278,-46.819,-75.26,-46.873,-75.388,-46.879,-75.296,-46.813,-75.418,-46.802,-75.385,-46.73,-75.321,-46.726,-75.478,-46.714,-75.582,-46.807,-75.537,-46.714,-75.36,-46.623,-75.191,-46.637,-75.185,-46.567,-75.151,-46.637,-75.038,-46.578,-75.016,-46.512,-74.895,-46.436,-74.919,-46.507,-75.046,-46.626,-74.968,-46.746,-74.791,-46.811,-74.806,-46.771,-74.728,-46.747,-74.755,-46.778,-74.774,-46.78,-74.635,-46.876,-74.461,-46.894,-74.383,-46.849,-74.62,-46.844,-74.639,-46.84,-74.676,-46.813,-74.672,-46.82,-74.694,-46.812,-74.424,-46.766,-74.563,-46.719,-74.234,-46.758,-74.249,-46.777,-74.311,-46.784,-74.065,-46.981,-74.008,-46.947,-73.977,-46.961,-74.049,-47.049,-74.133,-46.997,-74.197,-47.099,-74.133,-47.141,-74.143,-47.165,-74.1,-47.146,-74.09,-47.169,-74.014,-47.137,-74.004,-47.098,-73.959,-47.071,-74.008,-47.221,-74.019,-47.16,-74.094,-47.193,-74.028,-47.356,-74.151,-47.185,-74.143,-47.363,-74.193,-47.352,-74.18,-47.215,-74.241,-47.187,-74.481,-47.442,-74.419,-47.393,-74.312,-47.444,-74.227,-47.379,-74.288,-47.445,-74.426,-47.476,-74.313,-47.537,-74.372,-47.501,-74.059,-47.428,-74.277,-47.51,-74.288,-47.581,-74.117,-47.581,-74.024,-47.514,-73.901,-47.49,-74.097,-47.577,-73.94,-47.587,-74.239,-47.607,-74.029,-47.629,-74.092,-47.656,-74.251,-47.641,-74.214,-47.722,-74.064,-47.724,-74.158,-47.751,-74.264,-47.708,-74.285,-47.632,-74.415,-47.677,-74.489,-47.684,-74.518,-47.665,-74.542,-47.677,-74.566,-47.638,-74.504,-47.625,-74.478,-47.678,-74.348,-47.606,-74.42,-47.522,-74.454,-47.606,-74.501,-47.513,-74.605,-47.585,-74.527,-47.626,-74.622,-47.617,-74.679,-47.719,-74.559,-47.684,-74.636,-47.74,-74.513,-47.766,-74.409,-47.695,-74.505,-47.768,-74.471,-47.791,-74.319,-47.727,-74.266,-47.751,-74.365,-47.754,-74.005,-47.75,-73.888,-47.833,-73.712,-47.771,-73.719,-47.664,-73.619,-47.604,-73.701,-47.691,-73.672,-47.767,-73.511,-47.775,-73.57,-47.806,-73.444,-47.841,-73.493,-47.906,-73.429,-47.945,-73.286,-47.933,-73.208,-47.955,-73.2,-47.976,-73.399,-47.97,-73.607,-47.866,-73.591,-48.0,-73.476,-47.978,-73.559,-48.02,-73.322,-48.175,-73.249,-48.13,-73.249,-48.036,-73.224,-48.016,-73.229,-48.149,-73.33,-48.224,-73.15,-48.186,-73.267,-48.241,-73.331,-48.225,-73.38,-48.177,-73.441,-48.166,-73.453,-48.314,-73.534,-48.189,-73.591,-48.252,-73.565,-48.117,-73.623,-48.118,-73.671,-48.085,-73.704,-48.091,-73.711,-48.087,-73.745,-48.147,-73.71,-48.025,-73.793,-48.063,-73.797,-48.055,-73.809,-48.097,-73.834,-48.108,-73.822,-48.02,-73.915,-48.039,-73.99,-48.024,-74.052,-47.98,-73.985,-48.013,-73.988,-47.972,-74.142,-47.981,-74.126,-48.005,-74.129,-48.031,-74.098,-48.059,-74.112,-48.076,-74.137,-48.039,-74.148,-47.981,-74.259,-48.0,-74.146,-48.223,-74.056,-48.193,-74.136,-48.234,-74.193,-48.179,-74.215,-48.255,-74.286,-48.0,-74.489,-47.928,-74.606,-47.994,-74.54,-48.091,-74.364,-48.004,-74.377,-48.054,-74.47,-48.087,-74.256,-48.193,-74.226,-48.334,-74.098,-48.303,-73.912,-48.314,-74.146,-48.317,-74.197,-48.35,-74.122,-48.343,-74.093,-48.349,-73.976,-48.407,-73.837,-48.406,-73.981,-48.432,-73.978,-48.495,-74.002,-48.515,-73.917,-48.58,-74.147,-48.466,-74.227,-48.508,-74.173,-48.537,-74.359,-48.587,-74.313,-48.625,-74.302,-48.676,-74.174,-48.639,-74.258,-48.713,-74.161,-48.689,-74.083,-48.691,-74.025,-48.72,-73.97,-48.717,-74.274,-48.757,-74.324,-48.705,-74.326,-48.744,-74.392,-48.783,-74.342,-48.863,-74.403,-48.879,-74.296,-48.931,-74.42,-48.989,-74.302,-49.184,-74.362,-49.175,-74.344,-49.323,-74.391,-49.388,-74.166,-49.543,-74.095,-49.268,-74.156,-49.199,-74.008,-49.212,-73.787,-49.345,-73.766,-49.403,-74.006,-49.257,-74.074,-49.505,-73.907,-49.51,-73.646,-49.714,-73.696,-49.802,-73.897,-49.552,-74.013,-49.646,-73.982,-49.563,-74.073,-49.539,-74.308,-49.646,-74.279,-49.767,-74.249,-49.735,-74.021,-49.72,-74.025,-49.774,-74.068,-49.743,-74.3,-49.808,-74.315,-49.799,-74.317,-49.782,-74.327,-49.882,-74.275,-49.906,-74.161,-49.895,-74.023,-49.983,-73.974,-49.907,-73.912,-49.917,-73.881,-49.859,-73.813,-49.831,-73.899,-49.914,-73.804,-49.915,-74.012,-49.996,-73.857,-50.039,-73.974,-50.073,-74.212,-49.942,-74.316,-49.925,-74.359,-49.987,-74.192,-50.118,-74.163,-50.208,-74.066,-50.197,-73.851,-50.289,-74.08,-50.221,-74.165,-50.282,-74.244,-50.149,-74.336,-50.184,-74.26,-50.126,-74.358,-50.128,-74.365,-50.051,-74.432,-50.016,-74.556,-50.062,-74.613,-50.102,-74.554,-50.07,-74.477,-50.068,-74.383,-50.086,-74.406,-50.106,-74.409,-50.083,-74.524,-50.079,-74.501,-50.142,-74.573,-50.104,-74.706,-50.202,-74.574,-50.174,-74.634,-50.235,-74.465,-50.265,-74.549,-50.295,-74.355,-50.405,-74.288,-50.242,-74.252,-50.317,-74.326,-50.406,-74.246,-50.447,-74.065,-50.364,-73.965,-50.366,-74.159,-50.432,-73.863,-50.558,-73.972,-50.533,-73.987,-50.598,-73.995,-50.531,-74.298,-50.469,-74.088,-50.579,-74.228,-50.586,-74.131,-50.758,-74.124,-50.688,-74.052,-50.692,-74.047,-50.726,-74.126,-50.766,-74.029,-50.851,-73.821,-50.775,-73.767,-50.522,-73.71,-50.489,-73.631,-50.491,-73.559,-50.377,-73.574,-50.471,-73.747,-50.543,-73.35,-50.68,-73.77,-50.66,-73.788,-50.8,-73.717,-50.827,-73.877,-50.844,-73.7,-50.957,-73.87,-50.938,-73.884,-50.868,-74.231,-50.921,-74.193,-51.058,-74.097,-50.956,-74.04,-50.948,-74.145,-51.065,-74.112,-51.193,-74.001,-51.206,-73.993,-51.11,-73.889,-51.256,-73.748,-51.174,-73.822,-51.065,-73.674,-51.142,-73.664,-51.281,-73.719,-51.188,-73.786,-51.223,-73.683,-51.291,-73.68,-51.43,-73.559,-51.45,-73.636,-51.459,-73.581,-51.539,-73.68,-51.478,-73.584,-51.618,-73.724,-51.453,-73.899,-51.369,-73.974,-51.504,-73.923,-51.482,-73.876,-51.578,-73.858,-51.532,-73.806,-51.53,-73.782,-51.549,-73.859,-51.55,-73.814,-51.591,-73.885,-51.588,-73.91,-51.57,-73.921,-51.624,-73.737,-51.648,-73.826,-51.666,-73.813,-51.707,-73.74,-51.72,-73.706,-51.794,-73.621,-51.643,-73.611,-51.72,-73.458,-51.681,-73.595,-51.751,-73.497,-51.797,-73.371,-52.042,-73.592,-51.803,-73.64,-51.864,-73.556,-52.029,-73.434,-52.077,-73.3,-52.161,-73.306,-51.846,-73.4,-51.638,-73.262,-51.883,-73.229,-52.101,-73.131,-52.079,-73.16,-52.025,-73.119,-51.935,-72.912,-51.851,-72.98,-51.799,-73.097,-51.886,-73.21,-51.893,-73.036,-51.827,-73.073,-51.754,-73.145,-51.786,-73.164,-51.77,-73.081,-51.75,-73.039,-51.77,-73.027,-51.805,-72.999,-51.782,-73.09,-51.696,-73.138,-51.719,-73.284,-51.693,-73.222,-51.647,-73.29,-51.611,-73.292,-51.572,-73.202,-51.601,-73.195,-51.679,-73.131,-51.702,-73.09,-51.676,-72.969,-51.773,-72.818,-51.76,-72.734,-51.841,-72.526,-51.739,-72.999,-51.51,-73.034,-51.56,-73.113,-51.605,-73.054,-51.495,-73.204,-51.511,-73.26,-51.468,-73.08,-51.414,-73.032,-51.497,-72.707,-51.57,-72.668,-51.655,-72.683,-51.572,-72.453,-51.777,-72.514,-51.837,-72.461,-51.92,-72.665,-51.972,-72.474,-52.217,-72.55,-52.383,-72.624,-52.418,-72.698,-52.412,-72.516,-52.455,-72.558,-52.487,-72.735,-52.434,-72.928,-52.505,-72.991,-52.491,-72.717,-52.351,-72.685,-52.346,-72.623,-52.383,-72.526,-52.22,-72.712,-52.092,-72.77,-51.948,-72.9,-51.981,-72.914,-52.083,-72.957,-52.046,-72.915,-52.222,-72.838,-52.087,-72.765,-52.058,-72.738,-52.082,-72.816,-52.088,-72.901,-52.297,-72.996,-52.305,-72.931,-52.225,-73.001,-52.18,-73.133,-52.232,-73.004,-52.06,-73.155,-52.144,-73.226,-52.111,-73.248,-52.197,-73.363,-52.221,-73.734,-52.044,-73.55,-52.214,-73.6,-52.299,-73.549,-52.39,-73.64,-52.45,-73.544,-52.471,-73.52,-52.436,-73.507,-52.425,-73.496,-52.425,-73.506,-52.457,-73.541,-52.478,-73.465,-52.489,-73.452,-52.515,-73.58,-52.525,-73.525,-52.588,-73.621,-52.551,-73.562,-52.592,-73.665,-52.64,-73.551,-52.658,-73.659,-52.649,-73.629,-52.684,-73.708,-52.778,-73.628,-52.79,-73.657,-52.73,-73.652,-52.725,-73.597,-52.783,-73.643,-52.73,-73.548,-52.739,-73.5,-52.711,-73.525,-52.672,-73.358,-52.658,-73.391,-52.559,-73.237,-52.704,-73.292,-52.727,-73.301,-52.786,-73.25,-52.763,-73.195,-52.789,-73.388,-52.846,-73.385,-52.796,-73.407,-52.819,-73.472,-52.843,-73.42,-52.82,-73.572,-52.812,-73.362,-52.877,-73.356,-52.895,-73.37,-52.905,-73.577,-52.855,-73.604,-52.833,-73.565,-52.888,-73.736,-52.867,-73.799,-52.926,-73.611,-52.892,-73.539,-52.918,-73.549,-52.898,-73.54,-52.895,-73.485,-52.955,-73.412,-52.973,-73.357,-52.903,-73.175,-52.904,-73.236,-52.981,-73.388,-52.98,-73.285,-53.011,-73.448,-53.022,-73.208,-53.113,-73.206,-53.02,-73.157,-53.023,-73.119,-53.111,-72.939,-53.002,-72.976,-52.942,-72.938,-52.91,-73.06,-52.851,-72.672,-52.681,-72.813,-52.612,-72.904,-52.669,-72.922,-52.592,-72.85,-52.608,-72.824,-52.593,-72.897,-52.586,-72.864,-52.542,-72.61,-52.524,-72.618,-52.537,-72.649,-52.554,-72.664,-52.557,-72.572,-52.589,-72.401,-52.509,-72.381,-52.557,-72.563,-52.604,-72.432,-52.676,-72.324,-52.533,-72.123,-52.518,-71.559,-52.564,-71.384,-52.721,-71.371,-52.804,-71.175,-52.809,-71.117,-52.91,-71.355,-53.122,-71.744,-53.212,-71.749,-53.45,-71.777,-53.49,-71.816,-53.52,-71.995,-53.567,-72.054,-53.542,-71.764,-53.438,-71.871,-53.226,-72.021,-53.234,-72.018,-53.379,-72.077,-53.387,-72.178,-53.439,-72.216,-53.449,-72.285,-53.429,-72.201,-53.432,-72.153,-53.386,-72.113,-53.388,-72.118,-53.254,-72.176,-53.272,-72.309,-53.254,-72.445,-53.327,-72.478,-53.4,-72.112,-53.684,-71.878,-53.683,-71.852,-53.748,-71.294,-53.896,-70.974,-53.786,-70.911,-53.633,-70.988,-53.389,-70.782,-52.938,-70.887,-52.91,-70.735,-52.784,-70.802,-52.787,-70.882,-52.734,-70.211,-52.652,-69.853,-52.488,-69.67,-52.538,-69.474,-52.385,-69.456,-52.265,-69.211,-52.196,-68.432,-52.398,-68.364,-52.343,-68.605,-52.66,-68.769,-52.542,-68.779,-52.603,-69.158,-52.69,-69.43,-52.453,-69.602,-52.544,-69.591,-52.645,-69.697,-52.75,-69.922,-52.845,-70.14,-52.731,-70.438,-52.779,-70.28,-52.805,-70.301,-52.912,-70.091,-52.93,-70.249,-53.063,-70.34,-53.065,-70.321,-52.967,-70.419,-53.014,-70.468,-53.286,-70.366,-53.303,-70.449,-53.378,-70.216,-53.478,-69.575,-53.346,-69.371,-53.358,-69.317,-53.438,-69.377,-53.531,-70.171,-53.773,-70.078,-54.068,-69.922,-54.164,-69.153,-54.37,-68.961,-54.477,-69.131,-54.452,-69.132,-54.571,-69.215,-54.525,-69.376,-54.639,-69.341,-54.723,-69.384,-54.647,-69.469,-54.688,-69.514,-54.676,-69.199,-54.45,-69.455,-54.346,-69.549,-54.341,-69.494,-54.443,-69.597,-54.405,-69.585,-54.498,-69.625,-54.354,-69.847,-54.288,-69.717,-54.501,-69.721,-54.584,-69.768,-54.604,-69.772,-54.544,-69.789,-54.557,-69.85,-54.578,-69.746,-54.519,-69.801,-54.467,-69.873,-54.514,-69.911,-54.515,-69.818,-54.445,-69.968,-54.383,-69.988,-54.269,-70.198,-54.315,-70.037,-54.384,-70.143,-54.431,-70.933,-54.118,-70.824,-54.196,-70.91,-54.162,-70.874,-54.256,-70.526,-54.304,-70.514,-54.39,-70.103,-54.558,-70.302,-54.502,-70.236,-54.584,-70.559,-54.402,-70.708,-54.538,-70.622,-54.607,-70.719,-54.552,-70.768,-54.629,-70.711,-54.435,-70.575,-54.355,-70.798,-54.326,-70.937,-54.37,-70.94,-54.518,-71.37,-54.373,-71.425,-54.401,-71.337,-54.426,-71.383,-54.464,-71.393,-54.434,-71.509,-54.398,-71.561,-54.404,-71.507,-54.427,-71.527,-54.434,-71.615,-54.395,-71.487,-54.478,-71.585,-54.473,-71.577,-54.487,-71.541,-54.509,-71.707,-54.423,-71.744,-54.43,-71.728,-54.397,-71.87,-54.437,-71.515,-54.549,-71.756,-54.489,-71.656,-54.57,-71.799,-54.543,-71.902,-54.444,-71.935,-54.466,-71.994,-54.454,-71.949,-54.533,-72.019,-54.5,-71.964,-54.565,-71.906,-54.544,-71.883,-54.601,-71.975,-54.602,-71.887,-54.641,-71.64,-54.583,-71.695,-54.63,-71.654,-54.661,-71.657,-54.618,-71.614,-54.573,-71.537,-54.658,-71.526,-54.603,-71.509,-54.583,-71.495,-54.584,-71.493,-54.689,-71.415,-54.604,-71.351,-54.591,-71.364,-54.614,-71.323,-54.618,-71.341,-54.588,-71.402,-54.576,-71.401,-54.561,-71.445,-54.564,-71.408,-54.524,-71.329,-54.569,-71.343,-54.51,-71.181,-54.576,-71.252,-54.618,-71.137,-54.623,-71.149,-54.638,-71.31,-54.652,-71.189,-54.702,-71.04,-54.651,-71.058,-54.601,-70.909,-54.624,-71.017,-54.67,-70.926,-54.677,-71.049,-54.758,-71.025,-54.781,-70.91,-54.701,-70.919,-54.67,-70.757,-54.69,-70.932,-54.756],"holes":[[-149.132,-17.85,-149.153,-17.743,-149.305,-17.719,-149.341,-17.543,-149.494,-17.494,-149.631,-17.55,-149.586,-17.686,-149.59,-17.736,-149.583,-17.748,-149.586,-17.692,-149.471,-17.784,-149.325,-17.729,-149.273,-17.858,-149.132,-17.85],[-72.993,-53.793,-72.937,-53.802,-73.063,-53.825,-72.909,-53.841,-73.044,-53.912,-72.732,-53.761,-72.91,-53.96,-72.756,-53.836,-72.798,-53.917,-72.655,-53.874,-72.806,-53.943,-72.699,-53.983,-72.878,-53.974,-72.758,-54.064,-72.981,-54.029,-72.777,-54.095,-73.08,-54.089,-72.868,-54.138,-72.719,-54.118,-72.631,-54.02,-72.67,-54.09,-72.608,-54.048,-72.629,-54.1,-72.549,-54.108,-72.545,-53.988,-72.365,-54.057,-72.325,-54.009,-72.475,-53.961,-72.362,-53.958,-72.464,-53.886,-72.333,-53.925,-72.352,-53.85,-72.471,-53.828,-72.225,-53.859,-72.291,-53.804,-72.148,-53.794,-72.353,-53.756,-72.234,-53.728,-72.351,-53.683,-72.461,-53.754,-72.461,-53.674,-72.622,-53.721,-72.384,-53.64,-72.662,-53.524,-72.677,-53.65,-72.725,-53.508,-72.864,-53.45,-72.804,-53.559,-72.859,-53.663,-72.849,-53.532,-72.924,-53.553,-72.85,-53.514,-72.926,-53.523,-72.898,-53.454,-72.982,-53.396,-73.001,-53.494,-73.039,-53.375,-73.202,-53.423,-73.036,-53.534,-73.434,-53.466,-73.209,-53.564,-73.283,-53.594,-73.236,-53.64,-73.018,-53.568,-73.097,-53.652,-72.929,-53.624,-73.182,-53.68,-72.816,-53.704,-73.095,-53.715,-73.011,-53.754,-73.079,-53.789,-72.871,-53.749,-72.993,-53.793],[-73.772,-53.244,-73.894,-53.072,-73.794,-53.175,-73.792,-53.106,-73.753,-53.197,-73.714,-53.145,-73.566,-53.24,-73.601,-53.284,-73.464,-53.316,-73.616,-53.336,-73.087,-53.355,-73.38,-53.284,-73.314,-53.237,-73.478,-53.143,-73.506,-53.22,-73.429,-53.248,-73.528,-53.283,-73.6,-53.081,-73.655,-53.096,-73.626,-53.183,-73.735,-53.069,-73.894,-53.017,-73.906,-53.071,-74.115,-52.942,-74.112,-53.015,-74.327,-52.928,-74.323,-52.981,-74.412,-52.933,-74.35,-52.882,-74.51,-52.855,-74.431,-52.829,-74.731,-52.732,-74.546,-52.866,-74.649,-52.856,-74.58,-52.954,-74.474,-52.915,-74.504,-52.984,-74.295,-53.053,-74.32,-53.127,-74.23,-53.142,-74.245,-53.034,-74.16,-53.126,-73.973,-53.117,-74.107,-53.056,-73.885,-53.097,-73.971,-53.135,-73.772,-53.244],[-75.013,-52.146,-74.915,-52.073,-74.95,-52.133,-74.904,-52.136,-74.848,-52.036,-74.922,-52.002,-74.833,-52.02,-74.871,-51.981,-74.751,-51.829,-74.801,-51.635,-74.934,-51.659,-74.794,-51.863,-74.933,-51.927,-74.882,-51.869,-74.944,-51.808,-75.01,-51.955,-74.942,-51.954,-75.065,-52.02,-74.946,-52.038,-75.014,-52.092,-74.939,-52.052,-75.013,-52.146],[-74.74,-51.091,-74.494,-51.022,-74.59,-51.065,-74.565,-51.152,-74.401,-51.256,-74.485,-51.189,-74.385,-51.223,-74.43,-51.18,-74.366,-51.187,-74.371,-51.108,-74.509,-51.084,-74.423,-51.056,-74.476,-50.989,-74.353,-51.054,-74.376,-50.914,-74.425,-50.944,-74.394,-50.814,-74.474,-50.844,-74.425,-50.773,-74.676,-50.688,-74.599,-50.815,-74.519,-50.812,-74.627,-50.844,-74.53,-50.918,-74.608,-50.952,-74.94,-50.88,-74.852,-50.914,-74.948,-50.915,-74.95,-50.993,-74.768,-50.981,-74.899,-51.068,-74.795,-51.085,-74.761,-50.989,-74.602,-50.959,-74.74,-51.091],[-75.332,-50.81,-75.236,-50.753,-75.317,-50.71,-75.214,-50.666,-75.3,-50.654,-75.223,-50.634,-75.319,-50.563,-75.209,-50.618,-75.216,-50.553,-75.061,-50.506,-75.472,-50.498,-75.331,-50.6,-75.512,-50.665,-75.341,-50.688,-75.464,-50.71,-75.377,-50.793,-75.314,-50.75,-75.332,-50.81],[-75.261,-50.457,-74.951,-50.395,-75.071,-50.361,-74.913,-50.356,-74.929,-50.267,-75.202,-50.445,-74.954,-50.207,-74.829,-50.242,-74.879,-50.19,-74.765,-50.15,-74.874,-50.099,-74.808,-50.145,-74.932,-50.105,-75.001,-50.161,-74.942,-50.078,-75.03,-50.138,-75.186,-50.004,-75.266,-50.069,-75.277,-49.994,-75.42,-50.027,-75.285,-50.146,-75.098,-50.138,-75.167,-50.241,-75.25,-50.201,-75.239,-50.229,-75.135,-50.254,-75.205,-50.269,-75.361,-50.209,-75.252,-50.175,-75.417,-50.166,-75.398,-50.263,-75.479,-50.327,-75.384,-50.354,-75.492,-50.415,-75.193,-50.293,-75.34,-50.441,-75.261,-50.457],[-74.88,-49.989,-74.797,-50.055,-74.638,-49.988,-74.692,-50.039,-74.635,-50.059,-74.511,-49.985,-74.631,-49.876,-74.498,-49.98,-74.434,-49.919,-74.474,-49.843,-74.406,-49.901,-74.405,-49.781,-74.491,-49.746,-74.392,-49.769,-74.389,-49.678,-74.506,-49.622,-74.536,-49.689,-74.504,-49.603,-74.444,-49.603,-74.55,-49.51,-74.447,-49.481,-74.389,-49.31,-74.404,-49.092,-74.516,-49.015,-74.397,-48.936,-74.475,-48.884,-74.57,-48.951,-74.464,-48.874,-74.47,-48.696,-74.624,-48.863,-74.565,-48.733,-74.685,-48.652,-74.761,-48.771,-74.768,-48.669,-74.978,-48.753,-74.851,-48.823,-75.0,-48.771,-74.975,-48.868,-74.851,-48.83,-74.794,-48.799,-74.796,-48.832,-74.982,-48.88,-74.823,-48.877,-74.937,-48.976,-74.766,-48.916,-74.704,-49.023,-74.791,-48.943,-74.904,-48.988,-74.722,-49.09,-74.808,-49.101,-74.744,-49.19,-74.839,-49.175,-74.78,-49.23,-74.846,-49.213,-74.857,-49.371,-74.867,-49.303,-75.063,-49.169,-74.966,-49.292,-75.17,-49.264,-75.022,-49.347,-75.305,-49.248,-75.235,-49.377,-75.141,-49.391,-75.205,-49.409,-74.951,-49.396,-75.196,-49.415,-75.101,-49.478,-75.201,-49.421,-75.224,-49.432,-75.319,-49.276,-75.415,-49.326,-75.323,-49.253,-75.456,-49.274,-75.411,-49.435,-75.324,-49.411,-75.389,-49.483,-75.29,-49.423,-75.313,-49.515,-75.188,-49.477,-75.302,-49.533,-75.144,-49.504,-75.298,-49.605,-75.139,-49.574,-75.234,-49.639,-75.13,-49.641,-75.252,-49.656,-75.154,-49.673,-75.228,-49.686,-75.123,-49.749,-75.176,-49.768,-75.124,-49.856,-75.031,-49.835,-75.044,-49.937,-74.924,-49.798,-74.956,-49.709,-75.011,-49.764,-74.924,-49.623,-75.01,-49.698,-74.973,-49.607,-75.087,-49.534,-74.975,-49.597,-74.995,-49.471,-74.931,-49.429,-74.903,-49.531,-74.856,-49.39,-74.86,-49.504,-74.717,-49.412,-74.788,-49.346,-74.711,-49.403,-74.583,-49.308,-74.706,-49.414,-74.605,-49.43,-74.879,-49.546,-74.788,-49.613,-74.725,-49.55,-74.769,-49.618,-74.675,-49.633,-74.758,-49.634,-74.874,-49.596,-74.846,-49.66,-74.912,-49.683,-74.825,-49.717,-74.885,-49.783,-74.815,-49.849,-74.609,-49.791,-74.772,-49.873,-74.888,-49.859,-74.88,-49.976,-74.713,-49.898,-74.613,-49.949,-74.715,-49.909,-74.88,-49.989],[-75.159,-49.893,-75.347,-49.575,-75.607,-49.624,-75.485,-49.712,-75.633,-49.76,-75.62,-49.854,-75.355,-49.687,-75.356,-49.853,-75.241,-49.884,-75.278,-49.811,-75.159,-49.893],[-75.57,-48.939,-75.434,-48.909,-75.545,-48.972,-75.449,-49.037,-75.259,-48.906,-75.234,-49.064,-75.143,-49.001,-75.226,-48.842,-75.329,-48.807,-75.285,-48.908,-75.346,-48.826,-75.387,-48.865,-75.339,-48.795,-75.496,-48.88,-75.39,-48.802,-75.541,-48.792,-75.523,-48.846,-75.632,-48.849,-75.57,-48.939],[-75.535,-48.772,-75.25,-48.62,-75.221,-48.556,-75.303,-48.516,-75.254,-48.421,-75.308,-48.485,-75.29,-48.406,-75.604,-48.444,-75.582,-48.595,-75.328,-48.562,-75.534,-48.659,-75.293,-48.606,-75.465,-48.692,-75.593,-48.65,-75.535,-48.772],[-75.445,-48.416,-75.214,-48.311,-75.277,-48.388,-75.189,-48.396,-75.257,-48.471,-75.187,-48.495,-75.229,-48.581,-75.162,-48.565,-75.225,-48.598,-75.187,-48.684,-75.006,-48.591,-75.022,-48.48,-75.095,-48.511,-75.02,-48.472,-75.056,-48.402,-75.154,-48.43,-75.09,-48.374,-75.199,-48.303,-75.202,-48.205,-75.241,-48.239,-75.21,-48.125,-75.305,-48.166,-75.237,-48.089,-75.322,-48.131,-75.305,-47.997,-75.414,-48.116,-75.375,-48.032,-75.532,-48.09,-75.453,-48.241,-75.341,-48.18,-75.493,-48.322,-75.403,-48.244,-75.437,-48.325,-75.371,-48.324,-75.317,-48.212,-75.361,-48.319,-75.3,-48.261,-75.334,-48.335,-75.488,-48.355,-75.445,-48.416],[-74.193,-47.778,-74.313,-47.803,-74.155,-47.887,-74.329,-47.809,-74.42,-47.855,-74.349,-47.889,-74.47,-47.888,-74.262,-47.964,-73.861,-47.891,-73.892,-47.936,-73.821,-47.943,-73.746,-47.862,-74.193,-47.778],[-74.463,-48.56,-74.471,-48.627,-74.556,-48.586,-74.553,-48.46,-74.644,-48.414,-74.535,-48.382,-74.629,-48.217,-74.667,-48.264,-74.685,-48.114,-74.767,-48.289,-74.656,-48.444,-74.708,-48.543,-74.764,-48.377,-74.972,-48.482,-74.925,-48.602,-74.887,-48.55,-74.9,-48.614,-74.672,-48.613,-74.553,-48.691,-74.45,-48.668,-74.463,-48.56],[-74.945,-49.001,-74.946,-49.051,-75.079,-48.981,-75.027,-49.041,-75.102,-49.002,-75.206,-49.128,-75.096,-49.084,-75.178,-49.142,-75.041,-49.139,-74.871,-49.286,-74.822,-49.084,-74.945,-49.001],[-74.956,-48.447,-74.777,-48.366,-74.768,-48.24,-74.845,-48.292,-74.737,-48.169,-74.901,-48.251,-74.744,-48.139,-74.88,-48.231,-74.922,-48.198,-74.837,-48.162,-74.953,-48.094,-75.21,-48.072,-75.111,-48.138,-75.15,-48.181,-75.071,-48.152,-75.144,-48.222,-75.043,-48.204,-75.111,-48.262,-74.956,-48.447],[-74.695,-50.351,-74.798,-50.43,-74.734,-50.41,-74.753,-50.515,-74.608,-50.436,-74.692,-50.489,-74.352,-50.536,-74.459,-50.525,-74.632,-50.669,-74.471,-50.674,-74.516,-50.735,-74.311,-50.879,-74.327,-50.785,-74.28,-50.828,-74.242,-50.757,-74.244,-50.844,-74.143,-50.846,-74.205,-50.707,-74.292,-50.701,-74.205,-50.681,-74.357,-50.459,-74.6,-50.435,-74.529,-50.397,-74.695,-50.351],[-73.807,-52.026,-73.956,-51.941,-73.994,-51.998,-74.054,-51.884,-74.186,-51.897,-74.142,-51.849,-74.509,-51.708,-74.365,-51.893,-73.932,-52.092,-73.832,-52.033,-73.867,-52.122,-73.642,-52.225,-73.807,-52.026],[-73.732,-52.342,-73.821,-52.23,-74.07,-52.166,-74.069,-52.099,-74.24,-52.093,-73.765,-52.356,-73.862,-52.349,-73.856,-52.405,-73.726,-52.423,-73.968,-52.499,-74.073,-52.642,-73.842,-52.574,-73.968,-52.667,-73.839,-52.612,-73.919,-52.751,-73.71,-52.633,-73.804,-52.548,-73.694,-52.585,-73.724,-52.507,-73.649,-52.491,-73.732,-52.342],[-74.992,-51.485,-74.75,-51.423,-74.729,-51.294,-74.572,-51.404,-74.52,-51.357,-74.67,-51.244,-74.502,-51.269,-74.598,-51.189,-74.799,-51.21,-74.85,-51.263,-74.794,-51.287,-74.981,-51.344,-74.784,-51.329,-75.036,-51.432,-74.992,-51.485],[-72.964,-53.113,-72.733,-53.11,-72.658,-53.165,-72.854,-53.19,-72.684,-53.261,-72.763,-53.314,-72.753,-53.244,-72.963,-53.163,-73.314,-53.165,-72.557,-53.539,-72.39,-53.534,-72.521,-53.402,-72.617,-53.434,-72.674,-53.346,-72.54,-53.377,-72.557,-53.272,-72.42,-53.259,-72.619,-53.196,-72.204,-53.195,-72.388,-53.084,-72.604,-53.075,-72.402,-53.042,-72.235,-53.126,-72.355,-53.038,-72.034,-53.137,-71.908,-53.003,-71.41,-52.834,-71.396,-52.722,-71.477,-52.646,-71.781,-52.703,-72.166,-52.648,-72.439,-52.827,-72.422,-52.882,-72.487,-52.826,-72.639,-52.877,-72.522,-52.812,-72.577,-52.804,-72.696,-52.907,-72.602,-52.77,-72.696,-52.725,-72.804,-52.826,-73.01,-52.855,-72.902,-52.921,-72.964,-52.939,-72.919,-53.054,-72.849,-53.037,-72.964,-53.113],[-71.919,-53.844,-72.041,-53.898,-72.028,-53.966,-72.114,-53.939,-72.052,-53.992,-72.281,-53.915,-72.258,-54.006,-72.173,-53.983,-72.271,-54.063,-72.025,-54.093,-72.16,-54.111,-71.876,-54.238,-71.972,-54.334,-71.798,-54.342,-71.897,-54.29,-71.729,-54.24,-71.95,-54.044,-71.748,-54.172,-71.614,-54.092,-71.769,-54.048,-71.693,-54.054,-71.65,-53.925,-71.79,-53.886,-71.828,-53.986,-71.919,-53.844],[-70.873,-53.906,-70.882,-54.061,-70.791,-54.102,-70.842,-54.122,-70.493,-54.253,-70.219,-54.25,-70.529,-54.161,-70.671,-53.961,-70.413,-54.065,-70.332,-54.02,-70.455,-53.88,-70.474,-53.566,-70.716,-53.698,-70.614,-53.87,-70.784,-53.824,-70.873,-53.906],[-70.993,-54.107,-71.079,-54.059,-71.209,-54.185,-71.299,-54.11,-71.242,-54.093,-71.272,-54.0,-71.393,-53.972,-71.403,-54.093,-71.438,-53.95,-71.503,-54.008,-71.594,-53.931,-71.653,-54.026,-71.563,-54.082,-71.706,-54.166,-71.623,-54.249,-71.525,-54.185,-71.581,-54.263,-71.495,-54.224,-71.52,-54.319,-71.434,-54.337,-71.395,-54.264,-71.484,-54.262,-71.482,-54.212,-71.518,-54.184,-71.454,-54.159,-71.52,-54.127,-71.394,-54.115,-71.455,-54.207,-71.374,-54.263,-71.171,-54.244,-71.359,-54.318,-71.118,-54.403,-71.069,-54.349,-71.163,-54.309,-71.045,-54.348,-70.97,-54.279,-71.037,-54.244,-70.972,-54.164,-71.077,-54.203,-70.993,-54.107],[-74.183,-45.043,-74.211,-45.173,-74.149,-45.165,-74.151,-45.052,-74.132,-45.163,-73.844,-45.208,-73.982,-45.196,-73.975,-45.257,-73.68,-45.277,-73.708,-45.058,-73.809,-44.984,-74.183,-45.043],[-74.247,-44.708,-74.109,-44.729,-74.365,-44.746,-74.236,-44.808,-73.98,-44.665,-73.84,-44.701,-73.966,-44.598,-74.403,-44.616,-74.383,-44.705,-74.247,-44.708],[-176.577,-44.133,-176.325,-44.05,-176.422,-43.923,-176.484,-43.951,-176.524,-43.853,-176.443,-43.808,-176.55,-43.739,-176.364,-43.802,-176.417,-43.925,-176.338,-43.791,-176.192,-43.738,-176.627,-43.692,-176.875,-43.793,-176.568,-43.852,-176.539,-43.945,-176.686,-44.01,-176.577,-44.133],[-72.975,-44.364,-73.247,-44.436,-73.121,-44.548,-73.279,-44.542,-73.212,-44.635,-73.388,-44.567,-73.452,-44.639,-73.363,-44.799,-73.264,-44.749,-73.188,-44.807,-73.391,-44.816,-73.29,-44.92,-73.122,-44.914,-72.821,-44.735,-72.793,-44.669,-72.966,-44.584,-72.805,-44.647,-72.689,-44.539,-72.867,-44.427,-72.983,-44.574,-72.899,-44.446,-72.975,-44.364],[-74.147,-43.347,-73.834,-43.427,-73.865,-43.363,-73.8,-43.417,-73.795,-43.353,-73.874,-43.286,-73.661,-43.319,-73.752,-43.205,-73.73,-43.094,-73.702,-43.152,-73.48,-43.145,-73.618,-43.056,-73.51,-43.071,-73.545,-42.974,-73.751,-42.85,-73.587,-42.891,-73.616,-42.815,-73.486,-42.903,-73.472,-42.814,-73.804,-42.539,-73.74,-42.427,-73.785,-42.532,-73.577,-42.534,-73.684,-42.478,-73.665,-42.385,-73.355,-42.337,-73.359,-42.21,-73.487,-42.16,-73.414,-42.073,-73.561,-41.959,-73.47,-41.904,-73.538,-41.797,-73.8,-41.87,-73.713,-41.928,-73.768,-41.969,-73.806,-41.86,-73.986,-41.871,-73.854,-41.83,-73.901,-41.766,-74.055,-41.804,-73.995,-42.046,-74.174,-42.268,-74.152,-42.854,-74.405,-43.233,-74.147,-43.347],[-171.437,-14.044,-171.571,-13.887,-171.831,-13.793,-172.073,-13.866,-171.917,-14.005,-171.437,-14.044],[-172.242,-13.804,-172.174,-13.668,-172.333,-13.442,-172.804,-13.516,-172.54,-13.8,-172.242,-13.804],[-80.168,-3.024,-80.094,-2.95,-80.165,-2.853,-80.029,-2.86,-79.902,-2.739,-80.037,-2.675,-80.221,-2.739,-80.262,-3.026,-80.168,-3.024],[-91.172,-1.05,-90.849,-0.915,-90.781,-0.757,-90.98,-0.577,-90.948,-0.428,-91.175,-0.222,-91.199,-0.022,-91.359,0.166,-91.595,-0.011,-91.419,-0.031,-91.387,-0.261,-91.072,-0.616,-91.32,-0.691,-91.513,-0.908,-91.42,-1.013,-91.172,-1.05],[-90.251,-0.482,-90.513,-0.532,-90.535,-0.697,-90.398,-0.774,-90.24,-0.745,-90.169,-0.574,-90.251,-0.482],[-69.674,-54.879,-69.758,-54.89,-69.66,-54.943,-69.754,-54.991,-69.798,-54.885,-69.937,-54.917,-69.808,-54.966,-69.947,-54.969,-69.93,-55.07,-69.131,-54.971,-69.432,-54.913,-69.535,-54.968,-69.48,-54.915,-69.674,-54.879],[-68.754,-54.968,-69.538,-55.048,-69.531,-55.184,-69.568,-55.054,-70.039,-55.175,-69.85,-55.183,-69.99,-55.204,-69.839,-55.242,-69.973,-55.257,-69.855,-55.306,-69.472,-55.2,-69.673,-55.295,-69.526,-55.305,-69.789,-55.346,-69.375,-55.402,-69.484,-55.439,-69.396,-55.447,-69.36,-55.557,-69.331,-55.448,-69.115,-55.538,-69.071,-55.511,-69.142,-55.479,-69.072,-55.477,-69.234,-55.475,-69.192,-55.404,-69.262,-55.4,-69.434,-55.266,-69.252,-55.312,-69.195,-55.264,-69.329,-55.249,-69.224,-55.254,-69.28,-55.198,-69.228,-55.19,-69.078,-55.266,-69.091,-55.168,-69.05,-55.279,-68.959,-55.286,-68.776,-55.187,-68.934,-55.405,-68.749,-55.391,-68.918,-55.462,-68.775,-55.514,-68.768,-55.437,-68.563,-55.469,-68.611,-55.405,-68.532,-55.407,-68.53,-55.463,-68.215,-55.533,-68.263,-55.632,-68.127,-55.594,-68.103,-55.699,-67.983,-55.676,-67.975,-55.59,-68.117,-55.526,-68.022,-55.477,-68.172,-55.477,-68.133,-55.39,-68.541,-55.37,-68.724,-55.282,-68.171,-55.295,-68.326,-55.235,-68.245,-55.227,-68.734,-55.187,-69.069,-55.081,-69.011,-55.027,-68.622,-55.166,-68.537,-55.134,-68.607,-55.092,-68.303,-55.033,-68.495,-54.924,-68.754,-54.968],[-70.479,-55.224,-70.36,-55.173,-70.459,-55.112,-70.288,-55.146,-70.376,-55.102,-70.293,-55.064,-70.509,-55.085,-70.498,-55.034,-70.603,-55.063,-70.753,-55.004,-70.633,-55.036,-70.459,-54.948,-70.406,-55.014,-70.237,-54.955,-70.394,-54.946,-70.301,-54.918,-70.368,-54.9,-70.613,-54.95,-70.576,-54.988,-70.792,-54.93,-70.87,-54.992,-70.862,-54.928,-71.028,-54.977,-70.949,-55.092,-70.767,-55.053,-70.715,-55.141,-70.592,-55.07,-70.534,-55.116,-70.601,-55.193,-70.479,-55.224]]},{"outer":[-143.064,-16.992,-143.07,-17.102,-143.035,-17.182,-143.052,-17.203,-143.11,-17.176,-143.165,-17.078,-143.25,-17.051,-143.299,-17.005,-143.326,-16.937,-143.324,-16.931,-143.27,-16.922,-143.175,-16.93,-143.064,-16.992]},{"outer":[-142.799,-16.694,-142.796,-16.699,-142.794,-16.7,-142.787,-16.706,-142.787,-16.708,-142.787,-16.706,-142.785,-16.709,-142.787,-16.729,-142.868,-16.736,-142.871,-16.658,-142.845,-16.637,-142.814,-16.65,-142.814,-16.661,-142.805,-16.685,-142.803,-16.686,-142.804,-16.687,-142.801,-16.689,-142.801,-16.69,-142.8,-16.691,-142.799,-16.693,-142.799,-16.694]},{"outer":[-140.852,-15.811,-140.852,-15.813,-140.85,-15.814,-140.846,-15.811,-140.845,-15.809,-140.84,-15.806,-140.837,-15.812,-140.857,-15.833,-140.903,-15.827,-140.836,-15.802,-140.831,-15.809,-140.834,-15.822,-140.855,-15.838,-140.834,-15.814,-140.84,-15.804,-140.852,-15.811]},{"outer":[-142.258,-15.861,-142.259,-15.862,-142.264,-15.855,-142.265,-15.851,-142.249,-15.824,-142.164,-15.748,-142.144,-15.74,-142.137,-15.741,-142.14,-15.75,-142.239,-15.86,-142.246,-15.863,-142.258,-15.861]},{"outer":[-146.842,-15.722,-146.862,-15.707,-146.873,-15.683,-146.87,-15.667,-146.811,-15.647,-146.586,-15.729,-146.488,-15.823,-146.564,-15.839,-146.618,-15.835,-146.703,-15.809,-146.812,-15.758,-146.842,-15.722]},{"outer":[-142.642,-17.541,-142.558,-17.595,-142.563,-17.62,-142.604,-17.63,-142.655,-17.606,-142.666,-17.582,-142.667,-17.548,-142.642,-17.541]},{"outer":[-144.369,-17.049,-144.343,-17.047,-144.336,-17.049,-144.333,-17.048,-144.328,-17.05,-144.304,-17.074,-144.345,-17.165,-144.427,-17.105,-144.421,-17.071,-144.369,-17.049]},{"outer":[-140.125,-15.969,-140.11,-15.992,-140.16,-15.993,-140.159,-15.977,-140.125,-15.969]}],"names":{"ar":"المحيط الهادي الجنوبي","de":"Südpazifik","es":"Océano Pacífico del Sur","fr":"océan Pacifique sud","he":"האוקיינוס השקט הדרומי","nl":"Zuidelijke Grote Oceaan","pt":"Oceano Pacífico Sul","zh":"南太平洋"}}]} \ No newline at end of file diff --git a/assets/data/species.json b/assets/data/species.json index 6f1005da9f..35be7c5f0d 100644 --- a/assets/data/species.json +++ b/assets/data/species.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "species": [ { "id": "sp_whale_shark", @@ -4088,6 +4088,1398 @@ "category": "other", "taxonomyClass": "Reptilia", "description": "Largest living reptile, found in coastal and estuarine waters of the Indo-Pacific." + }, + { + "id": "sp_northern_pike", + "commonName": "Northern Pike", + "scientificName": "Esox lucius", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Long-bodied ambush predator with a duckbill snout, hanging motionless among weeds at lake margins." + }, + { + "id": "sp_muskellunge", + "commonName": "Muskellunge", + "scientificName": "Esox masquinongy", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "The largest pike, a barred or spotted giant of clear northern lakes, rarely seen and never forgotten." + }, + { + "id": "sp_chain_pickerel", + "commonName": "Chain Pickerel", + "scientificName": "Esox niger", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Slender pike of weedy eastern North American ponds, named for the chain-like pattern along its flanks." + }, + { + "id": "sp_walleye", + "commonName": "Walleye", + "scientificName": "Sander vitreus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Golden-olive perch relative with large reflective eyes, hunting at dusk over rocky and sandy lake bottoms." + }, + { + "id": "sp_sauger", + "commonName": "Sauger", + "scientificName": "Sander canadensis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Smaller, blotchier cousin of the walleye that favours turbid rivers and reservoirs." + }, + { + "id": "sp_yellow_perch", + "commonName": "Yellow Perch", + "scientificName": "Perca flavescens", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Schooling golden perch with dark vertical bars, common around docks and weed beds across North America." + }, + { + "id": "sp_european_perch", + "commonName": "European Perch", + "scientificName": "Perca fluviatilis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Striped, spiny-finned perch with red-orange lower fins, found in nearly every European lake and slow river." + }, + { + "id": "sp_zander", + "commonName": "Zander", + "scientificName": "Sander lucioperca", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Large pale predator with glassy eyes and fanged jaws, patrolling murky European lakes and rivers after dark." + }, + { + "id": "sp_ruffe", + "commonName": "Ruffe", + "scientificName": "Gymnocephalus cernua", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Small mottled perch with a spiny joined dorsal fin, abundant on soft bottoms of European lakes." + }, + { + "id": "sp_largemouth_bass", + "commonName": "Largemouth Bass", + "scientificName": "Micropterus nigricans", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Green-backed bass with a dark side stripe and a huge mouth, lurking beside logs and weed edges in warm lakes." + }, + { + "id": "sp_smallmouth_bass", + "commonName": "Smallmouth Bass", + "scientificName": "Micropterus dolomieu", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Bronze bass with faint vertical bars, holding over rock and gravel in clear, cool lakes and rivers." + }, + { + "id": "sp_rock_bass", + "commonName": "Rock Bass", + "scientificName": "Ambloplites rupestris", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Chunky red-eyed sunfish with rows of dark spots, sheltering among boulders in clear streams and lakes." + }, + { + "id": "sp_bluegill", + "commonName": "Bluegill", + "scientificName": "Lepomis macrochirus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Disc-shaped sunfish with a blue-black ear flap and orange breast, nesting in colonies on shallow sandy bottoms." + }, + { + "id": "sp_pumpkinseed", + "commonName": "Pumpkinseed", + "scientificName": "Lepomis gibbosus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Brightly speckled sunfish with a red-tipped ear flap and wavy blue cheek lines, common in weedy shallows." + }, + { + "id": "sp_black_crappie", + "commonName": "Black Crappie", + "scientificName": "Pomoxis nigromaculatus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silvery, deep-bodied panfish speckled with black, schooling around submerged brush and pilings." + }, + { + "id": "sp_white_crappie", + "commonName": "White Crappie", + "scientificName": "Pomoxis annularis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Paler crappie with faint vertical bands, favouring turbid reservoirs and slow rivers." + }, + { + "id": "sp_brown_trout", + "commonName": "Brown Trout", + "scientificName": "Salmo trutta", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Golden-brown trout with red and black spots, holding in the current of cool, clear rivers and lakes." + }, + { + "id": "sp_rainbow_trout", + "commonName": "Rainbow Trout", + "scientificName": "Oncorhynchus mykiss", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silvery trout with a pink lateral band and fine black speckling, stocked and wild in cold waters worldwide." + }, + { + "id": "sp_brook_trout", + "commonName": "Brook Trout", + "scientificName": "Salvelinus fontinalis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Char with worm-like markings on its back, red spots in blue halos and white-edged fins, in cold headwater streams." + }, + { + "id": "sp_lake_trout", + "commonName": "Lake Trout", + "scientificName": "Salvelinus namaycush", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Big grey char covered in pale spots with a forked tail, cruising the deep cold water of northern lakes." + }, + { + "id": "sp_arctic_char", + "commonName": "Arctic Char", + "scientificName": "Salvelinus alpinus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Northernmost freshwater fish, a slender char whose belly flushes orange-red in autumn spawning colours." + }, + { + "id": "sp_atlantic_salmon", + "commonName": "Atlantic Salmon", + "scientificName": "Salmo salar", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver sea-run salmon with black X-shaped spots, leaping falls on its return to natal rivers to spawn." + }, + { + "id": "sp_chinook_salmon", + "commonName": "Chinook Salmon", + "scientificName": "Oncorhynchus tshawytscha", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "The largest Pacific salmon, blue-green backed with black gums, ascending big western rivers to spawn." + }, + { + "id": "sp_sockeye_salmon", + "commonName": "Sockeye Salmon", + "scientificName": "Oncorhynchus nerka", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Salmon that turns brilliant red with a green head at spawning, crowding gravel beds of lake-fed rivers." + }, + { + "id": "sp_coho_salmon", + "commonName": "Coho Salmon", + "scientificName": "Oncorhynchus kisutch", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver salmon with white gums and spots only on the upper tail, spawning in small coastal streams." + }, + { + "id": "sp_lake_whitefish", + "commonName": "Lake Whitefish", + "scientificName": "Coregonus clupeaformis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silvery, small-mouthed whitefish of deep cold lakes, feeding on the bottom in large schools." + }, + { + "id": "sp_cisco", + "commonName": "Cisco", + "scientificName": "Coregonus artedi", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Slender herring-like whitefish that shoals in open water of cold northern lakes, prey for lake trout." + }, + { + "id": "sp_european_grayling", + "commonName": "European Grayling", + "scientificName": "Thymallus thymallus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver-grey river fish with a tall, sail-like dorsal fin edged in purple, holding in fast clean gravel runs." + }, + { + "id": "sp_common_carp", + "commonName": "Common Carp", + "scientificName": "Cyprinus carpio", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Heavy bronze carp with large scales and two barbels, rooting through soft bottoms of warm lakes and rivers." + }, + { + "id": "sp_grass_carp", + "commonName": "Grass Carp", + "scientificName": "Ctenopharyngodon idella", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Torpedo-shaped Asian carp introduced worldwide to graze on aquatic weeds, often seen in clear quarry lakes." + }, + { + "id": "sp_tench", + "commonName": "Tench", + "scientificName": "Tinca tinca", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Olive-green fish with tiny scales, red eyes and rounded fins, sliding through mud and reeds of still waters." + }, + { + "id": "sp_common_bream", + "commonName": "Common Bream", + "scientificName": "Abramis brama", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Deep, laterally flattened bronze fish that feeds head-down in muddy shoals, common across European lowlands." + }, + { + "id": "sp_roach", + "commonName": "Roach", + "scientificName": "Rutilus rutilus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver shoaling fish with red fins and a red iris, the most abundant fish in many European lakes and canals." + }, + { + "id": "sp_rudd", + "commonName": "Rudd", + "scientificName": "Scardinius erythrophthalmus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Golden-flanked relative of the roach with bright red fins and an upturned mouth, feeding just under the surface." + }, + { + "id": "sp_chub", + "commonName": "Chub", + "scientificName": "Squalius cephalus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Thick-set river fish with a broad head, large dark-edged scales and a big mouth, holding under overhanging trees." + }, + { + "id": "sp_barbel", + "commonName": "Barbel", + "scientificName": "Barbus barbus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Streamlined bottom-dweller with four barbels and an underslung mouth, hugging gravel in fast European rivers." + }, + { + "id": "sp_european_eel", + "commonName": "European Eel", + "scientificName": "Anguilla anguilla", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Snake-like fish that spends decades in rivers and lakes before migrating to the Sargasso Sea to spawn once." + }, + { + "id": "sp_american_eel", + "commonName": "American Eel", + "scientificName": "Anguilla rostrata", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "North American eel that hides under rocks by day in rivers and lakes and returns to the Sargasso Sea to breed." + }, + { + "id": "sp_burbot", + "commonName": "Burbot", + "scientificName": "Lota lota", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "The only freshwater cod, a mottled eel-like fish with a single chin barbel, hiding in cold deep water by day." + }, + { + "id": "sp_channel_catfish", + "commonName": "Channel Catfish", + "scientificName": "Ictalurus punctatus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Grey catfish with scattered dark spots, a forked tail and eight barbels, common in rivers and reservoirs across North America." + }, + { + "id": "sp_flathead_catfish", + "commonName": "Flathead Catfish", + "scientificName": "Pylodictis olivaris", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Huge mottled brown catfish with a flattened head and protruding lower jaw, lying in deep river holes." + }, + { + "id": "sp_brown_bullhead", + "commonName": "Brown Bullhead", + "scientificName": "Ameiurus nebulosus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Small stocky catfish with dark barbels and a squared tail, tolerating muddy, warm and low-oxygen ponds." + }, + { + "id": "sp_wels_catfish", + "commonName": "Wels Catfish", + "scientificName": "Silurus glanis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Europe's largest freshwater fish, a scaleless giant with a broad flat head and long whiskers, lying in deep river holes." + }, + { + "id": "sp_white_sturgeon", + "commonName": "White Sturgeon", + "scientificName": "Acipenser transmontanus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "North America's largest freshwater fish, an armoured grey giant with a shark-like tail cruising big western rivers." + }, + { + "id": "sp_lake_sturgeon", + "commonName": "Lake Sturgeon", + "scientificName": "Acipenser fulvescens", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Slow-growing armoured sturgeon of the Great Lakes and Mississippi basin, vacuuming the bottom with its tube mouth." + }, + { + "id": "sp_european_sturgeon", + "commonName": "European Sturgeon", + "scientificName": "Acipenser sturio", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Critically endangered armoured sturgeon of Atlantic rivers, now bred and released in the Garonne and Elbe." + }, + { + "id": "sp_alligator_gar", + "commonName": "Alligator Gar", + "scientificName": "Atractosteus spatula", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Prehistoric giant with a broad toothy snout and diamond-shaped armour scales, surfacing to gulp air in southern rivers." + }, + { + "id": "sp_longnose_gar", + "commonName": "Longnose Gar", + "scientificName": "Lepisosteus osseus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Slender armoured fish with a needle-like snout, hanging motionless just below the surface of warm rivers." + }, + { + "id": "sp_bowfin", + "commonName": "Bowfin", + "scientificName": "Amia calva", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Living fossil with a long undulating dorsal fin and a bony head, guarding its fry in weedy backwaters." + }, + { + "id": "sp_american_paddlefish", + "commonName": "American Paddlefish", + "scientificName": "Polyodon spathula", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Filter-feeding giant with a paddle-shaped snout a third of its length, swimming open-mouthed through big rivers." + }, + { + "id": "sp_sea_lamprey", + "commonName": "Sea Lamprey", + "scientificName": "Petromyzon marinus", + "category": "fish", + "taxonomyClass": "Petromyzontida", + "description": "Jawless eel-like parasite with a sucker mouth ringed with teeth, spawning in gravel streams after feeding at sea or in lakes." + }, + { + "id": "sp_freshwater_drum", + "commonName": "Freshwater Drum", + "scientificName": "Aplodinotus grunniens", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silvery humpbacked fish that grunts audibly and crushes mussels with throat teeth, common in big rivers and lakes." + }, + { + "id": "sp_white_sucker", + "commonName": "White Sucker", + "scientificName": "Catostomus commersonii", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Cylindrical bottom-feeder with a fleshy downturned mouth, running up streams in spring spawning crowds." + }, + { + "id": "sp_common_minnow", + "commonName": "Common Minnow", + "scientificName": "Phoxinus phoxinus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Tiny striped shoaling fish of clear cool streams and lakes, the males turning red and green in spring." + }, + { + "id": "sp_three_spined_stickleback", + "commonName": "Three-spined Stickleback", + "scientificName": "Gasterosteus aculeatus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Tiny armoured fish with three dorsal spines whose red-throated males build and guard nests of plant fibres." + }, + { + "id": "sp_alewife", + "commonName": "Alewife", + "scientificName": "Alosa pseudoharengus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver herring that runs up rivers in spring and now fills the Great Lakes in vast schools." + }, + { + "id": "sp_nile_perch", + "commonName": "Nile Perch", + "scientificName": "Lates niloticus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Massive silver predator with a black-rimmed eye, introduced into Lake Victoria where it dominates open water." + }, + { + "id": "sp_nile_tilapia", + "commonName": "Nile Tilapia", + "scientificName": "Oreochromis niloticus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Grey cichlid with vertical tail bars that broods its young in its mouth, farmed and feral in warm waters worldwide." + }, + { + "id": "sp_african_tigerfish", + "commonName": "African Tigerfish", + "scientificName": "Hydrocynus vittatus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Striped silver predator with interlocking dagger teeth, hunting in fast African rivers such as the Zambezi." + }, + { + "id": "sp_marbled_lungfish", + "commonName": "Marbled Lungfish", + "scientificName": "Protopterus aethiopicus", + "category": "fish", + "taxonomyClass": "Sarcopterygii", + "description": "Eel-shaped air-breathing fish with thread-like fins that survives droughts sealed in a mud cocoon." + }, + { + "id": "sp_electric_catfish", + "commonName": "Electric Catfish", + "scientificName": "Malapterurus electricus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Plump grey catfish of the Nile and Congo that stuns prey with shocks of several hundred volts." + }, + { + "id": "sp_zebra_mbuna", + "commonName": "Zebra Mbuna", + "scientificName": "Maylandia zebra", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Blue-barred rock cichlid of Lake Malawi, grazing algae from boulders in dense territorial crowds." + }, + { + "id": "sp_malawi_butterfly_peacock", + "commonName": "Malawi Butterfly Peacock", + "scientificName": "Aulonocara jacobfreibergi", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Iridescent blue peacock cichlid of Lake Malawi caves, the males glowing with white-edged fins." + }, + { + "id": "sp_fuelleborn_cichlid", + "commonName": "Fuelleborn's Cichlid", + "scientificName": "Labeotropheus fuelleborni", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Blunt-nosed Lake Malawi mbuna with a fleshy overhanging snout for scraping algae in the surf zone." + }, + { + "id": "sp_princess_of_burundi", + "commonName": "Princess of Burundi", + "scientificName": "Neolamprologus brichardi", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Elegant Lake Tanganyika cichlid with lyre-shaped fins, living in extended families that share nest duties." + }, + { + "id": "sp_frontosa", + "commonName": "Frontosa", + "scientificName": "Cyphotilapia frontosa", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Deep-water Tanganyika cichlid with bold blue-white bands and a humped forehead, moving slowly in groups over rocks." + }, + { + "id": "sp_tropheus_moorii", + "commonName": "Blunthead Cichlid", + "scientificName": "Tropheus moorii", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Stocky Tanganyika rock cichlid in dozens of colour forms, each confined to its own stretch of shore." + }, + { + "id": "sp_arapaima", + "commonName": "Arapaima", + "scientificName": "Arapaima gigas", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "One of the largest freshwater fish, an armoured Amazon giant with a red-flecked tail that rises to gulp air." + }, + { + "id": "sp_silver_arowana", + "commonName": "Silver Arowana", + "scientificName": "Osteoglossum bicirrhosum", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Ribbon-like silver Amazon fish with two chin barbels that leaps clear of the water to snatch insects from branches." + }, + { + "id": "sp_red_bellied_piranha", + "commonName": "Red-bellied Piranha", + "scientificName": "Pygocentrus nattereri", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Deep-bodied silver fish with a crimson belly and razor teeth, moving in shoals through Amazon backwaters." + }, + { + "id": "sp_black_piranha", + "commonName": "Black Piranha", + "scientificName": "Serrasalmus rhombeus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Large solitary piranha with red eyes and a dark diamond-shaped body, lurking in clear rocky Amazon tributaries." + }, + { + "id": "sp_red_bellied_pacu", + "commonName": "Red-bellied Pacu", + "scientificName": "Piaractus brachypomus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Piranha-like fruit eater with flat crushing teeth and a red belly, gathering under flooded forest trees." + }, + { + "id": "sp_tambaqui", + "commonName": "Tambaqui", + "scientificName": "Colossoma macropomum", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Huge dark pacu of the Amazon that crunches fallen nuts and seeds beneath flooded forest canopy." + }, + { + "id": "sp_electric_eel", + "commonName": "Electric Eel", + "scientificName": "Electrophorus electricus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Not an eel but a knifefish, a long dark air-breather that stuns prey with shocks of up to 600 volts." + }, + { + "id": "sp_redtail_catfish", + "commonName": "Redtail Catfish", + "scientificName": "Phractocephalus hemioliopterus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Big Amazon catfish with a dark back, white belly and bright orange-red tail, resting in deep river pools." + }, + { + "id": "sp_tiger_shovelnose_catfish", + "commonName": "Tiger Shovelnose Catfish", + "scientificName": "Pseudoplatystoma fasciatum", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Sleek striped catfish with a long flattened snout, hunting along sandy South American river channels at night." + }, + { + "id": "sp_peacock_bass", + "commonName": "Peacock Bass", + "scientificName": "Cichla ocellaris", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Aggressive Amazon cichlid with three dark bars and a tail eyespot, ambushing fish along sunken timber." + }, + { + "id": "sp_oscar", + "commonName": "Oscar", + "scientificName": "Astronotus ocellatus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Stout dark cichlid with orange marbling and a tail eyespot, patrolling slow Amazon waters and flooded margins." + }, + { + "id": "sp_freshwater_angelfish", + "commonName": "Freshwater Angelfish", + "scientificName": "Pterophyllum scalare", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Tall, disc-shaped Amazon cichlid with trailing fins and vertical stripes, drifting among submerged roots." + }, + { + "id": "sp_discus", + "commonName": "Discus", + "scientificName": "Symphysodon discus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Round, laterally flattened cichlid with wavy blue lines that feeds its fry on mucus from its own skin." + }, + { + "id": "sp_sailfin_pleco", + "commonName": "Sailfin Pleco", + "scientificName": "Pterygoplichthys pardalis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Armoured suckermouth catfish with a tall dorsal fin and leopard spots, rasping algae from wood and rock." + }, + { + "id": "sp_cardinal_tetra", + "commonName": "Cardinal Tetra", + "scientificName": "Paracheirodon axelrodi", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Tiny tetra with a neon blue stripe over a full-length red band, shoaling in the dark waters of the Rio Negro." + }, + { + "id": "sp_mexican_tetra", + "commonName": "Mexican Tetra", + "scientificName": "Astyanax mexicanus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver tetra of Mexican rivers whose cave populations are blind and pale, a favourite of cenote divers." + }, + { + "id": "sp_mekong_giant_catfish", + "commonName": "Mekong Giant Catfish", + "scientificName": "Pangasianodon gigas", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Critically endangered toothless giant of the Mekong, grey and barbel-less, once reaching three metres." + }, + { + "id": "sp_giant_barb", + "commonName": "Giant Barb", + "scientificName": "Catlocarpio siamensis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "The largest carp in the world, a big-scaled Mekong giant with a huge head, now rare in deep river pools." + }, + { + "id": "sp_asian_arowana", + "commonName": "Asian Arowana", + "scientificName": "Scleropages formosus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Metallic red or gold dragon fish of Southeast Asian blackwater rivers, gliding just under the surface." + }, + { + "id": "sp_striped_snakehead", + "commonName": "Striped Snakehead", + "scientificName": "Channa striata", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Torpedo-shaped air-breathing predator with a flat snake-like head, guarding its fry in weedy Asian ponds." + }, + { + "id": "sp_giant_snakehead", + "commonName": "Giant Snakehead", + "scientificName": "Channa micropeltes", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Large fierce snakehead, striped when young and dark when grown, defending its bright red fry in Southeast Asian lakes." + }, + { + "id": "sp_climbing_perch", + "commonName": "Climbing Perch", + "scientificName": "Anabas testudineus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Hardy olive fish that breathes air and crawls overland on its spiny gill covers between drying pools." + }, + { + "id": "sp_golden_mahseer", + "commonName": "Golden Mahseer", + "scientificName": "Tor putitora", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Golden-scaled carp of Himalayan rivers, a powerful swimmer holding in fast clear pools below rapids." + }, + { + "id": "sp_koi", + "commonName": "Koi", + "scientificName": "Cyprinus rubrofuscus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Ornamental carp bred in Japan in white, red, black and gold patterns, at home in ponds and warm clear lakes." + }, + { + "id": "sp_goldfish", + "commonName": "Goldfish", + "scientificName": "Carassius auratus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Domesticated Asian carp that reverts to olive-bronze in the wild, forming large feral schools in warm lakes." + }, + { + "id": "sp_giant_gourami", + "commonName": "Giant Gourami", + "scientificName": "Osphronemus goramy", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Broad, humped Southeast Asian fish with thread-like pelvic fins that builds bubble nests in slow weedy water." + }, + { + "id": "sp_clown_knifefish", + "commonName": "Clown Knifefish", + "scientificName": "Chitala ornata", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver blade-shaped fish with ocellated spots along a long rippling anal fin, hovering under Asian river snags." + }, + { + "id": "sp_walking_catfish", + "commonName": "Walking Catfish", + "scientificName": "Clarias batrachus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Slender air-breathing catfish that wriggles across wet ground between ponds, now feral in Florida." + }, + { + "id": "sp_japanese_eel", + "commonName": "Japanese Eel", + "scientificName": "Anguilla japonica", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "East Asian eel that grows up in rivers and lakes and migrates to the western Pacific to spawn." + }, + { + "id": "sp_ayu", + "commonName": "Ayu", + "scientificName": "Plecoglossus altivelis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Slender silver Japanese sweetfish that grazes algae from stones in clear rivers and defends a feeding territory." + }, + { + "id": "sp_baikal_omul", + "commonName": "Baikal Omul", + "scientificName": "Coregonus migratorius", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver whitefish found only in Lake Baikal, schooling in the cold open water and running up rivers to spawn." + }, + { + "id": "sp_baikal_oilfish", + "commonName": "Baikal Oilfish", + "scientificName": "Comephorus baikalensis", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Translucent, scaleless fish of Lake Baikal's depths, so rich in oil it is nearly see-through, giving birth to live young." + }, + { + "id": "sp_murray_cod", + "commonName": "Murray Cod", + "scientificName": "Maccullochella peelii", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Australia's largest freshwater fish, a mottled green giant with a white belly, holding beside snags in the Murray-Darling." + }, + { + "id": "sp_golden_perch", + "commonName": "Golden Perch", + "scientificName": "Macquaria ambigua", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Deep-bodied golden-olive perch of Australian inland rivers, sheltering by fallen timber and rock ledges." + }, + { + "id": "sp_australian_bass", + "commonName": "Australian Bass", + "scientificName": "Macquaria novemaculeata", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Bronze-green bass of eastern Australian coastal rivers that migrates downstream to spawn in brackish estuaries." + }, + { + "id": "sp_barramundi", + "commonName": "Barramundi", + "scientificName": "Lates calcarifer", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver, hump-backed perch of northern Australian rivers and estuaries, changing sex from male to female with age." + }, + { + "id": "sp_silver_perch", + "commonName": "Silver Perch", + "scientificName": "Bidyanus bidyanus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Silver-grey grunter of the Murray-Darling with a small mouth and forked tail, once schooling in huge numbers." + }, + { + "id": "sp_gulf_saratoga", + "commonName": "Gulf Saratoga", + "scientificName": "Scleropages jardinii", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Bronze Australian arowana with red-flecked scales that broods its eggs in its mouth in northern billabongs." + }, + { + "id": "sp_sooty_grunter", + "commonName": "Sooty Grunter", + "scientificName": "Hephaestus fuliginosus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Dark, thickset grunter of northern Australian rivers, grazing algae and fruit around rocks and rapids." + }, + { + "id": "sp_eel_tailed_catfish", + "commonName": "Eel-tailed Catfish", + "scientificName": "Tandanus tandanus", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Australian catfish with a tapering eel-like tail that builds and guards a gravel nest in clear river shallows." + }, + { + "id": "sp_spangled_perch", + "commonName": "Spangled Perch", + "scientificName": "Leiopotherapon unicolor", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Small silver-spotted grunter found across inland Australia, colonising any waterhole a flood connects." + }, + { + "id": "sp_eastern_rainbowfish", + "commonName": "Eastern Rainbowfish", + "scientificName": "Melanotaenia splendida", + "category": "fish", + "taxonomyClass": "Actinopterygii", + "description": "Small iridescent fish of eastern Australian creeks, the males flashing red and blue stripes in the sun." + }, + { + "id": "sp_signal_crayfish", + "commonName": "Signal Crayfish", + "scientificName": "Pacifastacus leniusculus", + "category": "invertebrate", + "taxonomyClass": "Malacostraca", + "description": "Large brown crayfish with a white patch at the claw joint, an invasive North American species spreading through European rivers." + }, + { + "id": "sp_red_swamp_crayfish", + "commonName": "Red Swamp Crayfish", + "scientificName": "Procambarus clarkii", + "category": "invertebrate", + "taxonomyClass": "Malacostraca", + "description": "Dark red crayfish with bumpy claws from Louisiana marshes, now burrowing into warm wetlands on every continent." + }, + { + "id": "sp_noble_crayfish", + "commonName": "Noble Crayfish", + "scientificName": "Astacus astacus", + "category": "invertebrate", + "taxonomyClass": "Malacostraca", + "description": "Europe's native crayfish, dark brown with red-undersided claws, hiding in bank burrows of clean cool streams and lakes." + }, + { + "id": "sp_white_clawed_crayfish", + "commonName": "White-clawed Crayfish", + "scientificName": "Austropotamobius pallipes", + "category": "invertebrate", + "taxonomyClass": "Malacostraca", + "description": "Small olive crayfish with pale claw undersides, a threatened native of clean limestone streams in western Europe." + }, + { + "id": "sp_tasmanian_giant_freshwater_crayfish", + "commonName": "Tasmanian Giant Freshwater Crayfish", + "scientificName": "Astacopsis gouldi", + "category": "invertebrate", + "taxonomyClass": "Malacostraca", + "description": "The world's largest freshwater invertebrate, a slow-growing blue-brown crayfish of shaded Tasmanian rivers." + }, + { + "id": "sp_zebra_mussel", + "commonName": "Zebra Mussel", + "scientificName": "Dreissena polymorpha", + "category": "invertebrate", + "taxonomyClass": "Bivalvia", + "description": "Thumbnail-sized striped mussel that carpets rocks, wrecks and pipes by the thousand, clearing the water as it spreads." + }, + { + "id": "sp_quagga_mussel", + "commonName": "Quagga Mussel", + "scientificName": "Dreissena bugensis", + "category": "invertebrate", + "taxonomyClass": "Bivalvia", + "description": "Rounder, paler relative of the zebra mussel that colonises soft bottoms and deep cold water the zebra cannot." + }, + { + "id": "sp_freshwater_pearl_mussel", + "commonName": "Freshwater Pearl Mussel", + "scientificName": "Margaritifera margaritifera", + "category": "invertebrate", + "taxonomyClass": "Bivalvia", + "description": "Dark, elongated mussel that can live over a century half-buried in clean gravel of fast salmon rivers." + }, + { + "id": "sp_swan_mussel", + "commonName": "Swan Mussel", + "scientificName": "Anodonta cygnea", + "category": "invertebrate", + "taxonomyClass": "Bivalvia", + "description": "Large thin-shelled mussel of muddy lakes and canals, filtering water with its siphons just above the silt." + }, + { + "id": "sp_chinese_pond_mussel", + "commonName": "Chinese Pond Mussel", + "scientificName": "Sinanodonta woodiana", + "category": "invertebrate", + "taxonomyClass": "Bivalvia", + "description": "Very large invasive Asian mussel with a glossy brown shell, arriving with farmed fish and spreading through warm lakes." + }, + { + "id": "sp_freshwater_sponge", + "commonName": "Freshwater Sponge", + "scientificName": "Spongilla lacustris", + "category": "invertebrate", + "taxonomyClass": "Demospongiae", + "description": "Green or grey branching sponge encrusting sticks and stones in clear lakes, coloured by algae living inside it." + }, + { + "id": "sp_freshwater_jellyfish", + "commonName": "Freshwater Jellyfish", + "scientificName": "Craspedacusta sowerbii", + "category": "invertebrate", + "taxonomyClass": "Hydrozoa", + "description": "Coin-sized transparent jellyfish that appears in swarms in warm quarry lakes and reservoirs in late summer." + }, + { + "id": "sp_great_pond_snail", + "commonName": "Great Pond Snail", + "scientificName": "Lymnaea stagnalis", + "category": "invertebrate", + "taxonomyClass": "Gastropoda", + "description": "Large pointed-shelled snail gliding over plants and glass-clear surfaces of still European waters, breathing air at the surface." + }, + { + "id": "sp_great_ramshorn_snail", + "commonName": "Great Ramshorn Snail", + "scientificName": "Planorbarius corneus", + "category": "invertebrate", + "taxonomyClass": "Gastropoda", + "description": "Flat, coiled snail like a tiny ram's horn, grazing algae from leaves and stones in weedy ponds." + }, + { + "id": "sp_channeled_apple_snail", + "commonName": "Channeled Apple Snail", + "scientificName": "Pomacea canaliculata", + "category": "invertebrate", + "taxonomyClass": "Gastropoda", + "description": "Large golden-brown snail that lays bright pink egg clusters above the waterline, invasive in warm wetlands and rice fields." + }, + { + "id": "sp_magnificent_bryozoan", + "commonName": "Magnificent Bryozoan", + "scientificName": "Pectinatella magnifica", + "category": "invertebrate", + "taxonomyClass": "Phylactolaemata", + "description": "Jelly-like colony the size of a football, studded with tiny animals, clinging to branches and ropes in warm still water." + }, + { + "id": "sp_chinese_mitten_crab", + "commonName": "Chinese Mitten Crab", + "scientificName": "Eriocheir sinensis", + "category": "invertebrate", + "taxonomyClass": "Malacostraca", + "description": "Burrowing crab with furry claws that spends years in rivers before walking downstream to breed in estuaries." + }, + { + "id": "sp_giant_freshwater_prawn", + "commonName": "Giant Freshwater Prawn", + "scientificName": "Macrobrachium rosenbergii", + "category": "invertebrate", + "taxonomyClass": "Malacostraca", + "description": "Big blue-clawed prawn of Asian and Australian rivers with claws longer than its body in old males." + }, + { + "id": "sp_common_snapping_turtle", + "commonName": "Common Snapping Turtle", + "scientificName": "Chelydra serpentina", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Heavy, rough-shelled turtle with a long saw-toothed tail, lying in the mud of ponds and slow rivers with its head out." + }, + { + "id": "sp_alligator_snapping_turtle", + "commonName": "Alligator Snapping Turtle", + "scientificName": "Macrochelys temminckii", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Prehistoric-looking giant with three ridged keels and a worm-like tongue lure, waiting open-mouthed on southern river bottoms." + }, + { + "id": "sp_painted_turtle", + "commonName": "Painted Turtle", + "scientificName": "Chrysemys picta", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Smooth dark turtle with red and yellow stripes on its neck and shell rim, basking in rows on logs across North America." + }, + { + "id": "sp_red_eared_slider", + "commonName": "Red-eared Slider", + "scientificName": "Trachemys scripta elegans", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Green-striped pond turtle with a red stripe behind each eye, the pet-trade turtle now feral in warm waters worldwide." + }, + { + "id": "sp_northern_map_turtle", + "commonName": "Northern Map Turtle", + "scientificName": "Graptemys geographica", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Olive turtle with map-like yellow lines on its shell and a low keel, basking on rocks along clear rivers and large lakes." + }, + { + "id": "sp_spiny_softshell_turtle", + "commonName": "Spiny Softshell Turtle", + "scientificName": "Apalone spinifera", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Flat, leathery pancake of a turtle with a snorkel snout, buried in sand in shallow rivers with only its head showing." + }, + { + "id": "sp_florida_softshell_turtle", + "commonName": "Florida Softshell Turtle", + "scientificName": "Apalone ferox", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Large dark softshell with a long tubular snout, common in Florida springs, canals and lakes." + }, + { + "id": "sp_pig_nosed_turtle", + "commonName": "Pig-nosed Turtle", + "scientificName": "Carettochelys insculpta", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Unique river turtle of New Guinea and northern Australia with sea-turtle flippers and a fleshy pig-like snout." + }, + { + "id": "sp_mary_river_turtle", + "commonName": "Mary River Turtle", + "scientificName": "Elusor macrurus", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Rare Australian turtle that breathes through its cloaca and grows a green algae mohawk, found in a single Queensland river." + }, + { + "id": "sp_yellow_spotted_river_turtle", + "commonName": "Yellow-spotted River Turtle", + "scientificName": "Podocnemis unifilis", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Amazon side-necked turtle with yellow head spots, basking in groups on logs and sandbanks of large rivers." + }, + { + "id": "sp_european_pond_turtle", + "commonName": "European Pond Turtle", + "scientificName": "Emys orbicularis", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Dark turtle speckled with yellow dots, Europe's native freshwater turtle, slipping off sunny banks into weedy ponds." + }, + { + "id": "sp_american_alligator", + "commonName": "American Alligator", + "scientificName": "Alligator mississippiensis", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Broad-snouted armoured reptile of southeastern US swamps, springs and rivers, floating with only eyes and nostrils showing." + }, + { + "id": "sp_spectacled_caiman", + "commonName": "Spectacled Caiman", + "scientificName": "Caiman crocodilus", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Small olive caiman with a bony ridge between its eyes, abundant in slow rivers and lagoons across Central and South America." + }, + { + "id": "sp_black_caiman", + "commonName": "Black Caiman", + "scientificName": "Melanosuchus niger", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "The Amazon's largest predator, a black armoured caiman up to five metres long, hunting at night in lakes and flooded forest." + }, + { + "id": "sp_freshwater_crocodile", + "commonName": "Freshwater Crocodile", + "scientificName": "Crocodylus johnstoni", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Slender-snouted Australian crocodile of northern rivers and gorges, shy and far smaller than the saltwater crocodile." + }, + { + "id": "sp_northern_water_snake", + "commonName": "Northern Water Snake", + "scientificName": "Nerodia sipedon", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "Thick-bodied banded brown snake basking on rocks and branches over eastern North American streams, harmless but quick to bite." + }, + { + "id": "sp_green_anaconda", + "commonName": "Green Anaconda", + "scientificName": "Eunectes murinus", + "category": "other", + "taxonomyClass": "Reptilia", + "description": "The heaviest snake on earth, an olive giant with black blotches, lying submerged in Amazon swamps and slow rivers." + }, + { + "id": "sp_hellbender", + "commonName": "Hellbender", + "scientificName": "Cryptobranchus alleganiensis", + "category": "other", + "taxonomyClass": "Amphibia", + "description": "Giant flat-headed salamander with wrinkled skin folds, hiding under big rocks in cold clear Appalachian rivers." + }, + { + "id": "sp_mudpuppy", + "commonName": "Mudpuppy", + "scientificName": "Necturus maculosus", + "category": "other", + "taxonomyClass": "Amphibia", + "description": "Brown spotted salamander that keeps its feathery red gills for life, crawling over lake and river bottoms at night." + }, + { + "id": "sp_axolotl", + "commonName": "Axolotl", + "scientificName": "Ambystoma mexicanum", + "category": "other", + "taxonomyClass": "Amphibia", + "description": "Smiling gilled salamander that never leaves the water, critically endangered in the canals of Xochimilco near Mexico City." + }, + { + "id": "sp_chinese_giant_salamander", + "commonName": "Chinese Giant Salamander", + "scientificName": "Andrias davidianus", + "category": "other", + "taxonomyClass": "Amphibia", + "description": "The largest amphibian alive, a wrinkled brown giant nearly two metres long, hiding in cool rocky mountain streams." + }, + { + "id": "sp_smooth_newt", + "commonName": "Smooth Newt", + "scientificName": "Lissotriton vulgaris", + "category": "other", + "taxonomyClass": "Amphibia", + "description": "Small olive newt that returns to ponds each spring, the males growing a wavy crest and spotted orange belly." + }, + { + "id": "sp_great_crested_newt", + "commonName": "Great Crested Newt", + "scientificName": "Triturus cristatus", + "category": "other", + "taxonomyClass": "Amphibia", + "description": "Large black warty newt with a fiery orange belly, the breeding males sporting a jagged dragon-like crest." + }, + { + "id": "sp_american_bullfrog", + "commonName": "American Bullfrog", + "scientificName": "Lithobates catesbeianus", + "category": "other", + "taxonomyClass": "Amphibia", + "description": "Huge green frog with a bass bellow, sitting among lily pads in warm ponds and now invasive on several continents." + }, + { + "id": "sp_common_frog", + "commonName": "Common Frog", + "scientificName": "Rana temporaria", + "category": "other", + "taxonomyClass": "Amphibia", + "description": "Brown frog with a dark eye mask that gathers in noisy spring crowds to spawn in European ponds and ditches." + }, + { + "id": "sp_north_american_river_otter", + "commonName": "North American River Otter", + "scientificName": "Lontra canadensis", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Sleek playful otter that hunts fish and crayfish in rivers and lakes across North America, leaving mud slides on banks." + }, + { + "id": "sp_eurasian_otter", + "commonName": "Eurasian Otter", + "scientificName": "Lutra lutra", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Shy brown otter of European rivers, lakes and coasts, recovering across its range after decades of decline." + }, + { + "id": "sp_giant_otter", + "commonName": "Giant Otter", + "scientificName": "Pteronura brasiliensis", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Nearly two-metre otter with a cream throat patch, living in noisy family groups on Amazon rivers and oxbow lakes." + }, + { + "id": "sp_north_american_beaver", + "commonName": "North American Beaver", + "scientificName": "Castor canadensis", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Large flat-tailed rodent that dams streams into ponds and swims beneath the ice with a lodge of sticks for shelter." + }, + { + "id": "sp_eurasian_beaver", + "commonName": "Eurasian Beaver", + "scientificName": "Castor fiber", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Europe's largest rodent, reintroduced across the continent, felling riverside trees and building dams and lodges." + }, + { + "id": "sp_muskrat", + "commonName": "Muskrat", + "scientificName": "Ondatra zibethicus", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Rat-sized brown rodent with a scaly flattened tail, swimming through cattail marshes and building domed reed lodges." + }, + { + "id": "sp_platypus", + "commonName": "Platypus", + "scientificName": "Ornithorhynchus anatinus", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Egg-laying mammal with a duck bill and webbed feet, foraging with closed eyes along eastern Australian creeks at dawn and dusk." + }, + { + "id": "sp_amazonian_manatee", + "commonName": "Amazonian Manatee", + "scientificName": "Trichechus inunguis", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "The smallest manatee, a smooth dark grazer with a white chest patch, browsing water plants in Amazon lakes and rivers." + }, + { + "id": "sp_amazon_river_dolphin", + "commonName": "Amazon River Dolphin", + "scientificName": "Inia geoffrensis", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Pink long-beaked dolphin with a flexible neck, weaving between trunks of flooded forest in the Amazon and Orinoco." + }, + { + "id": "sp_baikal_seal", + "commonName": "Baikal Seal", + "scientificName": "Pusa sibirica", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "The world's only freshwater seal, a small silver-grey seal that hauls out on Lake Baikal's ice and rocky shores." + }, + { + "id": "sp_capybara", + "commonName": "Capybara", + "scientificName": "Hydrochoerus hydrochaeris", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "The largest rodent, a barrel-shaped grazer that wades and swims in South American rivers and wetlands in calm herds." + }, + { + "id": "sp_hippopotamus", + "commonName": "Hippopotamus", + "scientificName": "Hippopotamus amphibius", + "category": "mammal", + "taxonomyClass": "Mammalia", + "description": "Massive African river giant that spends the day submerged in pods and walks the bottom rather than swims; dangerous to approach." + }, + { + "id": "sp_white_water_lily", + "commonName": "White Water Lily", + "scientificName": "Nymphaea alba", + "category": "plant", + "taxonomyClass": "Magnoliopsida", + "description": "Floating round leaves and large white flowers rising from thick rhizomes rooted in the mud of still European waters." + }, + { + "id": "sp_yellow_pond_lily", + "commonName": "Yellow Pond Lily", + "scientificName": "Nuphar lutea", + "category": "plant", + "taxonomyClass": "Magnoliopsida", + "description": "Heart-shaped floating leaves and cup-like yellow flowers, with large translucent underwater leaves visible to divers below." + }, + { + "id": "sp_american_eelgrass", + "commonName": "American Eelgrass", + "scientificName": "Vallisneria americana", + "category": "plant", + "taxonomyClass": "Liliopsida", + "description": "Ribbon-like leaves up to two metres long swaying in the current of clear rivers and springs, a favourite of manatees." + }, + { + "id": "sp_coontail", + "commonName": "Coontail", + "scientificName": "Ceratophyllum demersum", + "category": "plant", + "taxonomyClass": "Magnoliopsida", + "description": "Rootless submerged plant with whorls of stiff forked leaves like a raccoon's tail, drifting in dense masses in still water." + }, + { + "id": "sp_eurasian_watermilfoil", + "commonName": "Eurasian Watermilfoil", + "scientificName": "Myriophyllum spicatum", + "category": "plant", + "taxonomyClass": "Magnoliopsida", + "description": "Feathery submerged plant with whorls of finely divided leaves that forms thick mats near the surface, invasive in many lakes." + }, + { + "id": "sp_muskgrass", + "commonName": "Muskgrass", + "scientificName": "Chara vulgaris", + "category": "plant", + "taxonomyClass": "Charophyceae", + "description": "Brittle, musky-smelling green alga with whorled branches, often crusted with lime, carpeting the bottom of clear hard-water lakes." + }, + { + "id": "sp_canadian_waterweed", + "commonName": "Canadian Waterweed", + "scientificName": "Elodea canadensis", + "category": "plant", + "taxonomyClass": "Liliopsida", + "description": "Dense submerged plant with whorls of three small dark green leaves, spreading by fragments through cool lakes and canals worldwide." + }, + { + "id": "sp_curly_leaf_pondweed", + "commonName": "Curly-leaf Pondweed", + "scientificName": "Potamogeton crispus", + "category": "plant", + "taxonomyClass": "Liliopsida", + "description": "Submerged plant with wavy-edged reddish-green leaves like crinkled lasagne, growing early in spring before other weeds." + }, + { + "id": "sp_water_hyacinth", + "commonName": "Water Hyacinth", + "scientificName": "Pontederia crassipes", + "category": "plant", + "taxonomyClass": "Liliopsida", + "description": "Floating plant with glossy leaves on air-filled stalks and spikes of lavender flowers, choking warm waterways worldwide." + }, + { + "id": "sp_common_reed", + "commonName": "Common Reed", + "scientificName": "Phragmites australis", + "category": "plant", + "taxonomyClass": "Liliopsida", + "description": "Tall feathery-headed grass forming dense beds along lake shores, its submerged stems sheltering fry and dragonfly larvae." } ] -} \ No newline at end of file +} diff --git a/assets/data/species_gbif_keys.json b/assets/data/species_gbif_keys.json index 5ebb45f4b5..5974ed0666 100644 --- a/assets/data/species_gbif_keys.json +++ b/assets/data/species_gbif_keys.json @@ -1,5 +1,5 @@ { - "generated": "2026-08-18", + "generated": "2026-08-28", "speciesKeys": { "2417522": "sp_whale_shark", "2420694": "sp_great_white_shark", @@ -507,20 +507,203 @@ "2450131": "sp_olive_sea_snake", "7674056": "sp_yellow_bellied_sea_snake", "5224602": "sp_marine_iguana", - "2441333": "sp_saltwater_crocodile" + "2441333": "sp_saltwater_crocodile", + "2346633": "sp_northern_pike", + "2346634": "sp_muskellunge", + "2346629": "sp_chain_pickerel", + "2382172": "sp_walleye", + "2382152": "sp_sauger", + "2382004": "sp_yellow_perch", + "8140485": "sp_european_perch", + "2382155": "sp_zander", + "5209574": "sp_ruffe", + "5211250": "sp_smallmouth_bass", + "2394514": "sp_rock_bass", + "2394503": "sp_bluegill", + "2394486": "sp_pumpkinseed", + "2394526": "sp_black_crappie", + "2394534": "sp_white_crappie", + "8215487": "sp_brown_trout", + "5204019": "sp_rainbow_trout", + "2351271": "sp_brook_trout", + "2351327": "sp_lake_trout", + "4284021": "sp_arctic_char", + "7595433": "sp_atlantic_salmon", + "5204024": "sp_chinook_salmon", + "5204039": "sp_sockeye_salmon", + "5204034": "sp_coho_salmon", + "2351119": "sp_lake_whitefish", + "2351092": "sp_cisco", + "5203999": "sp_european_grayling", + "4286975": "sp_common_carp", + "2362030": "sp_grass_carp", + "2362524": "sp_tench", + "9809222": "sp_common_bream", + "2359706": "sp_roach", + "2362635": "sp_rudd", + "2365685": "sp_chub", + "5739539": "sp_barbel", + "5212973": "sp_european_eel", + "5212956": "sp_american_eel", + "2415460": "sp_burbot", + "5202520": "sp_channel_catfish", + "2341077": "sp_flathead_catfish", + "2340989": "sp_brown_bullhead", + "2337607": "sp_wels_catfish", + "2402122": "sp_white_sturgeon", + "2402120": "sp_lake_sturgeon", + "2402203": "sp_european_sturgeon", + "2346754": "sp_alligator_gar", + "2346701": "sp_longnose_gar", + "5207891": "sp_bowfin", + "5212833": "sp_american_paddlefish", + "8236498": "sp_sea_lamprey", + "2400265": "sp_freshwater_drum", + "2359014": "sp_white_sucker", + "4409653": "sp_common_minnow", + "4286327": "sp_three_spined_stickleback", + "2412740": "sp_alewife", + "2393184": "sp_nile_perch", + "4285694": "sp_nile_tilapia", + "2356183": "sp_african_tigerfish", + "4287144": "sp_marbled_lungfish", + "2342026": "sp_electric_catfish", + "2373039": "sp_zebra_mbuna", + "2370488": "sp_malawi_butterfly_peacock", + "2372167": "sp_fuelleborn_cichlid", + "2371754": "sp_princess_of_burundi", + "2373233": "sp_frontosa", + "2372563": "sp_tropheus_moorii", + "5212877": "sp_arapaima", + "5212838": "sp_silver_arowana", + "2352479": "sp_red_bellied_piranha", + "2354124": "sp_black_piranha", + "2353225": "sp_red_bellied_pacu", + "2353152": "sp_tambaqui", + "2401958": "sp_electric_eel", + "2338793": "sp_redtail_catfish", + "2338673": "sp_tiger_shovelnose_catfish", + "5208153": "sp_peacock_bass", + "2370243": "sp_oscar", + "5208518": "sp_freshwater_angelfish", + "5208131": "sp_discus", + "2339971": "sp_sailfin_pleco", + "2353911": "sp_cardinal_tetra", + "5204344": "sp_mexican_tetra", + "2340710": "sp_mekong_giant_catfish", + "2359467": "sp_giant_barb", + "2402231": "sp_asian_arowana", + "2391463": "sp_striped_snakehead", + "2391455": "sp_giant_snakehead", + "2391579": "sp_climbing_perch", + "2364530": "sp_golden_mahseer", + "2367196": "sp_koi", + "4286942": "sp_goldfish", + "2394154": "sp_giant_gourami", + "2402260": "sp_clown_knifefish", + "5202683": "sp_walking_catfish", + "5213022": "sp_japanese_eel", + "4283980": "sp_ayu", + "2350989": "sp_baikal_omul", + "2334860": "sp_baikal_oilfish", + "2380484": "sp_murray_cod", + "5209327": "sp_golden_perch", + "5209329": "sp_australian_bass", + "2393172": "sp_barramundi", + "2374555": "sp_silver_perch", + "2402234": "sp_gulf_saratoga", + "2374618": "sp_sooty_grunter", + "2341877": "sp_eel_tailed_catfish", + "2374497": "sp_spangled_perch", + "4284264": "sp_eastern_rainbowfish", + "2226990": "sp_signal_crayfish", + "2227300": "sp_red_swamp_crayfish", + "2226998": "sp_noble_crayfish", + "4312658": "sp_white_clawed_crayfish", + "5789955": "sp_tasmanian_giant_freshwater_crayfish", + "2287072": "sp_zebra_mussel", + "2287073": "sp_quagga_mussel", + "7911174": "sp_freshwater_pearl_mussel", + "4374774": "sp_swan_mussel", + "4559541": "sp_chinese_pond_mussel", + "2244988": "sp_freshwater_sponge", + "4408321": "sp_freshwater_jellyfish", + "2291253": "sp_great_pond_snail", + "2290946": "sp_great_ramshorn_snail", + "2292582": "sp_channeled_apple_snail", + "1003567": "sp_magnificent_bryozoan", + "2225776": "sp_chinese_mitten_crab", + "2224546": "sp_giant_freshwater_prawn", + "2441905": "sp_common_snapping_turtle", + "5220318": "sp_alligator_snapping_turtle", + "2443133": "sp_painted_turtle", + "6157026": "sp_red_eared_slider", + "2443074": "sp_northern_map_turtle", + "2442680": "sp_spiny_softshell_turtle", + "2442665": "sp_florida_softshell_turtle", + "2442506": "sp_pig_nosed_turtle", + "2442113": "sp_mary_river_turtle", + "2442782": "sp_yellow_spotted_river_turtle", + "8909809": "sp_european_pond_turtle", + "2441370": "sp_american_alligator", + "8953936": "sp_spectacled_caiman", + "2441409": "sp_black_caiman", + "9354985": "sp_freshwater_crocodile", + "5223334": "sp_northern_water_snake", + "5225712": "sp_green_anaconda", + "2432031": "sp_hellbender", + "2432048": "sp_mudpuppy", + "2431950": "sp_axolotl", + "2432037": "sp_chinese_giant_salamander", + "5218405": "sp_smooth_newt", + "2431885": "sp_great_crested_newt", + "2427091": "sp_american_bullfrog", + "2426805": "sp_common_frog", + "2433727": "sp_north_american_river_otter", + "2433753": "sp_eurasian_otter", + "2433681": "sp_giant_otter", + "2439838": "sp_north_american_beaver", + "4409131": "sp_eurasian_beaver", + "5219858": "sp_muskrat", + "2433376": "sp_platypus", + "2435292": "sp_amazonian_manatee", + "2440314": "sp_amazon_river_dolphin", + "5219368": "sp_baikal_seal", + "5786666": "sp_capybara", + "2441247": "sp_hippopotamus", + "2882437": "sp_white_water_lily", + "7551339": "sp_yellow_pond_lily", + "2865501": "sp_american_eelgrass", + "2882398": "sp_coontail", + "5361760": "sp_eurasian_watermilfoil", + "8405500": "sp_muskgrass", + "5301162": "sp_curly_leaf_pondweed", + "2765942": "sp_water_hyacinth", + "5290149": "sp_common_reed" }, "taxonKeys": [ 370, + 455, 459, 460, + 494, 495, + 496, 499, + 537, + 538, + 548, + 549, + 550, 551, + 553, 560, 587, 588, 590, 625, + 626, + 636, 637, 641, 670, @@ -529,11 +712,14 @@ 708, 711, 714, + 731, 733, 769, 770, + 771, 772, 773, + 791, 802, 810, 812, @@ -543,6 +729,7 @@ 838, 864, 865, + 879, 881, 882, 883, @@ -550,53 +737,90 @@ 885, 886, 887, + 952, + 953, 980, 989, 1006, 1015, + 1068, + 1069, + 1070, 1080, + 1103, 1113, + 1153, 1162, + 1165, + 1167, 1182, 1239, 1245, 1305, + 1313, + 1369, 1373, 1400, 1403, + 1435, 1462, 1463, 1467, 1468, 1482, 1489, + 2851, + 2852, 5309, 5310, + 5462, 5464, + 5469, + 5493, 5685, + 5721, 8173, + 9119, + 9120, 9413, 2433669, + 2433680, + 2433726, + 2441657, + 2442941, + 2443072, 2450119, 2450145, + 2454456, 2459538, + 2465273, + 3238603, 3243610, 3268702, + 5219857, + 5429068, 5964707, 6180736, + 7219248, + 7224003, 7390893, 7946450, 7954962, 8058188, 8214029, + 8226627, + 8391193, 9206453, + 9241244, 9250425, + 9301143, 9380407, 9382199, 9412443, 9477065, 9505681, 9529005, + 9529207, 9661062, 9682653, 12202578, diff --git a/assets/fonts/submersion-equipment.ttf b/assets/fonts/submersion-equipment.ttf index d12f3432ac..f18c2c24ab 100644 Binary files a/assets/fonts/submersion-equipment.ttf and b/assets/fonts/submersion-equipment.ttf differ diff --git a/docs/FEATURE_ROADMAP.md b/docs/FEATURE_ROADMAP.md index 1b35d764c6..b30f00adeb 100644 --- a/docs/FEATURE_ROADMAP.md +++ b/docs/FEATURE_ROADMAP.md @@ -848,7 +848,7 @@ - [ ] Caption and datetime editing per photo - [ ] Export dive with photos (ZIP archive) - [ ] Bulk photo import with auto-match to dives -- [ ] GPS extraction from photos (suggest site creation) +- [x] GPS extraction from photos (suggest site creation): desktop and video readers, nearest-site matching, detail-page and post-import surfacing, synced dismissal - [ ] Species tagging in photos (tap to tag, bounding box) - [ ] Species recognition suggestions (ML model) - [ ] Blue/green color cast removal filter diff --git a/docs/REMAINING_TASKS.md b/docs/REMAINING_TASKS.md index b278e22f21..bb611aab88 100644 --- a/docs/REMAINING_TASKS.md +++ b/docs/REMAINING_TASKS.md @@ -1,338 +1,338 @@ -# Submersion - Remaining Tasks - -> **Generated:** 2026-03-13 -> **Source:** FEATURE_ROADMAP.md -> **Current Version:** 1.2.25 (v1.5 Complete) - -This document contains only the features and tasks that are **not yet completed**. - ---- - -## Phase Summary - -| Phase | Status | Remaining Focus | -|-------|--------|-----------------| -| **v1.5** | Complete | All v1.5 tasks shipped | -| **v2.0** | In Progress | Advanced features, social, backend | -| **v3.0** | Future | Community platform, AI features | - ---- - -## v1.5 Remaining Tasks - -All v1.5 tasks complete. - ---- - -# v2.0 Planned Features - -## Category 2: Dive Profile & Telemetry - -### 2.2 Multi-Profile Support - -| Feature | Notes | -|---------|-------| -| Profile merging | Combine multiple sources | -| Multi-transmitter support | Track multiple tank transmitters (sidemount) | - -**Tasks:** - -- [ ] Side-by-side profile comparison view -- [ ] Default tank type for new and imported dives -- [ ] Provide an option to not deduplicate dives downloaded from a computer. - ---- - -## Category 3: Dive Computer Connectivity - -### 3.1 Connectivity Types - -| Feature | Notes | -|---------|-------| -| Wi-Fi / cloud devices | Garmin, Shearwater cloud API | - ---- - -## Category 4: Gases, Tanks & Technical Diving - -### 4.5 Planning Utilities - -| Feature | Notes | -|---------|-------| -| What-if scenarios | Deeper/longer/different gas | -| Lost gas scenarios | Plan for lost decompression gas | -| Turn pressure planning | Calculate gas turn pressures for penetration dives | -| Range plans | Multiple profiles with different depths/times | - -**Tasks:** - -- [ ] Repetitive dive planner with tissue loading from previous dive -- [ ] "Extend dive" tool (add 5 mins at depth, recalculate deco) -- [ ] "Add safety" tool (extend safety stop, add deep stop) - ---- - -## Category 6: Gear & Equipment Management - -### 6.1 Gear Inventory - -| Feature | Notes | -|-----------------|----------------------------| -| Photos of gear | Deferred with photos | - -### 6.3 Maintenance - -**Tasks:** - -- [ ] Service log export to PDF (professional format with full history) - ---- - -## Category 8: Training, Certifications & Medical Info - -### 8.2 Digital Cards (eCards) - -| Feature | Notes | -|---------|-------| -| QR codes | Scannable verification | - -**Tasks:** - -- [ ] Generate QR codes for certs (encode cert number, agency, level) -- [ ] QR code verification (backend required) - -### 8.4 Personal & Medical Data - -| Feature | Notes | -|---------|-------| -| Medical documents | PDF storage | - -**Tasks:** - -- [ ] Medical document storage (PDF of medical clearance) -- [ ] Export profile with certs + medical for dive operations - ---- - -## Category 9: Environment, Wildlife & Photography - -### 9.2 Marine Life Tracking - -| Feature | Notes | -|---------|-------| -| Species photos | Local or remote images | -| Distribution map | Map of sightings | -| AI species identification | Upload photo, AI identifies species | -| Offline species ID | Works without internet connection | - -**Tasks:** - -- [ ] Species photo library (local or remote images) -- [ ] Species distribution map (heatmap of sightings) -- [ ] "Life list" progress tracker (total species seen) -- [ ] Rare species badges -- [ ] AI-powered species identification from photos (ML model) -- [ ] Offline species recognition database -- [ ] Species identification confidence scores - -### 9.3 Underwater Photography - -| Feature | Notes | -|---------|-------| -| Tag species in photos | Image annotation | -| Color correction | Blue filter removal | -| Shareable dive cards | Generate visual summary for social media | - -**Tasks:** - -- [ ] Caption and datetime editing per photo -- [ ] Export dive with photos (ZIP archive) -- [ ] Bulk photo import with auto-match to dives -- [ ] GPS extraction from photos (suggest site creation) -- [ ] Species tagging in photos (tap to tag, bounding box) -- [ ] Species recognition suggestions (ML model) - ---- - -## Category 10: Search, Filters, Statistics & Reports - -### 10.1 Search & Filtering - -| Feature | Notes | -|---------|-------| -| Saved filters ("Smart Logs") | Persistent filter sets | - -**Tasks:** - -- [ ] Save filter configurations as "Smart Logs" -- [ ] Smart Log management (name, description, icon) -- [ ] Quick access to Smart Logs from home page -- [ ] Filter dives based on Dive Computer -- [ ] Tag management support in Settings->Manage - -### 10.2 Statistics - -**Tasks:** - -- [ ] Advanced analytics dashboard (customizable widgets) -- [ ] Year-in-review summary (auto-generated at year end) - -### 10.3 Reports & Printing - -| Feature | Notes | -|---------|-------| -| Custom report designer | Drag-drop fields | - -**Tasks:** - -- [ ] Custom report builder (select fields, layout, sorting) -- [ ] Save custom report templates -- [ ] Export to Excel/CSV with custom fields - ---- - -## Category 12: Cloud Sync, Backup & Multi-Device - -### 12.2 Synchronization - -| Feature | Notes | -|---------|-------| -| Web sync | Requires backend service | - -**Tasks:** - -- [ ] Web platform sync (requires backend) -- [ ] "Force push" and "force pull" options for troubleshooting - -### 12.4 Offline Behavior - -| Feature | Notes | -|---------|-------| -| Deferred sync | Queue changes when offline | - -**Tasks:** - -- [ ] Offline queue for pending sync operations -- [ ] Auto-sync when connectivity restored -- [ ] Sync conflict warnings and resolution - ---- - -## Category 13: Import, Export & Interoperability - -### 13.1 File Formats - -| Feature | Notes | -|---------|-------| -| HTML export | Web-viewable logbook | -| Subsurface import | Common formats | - -**Tasks:** - -- [ ] HTML export (static website with CSS, images, interactive map) -- [ ] MySQL dump export (for migration to other systems) -- [ ] Handle importing Subsurface XML natively -- [ ] Handle importing from Subsurface database natively -- [ ] Paper log OCR import - -### 13.2 Interoperability - -| Feature | Notes | -|---------|-------| -| Shearwater Cloud import | Import from Shearwater cloud | -| Suunto app import | Import via Suunto cloud/Movescount | -| Diviac import | Import from Diviac online logbook | -| Deepblu import | Import from Deepblu platform | - -**Tasks:** - -- [ ] Shearwater Cloud API integration -- [ ] Suunto app/Movescount API integration -- [ ] Diviac API integration -- [ ] Deepblu API integration - ---- - -## Category 14: Social, Community & Travel Features - -### 14.1 Social Sharing - -| Feature | Notes | -|---------|-------| -| Share dives to social media | FB, Instagram, Twitter | -| Generate composite images | Profile + photo + stats | -| Shareable dive cards | Visual summary image for social | - -**Tasks:** - -- [ ] "Share Dive" action with platform picker -- [ ] Generate shareable image (profile chart, photo, depth/time/location text overlay) -- [ ] Share as PNG or link (if cloud sync enabled) - ---- - -## Category 15: UX, Customization & Quality-of-Life - -### 15.4 Gamification & Achievements - -| Feature | Notes | -|---------|-------| -| Achievement badges | Earn badges for milestones | -| Dive milestones | 100 dives, 1000m depth, etc. | -| Species life list | Track total unique species seen | -| Depth achievements | First 20m, 30m, 40m dives | -| Streak tracking | Monthly/yearly dive streaks | -| Progress visualization | Journey timeline with milestones | - -**Tasks:** - -- [ ] Achievement system with badge definitions -- [ ] Milestone tracking (dive count, depths, locations, species) -- [ ] Badge unlock notifications -- [ ] Achievement showcase on diver profile -- [ ] Progress towards next milestone display -- [ ] Life list tracker (species collection progress) - -### 15.5 Wearable Integration - -| Feature | Notes | -|---------|-------| -| Garmin Connect cloud API | Cloud sync (not file-based) | - -**Tasks:** - -- [ ] Garmin Connect cloud API integration -- [ ] Automatic sync from connected wearables -- [ ] Merge wearable data with dive computer data - -### 15.6 Well-being & Safety - -| Feature | Notes | -|---------|-------| -| Pre-dive feeling monitor | Track readiness before dive | -| Post-dive feeling monitor | Track condition after dive | -| Breathing technique analysis | SAC improvement suggestions | -| Hydration reminders | DCS prevention | - -**Tasks:** - -- [ ] Pre/post dive well-being questionnaire -- [ ] Correlation analysis (feeling vs dive parameters) -- [ ] Breathing efficiency tips based on SAC trends -- [ ] Health trend visualization over time - ---- - -## Category 16: Manufacturer-Specific & Advanced Features - -### 16.2 Partner Ecosystem Integration - -| Feature | Notes | -|---------|-------| -| Shearwater Cloud sync | API integration | -| PADI eCard integration | Display PADI certs | - -**Tasks:** - -- [ ] Shearwater Cloud API (import dives from cloud) -- [ ] PADI app integration (OAuth, fetch eCards) +# Submersion - Remaining Tasks + +> **Generated:** 2026-03-13 +> **Source:** FEATURE_ROADMAP.md +> **Current Version:** 1.2.25 (v1.5 Complete) + +This document contains only the features and tasks that are **not yet completed**. + +--- + +## Phase Summary + +| Phase | Status | Remaining Focus | +|-------|--------|-----------------| +| **v1.5** | Complete | All v1.5 tasks shipped | +| **v2.0** | In Progress | Advanced features, social, backend | +| **v3.0** | Future | Community platform, AI features | + +--- + +## v1.5 Remaining Tasks + +All v1.5 tasks complete. + +--- + +# v2.0 Planned Features + +## Category 2: Dive Profile & Telemetry + +### 2.2 Multi-Profile Support + +| Feature | Notes | +|---------|-------| +| Profile merging | Combine multiple sources | +| Multi-transmitter support | Track multiple tank transmitters (sidemount) | + +**Tasks:** + +- [ ] Side-by-side profile comparison view +- [ ] Default tank type for new and imported dives +- [ ] Provide an option to not deduplicate dives downloaded from a computer. + +--- + +## Category 3: Dive Computer Connectivity + +### 3.1 Connectivity Types + +| Feature | Notes | +|---------|-------| +| Wi-Fi / cloud devices | Garmin, Shearwater cloud API | + +--- + +## Category 4: Gases, Tanks & Technical Diving + +### 4.5 Planning Utilities + +| Feature | Notes | +|---------|-------| +| What-if scenarios | Deeper/longer/different gas | +| Lost gas scenarios | Plan for lost decompression gas | +| Turn pressure planning | Calculate gas turn pressures for penetration dives | +| Range plans | Multiple profiles with different depths/times | + +**Tasks:** + +- [ ] Repetitive dive planner with tissue loading from previous dive +- [ ] "Extend dive" tool (add 5 mins at depth, recalculate deco) +- [ ] "Add safety" tool (extend safety stop, add deep stop) + +--- + +## Category 6: Gear & Equipment Management + +### 6.1 Gear Inventory + +| Feature | Notes | +|-----------------|----------------------------| +| Photos of gear | Deferred with photos | + +### 6.3 Maintenance + +**Tasks:** + +- [ ] Service log export to PDF (professional format with full history) + +--- + +## Category 8: Training, Certifications & Medical Info + +### 8.2 Digital Cards (eCards) + +| Feature | Notes | +|---------|-------| +| QR codes | Scannable verification | + +**Tasks:** + +- [ ] Generate QR codes for certs (encode cert number, agency, level) +- [ ] QR code verification (backend required) + +### 8.4 Personal & Medical Data + +| Feature | Notes | +|---------|-------| +| Medical documents | PDF storage | + +**Tasks:** + +- [ ] Medical document storage (PDF of medical clearance) +- [ ] Export profile with certs + medical for dive operations + +--- + +## Category 9: Environment, Wildlife & Photography + +### 9.2 Marine Life Tracking + +| Feature | Notes | +|---------|-------| +| Species photos | Local or remote images | +| Distribution map | Map of sightings | +| AI species identification | Upload photo, AI identifies species | +| Offline species ID | Works without internet connection | + +**Tasks:** + +- [ ] Species photo library (local or remote images) +- [ ] Species distribution map (heatmap of sightings) +- [ ] "Life list" progress tracker (total species seen) +- [ ] Rare species badges +- [ ] AI-powered species identification from photos (ML model) +- [ ] Offline species recognition database +- [ ] Species identification confidence scores + +### 9.3 Underwater Photography + +| Feature | Notes | +|---------|-------| +| Tag species in photos | Image annotation | +| Color correction | Blue filter removal | +| Shareable dive cards | Generate visual summary for social media | + +**Tasks:** + +- [ ] Caption and datetime editing per photo +- [ ] Export dive with photos (ZIP archive) +- [ ] Bulk photo import with auto-match to dives +- [x] GPS extraction from photos (suggest site creation): desktop and video readers, nearest-site matching, detail-page and post-import surfacing, synced dismissal +- [ ] Species tagging in photos (tap to tag, bounding box) +- [ ] Species recognition suggestions (ML model) + +--- + +## Category 10: Search, Filters, Statistics & Reports + +### 10.1 Search & Filtering + +| Feature | Notes | +|---------|-------| +| Saved filters ("Smart Logs") | Persistent filter sets | + +**Tasks:** + +- [ ] Save filter configurations as "Smart Logs" +- [ ] Smart Log management (name, description, icon) +- [ ] Quick access to Smart Logs from home page +- [ ] Filter dives based on Dive Computer +- [ ] Tag management support in Settings->Manage + +### 10.2 Statistics + +**Tasks:** + +- [ ] Advanced analytics dashboard (customizable widgets) +- [ ] Year-in-review summary (auto-generated at year end) + +### 10.3 Reports & Printing + +| Feature | Notes | +|---------|-------| +| Custom report designer | Drag-drop fields | + +**Tasks:** + +- [ ] Custom report builder (select fields, layout, sorting) +- [ ] Save custom report templates +- [ ] Export to Excel/CSV with custom fields + +--- + +## Category 12: Cloud Sync, Backup & Multi-Device + +### 12.2 Synchronization + +| Feature | Notes | +|---------|-------| +| Web sync | Requires backend service | + +**Tasks:** + +- [ ] Web platform sync (requires backend) +- [ ] "Force push" and "force pull" options for troubleshooting + +### 12.4 Offline Behavior + +| Feature | Notes | +|---------|-------| +| Deferred sync | Queue changes when offline | + +**Tasks:** + +- [ ] Offline queue for pending sync operations +- [ ] Auto-sync when connectivity restored +- [ ] Sync conflict warnings and resolution + +--- + +## Category 13: Import, Export & Interoperability + +### 13.1 File Formats + +| Feature | Notes | +|---------|-------| +| HTML export | Web-viewable logbook | +| Subsurface import | Common formats | + +**Tasks:** + +- [ ] HTML export (static website with CSS, images, interactive map) +- [ ] MySQL dump export (for migration to other systems) +- [ ] Handle importing Subsurface XML natively +- [ ] Handle importing from Subsurface database natively +- [ ] Paper log OCR import + +### 13.2 Interoperability + +| Feature | Notes | +|---------|-------| +| Shearwater Cloud import | Import from Shearwater cloud | +| Suunto app import | Import via Suunto cloud/Movescount | +| Diviac import | Import from Diviac online logbook | +| Deepblu import | Import from Deepblu platform | + +**Tasks:** + +- [ ] Shearwater Cloud API integration +- [ ] Suunto app/Movescount API integration +- [ ] Diviac API integration +- [ ] Deepblu API integration + +--- + +## Category 14: Social, Community & Travel Features + +### 14.1 Social Sharing + +| Feature | Notes | +|---------|-------| +| Share dives to social media | FB, Instagram, Twitter | +| Generate composite images | Profile + photo + stats | +| Shareable dive cards | Visual summary image for social | + +**Tasks:** + +- [ ] "Share Dive" action with platform picker +- [ ] Generate shareable image (profile chart, photo, depth/time/location text overlay) +- [ ] Share as PNG or link (if cloud sync enabled) + +--- + +## Category 15: UX, Customization & Quality-of-Life + +### 15.4 Gamification & Achievements + +| Feature | Notes | +|---------|-------| +| Achievement badges | Earn badges for milestones | +| Dive milestones | 100 dives, 1000m depth, etc. | +| Species life list | Track total unique species seen | +| Depth achievements | First 20m, 30m, 40m dives | +| Streak tracking | Monthly/yearly dive streaks | +| Progress visualization | Journey timeline with milestones | + +**Tasks:** + +- [ ] Achievement system with badge definitions +- [ ] Milestone tracking (dive count, depths, locations, species) +- [ ] Badge unlock notifications +- [ ] Achievement showcase on diver profile +- [ ] Progress towards next milestone display +- [ ] Life list tracker (species collection progress) + +### 15.5 Wearable Integration + +| Feature | Notes | +|---------|-------| +| Garmin Connect cloud API | Cloud sync (not file-based) | + +**Tasks:** + +- [ ] Garmin Connect cloud API integration +- [ ] Automatic sync from connected wearables +- [ ] Merge wearable data with dive computer data + +### 15.6 Well-being & Safety + +| Feature | Notes | +|---------|-------| +| Pre-dive feeling monitor | Track readiness before dive | +| Post-dive feeling monitor | Track condition after dive | +| Breathing technique analysis | SAC improvement suggestions | +| Hydration reminders | DCS prevention | + +**Tasks:** + +- [ ] Pre/post dive well-being questionnaire +- [ ] Correlation analysis (feeling vs dive parameters) +- [ ] Breathing efficiency tips based on SAC trends +- [ ] Health trend visualization over time + +--- + +## Category 16: Manufacturer-Specific & Advanced Features + +### 16.2 Partner Ecosystem Integration + +| Feature | Notes | +|---------|-------| +| Shearwater Cloud sync | API integration | +| PADI eCard integration | Display PADI certs | + +**Tasks:** + +- [ ] Shearwater Cloud API (import dives from cloud) +- [ ] PADI app integration (OAuth, fetch eCards) diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 605aa09116..48eabd0617 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -23,7 +23,7 @@ * [O2 Tracking](features/oxygen-tracking.md) * [Multi-Gas Diving](features/multi-gas.md) * [Buddies & Certs](features/buddies.md) - * [Marine Life](features/marine-life.md) + * [Species](features/marine-life.md) * [Trips](features/trips.md) * [Tags](features/tags.md) @@ -34,6 +34,7 @@ * [State Management](developer/state-management.md) * [Navigation](developer/navigation.md) * [Testing](developer/testing.md) + * [Local Test Performance](developer/local-test-performance.md) * [Building](developer/building.md) * [Release Process](developer/release-process.md) diff --git a/docs/assets/screenshots/readme/01-dive-logging.jpg b/docs/assets/screenshots/readme/01-dive-logging.jpg index ba2909784b..0a4a88eeaf 100644 Binary files a/docs/assets/screenshots/readme/01-dive-logging.jpg and b/docs/assets/screenshots/readme/01-dive-logging.jpg differ diff --git a/docs/assets/screenshots/readme/02-profile-deco.jpg b/docs/assets/screenshots/readme/02-profile-deco.jpg index 66b07cff79..b6ff558598 100644 Binary files a/docs/assets/screenshots/readme/02-profile-deco.jpg and b/docs/assets/screenshots/readme/02-profile-deco.jpg differ diff --git a/docs/assets/screenshots/readme/03-dive-computers.jpg b/docs/assets/screenshots/readme/03-dive-computers.jpg index b04a1822ea..71cf9ca935 100644 Binary files a/docs/assets/screenshots/readme/03-dive-computers.jpg and b/docs/assets/screenshots/readme/03-dive-computers.jpg differ diff --git a/docs/assets/screenshots/readme/04-sites-gps.jpg b/docs/assets/screenshots/readme/04-sites-gps.jpg deleted file mode 100644 index 5f2ea32d44..0000000000 Binary files a/docs/assets/screenshots/readme/04-sites-gps.jpg and /dev/null differ diff --git a/docs/assets/screenshots/readme/04-sites-maps.jpg b/docs/assets/screenshots/readme/04-sites-maps.jpg new file mode 100644 index 0000000000..c974d4c335 Binary files /dev/null and b/docs/assets/screenshots/readme/04-sites-maps.jpg differ diff --git a/docs/assets/screenshots/readme/05-planning.jpg b/docs/assets/screenshots/readme/05-planning.jpg new file mode 100644 index 0000000000..6d831f2fcd Binary files /dev/null and b/docs/assets/screenshots/readme/05-planning.jpg differ diff --git a/docs/assets/screenshots/readme/05-statistics.jpg b/docs/assets/screenshots/readme/05-statistics.jpg deleted file mode 100644 index db3b7d1235..0000000000 Binary files a/docs/assets/screenshots/readme/05-statistics.jpg and /dev/null differ diff --git a/docs/assets/screenshots/readme/06-photos-gear.jpg b/docs/assets/screenshots/readme/06-photos-gear.jpg new file mode 100644 index 0000000000..552654fa83 Binary files /dev/null and b/docs/assets/screenshots/readme/06-photos-gear.jpg differ diff --git a/docs/assets/screenshots/readme/07-statistics.jpg b/docs/assets/screenshots/readme/07-statistics.jpg new file mode 100644 index 0000000000..36bea6f6da Binary files /dev/null and b/docs/assets/screenshots/readme/07-statistics.jpg differ diff --git a/docs/assets/screenshots/readme/08-your-data.jpg b/docs/assets/screenshots/readme/08-your-data.jpg new file mode 100644 index 0000000000..d0808a3b80 Binary files /dev/null and b/docs/assets/screenshots/readme/08-your-data.jpg differ diff --git a/docs/assets/screenshots/readme/09-profile-player.jpg b/docs/assets/screenshots/readme/09-profile-player.jpg new file mode 100644 index 0000000000..3143d8dc64 Binary files /dev/null and b/docs/assets/screenshots/readme/09-profile-player.jpg differ diff --git a/docs/assets/screenshots/readme/10-tissue-3d.jpg b/docs/assets/screenshots/readme/10-tissue-3d.jpg new file mode 100644 index 0000000000..35211c0ecf Binary files /dev/null and b/docs/assets/screenshots/readme/10-tissue-3d.jpg differ diff --git a/docs/assets/screenshots/readme/11-site-terrain.jpg b/docs/assets/screenshots/readme/11-site-terrain.jpg new file mode 100644 index 0000000000..d30774be96 Binary files /dev/null and b/docs/assets/screenshots/readme/11-site-terrain.jpg differ diff --git a/docs/assets/screenshots/readme/12-marine-life.jpg b/docs/assets/screenshots/readme/12-marine-life.jpg new file mode 100644 index 0000000000..85a691446a Binary files /dev/null and b/docs/assets/screenshots/readme/12-marine-life.jpg differ diff --git a/docs/assets/screenshots/readme/13-gas-blender.jpg b/docs/assets/screenshots/readme/13-gas-blender.jpg new file mode 100644 index 0000000000..94f25d5f93 Binary files /dev/null and b/docs/assets/screenshots/readme/13-gas-blender.jpg differ diff --git a/docs/assets/screenshots/readme/14-themes.jpg b/docs/assets/screenshots/readme/14-themes.jpg new file mode 100644 index 0000000000..b44dbff669 Binary files /dev/null and b/docs/assets/screenshots/readme/14-themes.jpg differ diff --git a/docs/assets/screenshots/readme/hero.png b/docs/assets/screenshots/readme/hero.png deleted file mode 100644 index a7807c9a01..0000000000 Binary files a/docs/assets/screenshots/readme/hero.png and /dev/null differ diff --git a/docs/developer/README.md b/docs/developer/README.md index 2b27c59cf2..ed47e65cb6 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -9,6 +9,7 @@ Welcome to the Submersion developer documentation. This section covers architect - [State Management](developer/state-management.md) - Riverpod patterns - [Navigation](developer/navigation.md) - go_router setup - [Testing](developer/testing.md) - Test organization and running +- [Local Test Performance](developer/local-test-performance.md) - Making local test runs fast - [Building](developer/building.md) - Build and run instructions ## Technology Stack diff --git a/docs/developer/local-test-performance.md b/docs/developer/local-test-performance.md new file mode 100644 index 0000000000..87786431d8 --- /dev/null +++ b/docs/developer/local-test-performance.md @@ -0,0 +1,300 @@ +# Local Test Run Performance + +How to make `flutter test` fast on a development machine, especially when +several worktrees are running tests at once. + +This page is about the speed of *running* the test suite. For the tests that +measure application performance, see the Performance Tests section of +[Testing](testing.md). + +## Summary + +Local test runs are limited by disk throughput, not by CPU. `flutter test` +writes and copies a large kernel file for every test file, so a run moves tens +of gigabytes through `$TMPDIR`. Pointing `$TMPDIR` at a RAM disk measured +between 1.3x and 2x faster, and stops concurrent worktrees from fighting over +the SSD. The benefit scales with how busy the machine is: nearer 1.3x when +little else is running, nearer 2x when several worktrees are testing at once. + +The tuning is machine-local by design. Only these instructions live in the +repository; the scripts, shell config, and launch agent all sit outside it, in +`~/.local/bin`, `~/.zshenv`, and `~/Library/LaunchAgents`. Nothing here changes +CI behaviour, and the numbers below are tuned to one specific machine, so treat +them as a starting point rather than settings to copy verbatim. + +## Why runs are slow + +### Every test file writes a copy of the app kernel + +`flutter_tools` compiles each test file into a Dart kernel (`.dill`) and then +copies it, in `packages/flutter_tools/lib/src/test/test_compiler.dart`: + +```dart +if (shouldCopyDillFile) { + final File kernelReadyToRun = await outputFile.copy('$path.dill'); + if (firstCompile || !testCache.existsSync() || + (testCache.lengthSync() < outputFile.lengthSync())) { + await outputFile.copy(testFilePath); // second copy + } +``` + +Measured sizes for this project on a warm run: + +| Artifact | Size | +|---|---| +| `output.dill`, early in a run | 66 MB | +| `listener.dart.dill`, mid run | 133 MB | +| `listener.dart.dill`, late in a run | 207 MB | +| `.dart_tool/flutter_build/.../app.dill` | 217 MB | + +So each test file costs roughly 200 MB written, 200 MB copied, and 200 MB read +back by `flutter_tester`, or about half a gigabyte of I/O per file. During runs +the disk was observed sustaining 1.6 to 2.9 GB/s at 21,000 to 27,500 IOPS with +10 to 17 percent of CPU time spent in the kernel doing those copies. + +Note that the kernel grows as a run proceeds, because the resident compiler +accumulates every package it has seen. The 500th test file in a run is far more +expensive than the 10th. This is why per-file load cost looks flat and has no +single slow-file outlier to fix: the cost tracks accumulated kernel size, not +the complexity of the individual file. + +### Compilation is serialized within each invocation + +Each `flutter test` invocation creates one `TestCompiler` and queues every +compile through it. The queue comment in `test_compiler.dart` is explicit: + +> Only trigger processing if queue was empty ... This effectively enforces "one +> compilation request at a time". + +Test suites therefore cannot start faster than a single compiler emits kernels. +Sampling a run showed the live `flutter_tester` count swinging between 0 and 13 +while a lone compiler sat pinned at most of one core and the machine was over +80 percent idle. + +### The default concurrency is half your cores + +`package:test` defaults to `max(1, Platform.numberOfProcessors ~/ 2)`. On an +18-core machine that is 9. Raising it helps, but only slightly, because the +compiler and the disk are the real limits: + +| `-j` (24 files) | Wall time | +|---|---| +| 1 | 77.3s | +| 9 (default) | 27.1s | +| 18 | 26.3s | +| 36 | 25.3s | + +## Setup + +### 1. RAM disk helper + +Create `~/.local/bin/flutter-ramtmp`: + +```bash +#!/usr/bin/env bash +# Create (idempotently) a RAM-backed volume for Flutter's test temp traffic. +# Usage: flutter-ramtmp [size_gb] (default 16) +set -euo pipefail + +SIZE_GB="${1:-${FLUTTER_RAMTMP_GB:-16}}" +VOL_NAME="fltmp" +MOUNT="/Volumes/${VOL_NAME}" +TMPSUB="${MOUNT}/tmp" + +if mount | grep -q " on ${MOUNT} "; then + mkdir -p "${TMPSUB}"; echo "${TMPSUB}"; exit 0 +fi + +SECTORS=$(( SIZE_GB * 1024 * 1024 * 2 )) # 512-byte sectors +DEV="$(hdiutil attach -nomount "ram://${SECTORS}" | awk '{print $1}')" +[ -n "${DEV}" ] || { echo "hdiutil failed" >&2; exit 1; } + +# erasevolume formats and mounts in one step, no sudo required. +if ! diskutil erasevolume HFS+ "${VOL_NAME}" "${DEV}" >/dev/null 2>&1; then + hdiutil detach "${DEV}" >/dev/null 2>&1 || true + echo "failed to format ${DEV}" >&2; exit 1 +fi + +mkdir -p "${TMPSUB}" +echo "${TMPSUB}" +``` + +Then `chmod +x ~/.local/bin/flutter-ramtmp`. + +Size it for your machine. 16 GB out of 64 GB is comfortable. Allocation is +lazy, so an idle RAM disk costs almost nothing; only the pages actually written +consume memory. + +### 2. Point `TMPDIR` at it + +Append to `~/.zshenv`, not `~/.zshrc`. `.zshrc` is only sourced for +interactive shells, and a great deal of tooling shells out with `zsh -c`, which +would silently miss the setting. The guard matters too: if the volume is not +mounted, the shell falls back to the system default rather than breaking. + +```bash +if [ -d /Volumes/fltmp/tmp ]; then + export TMPDIR=/Volumes/fltmp/tmp/ +fi +``` + +#### One consequence worth knowing + +On macOS a RAM disk can only mount under `/Volumes/`, and that is exactly the +pattern `VolumeStatus.volumeRootOf` uses to recognise a removable drive. Any +test that writes a fixture to `Directory.systemTemp` and then expects it to +look like it is on the boot volume will therefore see the opposite once +`TMPDIR` points at the RAM disk. + +That bit `local_file_resolver_test.dart`, which asserted a temp path needs no +mount probe: the resolver classified the fixture as sitting on an unmounted +volume and returned `UnavailableData`. Because the pre-push hook runs the tests +affected by your change, the failure blocked pushes for a reason unrelated to +the code being pushed. CI never saw it, since CI uses the default temp +directory. + +That test now anchors its fixtures to the boot volume itself, so the RAM disk +is safe to use. If you hit the same shape in a new test, either anchor the +fixture the same way or pass an explicit `platformOverride` to `VolumeStatus` +rather than relying on the real heuristics. As a one-off escape hatch, +`TMPDIR=/private/tmp` in front of a single command restores the default without +unsetting anything. + +### 3. Recreate it at login + +A RAM disk does not survive a reboot. Create +`~/Library/LaunchAgents/app.local.flutter-ramtmp.plist`: + +```xml + + + + + Label + app.local.flutter-ramtmp + ProgramArguments + + /Users/YOUR_USERNAME/.local/bin/flutter-ramtmp + 16 + + RunAtLoad + + StandardOutPath + /tmp/flutter-ramtmp.out + StandardErrorPath + /tmp/flutter-ramtmp.err + + +``` + +Load it with +`launchctl load ~/Library/LaunchAgents/app.local.flutter-ramtmp.plist`. +`ProgramArguments` needs an absolute path, so substitute your own username. + +### 4. A wrapper that applies the tuning + +Create `~/.local/bin/ft` so you do not have to remember the flags: + +```bash +#!/usr/bin/env bash +# `flutter test` with this machine's tuning applied. +# Usage: ft [paths/flags...] Env: FT_JOBS (default 18) +# Measured 1.3x to 2x on a 48-file run, widening with concurrent load. +set -uo pipefail + +if [ -x "${HOME}/.local/bin/flutter-ramtmp" ]; then + if RAMTMP="$("${HOME}/.local/bin/flutter-ramtmp" 2>/dev/null)" && [ -d "${RAMTMP}" ]; then + export TMPDIR="${RAMTMP}/" + fi +fi + +exec flutter test -j "${FT_JOBS:-18}" "$@" +``` + +Set `FT_JOBS` to roughly your core count. The default of 18 is tuned for an +18-core machine and should be lowered on smaller hardware. + +## Results + +Identical 48 test files, `-j 18`, runs interleaved to control for background +load drifting during the measurement: + +| `TMPDIR` | Wall time | +|---|---| +| Default, on the internal SSD | 38.1s, 43.2s, 53.0s, 58.8s | +| RAM disk | 29.7s, 28.8s | + +That is a range of 1.3x (38.1s against 29.7s) to 2.0x (58.8s against 28.8s), +not a flat 2x. The spread is the interesting part rather than noise to average +away: RAM disk runs stayed flat near 29s while SSD runs degraded from 38s to +59s as other worktrees started their own test runs. Moving temp traffic off the +SSD is what decouples concurrent worktrees from each other, so the busier the +machine, the more it is worth. + +Two caveats on these numbers. They come from one machine, and no run was taken +on a genuinely idle system, since another worktree was testing throughout the +measurement window. The 1.3x figure is therefore a floor observed under light +load rather than a clean unloaded baseline, and a quiet machine may see less. + +## Do not run multiple invocations in one worktree + +It is tempting to shard the suite into several concurrent `flutter test` +invocations inside a single worktree. This was tried and measured, and it does +not work here. Two invocations in the same worktree share: + +- `build/native_assets//`, which every invocation rebuilds. One run + rewrites the dylibs while another is reading them, producing + `can't open file: .../libpdfium.dylib` and killing the run outright. +- The flutter startup lock, which serializes the phase anyway. + +`build-dir` is user-level flutter config, not a per-invocation flag, so the +directory cannot be separated per shard. + +Even after working around the startup race, sharding was not faster (49s versus +42s on 300 files) and it produced a spurious test failure that passed when run +alone. That is the same contention flakiness that makes overlapping local runs +unreliable in general. + +Parallelism **across** worktrees is fine. Each worktree has its own `build/` +directory, so there is no shared mutable state between them. + +## Maintenance + +Killed or crashed runs leak their temp directories. These accumulate quickly +given the file sizes involved: 23 GB across 18 abandoned directories was +observed on one machine. + +List what is safe to remove, keeping anything a live process still holds open: + +```bash +T="${TMPDIR:-/tmp}" +LIVE=$(lsof -n 2>/dev/null | grep -o "flutter_tools\.[A-Za-z0-9]*" | sort -u) +for d in "$T"/flutter_tools.*; do + b=$(basename "$d") + echo "$LIVE" | grep -qx "$b" || echo "stale: $b ($(du -sh "$d" | cut -f1))" +done +``` + +Orphaned `flutter_tester` processes also survive killed runs and pin their temp +directories. Identify them by a parent PID of 1, zero CPU, and a long elapsed +time: + +```bash +ps -Ao pid,ppid,etime,pcpu,comm | grep flutter_tester | awk '$2 == 1' +``` + +Kill those by explicit PID. Never use a blanket `pkill -f flutter_tester`, +because that will also kill test runs belonging to other worktrees and other +sessions, which then look like mysterious failures with no summary line. + +## Things that are not the problem + +Worth recording so nobody re-investigates them: + +- **Rosetta.** The engine artifacts live under a directory named `darwin-x64`, + which looks alarming on Apple Silicon. It is a legacy directory name. Check + with `file`, and the binary reports `Mach-O 64-bit executable arm64`. +- **Efficiency-core scheduling.** Check `sysctl hw.perflevel0.name` before + assuming test processes are being parked on efficiency cores. On an M5 Pro + the two levels report as "Super" and "Performance", with no efficiency + cores involved. diff --git a/docs/developer/testing.md b/docs/developer/testing.md index 1dd224e460..cb8c05bf71 100644 --- a/docs/developer/testing.md +++ b/docs/developer/testing.md @@ -36,6 +36,11 @@ test/ ## Running Tests +Local runs are limited by disk throughput rather than CPU. See +[Local Test Performance](local-test-performance.md) for the machine setup that +speeds them up by 1.3x to 2x, with the larger gains when several worktrees run +tests at once. + ### All Tests ```bash diff --git a/docs/features/marine-life.md b/docs/features/marine-life.md index fcf5a23abf..19fb9deebf 100644 --- a/docs/features/marine-life.md +++ b/docs/features/marine-life.md @@ -1,4 +1,4 @@ -# Marine Life +# Species Track species sightings and build a database of your underwater encounters. @@ -11,6 +11,12 @@ Submersion includes a pre-seeded species database with 511 species, plus you can Browsable species database with categories +The catalog covers freshwater species too (lakes, rivers, springs and +cenotes: pike, trout, cichlids, crayfish, turtles, otters and water plants +among them). Catalog corrections and additions reach existing installs on +the next launch after an update; a species you edited yourself is left as +you set it. + ## Species Categories | Category | Examples | @@ -97,6 +103,24 @@ For species not in the database: 7. Add photo (optional) 8. Save +### Looking a species up online + +When you add a species, tap **Look up online** to search iNaturalist by common +or scientific name. Choosing a result fills the common name (in your language +where iNaturalist has it), the scientific name, the category and the taxonomy +class; you can still edit anything before saving. The same lookup is offered +when you add a species from the dive's marine life picker, and on a dive site's +"Recorded nearby" list for names the catalog does not know yet. + +Lookups happen only when you tap **Look up**; nothing is sent while you type, +and nothing from iNaturalist is stored except the fields you save. + +### Suggesting a species for the catalog + +On a custom species, the menu offers **Suggest for the catalog**. It opens a +prefilled GitHub issue in your browser with the species' details; posting it +is up to you. + ## Sighting Statistics ### Marine Life Dashboard @@ -144,15 +168,44 @@ Each dive site shows: ### Species Photos -Add reference photos: - -- Database photos for ID -- Your photos for personal sightings -- Before/after comparisons - -
-Coming Soon: Photo management with per-sighting images will be available in v2.0. -
+Tag the photos on your dives with the species in them, and see every photo of +a species in one place. + +- On a species page, the **Photos** section shows every photo tagged with it. + **Tag photos** offers the untagged photos from the dives where you logged + the species; **Add photos** imports pictures from your camera roll, matches + each one to a dive as the media importer does, and tags it. +- In the photo viewer, the **Species** action lists the dive's sightings as + chips you can toggle, plus a search for any other species. Tagging a species + that is not yet logged on that dive adds the sighting for you. +- Tagged species appear as chips under the photo; tap one to open the species. +- The Species page shows each species' newest tagged photo in place of its + category icon. On a dive, each sighting row shows how many of the dive's + photos carry the species; tap the count to view them. The media library's + filter has a **Species** facet, which smart albums keep too. + +A species with tagged photos cannot be deleted from the catalog until the +tags are removed, the same rule as for sightings. + +## Growing the Catalog (maintainers) + +The bundled catalog is `assets/data/species.json`; its `version` gates a +one-time upgrade pass on each device that rewrites rows the diver never +edited. To add species: + +1. Add entries to `tool/data/freshwater_species_seed.json` (or a sibling seed) + with descriptions in all 11 locales, and any locale names iNaturalist lacks + to `tool/data/freshwater_species_name_overrides.json`. +2. `dart run tool/generate_freshwater_species.dart` (network) writes the + catalog rows and the localized names file and bumps the version. +3. `dart run tool/generate_species_arb_keys.dart`, `flutter gen-l10n`, + `dart run tool/generate_species_lookups.dart`, + `dart run tool/generate_species_gbif_keys.dart` (network). +4. Run `test/features/marine_life/presentation/species_lookup_coverage_test.dart`. + +Suggestions filed through the in-app "Suggest for the catalog" action arrive +as GitHub issues labelled `species-suggestion`; they land in the seed the same +way. ## Identification Tips diff --git a/docs/guide/dive-logging.md b/docs/guide/dive-logging.md index 670c661183..d7d4ecbe35 100644 --- a/docs/guide/dive-logging.md +++ b/docs/guide/dive-logging.md @@ -190,7 +190,7 @@ Track your weighting for each dive: | **Ankle** | Ankle weights | | **Backplate** | Backplate weights | -## Marine Life +## Species Log species sightings: diff --git a/docs/guide/installation.md b/docs/guide/installation.md index a5465e876f..5203059339 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -22,6 +22,75 @@ To build from source, you'll need: - **Android Studio** (for Android builds) - **Visual Studio** with C++ tools (for Windows builds) +## Install on Linux + +Submersion ships for Linux as native packages as well as a tarball. The +packages resolve their own dependencies, register a desktop entry and icon, +and install udev rules so dive computers connected by USB are reachable +without any group membership or `usermod` step. + +> **Distro requirement:** Submersion needs glibc 2.38 or newer, which means +> Ubuntu 24.04+, Debian 13+, Fedora 39+, Linux Mint 22+, Arch, or openSUSE +> Tumbleweed. Debian 12, Ubuntu 22.04, and RHEL 9 cannot run any current +> Submersion build. The packages install but the app will not start, and the +> tarball fails the same way, because three bundled libraries require +> GLIBC_2.38. Upgrading the distribution is the only path. + +> **Where the packages are today:** the `.deb` and `.rpm` ship with every beta +> build at +> [submersion-app/beta-builds](https://github.com/submersion-app/beta-builds/releases). +> They reach the stable +> [Releases page](https://github.com/submersion-app/submersion/releases) with +> the next stable release; that page currently carries the tarball only. + + + +#### **Debian / Ubuntu / Mint** + +Download `Submersion-v-Linux-amd64.deb` from the +[beta releases](https://github.com/submersion-app/beta-builds/releases): + +```bash +sudo apt install ./Submersion-*-Linux-amd64.deb +``` + +#### **Fedora / RHEL** + +Download `Submersion-v-Linux-x86_64.rpm` from the +[beta releases](https://github.com/submersion-app/beta-builds/releases): + +```bash +sudo dnf install ./Submersion-*-Linux-x86_64.rpm +``` + +#### **openSUSE** + +The same `.rpm`. openSUSE ships zypper rather than dnf: + +```bash +sudo zypper install ./Submersion-*-Linux-x86_64.rpm +``` + +#### **Other distros (tarball)** + +```bash +tar xzf Submersion-*-Linux.tar.gz +./install.sh +``` + +`install.sh` reports any missing shared libraries with the exact command to +install them for your package manager, installs a desktop entry and icon under +`~/.local/share`, and links the binary into `~/.local/bin`. It prints the +command to install the udev rules, which needs root. `./uninstall.sh` reverses +all of it and leaves your dive log data alone. + + + +Video compression is optional and uses `ffmpeg` if it is on your `PATH`. The +packages recommend it rather than requiring it, because Fedora ships `ffmpeg` +only through RPM Fusion. Without it, videos are uploaded at their original +size. + ## Install from Source ### 1. Clone the Repository diff --git a/docs/guide/statistics.md b/docs/guide/statistics.md index 95ea6cd7bc..e90b56a86f 100644 --- a/docs/guide/statistics.md +++ b/docs/guide/statistics.md @@ -109,7 +109,7 @@ Where you've been diving: World map with dive distribution -### Marine Life +### Species Species sighting statistics: diff --git a/docs/import-formats/macdive-zsamples.md b/docs/import-formats/macdive-zsamples.md index e1c8368c25..d715f26b34 100644 --- a/docs/import-formats/macdive-zsamples.md +++ b/docs/import-formats/macdive-zsamples.md @@ -1,11 +1,15 @@ # MacDive `ZDIVE.ZSAMPLES` / `ZDIVE.ZRAWDATA` Binary Format — Investigation Findings -**Status:** `ZRAWDATA` is **SOLVED** — see "Update 2026-08-09" at the end of this document. It is the raw Shearwater download stream stored *still compressed*; decompressing it with libdivecomputer's own two passes yields Petrel Native Format, and 266/267 dives in the reference corpus decode against ground truth. MacDive SQLite profile import works for Shearwater dives as of that date. +**Status:** BOTH COLUMNS SOLVED. `ZRAWDATA` since 2026-08-09 (see "Update 2026-08-09"); `ZSAMPLES` since 2026-09-02 (see "Update 2026-09-02: ZSAMPLES solved" at the end). `ZSAMPLES` is TEA in ECB mode under a key fixed in the MacDive binary, not AES under a per-dive key, and every one of the 350 blobs in the reference library decodes sample-for-sample against MacDive's own exports. MacDive SQLite profile import now covers every dive MacDive itself drew a profile for, on every platform, with `ZRAWDATA` still preferred where it parses because it carries more channels. -`ZSAMPLES` remains NO-GO (AES-encrypted with a per-dive key, documented below) but is now moot: every Shearwater dive that has `ZSAMPLES` also has `ZRAWDATA`. Non-Shearwater dives have neither and still need MacDive's XML export. +The "`ZSAMPLES` remains NO-GO (AES-encrypted with a per-dive key)" conclusion that stood here from 2026-04-23 to 2026-09-02 was wrong; the sections that reached it are retained below, unedited, as a record of how a fixed-key ECB cipher was misread as per-dive AES. + +**Second correction (see "Update 2026-09-01" below):** the per-model tables both earlier updates produced are gone. `ZCOMPUTER` is now resolved against libdivecomputer's own descriptor list, so any model it supports is attempted and the result is sanity-checked rather than trusted. Read that section before adding anything model-specific here. + +**Correction (see "Update 2026-08-29" below):** the claim in the previous paragraph that "non-Shearwater dives have neither [`ZSAMPLES` nor `ZRAWDATA`] and still need MacDive's XML export" was never actually tested against Suunto computers — the investigation corpus contained none. It turns out at least three Suunto models (EON Steel, EON Steel Black, Cobra) do populate `ZRAWDATA`, and unlike Shearwater's, theirs needs no decompression at all. The 2026-04-24 "ZRAWDATA pivot invalidated" section below is **historically inaccurate** and is retained only to show how the wrong conclusion was reached. -**Date:** 2026-04-23 (initial), 2026-04-24 (ZRAWDATA invalidation), 2026-08-09 (ZRAWDATA solved) +**Date:** 2026-04-23 (initial), 2026-04-24 (ZRAWDATA invalidation), 2026-08-09 (ZRAWDATA solved), 2026-08-29 (Suunto ZRAWDATA confirmed), 2026-09-01 (model allowlist removed), 2026-09-02 (ZSAMPLES solved) **Author:** Eric Griffin **Spec:** `docs/superpowers/specs/2026-04-23-macdive-sqlite-profile-decoding-design.md` **Plan:** `docs/superpowers/plans/2026-04-23-macdive-zsamples-phase-1-spike.md` @@ -474,3 +478,320 @@ Model number does not matter within the Petrel family: "Petrel 2" (3) and "Tern" ### Caller-facing trap `libdc_parse_raw_dive` returned **rc = 0 with an empty error buffer** on the failed compressed parse, having produced a zeroed dive — `extract_dive_fields` swallows per-field failures. Any caller must validate sample count or max depth rather than trusting the return code, or a regression in the decompression step lands silently as a wave of empty dives. `MacDiveDiveMapper._attachProfile` treats an empty sample list as failure for exactly this reason. + +## Update 2026-08-29: Suunto `ZRAWDATA` also confirmed + +The 2026-08-09 update solved `ZRAWDATA` for Shearwater but restated, unchallenged, the original spike's conclusion that non-Shearwater computers carry no `ZRAWDATA` at all. That conclusion was never actually tested against Suunto — the 540-dive investigation corpus (see "Per-computer presence" above) contained Shearwater, Oceanic Matrix Master, and no-computer dives only, never a Suunto one. + +A Submersion user's real MacDive database contains Suunto dives, and their `ZRAWDATA` **is** populated. Unlike Shearwater's stream, Suunto's is stored **uncompressed**, already in the exact byte layout libdivecomputer's own parsers expect for that computer family — no RLE/XOR decompression pass needed, confirmed by feeding the raw blob straight into `parseRawDiveData` and getting correct profile samples back. + +Confirmed by model (against real MacDive `ZRAWDATA`, not just the vendor name): + +| MacDive `ZCOMPUTER` string | libdivecomputer product | Native format | +|---|---|---| +| `Suunto EON Steel` | EON Steel | SBEM | +| `Suunto EON Steel Black` | EON Steel Black | SBEM | +| `Suunto Cobra` | Cobra | Vyper dive-header | + +Implemented in `MacDiveDiveMapper._suuntoVendorProduct` / `_attachProfile` (`lib/features/universal_import/data/services/macdive_dive_mapper.dart`): the mapper now branches on vendor, running `ShearwaterRawDecompressor` only for Shearwater and passing every other recognized vendor's bytes straight to `parse`. See [submersion-app/submersion#1399](https://github.com/submersion-app/submersion/issues/1399) and the fix PR. + +**Superseded by the 2026-09-01 update below:** `_suuntoVendorProduct` no longer exists, and no table entry is needed to support another model. The paragraph is kept for the reasoning it records. + +**Not yet validated (as of 2026-08-29):** other Suunto models likely follow the same uncompressed-native-format pattern (Suunto's own product line shares firmware lineage), but no real `ZRAWDATA` from any other Suunto model has been confirmed. Computers outside the three above still fall through to the "no `ZRAWDATA`" warning path — a future contributor with real data from another Suunto model should add it to `_suuntoVendorProduct` only after confirming its `ZRAWDATA` parses cleanly, per the caller-facing trap noted above (a failed native parse can silently return a zeroed dive rather than an error). + +## Update 2026-09-01: the model allowlist is gone + +Two rounds of this feature landed as one-model-at-a-time table entries: #961 +added Shearwater, #1400 added three Suunto models. Both were correct about the +bytes and wrong about the scope, because both recorded a *data* limit ("we only +had blobs for these") as a *format* limit ("only these have usable +`ZRAWDATA`"). A user with a Suunto D5, a Mares Puck, or an Oceanic Geo hit the +warning path with a perfectly decodable blob sitting in the database. #1436 +removed the tables rather than adding a third entry. + +### What resolution does now + +`ZDIVE.ZCOMPUTER` is matched against libdivecomputer's own descriptor list, +read once per import through `DiveComputerHostApi.getDeviceDescriptors()` and +indexed by `DiveComputerDescriptorIndex` +(`lib/features/universal_import/data/services/dive_computer_descriptor_index.dart`). +Every model the vendored submodule supports is a candidate, and the set grows +on its own when the submodule advances. + +Matching rules, in order: + +1. Case and whitespace are ignored, the same tolerance the native BLE-name + matcher applies (`strcasecmp_nospace` in `libdc_wrapper.c`), so "Puck4" + matches the product "Puck 4". +2. The name is matched a whole token at a time from the front, longest first, + so "Suunto EON Steel Black" prefers its own descriptor over the shorter + "Suunto EON Steel", and a decorated name falls back to the longest known + prefix. +3. A name matching nothing resolves to nothing. This is load-bearing: + "Oceanic Matrix Master" pairs a real libdivecomputer vendor with a product + that does not exist there, and "No Computer" is not a device at all. + +The #1400 note that Suunto has "no prefix convention to strip" was not quite +right. All three of its entries were exactly `"Suunto " + `, the same shape as `"Shearwater Teric"`; the convention was already +general, which is why one matcher now covers both. + +### Ambiguous product names + +libdivecomputer declares seven `(vendor, product)` pairs more than once, with +different model numbers: + +| Vendor | Product | Models | +|---|---|---| +| Aeris | Elite T3 | 0x4259, 0x4455 | +| Aeris | Epic | 0x4257, 0x4453 | +| Aqualung | i100 | 0x464E, 0x4745 | +| Aqualung | i200C | 0x4649, 0x4749 | +| Oceanic | OC1 | 0x434E, 0x4449, 0x4451 | +| Suunto | Zoop Novo | 0x1E, 0x1F | +| Uwatec | Aladin 2G | 0x13, 0x15 | + +Each set sits within one family, so the parser is the same either way, but the +model number reaches the parser and can steer header layout. The mapper tries +each candidate in declaration order and keeps the first whose parse passes the +sanity check below. Passing a real model number also means +`find_descriptor`'s `model == 0` wildcard (`libdc_download.c`) is no longer +load-bearing for this path. + +### The parse is checked, not trusted + +Attempting any recognised model makes a structurally plausible parse of the +wrong bytes a live possibility, which the "caller-facing trap" above already +warned about in its narrower form. `RawProfileSanityCheck` +(`lib/features/universal_import/data/services/raw_profile_sanity_check.dart`) +gates every result: samples must exist, sample times must be non-negative and +non-decreasing, depth must sit within (-3 m, 350 m], duration within 24 h, and +the parsed depth and duration must not contradict MacDive's own scalars for +the same dive by more than a factor of five. + +That last tolerance is deliberately gross. MacDive routinely omits its units +row, so a whole logbook can be read as feet when it is metres (#912) - a 3.28x +error in the recorded scalar that says nothing about whether the raw bytes +decoded correctly. A tighter bound would discard good profiles to catch bad +ones that the absolute limits already catch. The two sides of that comparison +are not symmetric: a recorded value of zero is MacDive saying it has no figure +and cannot reject anything, while a parsed depth of zero is libdivecomputer +asserting the diver never left the surface, which against a recorded 30 m dive +is exactly the wrong-format parse this check exists to catch. A rejected parse falls back to +exactly the behaviour an unrecognised computer already had: it counts toward +one aggregated warning pointing at MacDive's XML export. + +### Vendor pre-passes + +Shearwater remains the only vendor whose `ZRAWDATA` needs work before the +parser sees it, and it is the exception rather than the rule: libdivecomputer +decompresses inside its own device layer (`shearwater_common.c:550` and `:567`), +so any host that downloads through libdivecomputer ends up holding native +bytes. MacDive storing the compressed stream tells us its Shearwater download +path is its own. The transform is now a vendor-keyed entry in +`MacDiveDiveMapper._rawPrePasses`, so a second such vendor is one line. + +### Correcting the per-computer presence table + +The table under "Per-computer presence" is still accurate as a count, but the +conclusion drawn from it was not. Presence of `ZRAWDATA` tracks **how the dive +entered MacDive** - a native download versus manual entry or a file import - +not which vendor made the computer. The corpus happened to contain Shearwater +dives that MacDive downloaded and Oceanic dives that it did not, and that +coincidence read as a vendor rule for two releases running. + +### Confirming a new family + +The generic path means no code change is needed to support another computer. +What is still worth doing, when someone has real data, is proving a family +decodes: + +1. Pull one `ZRAWDATA` blob for the dive: + `sqlite3 MacDive.sqlite "SELECT writefile('blob.bin', ZRAWDATA) FROM ZDIVE WHERE Z_PK = ;"` +2. Feed it to `libdc_parse_raw_dive` with the vendor, product and model the + descriptor list gives for that `ZCOMPUTER` string. The native harness in + `packages/libdivecomputer_plugin/test/native/test_parse_raw_dive.c` is the + template; `fixtures/dive1_raw.bin` shows the shape. +3. Check the result against what MacDive shows for the dive: max depth, + duration and sample count, not just a zero return code. +4. Commit the blob as a fixture with an assertion on real decoded values. + Anonymise it first if it carries a serial number. + +**Still missing:** no anonymised Suunto `ZRAWDATA` exists in this repository, +so the #1400 tests continue to stub `parseRaw` and prove byte pass-through and +name resolution rather than a real decode. The Shearwater path does have real +bytes (`shearwater_raw_decompressor_test.dart` pins the Dart port against a +blob generated from the 540-dive corpus). A contributor with a real Suunto +database can close that gap by following the recipe above; it needs data, not +a design change. + +## Update 2026-09-02: ZSAMPLES solved + +The per-dive AES conclusion above was wrong, and it was wrong for a reason +worth recording: every piece of evidence in the spike was read through the +assumption that CommonCrypto was the codec. The `CCCryptorCreate` imports the +spike found belong to minizip's zip-password support (the same symbol table +carries `createZipFileAtPath:...password:AES:`), and the functions around them +are minizip's `mz_crypt_apple.c` verbatim, error codes included. The sample +codec never touches CommonCrypto. + +### Where the codec lives + +MacDive 2.16.3 keeps it in an Objective-C class, `MDSampleUtils`: + +| Selector | Role | +|---|---| +| `sampleDataForArrayOfSamples:withOptions:` | encoder | +| `sampleArrayFromData:` | dispatcher on the version word | +| `sampleArrayFromDataV2:` | decoder for versions 2 to 4 | +| `sampleArrayFromDataV1:` | decoder for version 1 | +| `optionsFromData:` | reads the options word | + +`-[MDDive sampleOptions]` supplies the options word from the dive, and +`-[MDDive samples]` runs the blob through `sampleArrayFromData:` and caches +the result in an `NSCache`. Two small C functions next to the class do the +cipher work. Their round function is +`((v << 4) + k0) ^ (v + sum) ^ ((v >> 5) + k1)` with `sum` stepping by +`0x9E3779B9` for 32 rounds: classic TEA, not XTEA (XTEA indexes the key by +`sum`; this does not). The key is four 32-bit words in `__TEXT,__const`: + +``` +k0 = 0x00000086 k1 = 0x00000016 k2 = 0x00000080 k3 = 0x00000060 +``` + +That the cipher is TEA also explains every "structural" observation the spike +made. TEA has 64-bit blocks, so the "8-byte semi-constant markers" were ECB +blocks whose plaintext repeated; the 28-byte record does not divide by 8, so +block alignment alternated between odd and even samples and produced the +"alternating sync word"; and the dominant `84b90aa09fbb1ecc` body prefix is +nothing more than the encryption of `(time 0.0, depth 0.0)`, the first record +of every dive that starts at the surface. The markers did not "transfer across +dives" because the spike compared depth *buckets*, and identical 8-byte +plaintext blocks (which need identical float bit patterns across two fields) +are rare between different dives. + +### The format + +```text +offset size meaning +0 4 u32le version: 1, or 2..4 (every blob seen in the wild says 4) +4 4 u32le options bitmask (versions 2..4 only) +8 n*8 TEA-ECB ciphertext, a whole number of 8-byte blocks +``` + +Decrypt the body under the key above, little-endian words. The plaintext is: + +```text +[records][zero padding to an 8-byte boundary][one more 8-byte block] +``` + +and the **last four bytes of the plaintext hold the byte length of the record +run**. MacDive's encoder allocates `((len + 7) / 8 + 1)` blocks, copies the +packed records in, stores `len` in the final word, and encrypts; its decoder +decrypts everything, then reads that word to know where the records stop. + +Records are fixed width for a given options word: `time` and `depth` as +32-bit floats, then one 4-byte field per set option bit, **in this order** +(which is the encoder's emission order, not bit order): + +| Order | Bit | Field | Type | Unit as stored | +|---:|---:|---|---|---| +| 1 | 0 | pressure | f32 | diver's display unit (psi or bar) | +| 2 | 5 | pressure2 (second transmitter) | f32 | same as pressure | +| 3 | 1 | heart rate | i32 | bpm | +| 4 | 2 | NDT | i32 | minutes | +| 5 | 3 | ppO2 | f32 | bar | +| 6 | 4 | temperature | f32 | Celsius | +| 7 | 6 | next stop depth | f32 | metres | +| 8 | 7 | TTS | i32 | minutes | + +Only bits 0 to 7 are consulted. The stride is therefore `8 + 4 * popcount(options & 0xFF)`, +which is exactly the 12/16/20/24/28 ladder the spike measured: the five +options words in the reference library are 16, 24, 25, 156 and 157. + +Depth and temperature are SI like every other Core Data column of that kind; +pressure follows the display unit like `ZTANKANDGAS.ZAIRSTART` does, so the +same `MacDiveUnitConverter.coreData` handles both. A zero pressure, ppO2 or +heart rate is MacDive's "no reading". + +Following the display unit means these pressures inherit #912 whole: a library +with no `SystemOfUnits` row resolves by inference, and a library that also +records no cylinders used to give the inference nothing to work with, so psi +passed through as bar. The sample pressures are themselves a strong witness +(the same 600 ceiling separates 200-300 bar from 2400-3500 psi), so +`MacDiveUnitInference` reads them as its last tier, after the cylinder columns +that cost nothing to look at. Decoding costs a TEA pass per dive, so the scan +stops at `sampleScanDiveLimit` dives or at the first reading that can only be +psi. That bound leaves `unknown` reachable for a library whose only pressures +sit past it, and there `_samplePoint` drops the pressure channel rather than +charting a number it cannot place - the dive keeps every other channel, and +before ZSAMPLES were readable it had none of them. + +### Failing closed + +The structural checks (version word in range, body a whole number of cipher +blocks, a length trailer inside the buffer and divisible by the stride) do not +prove the stride itself is right. A future record layout stamped with a +version this decoder claims, or an options bit above the low byte that a newer +encoder emits a field for, would misalign every read while still dividing the +run. Fields read at the wrong offset are floats built from unrelated bytes, so +the decoder also holds each record's time and depth to the bounds +`RawProfileSanityCheck` applies on the raw path (-3 to 350 m, 0 to 24 h) and +returns null for the whole blob otherwise. The upper bound on time does double +duty: Dart's `double.round()` saturates at the int64 limit instead of throwing, +so an unbounded huge time would multiply out into a negative `Duration` rather +than an obviously wrong one. + +Version 1 has no options word and no encryption: after the 4-byte version +come 24-byte records of time, depth, pressure (f32), NDT (i32), ppO2 (f32) +and temperature (f32). No version 1 blob exists in the reference library; the +decoder supports it because the MacDive binary still does. + +### Verification + +Against the 540-dive reference library and MacDive's own exports of it: + +- All 350 `ZSAMPLES` blobs decode; the length trailer and stride checks pass + on every one. Two blobs are the 16-byte header-plus-length-block form of an + empty sample array. +- Depth and time match the UDDF export sample-for-sample on all 348 non-empty + dives once the export's own additions are discounted: MacDive's UDDF writer + prepends a synthetic `(0 s, 0 m)` point when a dive's first sample is below + the surface and appends one after the last sample when the dive ends below + it. That accounts for exactly the 1-2 extra waypoints on 268 dives. +- Temperature matches the UDDF Kelvin values (as Celsius) on every dive that + carries it; pressure matches the XML export's psi on all 257 dives with bit + 0; NDT matches the XML export's minutes on all 268 dives with bit 2; ppO2 + matches on all 340 dives with bit 3. +- An independent Python encoder built from the disassembly reproduces real + ciphertext byte for byte; the Dart decoder's fixtures come from it. + +TTS, heart rate, second-transmitter pressure and next-stop depth appear in no +export MacDive produces, so their units are read from the decoder's setter +types and value ranges (TTS 0 to 9 on recreational dives, so minutes) rather +than cross-checked against ground truth. + +### Where it landed + +- `lib/features/universal_import/data/services/tea_block_cipher.dart` +- `lib/features/universal_import/data/services/macdive_samples_decoder.dart` +- `lib/features/universal_import/data/services/macdive_sqlite_sample.dart` +- `MacDiveDiveMapper._attachSamples`, the fallback behind the `ZRAWDATA` + path. The raw download keeps precedence because libdivecomputer yields + channels `ZSAMPLES` never carries (per-cell ppO2, deco settings, events); + the samples cover every dive without usable raw bytes and every platform + without the native channel. The "MacDive format Submersion cannot read" + warning is gone; only a dive with unreadable bytes in both columns is + reported. + + It reports a `_SamplesOutcome` rather than a bool because an empty sample + array is MacDive saying it drew no profile, which is the whole story only + when `ZSAMPLES` was the dive's only source. A dive whose raw parse was + rejected and whose `ZSAMPLES` is empty still lost a profile, and it is + counted: toward the platform warning when the native channel was never + reachable (an XML export cannot rescue what MacDive never drew), toward the + XML-export warning otherwise. + +The ratio of the two columns in the reference library: 267 dives have both, +83 have only `ZSAMPLES`, and 190 have neither. Those 83 were the entire +"needs the XML export" population and no longer are. diff --git a/docs/releases/v1.7.6.7161.md b/docs/releases/v1.7.6.7161.md new file mode 100644 index 0000000000..0e59a12e20 --- /dev/null +++ b/docs/releases/v1.7.6.7161.md @@ -0,0 +1,121 @@ +# 🎉 Submersion v1.7.6.7161 Release Notes + +v1.7.6 brings 58 merged changes since v1.7.5. The headliners are dive computers that finally join your gear on every dive they logged, a rebuilt 3D dive view with a metric of your choice on the third axis, historical weather backfill for the whole logbook, SAC and RMV separated into the two quantities they always were, and a set of sync and restore fixes that close every known path to a logbook suddenly showing no dives. This release upgrades the database (schema 160 to 175), so please read the upgrade notes at the end. + +--- + +## ✨ New and improved + +### Your dive computer joins your gear + +Submersion has always known which computer downloaded each dive, but the device never appeared in the dive's Equipment section, earned no service tracking, and could not be filtered on. Now every registered dive computer gets a matching gear item, each downloaded dive is linked to it automatically, and a one-time migration backfills your existing logbook, including dives registered by past file imports. Service intervals, purchase details, gear statistics, and equipment filters all work for the device you actually take on every dive. The computer appears on a dive both in the Details card, which opens the device page with firmware and download history, and as a gear tile, which opens the equipment page with service and purchase records; the two destinations are different on purpose. + +### The 3D dive view becomes an instrument + +The single-dive 3D view was a depth ribbon that showed nothing the 2D chart does not. It is rebuilt as a genuine three-axis chart: run time on X, depth on Y, and a metric you pick on Z, from temperature, ascent rate, tank pressure, ppO2, CNS, heart rate, and time to surface. The path casts shadows on the three walls of the scene, and each shadow is itself a readable 2D chart (depth against time, metric against time, depth against metric), with camera presets that snap each wall front-on. Axes carry unit-aware ticks and labels, hovering shows a tooltip anchored to the path, and the readout panel and marker sheet share one set of figures. + +### Weather for the dives that never had any + +Until now only a newly saved dive fetched weather. A new Fetch Conditions for All Dives action in the dive list menu backfills historical conditions across your whole logbook. The fill is per field and never overwrites anything: a dive with a wind speed but no cloud cover keeps its wind speed and gains a cloud cover, hand-entered weather is never relabelled as provider data, and your own written description is never touched. Requests are grouped by place and day, so a liveaboard week costs one archive call per day rather than one per dive, and the run shows progress and can be cancelled. Trip day weather is also now fetched once, stored with the trip, and synced, and it covers travel, port, and sea days instead of only surface days. + +### SAC and RMV separated at last + +SAC (pressure drop per minute, a property of one cylinder) and RMV (surface volume per minute, a property of the diver) were one value with a unit toggle, which guaranteed the label was wrong in one of the two modes. They are now separate quantities. Settings > Units > Gas consumption offers SAC, RMV, or Both; existing users land on the lane they were already seeing. The dive detail shows a row per lane, the cylinders card shows one line per lane per cylinder, the consumption-by-segment and gas statistics pages get a SAC / RMV switch, and the dive table gets independent, sortable SAC and RMV columns, with saved layouts migrated. German keeps AMV for the volume rate and uses Druckverbrauch for the pressure rate. Alongside this, dive-computer downloads finally honour the apply-default-tank setting, so a downloaded dive without a recorded cylinder volume can compute a volume rate instead of silently hiding the row. + +### Pin a photo to its moment in the dive + +A camera with a wrongly set clock used to produce a confident marker at the dive's entry or exit and a nonsense elapsed-time chip, with no way to correct it. The elapsed chip in the media viewer is now tappable and opens a dialog with a time field, a slider across the dive, and a live mini-profile preview of where the shot will sit; Reset returns to automatic. A capture time far outside the dive is now shown as unknown instead of being clamped to the entry or exit, and when linking media to dives, capture-time mismatches are explained and can be corrected on the spot. + +### Sites that fill themselves in + +Picking coordinates used to fill only country and region. Every coordinate source now also fills the town and the body of water, a Look up from coordinates action on the site form fills empty fields (and offers Replace or Keep when a lookup disagrees with what is there), and a Fill in missing location details sweep in the sites list walks every site that has coordinates and completes only the empty fields, with progress and cancel. A new synced Place name language setting controls the language the lookups answer in. Body of water works for lakes, reservoirs, rivers, bays, and straits; the open sea has no named polygons, and islands stay a manual field because the data source does not carry them. + +### Richer site and buddy cards + +Site list cards gain a rating, difficulty, water type, one chip per site feature (wreck, mooring, swim-through, and friends), a depth range, dive count, last dived date, and your own max depth there, all configurable through the card layout settings that existed but were never wired up. Buddy cards gain an avatar ring in the certification agency's colour, a certification chip, dives together, the last shared dive, the buddy's usual role, and contact icons. Both lists get true compact variants. + +### Sharper search and a smarter buddy picker + +Advanced Search gains a Decompression filter (Any, Yes, or No, derived from the recorded profile the same way the deco statistic is) and a weekday filter that combines with a date range, so "Saturdays last summer" is now a query. The Add Buddy picker can sort buddies by how many dives you have shared, and buddies can be starred as favorites that pin to the top regardless of sort. All contributed by @alpheios-one. + +### A better GPS log page + +On desktop the GPS Log was mostly empty space. The track list now sits beside the overview map: tapping a track selects it, frames it, and floats a details card. Every width gets a summary strip with track count, recorded time, and dives covered, plus a real empty state instead of a lone sentence. + +### Smaller additions + +- **Dive type badges** (Wreck, Night, Drift, and the rest) appear in the dive detail header and on the list cards, collapsing to a "+N" badge when space runs out. Custom types can set their own short name, and every type gets visibility toggles for the header and the lists. Contributed by @readme42. +- **The divider between the list and detail panes is now draggable.** Contributed by @readme42. +- Whether the **CCR O2 cell curves** are shown by default is now a setting. Contributed by @readme42. +- **Subsurface imports now carry over the photos** attached to each dive. +- **Safety review findings can be dismissed in bulk**, on one dive or across the whole logbook, always scoped to the rules you have switched on. +- The bundled **recompression chamber directory** grew from 14 chambers to 209 across 59 countries, each entry verified against a named source. +- **Sideloaded Android installs now check for updates** and show the update banner. Submersion is not on Google Play quite yet (but almost!), so previously they never learned a new build existed. +- Debug log exports name the exact build they came from. +- Layout polish: Media Library selection follows the shared selection pattern, wide dive-detail panes pair Surface GPS with Tide and Cylinders with Weights, and the Site Features section moved below the depth statistics. + +--- + +## 🐞 Bug fixes + +**Sync, backup, and restore** + +Four fixes close every known path to a logbook that suddenly shows no dives: + +- **A restore could be undone by the next sync.** With no recorded sync time, which is exactly the state after a recovery, deletions recorded by other devices were applied without any age check. Deletions are now compared against the record's own edit history, and a disagreement surfaces as a conflict instead of a silent delete. +- **Restoring from a backup whose file had gone missing reported "Restore Complete"** while restoring nothing at all. It now reports the missing file as the error it is. +- **A stale active-diver reference showed an empty logbook** even though every dive was still in the database. The app now detects and repairs the reference on its own. +- **Adopting an existing sync collection, or rejoining after retiring a device, ignored the schema compatibility floor.** Both now hold until the out-of-date device updates, the same way ordinary sync does. + +And the rest: + +- Publishing sync base data from a Windows device wrote into a phantom "Temp" subfolder because paths were joined with mixed separators, breaking sync setup from Windows. +- iCloud backups were uploaded into the sync data folder. They now land in Submersion Backups. +- A hung Dropbox or Google Drive request could stall sync indefinitely. Cloud requests now carry deadlines. +- The Resolve Conflicts dialog dumped raw database columns for entities without a name, such as a dive's tags. It now describes them in words. + +**Dive computers and imports** + +- On a multi-tank dive with several transmitters, only one pressure reading per sample was kept, so the other transmitters' data was lost. libdivecomputer reports one pressure per transmitter, and every one of them now comes through. +- Downloading from a saved computer connected straight to the stored address without scanning first, which could fail to find the device. A scan now precedes the connection. +- Dives imported from files registered no dive computer, so they could not be attributed to or filtered by the computer that logged them. +- Cylinder end pressure was read at the very end of the recording rather than at surfacing. On a rebreather whose closed oxygen valve bleeds the hose down after the dive, that overstated oxygen consumption roughly tenfold. +- Gauge-mode dives grew an estimated tank pressure line for a gas they were not breathing, with no way to remove it. Gauge dives no longer estimate pressure, and estimates can be switched off entirely. +- An OSTC Sport was labelled as the wrong model. + +**Media** + +- A media transfer queue that stopped, for any reason, never restarted; uploads could sit pending forever, especially on desktop where the app runs in one process for days. The queue now resumes itself. +- Adding an iOS Live Photo failed with a bare framework error code. It is now reported as unsupported, which it currently is. + +**Statistics and lists** + +- Personal records in Statistics ignored the active filter. +- The Deco and No Deco legend labels were attached to the wrong bar segments. +- A certification entered with a custom name showed only its agency on the card, hiding the certification itself. +- A buddy's shared-dives list was not sorted by dive date and omitted the year. +- The detailed dive site list showed depths in meters regardless of your unit settings, and the depth filter compared imperial input directly against metric values, so an imperial diver filtering at 100 was actually filtering at 100 meters. + +**Interface and platforms** + +- On iPad, exporting UDDF blanked the screen, and export share sheets opened detached from the control that opened them. Garmin FIT imports also now explain why tank pressure is absent instead of showing nothing. +- In the 3D site view, two-finger zoom now works, the zoom buttons are no longer covered, and the appearance sheet can actually be closed. +- On Windows, app data has moved out of a legacy company-named directory; the move happens automatically on first launch. + +--- + +## ⚠️ Upgrade notes + +- **The database upgrades from schema 160 to 175.** The migration runs once on first launch, and the app takes an automatic backup beforehand, so there is nothing you need to do first. Update your other devices reasonably soon: one migration renames a synced column, which raises the sync compatibility floor, so devices on older builds will be held from syncing until they update. +- **Every registered dive computer gains a gear item,** and its downloaded dives are linked to it, retroactively. If you had already added your computer as equipment by hand, that item is adopted rather than duplicated. Expect Most Used Gear to be topped by your dive computer, since it is on every downloaded dive. +- **A small buoyancy correction:** dive computers previously fell into a 0.5 kg default dry mass in the buoyancy planner. They now count 0.0 kg unless you set a weight yourself, so a rig that included a hand-added computer shifts slightly. +- **Your gas consumption display carries over.** The old SAC unit toggle maps to the matching lane: L/min becomes RMV, pressure per minute becomes SAC. The numbers themselves are computed exactly as before, and new installs default to showing both. +- **No minimum operating system change** on any platform. + +--- + +## 🙏 Contributors + +Thank you to everyone whose work is in this release. @readme42 added the dive type badges, the draggable pane divider, and the persisted CCR cell visibility default. @alpheios-one added the decompression and weekday search filters and the buddy picker's sorting and favorites. And @ericgriffin. + +--- diff --git a/docs/releases/v1.7.7.8077.md b/docs/releases/v1.7.7.8077.md new file mode 100644 index 0000000000..6c6ba2182b --- /dev/null +++ b/docs/releases/v1.7.7.8077.md @@ -0,0 +1,205 @@ +# 🎉 Submersion v1.7.7.8077 Release Notes + +v1.7.7 brings 143 merged changes since v1.7.6, the largest release so far. The headliners are dive logs that shrink to roughly a tenth of their previous size, a proper recovery path when a database turns out to be newer than the app opening it, Suunto Cloud and Garmin Connect imports, USB downloads for the Scubapro G2 and Suunto EON Steel families, a rebuilt MacDive importer, real Linux packages, species photo tagging with an online lookup and 174 freshwater species, site suggestions from photo GPS, per-dive statistics charts, and a reworked dive planner. This release upgrades the database (schema 175 to 191) and compacts the file once, so please read the upgrade notes at the end. + +--- + +## ✨ New and improved + +### Dive logs a tenth the size + +The second-by-second profile behind every dive chart used to be stored one database row per sample, and on a typical logbook those rows were about 90% of the file. They are now packed losslessly, one record per profile per source: a 1,040-dive test library went from 751 MB to 35 MB, and profile reads got several times faster. Charts, statistics, exports and sync are unchanged, and the data round-trips byte for byte. The raw bytes downloaded from your dive computer, kept so a dive can be re-parsed later, are now compressed too, typically 2.4 to 4.7 times smaller, and since backup retention keeps up to 13 copies of the database the savings multiply across your backups. Raw computer data now also travels in UDDF exports, in the standard's dive computer dump section, so Re-parse keeps working after a UDDF restore and every data source comes back with its original import date. + +### When your data is newer than the app + +If a beta build, or a backup restored from a newer build, had upgraded your database, the stable build refused to open it and its only button led to the stable download page: the exact build that had just refused the file. That screen is now titled "Your Data Is Newer Than This App", explains the likely causes, links both the stable and the clearly marked pre-release download pages, and, when the app finds the pre-upgrade backup it took automatically, offers a one-tap restore of it. Before swapping, it saves the newer database into your backups folder as a pinned backup so nothing logged in the newer build is lost. Pre-migration backup pruning was also corrected: it kept the three newest copies, which could all be too new for the build you had, and it now always retains the copy with the lowest schema version. Backups are additionally tagged with a non-identifying fingerprint of the device that wrote them, so future cleanups can never remove another device's only copy. + +### Suunto Cloud and Garmin Connect imports + +The Transfer page gains a Cloud section. Sign in to your Suunto app account or Garmin Connect account and dives come in through the same wizard a Bluetooth or USB download uses, so tanks, gas switches, duplicate detection and consolidation behave identically. Only a session token is kept in the platform keychain; the password is never stored, and Garmin's multi-factor challenge is supported. Both fetches are paged newest first (15 dives by default) with Load More and Fetch All, and the Garmin step lets you pick dives from a checkbox list. Garmin dives whose FIT file carries no GPS now take the activity position Garmin Connect reports. The import review list also gains a sort by date, depth or time. + +### USB downloads for the Scubapro G2 and Suunto EON Steel families + +Submersion now speaks USB HID on macOS, Windows and Linux. The Scubapro G2, G2 TEK, G2 Console, G2 HUD, G3 and Aladin Square, and the Suunto EON Steel, EON Core, D5 and EON Steel Black, are selectable under USB in the dive computer wizard, where before the only route (if any) was Bluetooth; the Aladin Square was previously unreachable altogether. Which hardware is supported comes straight from libdivecomputer's device tables. This path has not yet been verified against physical hardware, so reports from G2 and EON Steel owners are very welcome. + +### A rebuilt MacDive importer + +Importing a MacDive database used to decode profiles only for Shearwater computers, and later three Suunto models; everything else got a "use MacDive's XML export" warning. The importer now resolves the computer name MacDive stored against libdivecomputer's full device list, so every model Submersion supports is decoded, with a plausibility check (depth, duration, sample order, agreement with MacDive's own figures) before a profile is attached. For dives with no usable raw data, MacDive's own drawn profile turned out to be a simple fixed-key cipher, and it is now decoded as a fallback: on a 540-dive reference library every profile now imports, sample for sample. Photos referenced by a MacDive logbook are imported through the wizard's Photos step, linked in place from MacDive's image folder rather than copied. And a bug that imported EAN32 as 3200% oxygen, corrupting CNS and OTU, is fixed (dives imported before this fix need a re-import). + +### Real Linux packages + +The Linux download was a tarball of loose files that installed nothing, declared no dependencies, and gave no device permissions, so USB dive computers never appeared. Linux now ships .deb and .rpm packages built from the same binary: they add a desktop entry and icons, register with GNOME Software and KDE Discover, and install udev rules generated from libdivecomputer's device tables that grant USB access to the logged-in user with no group membership or re-login. One .rpm installs on Fedora, openSUSE and RHEL alike. The tarball remains for Arch, NixOS and others, now with install and uninstall scripts that report missing libraries with the exact apt, dnf, pacman or zypper command. Also on Linux: every export used to fail silently because there is no share sheet, and exports now open a save dialog; and the OAuth sign-in for Dropbox, Google Drive and Lightroom falls back through xdg-open and friends and always offers a Copy link button. + +### Species, photos and a bigger catalog + +Species is now a top-level destination in the desktop sidebar and the phone's More sheet, and "Marine Life" is called "Species" everywhere. You can tag the photos on your dives with the species in them, from the photo viewer or from a species' detail page, which gains a photo gallery; tagging a photo adds the sighting to that dive if it was missing, and tags sync between devices. Each tile on the Species page shows the species' newest photo, sighting rows show photo counts, and the media library gains a Species filter. When a species is not in the catalog, look it up on iNaturalist from the species editor, the dive picker or a site's nearby-species list: the lookup fills in common name, scientific name and category, with licensed photos and attribution, and sends nothing until you tap Look up. The bundled catalog gains 174 freshwater species (pike, perch, trout, carp, sturgeon, rift-lake cichlids, crayfish, turtles, otters, water plants and more) in all 11 languages, and it is now versioned, so future additions reach existing installs while any built-in species you edited is left exactly as you changed it. + +### Site suggestions from photo GPS + +The "GPS found in photos" banner used to appear only on the dive edit page, only from phone stills, and only offered to create a brand-new site. Photo location is now read on every platform, including desktop stills and videos from iPhones, most cameras and GoPros, using the photo taken closest to the dive's entry time. Before offering to create anything, the app matches the location against your own sites and the bundled catalog, so the suggestion can assign the nearest site, let you pick a nearby one, add coordinates to your current coordinate-less site, or create a new one with country, region and city prefilled. It appears on the dive detail page as well as the editor, importing photos across several dives prompts a review of all of them at once, and dismissals sync across devices. + +### Statistics that show every dive + +The depth, bottom time, weight, SAC and water temperature trend charts averaged each month over a fixed five-year window. They now plot every dive as its own point on a true date axis, with a rolling average on by default and an optional straight-line trend that reports a rate such as "+51.8 ft/yr" in your units; weekly and monthly averaging remain as options with a shaded min-to-max band. "All time" now reaches every dive, Last 5 years and Last 10 years join the date presets, and the statistics detail pages get their own filter button. The depth distribution chart extends to 130 m in 10 m steps with count and total time per bucket and per dive type, and dive site pages gain a computed Dive Statistics card (count, deepest, longest, average duration, first and last dive). The Water Type and Entry Method charts now count dives that inherit those values from their site. + +### Combined and consolidated dives, taken apart and compared + +A combined dive can now be separated back into its segments from the Data Sources section, each with its own profile, tank pressures, events and GPS fix, including dives combined before this update; the logbook details stay on the original dive, and the dialog says so first. Dives recorded on two computers get a full comparison: every per-computer analysis (deco, ceiling, NDL, TTS, gas, CNS, OTU, GTR and the rest) is computed independently and can be overlaid in the chart and its tooltip, tanks with the same mix are merged even when one side lacks pressure data, and legend toggles are coloured names you can click. The detail chart and sparklines now draw the active computer's series rather than an interleaved union, which fixed a sawtooth temperature line and stutter on consolidated dives. + +### A reworked dive planner + +Segments no longer carry a type you pick from a dropdown: descent, level, stop and ascent are read off the depths, so a leg can no longer be labelled Ascent while descending, and a new segment starts where the previous one ended. Tanks sit above the segments, saved cylinders can be added to a plan with one tap and any plan tank can be saved for reuse, and cylinder roles follow what the segments actually breathe. Three deco engine defects were fixed, including a schedule that could tell you to ascend through your own ceiling; ascent rates are now 9 m/min from the bottom, 6 between deep stops, 3 between shallow stops and 1 off the last stop; stops snap to whole minutes; and you can choose a last stop of 3, 4, 5 or 6 m. The ceiling shading on the plan chart now follows the selected contingency and is drawn only where a ceiling exists. Separately, a plan seeded from a previous logged dive came out permanently less conservative than a fresh plan regardless of surface interval, and now matches it stop for stop. + +### Equipment records that satisfy an insurer + +Every item gains SKU, Retailer and Web link fields beside the purchase date and price, shown on both the edit form and the detail card and carried through sync, backup and CSV export. A web link typed as a bare host is treated as https, and the detail row is tappable with a copy-link fallback. A new Documents card on the equipment detail page holds invoices, receipts and warranty paperwork, linked in place rather than copied, with PDFs opening in the in-app viewer; attachments are removed with the item when it is deleted. + +### Nine new equipment types + +Rash Guard, Snorkel, Tool, Instrument / Gauge and Compass each arrive with their own fields (sleeves and UPF rating, purge valve, gauge range and depth rating, balance zone and tilt tolerance) and their own icon, and the Other type moves off the wrench icon so it no longer looks like a tool. Undersuit and Base Layer become first-class types with a warmth rating and fill material, and gloves gain three-finger, dry glove liner and utility variants. Undersuit warmth and glove type feed the buoyancy engine, so swapping a light undersuit for a heavy one now shows up as its own lead term in a dive's Buoyancy section. UDDF and MacDive imports recognise the new types instead of filing them under Other or as a second drysuit, and an older version of the app shows them as Other rather than failing. + +### Profile photos for divers and buddies + +Divers and buddies can now carry a profile photo, with a circular crop tool at pick time. Photos are stored in the database itself, resized to 512 by 512, so they sync to your other devices and survive reinstalls; orientation is corrected and location metadata is stripped before saving. They show on buddy rows, the dive detail page and the Detailed PDF's diver page. + +### Where your storage goes + +A new Storage usage page under Settings reports what the database, media caches, staging files, map tiles, thumbnails, backups, temporary files and exports each occupy. Browse map tiles now live in their own cache capped at 5,000 tiles and swept after 30 days, while your downloaded offline regions are never capped or swept and each new region gets its own store so deleting it really frees the bytes. The media cache checks its budget on every launch, scratch files that nothing ever deleted (picked-file copies, abandoned uploads, transcode leftovers, stale thumbnails) are swept daily, and devices that never sync purge their deletion records after 30 days. + +### One record per dive computer + +The same physical computer could be saved more than once, most often one record per Apple device because each Mac, iPhone and iPad assigns its own Bluetooth identifier, with dives split between the records. Duplicates can now be merged from the dive computer list, from a computer's detail menu, or from a banner that appears whenever another record shares the serial; every dive, tank, profile and equipment link moves to the survivor and there is no undo. Related: a saved computer whose Bluetooth identifier changed used to scan for 35 seconds and fail, and the download now proceeds when exactly one discovered device matches the saved model, writing the new identifier back if the serial agrees. + +### Seas named, place names repaired + +The body-of-water field stayed empty for nearly every dive site because OpenStreetMap has no ocean or sea polygons. The app now ships the IHO Limits of Oceans and Seas table (101 named areas, in all supported languages) and consults it before going online, with the smallest area winning, so the Gulf of Aqaba beats the Red Sea and the Adriatic beats the Mediterranean; marine sites also need one online request instead of two. If your place-name language changed at some point and older sites hold English names while newer ones hold local ones, a new Refresh place names action in the site list re-looks-up every site with coordinates in the current language, and the same run is offered right after you change the language. This is the one action in the app that overwrites hand-typed location fields, and its dialog says so. + +### Smaller additions + +- **Choose which dive detail sections show, and how.** A display-options menu picks the sections and offers the existing Detailed layout or a new List layout that folds each section to a header row and lets you drag them into a new order. +- **The navigation customizer now saves the whole order**, not just the first three, and the desktop sidebar gets its own order behind a Phone / Desktop switch. +- **Find gear faster.** The equipment list gets a filter panel with a Category section alongside Status, advanced dive search gains an equipment picker so you can find every dive on which you used a particular regulator, and a pre-dive checklist item can be linked to one piece of gear so overdue service surfaces in the checklist itself. +- **Exclude a dive from statistics.** A dive can now be excluded from statistics, or from gas statistics only, in the editor or in bulk, with a badge explaining why it is not counted and a filter that finds your excluded dives. It stays fully visible and editable in the logbook. +- **GTR (gas time remaining)** joins the profile chart, either your computer's own or one calculated from your SAC and a reserve pressure set under Settings > Decompression. +- **Gas Calculators is a split view** like the rest of Planning instead of a sideways-scrolling tab strip: a list of the six calculators beside the selected one on wide windows, or a list that opens each on its own page on narrow ones. Nothing about the calculations changed, and typed values survive switching between them. +- **The trimix blender remembers your work.** Start and target pressure and mix, plus the fill gases, now persist across app restarts instead of resetting every launch. The fill-gas, blending-conditions and billing cards move behind a settings gear on the blender screen, with a second gear beside the cost card that jumps straight to the default settings and billing section, so the main screen shows only the blend. Cylinder sizes are now a single editable list of name and litres under that section, replacing the fixed presets in the cylinder dropdown. +- **The weight planner takes height into account** through an optional BMI term, matches the AL30 and AL40 stage entries in its catalogue correctly, and gains the AL100 as a built-in cylinder preset. +- **CCR ppN2, ppHe, gas density and MOD curves now follow the loop** instead of the first tank's fixed fractions, so MOD on a rebreather is the diluent's MOD. +- **The emergency card leads with your own insurer** once you record its 24-hour line on your insurance details, with the regional DAN or DES hotline as the fallback. The chamber directory gains 14 facilities across Belgium, the Netherlands and Luxembourg, now 223 in total. +- **The dashboard's red urgent banner is gone**; overdue gear shows as red chips at the front of the gauge strip, and safety chips show their alert state even if you hid them. +- **Last Dived sorts** for the sites list and the buddies list, never-dived entries always last. +- **Imported dives whose profile shows a deco obligation default to the Technical type** instead of Recreational, when the source carries no type. +- **The Detailed PDF logbook is finally detailed**: one dive per page with a vector profile, full notes, every cylinder, and field groups for conditions, team, equipment, technical data, marine life and signatures. Certification lists paginate instead of stopping after one page, every template prints in your units, and the Professional template is folded into Detailed. +- **The 3D seascape picks the best bathymetry source** by resolution and coverage and adds a high-resolution coastal tier for US waters, so Bonaire renders with a real coastline instead of a flat slab; and the depth axis in both 3D views now scales to the fetched terrain rather than the site's recorded maximum, which was squashing the terrain toward the surface. +- **The Cressi Leonardo's deco obligation and ascent-rate alarms** are now read correctly by libdivecomputer instead of flagging most of an ordinary ascent. +- **Your date format is respected everywhere**: Trips, Certifications, settings, media, safety, checklists, courses, buddies, backups, the GPS log and chart axes all follow the Manage > Units setting, and the calendar dialog stays in the app's language whatever format you choose. +- Google Drive now appears in the first-run wizard's Backup & Sync step. + +--- + +## 🐞 Bug fixes + +**Sync, backup and cloud storage** + +- Google Drive sync could fail on every attempt with "Client is already closed" until the app was restarted, and Drive media transfers failed the same way after any token refresh. Both recover on their own now. +- Google Drive listings silently stopped at 100 files, so peer libraries could go unseen and media past the first 100 objects was invisible. Listings now page to the end. +- A fresh install on macOS could inherit the old install's sync identity, see the whole library as its own files, and offer Start Fresh over live data. +- Photos uploaded from the desktop showed as "Not backed up" on the phone and failed with "source unavailable"; a one-time repair runs at launch. Update both devices before their next sync. +- With iCloud as the media store on a Mac and an iPhone, the Mac's uploads could sit at "Waiting" forever after the second device connected. A library already split still needs a disconnect and reconnect, which the new "Transfers paused" notice explains. +- Adopting a large library from another device sat at "Pulling changes..." with a frozen bar and could hang the app. Progress now advances through every pass and the parser is many times faster. +- If one device's changes failed to read during a sync, the failure was silent and that device stopped merging. The Cloud Sync page now names the device and retries it next time. +- Sync files and peer-supplied GPS tracks were decompressed without limits, so one corrupt payload could exhaust memory on every device in the library. Both are now bounded and refused quickly. +- Reset Sync State could delete a sync export another process was writing at that moment. + +**Dive computers** + +- Every platform's translation of libdivecomputer's event codes was off by one past a certain entry, so ascent-rate alarms showed as Deco Violation on every computer (a Mares Sirius owner saw them all over a 16 m no-deco dive), and ceiling, deep stop and PO2 events were dropped. See the upgrade notes for re-parsing. +- A Ratio iX3M 2 on APOS5 firmware 5.2.11 imported every dive as 0 m and 0 minutes, and a 300 bar transmitter read half its pressure. Re-parse recovers the profiles. +- A Shearwater Petrel 2 failed every download on Android a few milliseconds after connecting, because Android chose the Classic transport on a dual-mode radio. The Windows half of that report is still open pending a log. +- A Shearwater Perdix 3 was rejected by the Android scanner because of a stray terminator byte in its advertised name. +- A Seac Tablet never appeared in a Bluetooth scan because libdivecomputer accepted only one exact name shape. It is discovered now, though its download still fails for a separate reason that is tracked. +- A Suunto Ocean was misidentified as an Oceans S1 and then failed to connect. It is now passed over honestly; Ocean owners should import from the Suunto app. +- Several Android and Windows Bluetooth failures either reported success on a dead link, hung for the full 15-second timeout, or blamed the wrong cause. They now fail fast with the real reason. +- Downloading from a saved computer listed the dives and then showed "Dives (0)" on the Review step, because a late device-info write rebuilt the page with an empty session. + +**Imports and exports** + +- Importing CSV, UDDF, SSRF or XML files could strand the wizard on a Map Fields step showing "0 of 0 columns", and Next could do nothing after a Peregrine download. A failed step now stays put and shows why, and duplicate detection is advisory rather than fatal. +- Subsurface UDDF dives entered without a time were dated the day of the import and numbered wrongly, and Subsurface trips were not imported at all. +- Writing dive metadata to a video could delete your original from your library, and on Apple platforms that deletion propagated through iCloud Photos. Video metadata writing is withdrawn on every platform; photo metadata writing is unchanged and edits in place. +- Photos imported as files sometimes showed a broken-image tile with a cloud badge even on the device that imported them. Thumbnails now resolve the same way the viewer does. + +**Dive log and charts** + +- Opening a dive in the editor and saving it unchanged silently reset eleven values, including the dive computer model, serial and firmware, deco algorithm and gradient factors, and the planned-dive flag. All survive a save now; values already wiped need a re-download or re-import. +- A star rating could never be removed. Tapping the current star, or a new clear icon, clears it, in the editor and in bulk edit. +- Capturing a buddy or instructor signature has never saved anything on any platform. Signatures now save, sync, render at the size you drew them, and stay out of the media library. +- Range-stats handles on the profile chart sat outside the plot, so the marker never lined up with the figures it reported; a related right-edge offset affected the gas strip, photo markers and hover mapping. +- The profile chart flashed once about 300 ms after a dive was first opened. +- Consolidating a dive while touching the chart could crash it with a range error. +- Consolidating tanks from two computers skipped the pressure check whenever any reading was missing, so a 207/63 bar tank could merge with a null/150 bar one silently. +- A dive combined from two file or cloud imports drew only one half on the detail chart. +- Date filters compared against the device's time zone offset rather than local midnight, so at UTC-5 "This year" dropped dives logged before 05:00 on 1 January. Filters are calendar days everywhere now. +- Opening Appearance > Dives > Dive List Fields pinned a CPU core and froze the app. +- In bulk edit, Tags > Add > Browse opened the picker behind its dialog where nothing could be tapped. +- Some dives showed the same dive type badge two or more times after syncing across devices. Duplicates are collapsed on upgrade and cannot return. +- Clicking zoom "+" on a chart made the reset button appear under the cursor, so a quick second click reset instead of zooming. +- The seasonal water temperature chart drew its labels off the grid lines and let unit-formatted labels wrap and collide. + +**Planner** + +- A new planner segment always started at 0 m and ended at 18 m regardless of the previous segment. +- On phones the planner pinned the chart and readouts at the top, leaving a third of the screen for the results. The page now scrolls as one. + +**Equipment and safety** + +- The Service Due equipment filter returned nothing, even for gear badged OVERDUE, because it read a retired service field. +- Filtering an item's maintenance history changed the rows but not the Total. +- Equipment value totals showed a bare "$" for both US and Canadian dollars; labels now carry the currency code. +- Trip checklists were only editable on liveaboard trips. The section now appears on every trip, with a Clear checklist action. + +**Interface and platforms** + +- The trip and certification lists, the update check's "Last checked" stamp and the purchase date field ignored the diver's date format. +- The More sheet on phones could grow under the Android status bar, hiding its title and close button. +- Offline map regions that were deleted freed no storage. New regions get their own store; regions downloaded before this update show an Unknown size and cannot be freed individually (see upgrade notes). +- The "Update Required" screen could not scroll, cutting content off on small windows and large text sizes. + +--- + +## ⚠️ Upgrade notes + +- **The database upgrades from schema 175 to 191.** The migration runs once on first launch and the app takes an automatic backup beforehand, so there is nothing you need to do first. The profile repacking then compacts the file once, which can take a little while on a large logbook; let it finish. The packed profile storage raises the sync compatibility floor, so update every device in your library reasonably soon; older devices are held from syncing until they update. +- **Re-parse your dives once after updating.** Three libdivecomputer corrections (the off-by-one event table behind false Deco Violation markers on every computer, the Ratio iX3M 2 APOS5 5.2.11 profiles, and the Cressi Leonardo deco obligation) apply only to new downloads. Open each dive computer's page and run "Re-parse all dives" to rebuild existing dives from their stored raw data. +- **Your dive count may change.** Planned dives that were never logged no longer count toward any statistic, so anyone who has used the planner will see counts and averages shift. Dives you exclude by hand are still counted for gear service intervals, course progress and the logbook number. +- **Planner schedules change slightly.** The corrected deco engine, new ascent rates and whole-minute stops mean existing plans will show slightly different stop times and runtimes. +- **MacDive gas mixes imported before this release** are inflated a hundredfold on any non-air tank and need a re-import or a manual correction. +- **Offline map regions downloaded before this release** cannot be separated from the shared tile cache after the fact. They keep working, show an Unknown size, and can only be freed by clearing the whole cache; regions downloaded from now on delete cleanly. +- **The weight planner's predictions move a little** for dives with a stage bottle (the AL30 and AL40 entries are now matched correctly), for divers whose profile records a height, and for anyone who re-types gloves or undergarments as the new equipment types. +- **Dive detail section order:** if you sync with a device still on an older version, that device falls back to its default section order. Nothing is lost. +- **"Marine Life" is now "Species"** throughout the app; the "Marine life" incident category in safety records keeps its name because it describes a cause of injury. +- **Linux requires glibc 2.38 or newer**, which has been true of every release since three bundled libraries need it; Debian 12, Ubuntu 22.04 and RHEL 9 cannot run Submersion. The new .deb and .rpm packages install to /usr/lib/submersion and show the package-manager upgrade command instead of a Download button when an update is available. If you were using the tarball, uninstall it before installing a package so the two do not shadow each other. +- **If you run beta builds alongside stable**, note that the pre-migration backup set now always keeps the copy with the lowest schema version, and the "Your Data Is Newer Than This App" screen can restore it for you. Restoring a pinned copy of the newer database later requires a build that supports its schema. +- **No minimum operating system change** on macOS, Windows, iOS or Android. + +--- + +## 🙏 Contributors + +Thank you to everyone whose work is in this release. A first-time contributor, @trustthegoose, taught the MacDive importer to decode Suunto raw data and fixed the hundredfold gas mix error. @dotanalon contributed the Suunto Cloud and Garmin Connect imports, the planner rework, the dive detail display options, the full metric comparison for combined dives, the equipment category filter, the Last Dived sorts, the Technical default for imported deco dives, the Linux export dialog and several planner and equipment fixes. @alpheios-one contributed the 130 m depth distribution with per-type dive time, the site Dive Statistics card, the equipment filter in advanced search, checklist items linked to equipment, the trimix calculator persistence and settings, and the 3D depth axis fix. And @ericgriffin. + +--- + +## ⬇️ Download + +- **Website:** [submersion.app](https://submersion.app) +- **All platforms, release page:** [v1.7.7.8077 on GitHub](https://github.com/submersion-app/submersion/releases/tag/v1.7.7.8077) +- **macOS:** [Submersion-v1.7.7.8077-macOS.dmg](https://github.com/submersion-app/submersion/releases/download/v1.7.7.8077/Submersion-v1.7.7.8077-macOS.dmg) +- **Windows:** [Submersion-v1.7.7.8077-Windows-Setup.exe](https://github.com/submersion-app/submersion/releases/download/v1.7.7.8077/Submersion-v1.7.7.8077-Windows-Setup.exe) +- **Linux:** [.deb](https://github.com/submersion-app/submersion/releases/download/v1.7.7.8077/Submersion-v1.7.7.8077-Linux-amd64.deb) for Debian and Ubuntu, [.rpm](https://github.com/submersion-app/submersion/releases/download/v1.7.7.8077/Submersion-v1.7.7.8077-Linux-x86_64.rpm) for Fedora, openSUSE and RHEL, or the [tarball](https://github.com/submersion-app/submersion/releases/download/v1.7.7.8077/Submersion-v1.7.7.8077-Linux.tar.gz) for everything else +- **Android:** [Submersion-v1.7.7.8077-Android.apk](https://github.com/submersion-app/submersion/releases/download/v1.7.7.8077/Submersion-v1.7.7.8077-Android.apk) +- **iOS:** [App Store](https://apps.apple.com/us/app/submersion-dive-log/id6757456915) + +Existing installs on macOS, Windows, Linux and sideloaded Android offer this update through the in-app updater. Want fixes weeks earlier? The beta channel publishes every merged change: [beta-builds](https://github.com/submersion-app/beta-builds/releases) for desktop and Android, the [Play open test](https://play.google.com/apps/testing/app.submersion) for Android, and [TestFlight](https://testflight.apple.com/join/aMD393sB) for iOS. Betas may upgrade your database ahead of the stable release, so devices that sync together should stay on one channel. + +--- diff --git a/docs/superpowers/plans/2026-08-20-pdf-logbook-export.md b/docs/superpowers/plans/2026-08-20-pdf-logbook-export.md new file mode 100644 index 0000000000..ead5c91ad6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-pdf-logbook-export.md @@ -0,0 +1,1375 @@ +# PDF Logbook Export Rework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Detailed PDF logbook actually detailed (one dive per page, a depth profile chart, and the full field list from issue #1017), fix unit handling and certification pagination across every template, delete the redundant Professional template, and route every PDF entry point through the same template system. + +**Architecture:** Templates stay behind `PdfTemplateBuilder` and `PdfTemplateFactory`. A `UnitFormatter` is threaded into `buildPdf` the same way `PdfDateFormatter` was for #964. Depth profiles are loaded in batch by the export provider, downsampled to a bounded point count, and drawn as native PDF vector charts with the `pdf` package's own `Chart` widgets. The legacy duplicate builder inside `PdfExportService` is deleted and its two callers are routed through the factory. + +**Tech Stack:** Flutter, Dart, `pdf` 3.13.0 (`pw.Chart`, `pw.LineDataSet`, `pw.CartesianGrid`, `pw.MultiPage`), Drift, Riverpod, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-08-20-pdf-logbook-export-design.md` + +## Global Constraints + +- **No em-dashes** (U+2014) in any output: code, comments, docs, commit messages. En-dashes as prose punctuation and spaced hyphens are equally forbidden. Hyphens inside compound words and CLI flags are fine. +- **No emojis** in code, comments, or documentation. +- **Units must follow the active diver's settings.** Never print a hardcoded `m`, `°C`, `bar`, `kg`, or `L`. Use `UnitFormatter`. +- **`dart format .`** must run clean after every task. Run it over the whole project, not just changed files. +- **`flutter analyze`** must be clean. Info-level diagnostics are fatal in CI. +- **Never pipe `flutter test` into `grep`/`tail`.** The pipeline returns the last command's exit status, so a failing run reads as exit 0. Run it bare and read the summary. +- **l10n changes must cover all 11 arb files** in `lib/l10n/arb/`. +- **Do not use `git stash`.** The stash stack is shared with other worktrees. Use a WIP commit instead. +- Work only inside the worktree at `.claude/worktrees/pdf-detailed-log-export`. + +## File Structure + +**Created:** +- `lib/core/services/pdf_templates/pdf_front_matter.dart` - diver information page absorbed from the deleted Professional template +- `lib/core/services/pdf_templates/pdf_profile_series.dart` - downsampled profile value object +- `lib/core/services/pdf_templates/pdf_profile_chart.dart` - vector depth-profile chart widget + +**Modified:** +- `lib/core/constants/pdf_templates.dart` - remove `professional`, add `includeVerificationAreas` +- `lib/core/services/pdf_templates/pdf_template_builder.dart` - `units`, `profiles`, `diverPhoto`, `includeVerificationAreas` parameters +- `lib/core/services/pdf_templates/pdf_shared_components.dart` - unit-aware output, paginating certifications, enriched summary +- `lib/core/services/pdf_templates/pdf_template_detailed.dart` - full rebuild +- `lib/core/services/pdf_templates/pdf_template_simple.dart` - summary block, certifications, units +- `lib/core/services/pdf_templates/pdf_template_padi.dart` - units, certification pagination +- `lib/core/services/pdf_templates/pdf_template_naui.dart` - units, shared summary helper +- `lib/core/services/pdf_templates/pdf_template_factory.dart` - drop the Professional branch +- `lib/features/dive_log/data/repositories/dive_repository_impl.dart` - batch profile loading +- `lib/features/settings/presentation/providers/export_providers.dart` - pass units, profiles, diver photo +- `lib/features/transfer/presentation/widgets/pdf_export_dialog.dart` - verification-areas switch +- `lib/core/services/export/export_service.dart`, `lib/core/services/export/pdf/pdf_export_service.dart` - accept options, delete legacy builder +- `lib/features/dive_log/presentation/widgets/dive_list_content.dart`, `lib/features/dive_log/presentation/pages/dive_detail_page.dart` - template picker + +**Deleted:** +- `lib/core/services/pdf_templates/pdf_template_professional.dart` + +## Task Ordering Rationale + +Professional's diver page is absorbed (Task 1) before Professional is deleted (Task 2), so nothing is lost. Units land (Task 3) after the deletion so no unit work is spent on a doomed file. The profile pipeline (Tasks 6 and 7) is built before the Detailed rebuild (Task 8) consumes it. + +--- + +### Task 1: Absorb Professional's diver page into shared front matter + +The Professional template owns the only diver information page in the app. It draws an empty 100x120 box literally labelled `'Photo'` and caps the certification list at five. Move it to a shared file, render the real portrait from `Diver.photoPath`, and remove the cap. + +**Files:** +- Create: `lib/core/services/pdf_templates/pdf_front_matter.dart` +- Create: `test/core/services/pdf_templates/pdf_front_matter_test.dart` +- Modify: `lib/core/services/pdf_templates/pdf_template_professional.dart:53-66` (call the shared version) + +**Interfaces:** +- Consumes: `PdfSharedComponents`, `Certification`, `Diver`, `PdfDateFormatter`, `certificationTitle` +- Produces: + ```dart + class PdfFrontMatter { + static pw.Widget buildDiverPage({ + required Diver diver, + required PdfDateFormatter dates, + required int diveCount, + List certifications = const [], + Uint8List? photoBytes, + PdfColor accentColor = PdfColors.blue800, + }); + } + ``` + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/pdf_templates/pdf_front_matter_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_front_matter.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; + +import '../../../helpers/pdf_text.dart'; + +void main() { + final dates = PdfDateFormatter( + dateFormat: DateFormatPreference.ddmmyyyy, + timeFormat: TimeFormat.twentyFourHour, + ); + + Certification cert(String name, {String? card}) => Certification( + id: name, + name: name, + agency: CertificationAgency.padi, + cardNumber: card, + issueDate: DateTime(2020, 5, 1), + createdAt: DateTime(2020, 5, 1), + updatedAt: DateTime(2020, 5, 1), + ); + + Future render(List certs) async { + final doc = pw.Document(); + doc.addPage( + pw.Page( + build: (context) => PdfFrontMatter.buildDiverPage( + diver: Diver( + id: 'd1', + name: 'Ada Lovelace', + createdAt: DateTime(2020, 1, 1), + updatedAt: DateTime(2020, 1, 1), + ), + dates: dates, + diveCount: 42, + certifications: certs, + ), + ), + ); + return pdfVisibleText(await doc.save()); + } + + test('renders the diver name and dive count', () async { + final text = await render([cert('Open Water')]); + expect(text, contains('Ada Lovelace')); + expect(text, contains('42')); + }); + + test('renders every certification, not just the first five', () async { + final certs = List.generate(8, (i) => cert('Course $i')); + final text = await render(certs); + for (var i = 0; i < 8; i++) { + expect(text, contains('Course $i'), reason: 'certification $i missing'); + } + expect(text, isNot(contains('more'))); + }); +} +``` + +Adjust the `Diver` and `Certification` constructor arguments if the required fields differ; read the entity files first. + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_front_matter_test.dart` +Expected: FAIL, `pdf_front_matter.dart` does not exist. + +- [ ] **Step 3: Create the shared front matter** + +Create `lib/core/services/pdf_templates/pdf_front_matter.dart`. Port the body of `PdfTemplateProfessional._buildDiverProfilePage` (currently `pdf_template_professional.dart:213-323`) with three changes: + +1. Replace the placeholder photo box at `:249-264` with a real image when `photoBytes` is non-null, falling back to the existing empty box when it is null: + +```dart +photoBytes != null + ? pw.Container( + width: 100, + height: 120, + child: pw.Image(pw.MemoryImage(photoBytes), fit: pw.BoxFit.cover), + ) + : pw.Container( + width: 100, + height: 120, + decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)), + child: pw.Center(child: pw.Text('Photo', style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey500))), + ), +``` + +2. Delete the `.take(5)` at `:279` and the `'... and N more'` block at `:315-317`. Map over the full list. +3. Add the card number to each entry when present, since #1017 asks for it: + +```dart +if (cert.cardNumber != null) pw.Text('Card #: ${cert.cardNumber}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700)), +``` + +- [ ] **Step 4: Point Professional at the shared version** + +In `pdf_template_professional.dart:53-66`, replace the call to the private `_buildDiverProfilePage` with `PdfFrontMatter.buildDiverPage(...)` and delete the private method. This keeps the template green until Task 2 removes it. + +- [ ] **Step 5: Run the tests and verify they pass** + +Run: `flutter test test/core/services/pdf_templates/` +Expected: PASS, including the pre-existing template tests. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "refactor(export): move the diver page into shared front matter" +``` + +--- + +### Task 2: Delete the Professional template + +**Files:** +- Delete: `lib/core/services/pdf_templates/pdf_template_professional.dart` +- Modify: `lib/core/constants/pdf_templates.dart` (enum case at `:10`, `divesPerPage` at `:48`, `supportsCertificationCards` at `:64`) +- Modify: `lib/core/services/pdf_templates/pdf_template_factory.dart:36-37` +- Modify: `lib/l10n/arb/*.arb` (11 files, keys `enum_pdfTemplate_professional` and `enum_pdfTemplate_professional_description`) +- Modify: any test referencing `PdfTemplateProfessional` (notably `test/core/services/pdf_templates/pdf_date_preference_test.dart:47` and `pdf_visibility_test.dart`) + +**Interfaces:** +- Consumes: nothing new +- Produces: `PdfTemplate.values` with 4 entries: `simple`, `detailed`, `padiStyle`, `nauiStyle` + +`PdfTemplate` is never persisted anywhere in the app, so there is no stored value that can fail to deserialize and no migration is required. This was verified before planning. + +- [ ] **Step 1: Write the failing test** + +Add to `test/core/services/pdf_templates/pdf_templates_test.dart`: + +```dart +test('the professional template is gone', () { + expect(PdfTemplate.values.map((t) => t.name), isNot(contains('professional'))); + expect(PdfTemplate.values, hasLength(4)); +}); +``` + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_templates_test.dart` +Expected: FAIL, `professional` is still present and there are 5 values. + +- [ ] **Step 3: Remove the enum case and its switch branches** + +In `lib/core/constants/pdf_templates.dart`, delete `professional` from the enum and its `case` in `displayName`, `description`, `divesPerPage` and `supportsCertificationCards`. Dart exhaustiveness checking will point at any switch you miss, so let `flutter analyze` guide you. + +- [ ] **Step 4: Remove the factory branch and the file** + +```bash +git rm lib/core/services/pdf_templates/pdf_template_professional.dart +``` + +Delete the `case PdfTemplate.professional:` branch and the now-unused import in `pdf_template_factory.dart`. + +- [ ] **Step 5: Remove the l10n keys from all 11 locales** + +For each file in `lib/l10n/arb/`, remove `enum_pdfTemplate_professional`, `enum_pdfTemplate_professional_description`, and any `@enum_pdfTemplate_professional*` metadata entries. Verify none remain: + +```bash +grep -rn "pdfTemplate_professional" lib/l10n/arb/ +``` + +Expected: no output. Then regenerate: + +```bash +flutter gen-l10n +``` + +- [ ] **Step 6: Update tests that enumerate templates** + +Remove the `'Professional': PdfTemplateProfessional.new` entry and its import from `pdf_date_preference_test.dart`, `pdf_visibility_test.dart`, and any other test that lists all builders. Delete any Professional-specific test file. + +- [ ] **Step 7: Run the tests and verify they pass** + +Run: `flutter test test/core/services/pdf_templates/` +Expected: PASS. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "feat(export): remove the redundant Professional PDF template" +``` + +--- + +### Task 3: Thread UnitFormatter through every template + +Nothing in `pdf_templates/` imports `unit_formatter.dart` today. Every depth, temperature and pressure is printed off the metric-base field with a literal suffix, and PADI prints cylinder pressures with no unit at all. + +**Files:** +- Modify: `lib/core/services/pdf_templates/pdf_template_builder.dart:37-45` +- Modify: all four remaining templates and `pdf_shared_components.dart` +- Modify: `lib/features/settings/presentation/providers/export_providers.dart:342-353` +- Create: `test/core/services/pdf_templates/pdf_unit_preference_test.dart` +- Modify: every existing test that calls `buildPdf` + +**Interfaces:** +- Consumes: `UnitFormatter` from `lib/core/utils/unit_formatter.dart`, constructed as `UnitFormatter(settings)`. Relevant methods: `formatDepth(double?, {int decimals = 1})`, `formatTemperature(double?, {int decimals = 1})`, `formatPressure(double?, {int decimals = 0})`, `formatWeight(double?, {int decimals = 1})`, `formatTankVolume(...)`, `depthSymbol`, `sacSymbol`. +- Produces: `buildPdf` gains `required UnitFormatter units`. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/pdf_templates/pdf_unit_preference_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/pdf_templates.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_template_builder.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_template_detailed.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_template_naui.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_template_padi.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_template_simple.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +import '../../../helpers/pdf_text.dart'; + +void main() { + final dive = Dive( + id: 'd1', + diveNumber: 1, + dateTime: DateTime(2026, 3, 28, 14, 30), + runtime: const Duration(minutes: 50), + maxDepth: 30.0, + avgDepth: 18.0, + waterTemp: 20.0, + tanks: const [DiveTank(id: 't1', startPressure: 200, endPressure: 50)], + ); + + final dates = PdfDateFormatter( + dateFormat: DateFormatPreference.ddmmyyyy, + timeFormat: TimeFormat.twentyFourHour, + ); + + // Build the imperial and metric AppSettings the same way the existing + // settings tests do; read test/helpers for an existing factory before + // hand-rolling one. + final imperial = UnitFormatter(imperialSettings()); + final metric = UnitFormatter(metricSettings()); + + final builders = { + 'Simple': PdfTemplateSimple.new, + 'Detailed': PdfTemplateDetailed.new, + 'PADI': PdfTemplatePadi.new, + 'NAUI': PdfTemplateNaui.new, + }; + + Future render(PdfTemplateBuilder b, UnitFormatter units) async => + pdfVisibleText(await b.buildPdf( + dives: [dive], + pageSize: PdfPageSize.a4, + dates: dates, + units: units, + )); + + builders.forEach((name, make) { + test('$name renders imperial depth for an imperial diver', () async { + final text = await render(make(), imperial); + expect(text, contains('ft')); + expect(text, isNot(contains('30.0 m'))); + }); + + test('$name renders metric depth for a metric diver', () async { + final text = await render(make(), metric); + expect(text, contains('m')); + expect(text, isNot(contains('ft'))); + }); + }); +} +``` + +Before writing this, look for an existing `AppSettings` test factory under `test/helpers/`. Reuse it rather than constructing `AppSettings` by hand. + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_unit_preference_test.dart` +Expected: FAIL, `buildPdf` has no `units` parameter. + +- [ ] **Step 3: Add the parameter to the base class** + +In `pdf_template_builder.dart`, add `required UnitFormatter units,` to `buildPdf` and document it alongside the existing `dates` doc line. + +- [ ] **Step 4: Sweep the hardcoded suffixes** + +Replace in each template. The known sites, from the survey: + +| File | Lines | +| --- | --- | +| `pdf_template_simple.dart` | 152 (`m`), 164 (`°C`) | +| `pdf_template_detailed.dart` | 153 (`m`), 163 (`°C`), 169 (`bar`) | +| `pdf_template_padi.dart` | 315 (`m`), 323 (`°C`), 342 (pressures with **no** unit), 338 | +| `pdf_template_naui.dart` | 173, 322, 326, 366 (`m`), 341, 345 (`bar`), 359 (`°C`) | +| `pdf_shared_components.dart` | 557, 558 (`m`) | + +Pattern: + +```dart +// before +'${dive.maxDepth?.toStringAsFixed(1) ?? '-'}m' +// after +units.formatDepth(dive.maxDepth) +``` + +`formatDepth`, `formatTemperature` and `formatPressure` already handle null by returning a placeholder, so the `?? '-'` fallbacks go away. Verify that behaviour by reading `unit_formatter.dart:22` before relying on it. + +- [ ] **Step 5: Update the single production call site** + +In `export_providers.dart`, inside `_buildLogbookPdfBytes`, the settings object is already read at `:337`. Add to the `builder.buildPdf(...)` call: + +```dart +units: UnitFormatter(settings), +``` + +- [ ] **Step 6: Update every existing test that calls buildPdf** + +`flutter analyze` will list them. Each needs `units: UnitFormatter()` added. + +- [ ] **Step 7: Verify no hardcoded suffixes remain** + +```bash +grep -n "'m'\|°C\|'bar'\|}m'\|}°C'" lib/core/services/pdf_templates/*.dart +``` + +Expected: no matches other than inside `unit_formatter` calls or comments. Investigate anything that appears. + +- [ ] **Step 8: Run the tests and verify they pass** + +Run: `flutter test test/core/services/pdf_templates/ test/core/services/export/ test/features/settings/presentation/providers/export_pdf_logbook_test.dart` +Expected: PASS. + +- [ ] **Step 9: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "fix(export): follow the diver's unit settings in PDF logbooks" +``` + +--- + +### Task 4: Paginate certifications and add the missing fields + +`buildCertificationCardsPage` renders every certification with no cap, but all callers wrap it in a single `pw.Page`, so anything past one page is silently dropped. That is what #1017 reports as missing certifications. + +**Files:** +- Modify: `lib/core/services/pdf_templates/pdf_shared_components.dart:241-283` and `_buildCertificationCard:285-466` +- Modify: `pdf_template_detailed.dart:64-76`, `pdf_template_padi.dart:56-70`, `pdf_template_naui.dart:54-68` +- Modify: `lib/core/constants/pdf_templates.dart` (`supportsCertificationCards` returns true for `simple`) +- Create: `test/core/services/pdf_templates/pdf_certification_pagination_test.dart` + +**Interfaces:** +- Produces: + ```dart + // replaces buildCertificationCardsPage + static List buildCertificationCardsBody({ + required List certifications, + required PdfDateFormatter dates, + Diver? diver, + String? highlightAgency, + PdfColor accentColor = PdfColors.blue800, + }); + ``` + Callers place the result as the `build` body of a `pw.MultiPage`. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/pdf_templates/pdf_certification_pagination_test.dart`: + +```dart +test('a certification list longer than one page still renders the last card', () async { + final certs = List.generate( + 24, + (i) => Certification( + id: 'c$i', + name: 'Specialty $i', + agency: CertificationAgency.padi, + cardNumber: 'CARD-$i', + issueDate: DateTime(2019, 1, 1), + expiryDate: DateTime(2030, 1, 1), + createdAt: DateTime(2019, 1, 1), + updatedAt: DateTime(2019, 1, 1), + ), + ); + + final bytes = await PdfTemplateDetailed().buildPdf( + dives: [dive], + pageSize: PdfPageSize.a4, + dates: dates, + units: metric, + certifications: certs, + ); + + final text = pdfVisibleText(bytes); + expect(text, contains('Specialty 0')); + expect(text, contains('Specialty 23'), reason: 'last certification was dropped'); + expect(text, contains('CARD-23')); +}); +``` + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_certification_pagination_test.dart` +Expected: FAIL, `Specialty 23` is absent because it overflowed the single page. + +- [ ] **Step 3: Convert the renderer to a body list** + +Rename `buildCertificationCardsPage` to `buildCertificationCardsBody` and change the return type from `pw.Widget` to `List`. Return the heading widget followed by the individual cards as separate list entries so `MultiPage` can break between them, rather than one `Wrap` that cannot split. + +- [ ] **Step 4: Add expiry to the card** + +In `_buildCertificationCard`, after the issue-date line at `:389-396`, add: + +```dart +if (cert.expiryDate != null) + pw.Text( + 'Expires: ${dates.date(cert.expiryDate!)}', + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), + ), +``` + +- [ ] **Step 5: Switch every caller to MultiPage** + +In Detailed, PADI and NAUI, replace: + +```dart +pdf.addPage(pw.Page(pageFormat: pageFormat, build: (context) => PdfSharedComponents.buildCertificationCardsPage(...))); +``` + +with: + +```dart +pdf.addPage(pw.MultiPage( + pageFormat: pageFormat, + margin: const pw.EdgeInsets.all(32), + build: (context) => PdfSharedComponents.buildCertificationCardsBody(...), +)); +``` + +- [ ] **Step 6: Enable certifications for Simple** + +In `pdf_templates.dart`, `supportsCertificationCards` returns `true` for `PdfTemplate.simple`. The Simple template renders them in Task 9. + +- [ ] **Step 7: Run the tests and verify they pass** + +Run: `flutter test test/core/services/pdf_templates/` +Expected: PASS. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "fix(export): paginate certifications so none are dropped" +``` + +--- + +### Task 5: Enrich the shared summary page + +#1017 asks for first dive, last dive and total dive time on both Detailed and Simple. `buildSummaryPage` has count, total time, deepest, average depth and unique sites, but no date range. NAUI recomputes its own aggregates inline and can drift. + +**Files:** +- Modify: `lib/core/services/pdf_templates/pdf_shared_components.dart:517-565` +- Modify: `lib/core/services/pdf_templates/pdf_template_naui.dart:113-117, 166-173` +- Create: `test/core/services/pdf_templates/pdf_summary_page_test.dart` + +**Interfaces:** +- Produces: + ```dart + static pw.Widget buildSummaryPage({ + required List dives, + required PdfDateFormatter dates, + required UnitFormatter units, + PdfColor accentColor = PdfColors.blue800, + }); + ``` + Note the two new required parameters. Update the Detailed caller at `pdf_template_detailed.dart:58`. + +- [ ] **Step 1: Write the failing test** + +```dart +test('the summary reports the first and last dive dates and total time', () async { + final dives = [ + diveOn(DateTime(2026, 8, 17), runtime: const Duration(minutes: 40)), + diveOn(DateTime(2024, 1, 3), runtime: const Duration(minutes: 50)), + ]; + + final text = pdfVisibleText(await PdfTemplateDetailed().buildPdf( + dives: dives, + pageSize: PdfPageSize.a4, + dates: dates, + units: metric, + )); + + expect(text, contains('First Dive')); + expect(text, contains('03/01/2024')); + expect(text, contains('Last Dive')); + expect(text, contains('17/08/2026')); + expect(text, contains('1h 30m')); +}); +``` + +`dives` arrives newest-first from `getAllDives`, so first dive is `dives.last`. Do not assume sort order in the implementation; compute min and max explicitly. + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_summary_page_test.dart` +Expected: FAIL, no `First Dive` row. + +- [ ] **Step 3: Add the rows** + +In `buildSummaryPage`, compute the range defensively and add two `buildStatRow` entries before the existing `Total Dive Time` row: + +```dart +final sorted = dives.map((d) => d.dateTime).toList()..sort(); +final firstDive = sorted.first; +final lastDive = sorted.last; +``` + +Then unit-format the depth rows at `:557-558` with `units.formatDepth(...)`. + +- [ ] **Step 4: Point NAUI at the shared aggregates** + +Replace the inline `totalRuntime` and `maxDepth` computation at `pdf_template_naui.dart:113-117` with the shared helpers, and unit-format the `'${maxDepth.toStringAsFixed(0)}m'` stat box at `:173`. + +- [ ] **Step 5: Run the tests and verify they pass** + +Run: `flutter test test/core/services/pdf_templates/` +Expected: PASS. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "feat(export): add the dive date range to the PDF summary" +``` + +--- + +### Task 6: Load dive profiles in batch for export + +`getAllDives` skips profile hydration on purpose (`dive_repository_impl.dart:307-308`), so `dive.profile` is empty for every dive that reaches a template. The chart in Task 7 needs data. + +**Do not use `getDiveProfile`.** It filters `isPrimary = true`, and per the #623 investigation `setPrimaryDataSource` can leave a file-imported dive with zero primary rows, so that query would render blank charts for exactly the dives imported from other logbooks. Mirror `getMergedProfile` instead, which keeps every source's rows and drops the originals a saved edit superseded. + +**Files:** +- Create: `lib/core/services/pdf_templates/pdf_profile_series.dart` +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (add the batch method near `getMergedProfile` at `:4483`) +- Modify: the abstract repository interface that declares `getMergedProfile` +- Create: `test/core/services/pdf_templates/pdf_profile_series_test.dart` +- Create: `test/features/dive_log/data/repositories/batch_profile_load_test.dart` + +**Interfaces:** +- Produces: + ```dart + // pdf_profile_series.dart + class PdfProfileSeries { + static const int maxPoints = 200; + final List points; + const PdfProfileSeries(this.points); + factory PdfProfileSeries.downsampled(List raw); + bool get isEmpty; + double get maxDepth; + int get durationSeconds; + } + + // DiveRepository + Future>> getMergedProfilesForDives( + List diveIds, + ); + ``` + +- [ ] **Step 1: Write the failing downsample test** + +Create `test/core/services/pdf_templates/pdf_profile_series_test.dart`: + +```dart +void main() { + DiveProfilePoint p(int t, double d) => DiveProfilePoint(timestamp: t, depth: d); + + test('leaves a short profile untouched', () { + final raw = [p(0, 0), p(10, 5), p(20, 0)]; + expect(PdfProfileSeries.downsampled(raw).points, hasLength(3)); + }); + + test('caps a long profile at maxPoints', () { + final raw = List.generate(5000, (i) => p(i * 10, i % 30 + 1.0)); + final series = PdfProfileSeries.downsampled(raw); + expect(series.points.length, lessThanOrEqualTo(PdfProfileSeries.maxPoints)); + }); + + test('preserves the first sample, the last sample and the maximum depth', () { + final raw = List.generate(5000, (i) => p(i * 10, i == 3111 ? 42.5 : 5.0)); + final series = PdfProfileSeries.downsampled(raw); + expect(series.points.first.timestamp, 0); + expect(series.points.last.timestamp, 49990); + expect(series.maxDepth, 42.5); + expect(series.points.map((e) => e.depth), contains(42.5), + reason: 'the deepest sample must survive downsampling'); + }); + + test('an empty profile reports isEmpty', () { + expect(PdfProfileSeries.downsampled(const []).isEmpty, isTrue); + }); +} +``` + +The maximum-depth requirement matters: a naive "every Nth sample" downsample can drop the deepest point and make the chart lie about the dive. Bucket the samples and keep the deepest in each bucket, always retaining the first and last. + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_profile_series_test.dart` +Expected: FAIL, the file does not exist. + +- [ ] **Step 3: Implement PdfProfileSeries** + +Bucketed downsample: split the sample list into at most `maxPoints` buckets and keep the deepest sample from each, preserving chronological order and always including the first and last raw samples. + +- [ ] **Step 4: Write the failing batch-load test** + +Create `test/features/dive_log/data/repositories/batch_profile_load_test.dart`. Follow the setup in the existing `edited_profile_supersedes_originals_test.dart`, which already builds an in-memory database with profile rows. + +```dart +test('returns profiles for several dives in one call', () async { + // insert profile rows for dive A and dive B + final result = await repo.getMergedProfilesForDives(['diveA', 'diveB']); + expect(result['diveA'], isNotEmpty); + expect(result['diveB'], isNotEmpty); +}); + +test('returns rows for a dive whose samples are all non-primary', () async { + // Insert profile rows for 'imported' with isPrimary: false on every row, + // which is the file-import state described in issue #623. + final result = await repo.getMergedProfilesForDives(['imported']); + expect(result['imported'], isNotEmpty, + reason: 'an isPrimary filter would silently drop file-imported dives'); +}); + +test('drops the originals a saved edit superseded', () async { + // Mirror the fixture from edited_profile_supersedes_originals_test.dart: + // demoted originals plus promoted edited rows for the same dive. + final result = await repo.getMergedProfilesForDives(['edited']); + expect(result['edited'], hasLength(editedRowCount), + reason: 'demoted originals must not be unioned back in'); +}); +``` + +- [ ] **Step 5: Run it and verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/batch_profile_load_test.dart` +Expected: FAIL, `getMergedProfilesForDives` is not defined. + +- [ ] **Step 6: Implement the batch loader** + +Add next to `getMergedProfile` (`:4483`). One query per chunk of ids, grouped by dive, then the existing per-dive rules applied to each group: + +```dart +@override +Future>> getMergedProfilesForDives( + List diveIds, +) async { + const chunkSize = 50; + final result = >{}; + + for (var i = 0; i < diveIds.length; i += chunkSize) { + final chunk = diveIds.skip(i).take(chunkSize).toList(); + final rows = await (_db.select(_db.diveProfiles) + ..where((t) => t.diveId.isIn(chunk)) + ..orderBy([(t) => OrderingTerm.asc(t.timestamp)])) + .get(); + + final byDive = >{}; + for (final row in rows) { + byDive.putIfAbsent(row.diveId, () => []).add(row); + } + + for (final entry in byDive.entries) { + final kept = await _dropSupersededOriginals(entry.key, entry.value); + result[entry.key] = + _dropDuplicateSamples(kept).map(_profilePointFromRow).toList(); + } + } + + return result; +} +``` + +Declare it on the abstract repository as well. Chunking bounds both the `IN` clause size and peak memory. + +- [ ] **Step 7: Run the tests and verify they pass** + +Run: `flutter test test/features/dive_log/data/repositories/ test/core/services/pdf_templates/pdf_profile_series_test.dart` +Expected: PASS. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "feat(export): batch-load merged dive profiles for PDF export" +``` + +--- + +### Task 7: Draw the depth profile as a vector chart + +**Files:** +- Create: `lib/core/services/pdf_templates/pdf_profile_chart.dart` +- Create: `test/core/services/pdf_templates/pdf_profile_chart_test.dart` + +**Interfaces:** +- Consumes: `PdfProfileSeries` from Task 6, `UnitFormatter` +- Produces: + ```dart + class PdfProfileChart { + /// Returns null when [series] has no points, so callers omit the region + /// entirely rather than printing an empty frame. + static pw.Widget? build({ + required PdfProfileSeries series, + required UnitFormatter units, + double height = 150, + PdfColor color = PdfColors.blue700, + }); + } + ``` + +Rasterizing the existing `dive_profile_chart.dart` through its `exportKey` `RepaintBoundary` was considered and rejected: `buildPdf` has no `BuildContext`, and `track_shape_painter.dart:10` documents that `toImage()` hangs under `flutter test`, which would make this untestable. The `pdf` package ships its own vector chart widgets. + +- [ ] **Step 1: Write the failing test** + +```dart +void main() { + test('returns null for an empty series', () { + expect( + PdfProfileChart.build(series: const PdfProfileSeries([]), units: metric), + isNull, + ); + }); + + test('renders a chart with depth axis labels for a real profile', () async { + final series = PdfProfileSeries.downsampled([ + DiveProfilePoint(timestamp: 0, depth: 0), + DiveProfilePoint(timestamp: 300, depth: 18.0), + DiveProfilePoint(timestamp: 900, depth: 18.0), + DiveProfilePoint(timestamp: 1500, depth: 0), + ]); + + final doc = pw.Document(); + doc.addPage(pw.Page( + build: (context) => + PdfProfileChart.build(series: series, units: metric)!, + )); + + final bytes = await doc.save(); + expect(bytes, isNotEmpty); + expect(pdfVisibleText(bytes), contains('18')); + }); +} +``` + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_profile_chart_test.dart` +Expected: FAIL, the file does not exist. + +- [ ] **Step 3: Implement the chart** + +Depth increases downward, so plot depth as a negative y value and label the axis with positive converted depths. Convert both axes through `units` so an imperial diver sees feet. + +```dart +static pw.Widget? build({ + required PdfProfileSeries series, + required UnitFormatter units, + double height = 150, + PdfColor color = PdfColors.blue700, +}) { + if (series.isEmpty) return null; + + final data = series.points + .map((p) => pw.PointChartValue( + p.timestamp / 60.0, + -units.convertDepth(p.depth), + )) + .toList(); + + // Five evenly spaced depth ticks from the surface to the deepest sample, + // negated because depth is plotted downward. + final maxConverted = units.convertDepth(series.maxDepth); + final depthStep = maxConverted / 4; + final depthTicks = [ + for (var i = 0; i <= 4; i++) -(depthStep * i), + ]; + + // A minute tick every 5 minutes for short dives, every 10 for longer ones, + // so a 2 hour dive does not produce 24 crowded labels. + final totalMinutes = series.durationSeconds / 60.0; + final minuteStep = totalMinutes > 60 ? 10.0 : 5.0; + final timeTicks = [ + for (var t = 0.0; t <= totalMinutes; t += minuteStep) t, + ]; + + return pw.SizedBox( + height: height, + child: pw.Chart( + grid: pw.CartesianGrid( + xAxis: pw.FixedAxis( + timeTicks, + buildLabel: (v) => pw.Text( + v.toStringAsFixed(0), + style: const pw.TextStyle(fontSize: 7), + ), + ), + yAxis: pw.FixedAxis( + depthTicks, + buildLabel: (v) => pw.Text( + (-v).toStringAsFixed(0), + style: const pw.TextStyle(fontSize: 7), + ), + ), + ), + datasets: [ + pw.LineDataSet( + data: data, + drawPoints: false, + drawSurface: true, + surfaceOpacity: 0.2, + color: color, + lineWidth: 1.0, + ), + ], + ), + ); +} +``` + +Derive the tick lists from the series rather than hardcoding: roughly 5 depth ticks between 0 and max, and a minute tick every 5 or 10 minutes depending on duration. Read `~/.pub-cache/hosted/pub.dev/pdf-3.13.0/lib/src/widgets/chart/` for the exact axis API before finalising. + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: `flutter test test/core/services/pdf_templates/pdf_profile_chart_test.dart` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "feat(export): draw dive profiles as vector charts in PDFs" +``` + +--- + +### Task 8: Rebuild the Detailed template + +This is the task that fixes the reported bug. Today `_buildDiveEntry` renders 6 fields and truncates notes at `maxLines: 2`. + +**Files:** +- Modify: `lib/core/services/pdf_templates/pdf_template_detailed.dart` (full rewrite of the dive page) +- Modify: `lib/core/services/pdf_templates/pdf_template_builder.dart` (add `profiles`, `diverPhoto`, `includeVerificationAreas`) +- Modify: `lib/core/constants/pdf_templates.dart` (`divesPerPage` for detailed becomes 1, add `includeVerificationAreas` to `PdfExportOptions`) +- Modify: `lib/features/settings/presentation/providers/export_providers.dart` (load profiles and the diver photo) +- Modify: `lib/features/transfer/presentation/widgets/pdf_export_dialog.dart` (verification-areas switch) +- Create: `test/core/services/pdf_templates/pdf_detailed_content_test.dart` + +**Interfaces:** +- Consumes: `PdfProfileSeries`, `PdfProfileChart.build`, `PdfFrontMatter.buildDiverPage`, `UnitFormatter` +- Produces: final `buildPdf` signature, which every template implements: + ```dart + Future> buildPdf({ + required List dives, + required PdfPageSize pageSize, + required PdfDateFormatter dates, + required UnitFormatter units, + String title = 'Dive Logbook', + Map>? diveSignatures, + List? certifications, + Diver? diver, + Map? profiles, + Uint8List? diverPhoto, + bool includeVerificationAreas = false, + }); + ``` + +Field groups for the one-page layout, covering the #1017 checklist: + +| Group | Fields | +| --- | --- | +| Header | dive number, site name, date, time, rating | +| Profile | the chart, max depth, average depth, runtime, entry and exit times, surface interval | +| Gas | every cylinder: name or preset, material, volume, gas mix O2 and He, start and end pressure, pressure used, per-cylinder SAC | +| Conditions | water temp, air temp, visibility, current direction and strength, water type, weather description, entry and exit method | +| Team | buddies with roles, dive master, dive center | +| Equipment | equipment items, weight amount and type, exposure suit | +| Technical | dive computer model, deco algorithm, gradient factors, dive mode, setpoints where present | +| Notes | full untruncated text | +| Sightings | marine life, when present | +| Verification | stamp area and large signature blocks, only when `includeVerificationAreas` | + +Every group omits itself entirely when it has no data, so a manually logged dive does not print a page of empty labels. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/pdf_templates/pdf_detailed_content_test.dart`: + +```dart +void main() { + const longNote = + 'Dropped in from Devil Ray at Casique. Stayed on surface while Jack ' + 'tied DSMB to a rock on the bottom as a marker. Backed off 100 yards, ' + 'performed tired diver tow via push technique. Snorkel was flooding so ' + 'switched to regulator and finished the tow on the surface.'; + + final dive = Dive( + id: 'd1', + diveNumber: 197, + dateTime: DateTime(2026, 8, 17, 11, 7), + runtime: const Duration(minutes: 26), + maxDepth: 9.8, + avgDepth: 6.1, + waterTemp: 28.0, + airTemp: 31.0, + notes: longNote, + tanks: const [ + DiveTank(id: 't1', name: 'AL80', volume: 11.1, startPressure: 192, endPressure: 119), + DiveTank(id: 't2', name: 'Deco 50', volume: 11.1, startPressure: 200, endPressure: 180), + ], + ); + + // Shared render helper. Every test below goes through it so the parameter + // list lives in one place. + Future> render( + Dive d, { + List extra = const [], + Map? profiles, + bool verification = false, + }) => + PdfTemplateDetailed().buildPdf( + dives: [d, ...extra], + pageSize: PdfPageSize.a4, + dates: dates, + units: metric, + profiles: profiles, + includeVerificationAreas: verification, + ); + + final secondDive = dive.copyWith(id: 'd2', diveNumber: 198); + final thirdDive = dive.copyWith(id: 'd3', diveNumber: 199); + + test('renders the notes in full, without truncation', () async { + final text = pdfVisibleText(await render(dive)); + expect(text, contains('finished the tow on the surface'), + reason: 'this is the reported bug: notes were capped at maxLines 2'); + }); + + test('renders every cylinder, not only the first', () async { + final text = pdfVisibleText(await render(dive)); + expect(text, contains('AL80')); + expect(text, contains('Deco 50')); + }); + + test('renders average depth and air temperature', () async { + final text = pdfVisibleText(await render(dive)); + expect(text, contains('6.1')); + expect(text, contains('31')); + }); + + test('puts one dive on each page', () async { + final bytes = await render(dive, extra: [secondDive, thirdDive]); + expect(pdfPageCount(bytes), greaterThanOrEqualTo(3)); + }); + + test('omits the profile section when the dive has no samples', () async { + final text = pdfVisibleText(await render(dive)); + expect(text, isNot(contains('Depth Profile'))); + }); + + test('renders the profile chart when samples are supplied', () async { + final text = pdfVisibleText(await render(dive, profiles: { + 'd1': PdfProfileSeries.downsampled([ + DiveProfilePoint(timestamp: 0, depth: 0), + DiveProfilePoint(timestamp: 600, depth: 9.8), + DiveProfilePoint(timestamp: 1560, depth: 0), + ]), + })); + expect(text, contains('Depth Profile')); + }); + + test('omits verification areas unless requested', () async { + expect(pdfVisibleText(await render(dive)), isNot(contains('Official Stamp'))); + expect( + pdfVisibleText(await render(dive, verification: true)), + contains('Official Stamp'), + ); + }); +} +``` + +If `test/helpers/pdf_text.dart` has no page-count helper, add `int pdfPageCount(List bytes)` there rather than inlining the parsing. + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_detailed_content_test.dart` +Expected: FAIL. The notes assertion fails because of `maxLines: 2`, and the parameters do not exist yet. + +- [ ] **Step 3: Extend the builder signature** + +Add `profiles`, `diverPhoto` and `includeVerificationAreas` to `buildPdf` in `pdf_template_builder.dart` and to all four template implementations. Simple, PADI and NAUI accept and ignore them for now. + +- [ ] **Step 4: Add the export option** + +In `pdf_templates.dart`, add `includeVerificationAreas` to `PdfExportOptions` with a `false` default, and to `copyWith`, `operator ==` and `hashCode`. Set `divesPerPage` for `detailed` to 1. + +- [ ] **Step 5: Rewrite the Detailed dive page** + +Replace `_buildDiveEntry` with one `pw.MultiPage` per dive, or a `pw.Page` per dive with the groups laid out in two columns. Requirements: + +- No `maxLines` on notes anywhere. +- Iterate `dive.tanks`, not `dive.tanks.first`. +- Each group returns null or an empty list when it has no data, and the layout filters those out. +- The profile section is headed `'Depth Profile'` and is included only when `profiles?[dive.id]` is non-null and not empty. +- Verification areas use the existing `PdfSharedComponents.buildStampArea` and `buildLargeSignatureBlock`. + +- [ ] **Step 6: Load profiles and the diver photo in the provider** + +In `_buildLogbookPdfBytes` in `export_providers.dart`, before building: + +```dart +Map? profiles; +if (exportOptions.template == PdfTemplate.detailed) { + state = state.copyWith(message: _l10n.settings_export_progress_loadingProfiles); + final raw = await _ref + .read(diveRepositoryProvider) + .getMergedProfilesForDives(dives.map((d) => d.id).toList()); + profiles = raw.map((id, pts) => MapEntry(id, PdfProfileSeries.downsampled(pts))); +} +``` + +Add the `settings_export_progress_loadingProfiles` string to all 11 arb files and run `flutter gen-l10n`. Load the diver portrait from `diver?.photoPath` with a guarded file read, passing null when the path is missing or unreadable. Do not let a missing photo fail the export. + +- [ ] **Step 7: Add the dialog switch** + +In `pdf_export_dialog.dart`, add a switch for verification areas next to the certification-cards switch, shown only for `PdfTemplate.detailed`, and include it in the `PdfExportOptions` built at `:307-315`. + +- [ ] **Step 8: Run the tests and verify they pass** + +Run: `flutter test test/core/services/pdf_templates/ test/features/settings/presentation/providers/export_pdf_logbook_test.dart` +Expected: PASS. + +- [ ] **Step 9: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "feat(export): rebuild the detailed logbook with full dive data (#1017)" +``` + +--- + +### Task 9: Give Simple its key metrics and certifications + +#1017 reports that Simple is missing dive count, first dive, last dive, total dive time and max depth, and that certifications cannot be listed. + +**Files:** +- Modify: `lib/core/services/pdf_templates/pdf_template_simple.dart` +- Create: `test/core/services/pdf_templates/pdf_simple_content_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +test('the simple logbook opens with the key metrics', () async { + final text = pdfVisibleText(await PdfTemplateSimple().buildPdf( + dives: dives, + pageSize: PdfPageSize.a4, + dates: dates, + units: metric, + )); + expect(text, contains('Total Dives')); + expect(text, contains('First Dive')); + expect(text, contains('Last Dive')); + expect(text, contains('Total Dive Time')); + expect(text, contains('Deepest Dive')); +}); + +test('the simple logbook lists certifications when supplied', () async { + final text = pdfVisibleText(await PdfTemplateSimple().buildPdf( + dives: dives, + pageSize: PdfPageSize.a4, + dates: dates, + units: metric, + certifications: [openWaterCert], + )); + expect(text, contains('Open Water')); + expect(text, contains('CARD-1')); +}); +``` + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/pdf_templates/pdf_simple_content_test.dart` +Expected: FAIL, Simple renders neither. + +- [ ] **Step 3: Add the summary block** + +Prepend a summary page using `PdfSharedComponents.buildSummaryPage(dives: dives, dates: dates, units: units)` before the table pages. Keep the compact table itself unchanged; Simple stays the dense option. + +- [ ] **Step 4: Add the certification pages** + +Append a `pw.MultiPage` using `buildCertificationCardsBody` when `certifications` is non-null and non-empty, matching the pattern from Task 4. + +- [ ] **Step 5: Run the tests and verify they pass** + +Run: `flutter test test/core/services/pdf_templates/` +Expected: PASS. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "feat(export): add key metrics and certifications to the simple logbook" +``` + +--- + +### Task 10: Route the remaining exports through the factory + +The dive-list bulk export and the single-dive export never see a template and use a duplicated copy of the old layout that hardcodes A4 and builds `pw.Document()` with no Unicode font, so accented site names are mangled today. + +**Files:** +- Modify: `lib/core/services/export/pdf/pdf_export_service.dart` (delete `_buildDivePdf:233-370`, `_buildPdfDiveEntry:393+`, `_buildPdfStatRow:374`, `_buildPdfInfoChip:593`, `_buildPdfSignatureBlock:528`) +- Modify: `lib/core/services/export/export_service.dart:101-131` +- Modify: `lib/features/dive_log/presentation/widgets/dive_list_content.dart:600-610` +- Modify: `lib/features/dive_log/presentation/pages/dive_detail_page.dart:2969-2975` +- Modify: `test/features/dive_log/presentation/widgets/dive_list_bulk_export_test.dart`, `test/core/services/export/pdf/pdf_export_service_test.dart` + +**Interfaces:** +- Consumes: `PdfTemplateFactory`, `PdfExportOptions`, `UnitFormatter`, the full `buildPdf` signature from Task 8 +- Produces: + ```dart + Future<({List bytes, String fileName})> generateDivePdfBytes( + List dives, { + required PdfDateFormatter dates, + required UnitFormatter units, + PdfExportOptions options = const PdfExportOptions(), + Map>? diveSignatures, + }); + ``` + +`exportTripToPdf` and `pdf_course_export_service.dart` are out of scope. They are separate documents with their own layouts and keep their current builders. + +- [ ] **Step 1: Write the failing test** + +```dart +test('bulk export honours the selected template', () async { + final simple = await service.generateDivePdfBytes( + dives, + dates: dates, + units: metric, + options: const PdfExportOptions(template: PdfTemplate.simple), + ); + final detailed = await service.generateDivePdfBytes( + dives, + dates: dates, + units: metric, + options: const PdfExportOptions(template: PdfTemplate.detailed), + ); + expect(pdfVisibleText(simple.bytes), isNot(pdfVisibleText(detailed.bytes))); +}); + +test('the legacy single-layout builder is gone', () async { + // The legacy builder emitted no summary page. Every template does now. + final result = await service.generateDivePdfBytes( + dives, + dates: dates, + units: metric, + options: const PdfExportOptions(template: PdfTemplate.detailed), + ); + expect(pdfVisibleText(result.bytes), contains('Total Dives')); +}); + +test('renders a Unicode site name correctly', () async { + final result = await service.generateDivePdfBytes( + [diveAtSite('Café Réunion')], + dates: dates, + units: metric, + ); + expect(pdfVisibleText(result.bytes), contains('Café Réunion')); +}); +``` + +The Unicode test is the regression guard for the missing `PdfFonts.instance.theme` in the deleted builder. + +- [ ] **Step 2: Run it and verify it fails** + +Run: `flutter test test/core/services/export/pdf/pdf_export_service_test.dart` +Expected: FAIL, `generateDivePdfBytes` takes no `options`. + +- [ ] **Step 3: Route the service through the factory** + +Replace the body of `generateDivePdfBytes` with a factory lookup, mirroring `_buildLogbookPdfBytes`. Call `await PdfFonts.instance.initialize()` before building, which the legacy path never did. Thread `options` through `exportDivesToPdf` and `saveDivesToPdfFile`, and through the matching methods on `ExportService`. + +- [ ] **Step 4: Delete the legacy builder** + +Remove `_buildDivePdf`, `_buildPdfDiveEntry`, `_buildPdfStatRow`, `_buildPdfInfoChip` and `_buildPdfSignatureBlock`. Keep `savePdfBytesToFile`, `sharePdfBytes` and the trip export, which are still used. Confirm nothing else references the removed methods: + +```bash +grep -rn "_buildPdfDiveEntry\|_buildDivePdf" lib test +``` + +Expected: no output. + +- [ ] **Step 5: Add the picker to both call sites** + +In `dive_list_content.dart`, when the chosen format is PDF, show `PdfExportDialog.show(context)` and pass the returned options through. A null return means the user cancelled, so abort the export without an error. In `dive_detail_page.dart`, do the same for the single-dive sheet. + +Both sites need `UnitFormatter(ref.read(settingsProvider))`, which they can read the same way they already read `settingsProvider` for `PdfDateFormatter`. + +- [ ] **Step 6: Run the full PDF and export suites** + +Run: `flutter test test/core/services/export/ test/core/services/pdf_templates/ test/features/dive_log/presentation/widgets/dive_list_bulk_export_test.dart test/features/settings/presentation/providers/export_pdf_logbook_test.dart` +Expected: PASS. + +- [ ] **Step 7: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add -A +git commit -m "fix(export): route every PDF export through the template system" +``` + +--- + +### Task 11: Full verification + +- [ ] **Step 1: Run the whole suite twice** + +```bash +flutter test +flutter test +``` + +Run it bare, never piped into `grep` or `tail`, because a pipeline returns the last command's status and a failing run would read as exit 0. Run it twice: disjoint failure sets across the two runs indicate a pre-existing flake rather than a regression from this work. Known pre-existing flakes are recorded in the project memory index and should not be chased here. Do not start a second run while one is in flight. + +- [ ] **Step 2: Verify the reported bug is actually fixed** + +Export a Detailed PDF from the Transfer page with a dive whose notes run past two lines, and confirm the notes are complete, the profile chart is present, and every cylinder appears. + +- [ ] **Step 3: Confirm the l10n sweep is complete** + +```bash +grep -rn "pdfTemplate_professional" lib/ +``` + +Expected: no output. + +- [ ] **Step 4: Format and analyze the whole project** + +```bash +dart format . +flutter analyze +``` + +- [ ] **Step 5: Commit any remaining formatting** + +```bash +git add -A +git commit -m "chore(export): formatting after the PDF logbook rework" +``` diff --git a/docs/superpowers/plans/2026-08-26-photo-gps-desktop-readers.md b/docs/superpowers/plans/2026-08-26-photo-gps-desktop-readers.md new file mode 100644 index 0000000000..6e14816893 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-photo-gps-desktop-readers.md @@ -0,0 +1,1960 @@ +# Photo and Video GPS Readers (Plan A) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Populate `media.latitude/longitude` on every platform by reading GPS from JPEG/HEIC EXIF, QuickTime location metadata, and GoPro GPMF telemetry with pure Dart, so the site-suggestion feature (Plan B) has coordinates to work with on desktop. + +**Architecture:** The existing pure-Dart capture-time reader is split into a shared ISO-BMFF box walker, a shared EXIF loader (JPEG + HEIC), and thin consumers. New readers (`local_gps_reader`, `quicktime_location_reader`, `gpmf_gps_reader`) sit beside them and are wired into `ExifExtractor` (after the `native_exif` block, mirroring the `takenAt ??=` fallback) and the desktop gallery picker. Every reader returns null on malformed input and never throws out of its public function. + +**Tech Stack:** Dart `dart:io` `RandomAccessFile` seeks; `package:image ^4.3.0` (resolved 4.9.1) for JPEG EXIF and `ExifData.gpsIfd`; `flutter_test`; `--dart-define` gated real-sample tests. + +**Spec:** `docs/superpowers/specs/2026-08-26-photo-gps-site-suggestions-design.md` (Section 2). Plan B (`2026-08-26-photo-gps-site-suggestions.md`) implements the rest of the spec and does not depend on this plan. + +## Global Constraints + +- Worktree: `.claude/worktrees/photo-gps-site-suggestions`, branch `worktree-photo-gps-site-suggestions`. Run every command from that directory. +- No em-dashes anywhere (code, comments, commits). No emojis in code or docs. +- Files stay under 800 lines; target 200-400. +- TDD: write the failing test, run it, implement, run again, commit. +- `dart format .` before every commit; `flutter analyze` must be clean (infos are fatal in CI). +- Timestamps stay wall-clock-UTC as today; this plan does not touch time handling. +- Coordinates are decimal degrees, latitude in [-90, 90], longitude in [-180, 180]; `(0, 0)` is treated as "no fix" everywhere, matching `MediaRepository.getGpsFromDiveMedia`'s `!= 0` guard. +- Never read a video's `mdat` wholesale. Read only the boxes and the individual samples you need, and cap every length you trust from the file. +- Run tests one file at a time as shown; do not overlap `flutter test` runs. + +--- + +## File map + +New files (all under `lib/features/media/data/services/` unless noted): + +| File | Responsibility | +| --- | --- | +| `gps_fix.dart` | `GpsFix` record typedef and `isPlausibleFix` validation | +| `isobmff_boxes.dart` | ISO-BMFF/QuickTime box walking over `RandomAccessFile` and byte buffers; big-endian readers | +| `local_exif_loader.dart` | `readLocalExif(File, mime)`: JPEG and HEIC to `img.ExifData` | +| `local_gps_reader.dart` | `gpsFromExif(ExifData)` and the `readLocalGps(File, mime)` dispatcher | +| `quicktime_location_reader.dart` | `parseIso6709` and `readQuickTimeLocation(File)` (`udta > ©xyz`, `meta > keys/ilst`) | +| `gpmf_gps_reader.dart` | GoPro `gpmd` track locator, sample iterator, KLV parser, `readGpmfGps(File)` | +| `test/helpers/media_container_fixtures.dart` | Shared byte builders for MP4/HEIC/JPEG fixtures | +| `test/features/media/data/services/video_gps_real_sample_test.dart` | Gated real-sample tests | + +Modified: + +| File | Change | +| --- | --- | +| `capture_time_reader.dart` | Delegates JPEG/HEIC EXIF loading and box walking to the new files; keeps the `mvhd` reader | +| `exif_extractor.dart` | `lat ??=` / `lon ??=` from `readLocalGps` after the `native_exif` block | +| `photo_picker_service_desktop.dart` | `AssetInfo.latitude/longitude` from `readLocalGps` | + +--- + +### Task 1: Shared ISO-BMFF box walker + +**Files:** +- Create: `lib/features/media/data/services/isobmff_boxes.dart` +- Create: `test/helpers/media_container_fixtures.dart` +- Modify: `lib/features/media/data/services/capture_time_reader.dart` (remove the private box helpers, import the shared ones) +- Test: `test/features/media/data/services/isobmff_boxes_test.dart` + +**Interfaces:** +- Produces: + - `class BoxRange { const BoxRange(this.start, this.end); final int start; final int end; int get length; }` + - `BoxRange? findBox(RandomAccessFile raf, int start, int end, String type)` + - `List findBoxes(RandomAccessFile raf, int start, int end, String type)` + - `BoxRange? findBoxInBytes(Uint8List b, int start, int end, String type)` + - `List findBoxesInBytes(Uint8List b, int start, int end, String type)` + - `int beU16(Uint8List b, int o)`, `int beU32(Uint8List b, int o)`, `int beU64(Uint8List b, int o)`, `int beS32(Uint8List b, int o)`, `int beS16(Uint8List b, int o)` + - `String fourCC(Uint8List b, int o)` + - `int readByteAt(RandomAccessFile raf, int position)`, `int readU32At(RandomAccessFile raf, int position)`, `int readU64At(RandomAccessFile raf, int position)`, `Uint8List readBytesAt(RandomAccessFile raf, int position, int length)` + - Test helpers in `test/helpers/media_container_fixtures.dart`: `u16(int)`, `u32(int)`, `u64(int)`, `box(String type, List payload, {bool largeSize = false})`, `fullBox(String type, List payload, {int version = 0})` (prepends version + 3 zero flag bytes), `jpegWithExif(void Function(img.ExifData) configure)` returning `Uint8List` + +- [ ] **Step 1: Write the shared fixture helpers** + +Create `test/helpers/media_container_fixtures.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +/// Big-endian byte builders for hand-assembling ISO-BMFF (MP4/MOV/HEIC) +/// boxes in tests. A box is [size:uint32][type:4 ascii][payload]. +List u16(int v) => [(v >> 8) & 0xff, v & 0xff]; + +List u32(int v) => [ + (v >> 24) & 0xff, + (v >> 16) & 0xff, + (v >> 8) & 0xff, + v & 0xff, +]; + +List u64(int v) => [...u32(v >> 32), ...u32(v & 0xffffffff)]; + +/// When [largeSize] is set the box uses the 64-bit form (size field == 1, +/// followed by an 8-byte size). +List box(String type, List payload, {bool largeSize = false}) { + if (largeSize) { + return [ + ...u32(1), + ...type.codeUnits, + ...u64(16 + payload.length), + ...payload, + ]; + } + return [...u32(8 + payload.length), ...type.codeUnits, ...payload]; +} + +/// A FullBox: a box whose payload starts with 1 version byte + 3 flag bytes. +List fullBox(String type, List payload, {int version = 0}) => + box(type, [version, 0, 0, 0, ...payload]); + +/// A 4x4 JPEG whose EXIF was populated by [configure] (dates, GPS, ...). +/// `encodeJpg` embeds whatever is on `image.exif`. +Uint8List jpegWithExif(void Function(img.ExifData exif) configure) { + final image = img.Image(width: 4, height: 4); + configure(image.exif); + return img.encodeJpg(image); +} +``` + +- [ ] **Step 2: Write the failing box-walker test** + +Create `test/features/media/data/services/isobmff_boxes_test.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/isobmff_boxes.dart'; + +import '../../../../helpers/media_container_fixtures.dart'; + +void main() { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('isobmff_'); + }); + tearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + + File write(String name, List bytes) => + File('${tempDir.path}/$name')..writeAsBytesSync(bytes); + + group('findBox over a file', () { + test('returns the payload range of the first matching sibling', () { + final f = write('a.mp4', [ + ...box('ftyp', 'isom'.codeUnits), + ...box('moov', [1, 2, 3]), + ]); + final raf = f.openSync(); + addTearDown(raf.closeSync); + final moov = findBox(raf, 0, raf.lengthSync(), 'moov'); + expect(moov, isNotNull); + expect(moov!.start, 12 + 8); + expect(moov.end, 12 + 8 + 3); + expect(moov.length, 3); + }); + + test('handles the 64-bit size form', () { + final f = write('b.mp4', [ + ...box('mdat', [0, 0], largeSize: true), + ...box('moov', [9]), + ]); + final raf = f.openSync(); + addTearDown(raf.closeSync); + final moov = findBox(raf, 0, raf.lengthSync(), 'moov'); + expect(moov, isNotNull); + expect(readByteAt(raf, moov!.start), 9); + }); + + test('returns null for a missing type or a corrupt size', () { + final f = write('c.mp4', [...u32(3), ...'moov'.codeUnits]); + final raf = f.openSync(); + addTearDown(raf.closeSync); + expect(findBox(raf, 0, raf.lengthSync(), 'moov'), isNull); + expect(findBox(raf, 0, raf.lengthSync(), 'trak'), isNull); + }); + + test('findBoxes returns every sibling of the type in order', () { + final f = write('d.mp4', [ + ...box('trak', [1]), + ...box('udta', [0]), + ...box('trak', [2]), + ]); + final raf = f.openSync(); + addTearDown(raf.closeSync); + final traks = findBoxes(raf, 0, raf.lengthSync(), 'trak'); + expect(traks.map((r) => readByteAt(raf, r.start)), [1, 2]); + }); + }); + + group('byte-buffer twins', () { + test('findBoxInBytes and findBoxesInBytes mirror the file walkers', () { + final bytes = Uint8List.fromList([ + ...box('keys', [1]), + ...box('ilst', [2]), + ...box('ilst', [3]), + ]); + final one = findBoxInBytes(bytes, 0, bytes.length, 'ilst'); + expect(one, isNotNull); + expect(bytes[one!.start], 2); + final all = findBoxesInBytes(bytes, 0, bytes.length, 'ilst'); + expect(all.map((r) => bytes[r.start]), [2, 3]); + }); + + test('big-endian readers', () { + final b = Uint8List.fromList([ + 0xff, 0xfe, // beU16 = 65534, beS16 = -2 + 0xff, 0xff, 0xff, 0xfe, // beU32 = 4294967294, beS32 = -2 + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // beU64 = 1 << 32 + 0x67, 0x70, 0x6d, 0x64, // 'gpmd' + ]); + expect(beU16(b, 0), 65534); + expect(beS16(b, 0), -2); + expect(beU32(b, 2), 4294967294); + expect(beS32(b, 2), -2); + expect(beU64(b, 6), 1 << 32); + expect(fourCC(b, 14), 'gpmd'); + }); + + test('readBytesAt reads exactly the requested window', () { + final f = write('e.bin', [10, 11, 12, 13, 14]); + final raf = f.openSync(); + addTearDown(raf.closeSync); + expect(readBytesAt(raf, 1, 3), [11, 12, 13]); + expect(readU32At(raf, 1), (11 << 24) | (12 << 16) | (13 << 8) | 14); + }); + }); +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `flutter test test/features/media/data/services/isobmff_boxes_test.dart` +Expected: FAIL, "Target of URI doesn't exist" for `isobmff_boxes.dart`. + +- [ ] **Step 4: Create `isobmff_boxes.dart`** + +Move the box helpers out of `capture_time_reader.dart` and make them public: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +/// ISO-BMFF / QuickTime box walking shared by the capture-time, EXIF, and +/// GPS readers. A box is [size:uint32][type:4 ascii][payload]; size == 1 +/// means a 64-bit size follows the type; size == 0 means "to end of range". +/// +/// The file walkers seek, so a multi-GB clip whose `moov` sits after `mdat` +/// is walked without ever reading the media payload. + +/// Half-open byte range [start, end) of a box's content (payload). +class BoxRange { + const BoxRange(this.start, this.end); + final int start; + final int end; + int get length => end - start; +} + +/// Returns the content range of the first sibling box of [type] within +/// [start, end), or null. +BoxRange? findBox(RandomAccessFile raf, int start, int end, String type) { + for (final r in _walk(raf, start, end)) { + if (r.type == type) return r.range; + } + return null; +} + +/// Every sibling box of [type] within [start, end), in file order. +List findBoxes( + RandomAccessFile raf, + int start, + int end, + String type, +) => [ + for (final r in _walk(raf, start, end)) + if (r.type == type) r.range, +]; + +/// Byte-buffer twin of [findBox], for sub-boxes already read into memory. +BoxRange? findBoxInBytes(Uint8List b, int start, int end, String type) { + for (final r in _walkBytes(b, start, end)) { + if (r.type == type) return r.range; + } + return null; +} + +/// Byte-buffer twin of [findBoxes]. +List findBoxesInBytes( + Uint8List b, + int start, + int end, + String type, +) => [ + for (final r in _walkBytes(b, start, end)) + if (r.type == type) r.range, +]; + +typedef _Header = ({String type, BoxRange range}); + +/// Lazily yields sibling boxes; stops at the first corrupt header so a bad +/// size never walks past [end] or spins. +Iterable<_Header> _walk(RandomAccessFile raf, int start, int end) sync* { + var pos = start; + while (pos + 8 <= end) { + raf.setPositionSync(pos); + final header = raf.readSync(8); + if (header.length < 8) return; + var size = beU32(header, 0); + var headerLen = 8; + if (size == 1) { + final ext = raf.readSync(8); + if (ext.length < 8) return; + size = beU64(ext, 0); + headerLen = 16; + } else if (size == 0) { + size = end - pos; + } + if (size < headerLen || pos + size > end) return; + yield ( + type: String.fromCharCodes(header, 4, 8), + range: BoxRange(pos + headerLen, pos + size), + ); + pos += size; + } +} + +Iterable<_Header> _walkBytes(Uint8List b, int start, int end) sync* { + var pos = start; + while (pos + 8 <= end) { + var size = beU32(b, pos); + var headerLen = 8; + if (size == 1) { + if (pos + 16 > end) return; + size = beU64(b, pos + 8); + headerLen = 16; + } else if (size == 0) { + size = end - pos; + } + if (size < headerLen || pos + size > end) return; + yield (type: fourCC(b, pos + 4), range: BoxRange(pos + headerLen, pos + size)); + pos += size; + } +} + +int beU16(Uint8List b, int o) => (b[o] << 8) | b[o + 1]; + +int beS16(Uint8List b, int o) => beU16(b, o).toSigned(16); + +int beU32(Uint8List b, int o) => + (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]; + +int beS32(Uint8List b, int o) => beU32(b, o).toSigned(32); + +int beU64(Uint8List b, int o) => (beU32(b, o) << 32) | beU32(b, o + 4); + +String fourCC(Uint8List b, int o) => String.fromCharCodes(b, o, o + 4); + +int readByteAt(RandomAccessFile raf, int position) { + raf.setPositionSync(position); + return raf.readSync(1).first; +} + +int readU32At(RandomAccessFile raf, int position) { + raf.setPositionSync(position); + return beU32(raf.readSync(4), 0); +} + +int readU64At(RandomAccessFile raf, int position) { + raf.setPositionSync(position); + return beU64(raf.readSync(8), 0); +} + +Uint8List readBytesAt(RandomAccessFile raf, int position, int length) { + raf.setPositionSync(position); + return raf.readSync(length); +} +``` + +- [ ] **Step 5: Point `capture_time_reader.dart` at the shared walker** + +In `capture_time_reader.dart`: +- Add `import 'package:submersion/features/media/data/services/isobmff_boxes.dart';`. +- Delete `class _BoxRange`, `_findBox`, `_findBoxInBytes`, `extension _ReadAt`, `_readU32`, `_readU64`, `_beU16`, `_beU32`, `_beU64`, `_fourCC`. +- Replace every `_BoxRange` with `BoxRange`, `_findBox(` with `findBox(`, `_findBoxInBytes(` with `findBoxInBytes(`, `_beU16(` with `beU16(`, `_beU32(` with `beU32(`, `_beU64(` with `beU64(`, `_fourCC(` with `fourCC(`. +- In `_readMp4CreationTime`: `raf.readByteAt(mvhd.start)` becomes `readByteAt(raf, mvhd.start)`; `_readU64(raf, mvhd.start + 4)` becomes `readU64At(raf, mvhd.start + 4)`; `_readU32(...)` becomes `readU32At(...)`. + +- [ ] **Step 6: Run both test files** + +Run: `flutter test test/features/media/data/services/isobmff_boxes_test.dart` +Expected: PASS. +Run: `flutter test test/features/media/data/services/capture_time_reader_test.dart` +Expected: PASS (behaviour unchanged). + +- [ ] **Step 7: Format, analyze, commit** + +```bash +dart format lib/features/media/data/services test/features/media/data/services test/helpers +flutter analyze lib/features/media/data/services test/features/media/data/services test/helpers +git add lib/features/media/data/services/isobmff_boxes.dart lib/features/media/data/services/capture_time_reader.dart test/features/media/data/services/isobmff_boxes_test.dart test/helpers/media_container_fixtures.dart +git commit -m "refactor(media): share the ISO-BMFF box walker between readers" +``` + +--- + +### Task 2: Shared EXIF loader (JPEG and HEIC) + +**Files:** +- Create: `lib/features/media/data/services/local_exif_loader.dart` +- Modify: `lib/features/media/data/services/capture_time_reader.dart` +- Test: `test/features/media/data/services/local_exif_loader_test.dart` + +**Interfaces:** +- Consumes: `findBox`, `findBoxInBytes`, `beU16`, `beU32`, `fourCC` from Task 1. +- Produces: `img.ExifData? readLocalExif(File file, String mime)` for `image/jpeg`, `image/heic`, `image/heif`; null for other mimes, unreadable files, or files without EXIF. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/media/data/services/local_exif_loader_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:submersion/features/media/data/services/local_exif_loader.dart'; + +import '../../../../helpers/media_container_fixtures.dart'; + +/// A real TIFF/EXIF block lifted from an encoded JPEG's APP1 segment, +/// prefixed by the 4-byte tiff-header offset a HEIC `Exif` item carries. +List exifItemPayload(void Function(img.ExifData) configure) { + final jpg = jpegWithExif(configure); + for (var i = 0; i + 3 < jpg.length; i++) { + if (jpg[i] == 0xFF && jpg[i + 1] == 0xE1) { + final segLen = (jpg[i + 2] << 8) | jpg[i + 3]; + return [...u32(6), ...jpg.sublist(i + 4, i + 2 + segLen)]; + } + } + throw StateError('no APP1 EXIF segment in encoded JPEG'); +} + +/// Minimal HEIC: ftyp, mdat holding the Exif payload, then meta whose iinf +/// declares an 'Exif' item and iloc points at the payload's absolute offset. +List heicFile(void Function(img.ExifData) configure) { + final ftyp = box('ftyp', 'heic'.codeUnits); + final payload = exifItemPayload(configure); + final mdat = box('mdat', payload); + final payloadOffset = ftyp.length + 8; + final infe = box('infe', [ + 2, 0, 0, 0, + ...u16(1), // item_ID + ...u16(0), // protection_index + ...'Exif'.codeUnits, + 0, + ]); + final iinf = box('iinf', [0, 0, 0, 0, ...u16(1), ...infe]); + final iloc = box('iloc', [ + 1, 0, 0, 0, // version 1 + flags + 0x44, // offset_size=4, length_size=4 + 0x00, // base_offset_size=0, index_size=0 + ...u16(1), ...u16(1), // item_count, item_ID + ...u16(0), // construction_method + ...u16(0), // data_reference_index + ...u16(1), // extent_count + ...u32(payloadOffset), + ...u32(payload.length), + ]); + final meta = box('meta', [0, 0, 0, 0, ...iinf, ...iloc]); + return [...ftyp, ...mdat, ...meta]; +} + +void main() { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('exif_loader_'); + }); + tearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + File write(String name, List bytes) => + File('${tempDir.path}/$name')..writeAsBytesSync(bytes); + + test('JPEG: returns the parsed EXIF block', () { + final f = write( + 'a.jpg', + jpegWithExif((e) => e.exifIfd['DateTimeOriginal'] = '2025:12:27 12:08:19'), + ); + final exif = readLocalExif(f, 'image/jpeg'); + expect(exif?.exifIfd['DateTimeOriginal'].toString(), '2025:12:27 12:08:19'); + }); + + test('HEIC: returns the EXIF block from the meta Exif item', () { + final f = write( + 'a.heic', + heicFile((e) { + e.exifIfd['DateTimeOriginal'] = '2026:05:06 17:35:39'; + e.gpsIfd.gpsLatitude = 12.5; + e.gpsIfd.gpsLatitudeRef = 'N'; + }), + ); + final exif = readLocalExif(f, 'image/heic'); + expect(exif?.exifIfd['DateTimeOriginal'].toString(), '2026:05:06 17:35:39'); + expect(exif?.gpsIfd.gpsLatitude, closeTo(12.5, 1e-6)); + }); + + test('returns null for other mimes, corrupt bytes, and missing files', () { + final f = write('bad.jpg', [1, 2, 3]); + expect(readLocalExif(f, 'image/jpeg'), isNull); + expect(readLocalExif(f, 'video/mp4'), isNull); + expect(readLocalExif(File('${tempDir.path}/none.jpg'), 'image/jpeg'), isNull); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/media/data/services/local_exif_loader_test.dart` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create `local_exif_loader.dart`** + +Move the HEIC item/extent/TIFF helpers out of `capture_time_reader.dart` into this file, verbatim, and wrap them: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +import 'package:submersion/features/media/data/services/isobmff_boxes.dart'; + +/// Loads the EXIF block of a JPEG or HEIC/HEIF file with pure Dart, for the +/// platforms where `native_exif` has no implementation. One parse serves +/// every consumer (capture time, GPS); callers never decode pixels. +/// +/// Returns null for unsupported mimes, unreadable or corrupt files, and files +/// that simply carry no EXIF. +img.ExifData? readLocalExif(File file, String mime) { + try { + switch (mime) { + case 'image/jpeg': + return img.decodeJpgExif(file.readAsBytesSync()); + case 'image/heic': + case 'image/heif': + return _readHeicExif(file); + default: + return null; + } + } on Object { + return null; + } +} + +// Upper bounds on HEIC reads. Real `meta` boxes and `Exif` items are tens of +// KB; these caps reject a corrupt/crafted length before it allocates. +const _maxMetaBytes = 64 * 1024 * 1024; +const _maxExifItemBytes = 64 * 1024 * 1024; + +img.ExifData? _readHeicExif(File file) { + RandomAccessFile? raf; + try { + raf = file.openSync(); + final end = raf.lengthSync(); + final meta = findBox(raf, 0, end, 'meta'); + if (meta == null) return null; + final metaLen = meta.end - meta.start - 4; + if (metaLen <= 0 || metaLen > _maxMetaBytes) return null; + final metaBytes = readBytesAt(raf, meta.start + 4, metaLen); + + final iinf = findBoxInBytes(metaBytes, 0, metaBytes.length, 'iinf'); + final iloc = findBoxInBytes(metaBytes, 0, metaBytes.length, 'iloc'); + if (iinf == null || iloc == null) return null; + + final itemId = _heicExifItemId(metaBytes, iinf.start, iinf.end); + if (itemId == null) return null; + final extent = _heicExifExtent(metaBytes, iloc.start, iloc.end, itemId); + if (extent == null) return null; + if (extent.offset < 0 || + extent.length <= 0 || + extent.length > _maxExifItemBytes || + extent.offset + extent.length > end) { + return null; + } + + final item = readBytesAt(raf, extent.offset, extent.length); + final tiff = _tiffHeaderOffset(item); + if (tiff == null) return null; + return img.ExifData.fromInputBuffer(img.InputBuffer(item.sublist(tiff))); + } on Object { + return null; + } finally { + raf?.closeSync(); + } +} + +// _heicExifItemId, _heicExifExtent, _Extent, _tiffHeaderOffset, _isTiffHeader: +// moved verbatim from capture_time_reader.dart, with _beU16/_beU32/_fourCC +// renamed to the shared beU16/beU32/fourCC. +``` + +(Paste the five moved helpers below the comment; their bodies are unchanged apart from the renamed byte readers.) + +- [ ] **Step 4: Slim `capture_time_reader.dart` to use the loader** + +Replace `_readJpegExifDate` and `_readHeicExifDate` (and the helpers you moved) with: + +```dart +DateTime? readLocalCaptureTime(File file, String mime) { + switch (mime) { + case 'image/jpeg': + case 'image/heic': + case 'image/heif': + final exif = readLocalExif(file, mime); + return exif == null ? null : _dateFromExif(exif); + case 'video/mp4': + case 'video/quicktime': + case 'video/x-m4v': + return _readMp4CreationTime(file); + default: + return null; + } +} +``` + +Keep `_dateFromExif` and `_readMp4CreationTime`; add the `local_exif_loader.dart` import; remove the now-unused `dart:typed_data` import if nothing else needs it. The file should shrink to roughly 90 lines. + +- [ ] **Step 5: Run the three affected test files** + +Run: `flutter test test/features/media/data/services/local_exif_loader_test.dart` +Expected: PASS. +Run: `flutter test test/features/media/data/services/capture_time_reader_test.dart` +Expected: PASS (HEIC and JPEG date tests unchanged). +Run: `flutter test test/features/media/data/services/exif_extractor_test.dart` +Expected: PASS. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format lib/features/media/data/services test/features/media/data/services +flutter analyze lib/features/media/data/services test/features/media/data/services +git add lib/features/media/data/services/local_exif_loader.dart lib/features/media/data/services/capture_time_reader.dart test/features/media/data/services/local_exif_loader_test.dart +git commit -m "refactor(media): load JPEG and HEIC EXIF once through a shared loader" +``` + +--- + +### Task 3: GPS fix type and EXIF GPS reader + +**Files:** +- Create: `lib/features/media/data/services/gps_fix.dart` +- Create: `lib/features/media/data/services/local_gps_reader.dart` +- Test: `test/features/media/data/services/local_gps_reader_test.dart` + +**Interfaces:** +- Consumes: `readLocalExif` (Task 2). +- Produces: + - `typedef GpsFix = ({double latitude, double longitude});` + - `bool isPlausibleFix(double latitude, double longitude)`: finite, in range, not `(0, 0)`. + - `GpsFix? gpsFromExif(img.ExifData exif)` + - `GpsFix? readLocalGps(File file, String mime)`: images only in this task; Task 5 adds the video branch. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/media/data/services/local_gps_reader_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:submersion/features/media/data/services/gps_fix.dart'; +import 'package:submersion/features/media/data/services/local_gps_reader.dart'; + +import '../../../../helpers/media_container_fixtures.dart'; + +void main() { + group('isPlausibleFix', () { + test('accepts in-range coordinates', () { + expect(isPlausibleFix(12.34, -98.76), isTrue); + expect(isPlausibleFix(-90, 180), isTrue); + }); + test('rejects (0,0), NaN, and out-of-range values', () { + expect(isPlausibleFix(0, 0), isFalse); + expect(isPlausibleFix(double.nan, 10), isFalse); + expect(isPlausibleFix(91, 10), isFalse); + expect(isPlausibleFix(10, -181), isFalse); + expect(isPlausibleFix(double.infinity, 0), isFalse); + }); + }); + + group('gpsFromExif', () { + img.ExifData exifWith({ + double? lat, + String? latRef, + double? lon, + String? lonRef, + }) { + final e = img.ExifData(); + if (lat != null) e.gpsIfd.gpsLatitude = lat; + if (latRef != null) e.gpsIfd.gpsLatitudeRef = latRef; + if (lon != null) e.gpsIfd.gpsLongitude = lon; + if (lonRef != null) e.gpsIfd.gpsLongitudeRef = lonRef; + return e; + } + + test('north-east stays positive', () { + final fix = gpsFromExif( + exifWith(lat: 12.3456, latRef: 'N', lon: 98.7654, lonRef: 'E'), + ); + expect(fix?.latitude, closeTo(12.3456, 1e-6)); + expect(fix?.longitude, closeTo(98.7654, 1e-6)); + }); + + test('south and west negate', () { + final fix = gpsFromExif( + exifWith(lat: 33.8688, latRef: 'S', lon: 151.2093, lonRef: 'W'), + ); + expect(fix?.latitude, closeTo(-33.8688, 1e-6)); + expect(fix?.longitude, closeTo(-151.2093, 1e-6)); + }); + + test('a missing ref is treated as N / E', () { + final fix = gpsFromExif(exifWith(lat: 1.5, lon: 2.5)); + expect(fix, (latitude: 1.5, longitude: 2.5)); + }); + + test('a missing axis or an empty GPS IFD yields null', () { + expect(gpsFromExif(exifWith(lat: 1.5, latRef: 'N')), isNull); + expect(gpsFromExif(img.ExifData()), isNull); + }); + + test('(0,0) and out-of-range values yield null', () { + expect(gpsFromExif(exifWith(lat: 0, lon: 0)), isNull); + expect(gpsFromExif(exifWith(lat: 95, lon: 10)), isNull); + }); + + test('reads degree/minute/second rationals as the EXIF spec stores them', () { + // 12 deg 20 min 44.16 sec = 12.3456; the typed setter writes exactly + // this triple, so the reader must sum d + m/60 + s/3600. + final e = img.ExifData(); + e.gpsIfd[0x0002] = img.IfdValueRational.list([ + img.Rational(12, 1), + img.Rational(20, 1), + img.Rational(4416, 100), + ]); + e.gpsIfd[0x0001] = img.IfdValueAscii('N'); + e.gpsIfd[0x0004] = img.IfdValueRational.list([ + img.Rational(98, 1), + img.Rational(45, 1), + img.Rational(5544, 100), + ]); + e.gpsIfd[0x0003] = img.IfdValueAscii('W'); + final fix = gpsFromExif(e); + expect(fix?.latitude, closeTo(12.3456, 1e-6)); + expect(fix?.longitude, closeTo(-98.7654, 1e-6)); + }); + }); + + group('readLocalGps for images', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('local_gps_'); + }); + tearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + + test('JPEG with GPS', () { + final f = File('${tempDir.path}/a.jpg') + ..writeAsBytesSync( + jpegWithExif((e) { + e.gpsIfd.gpsLatitude = 20.5; + e.gpsIfd.gpsLatitudeRef = 'N'; + e.gpsIfd.gpsLongitude = 87.25; + e.gpsIfd.gpsLongitudeRef = 'W'; + }), + ); + final fix = readLocalGps(f, 'image/jpeg'); + expect(fix?.latitude, closeTo(20.5, 1e-6)); + expect(fix?.longitude, closeTo(-87.25, 1e-6)); + }); + + test('JPEG without GPS, PNG, and a missing file yield null', () { + final noGps = File('${tempDir.path}/b.jpg') + ..writeAsBytesSync( + jpegWithExif((e) => e.exifIfd['DateTimeOriginal'] = '2025:01:01 00:00:00'), + ); + expect(readLocalGps(noGps, 'image/jpeg'), isNull); + expect(readLocalGps(noGps, 'image/png'), isNull); + expect(readLocalGps(File('${tempDir.path}/none.jpg'), 'image/jpeg'), isNull); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/media/data/services/local_gps_reader_test.dart` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create `gps_fix.dart`** + +```dart +/// A decimal-degree position read from a media file's own metadata. +typedef GpsFix = ({double latitude, double longitude}); + +/// True when the pair is a usable position: finite, inside the valid +/// ranges, and not the `(0, 0)` that cameras and phones write when the +/// receiver had no fix. Matches the `!= 0` guard the media repository +/// applies when it reads coordinates back. +bool isPlausibleFix(double latitude, double longitude) { + if (!latitude.isFinite || !longitude.isFinite) return false; + if (latitude < -90 || latitude > 90) return false; + if (longitude < -180 || longitude > 180) return false; + if (latitude == 0 && longitude == 0) return false; + return true; +} +``` + +- [ ] **Step 4: Create `local_gps_reader.dart`** + +```dart +import 'dart:io'; + +import 'package:image/image.dart' as img; + +import 'package:submersion/features/media/data/services/gps_fix.dart'; +import 'package:submersion/features/media/data/services/local_exif_loader.dart'; + +/// Reads a position from a media file's own metadata with pure Dart, for the +/// platforms and files where `native_exif` yields nothing. Returns null when +/// the file carries no plausible fix. +GpsFix? readLocalGps(File file, String mime) { + try { + switch (mime) { + case 'image/jpeg': + case 'image/heic': + case 'image/heif': + final exif = readLocalExif(file, mime); + return exif == null ? null : gpsFromExif(exif); + default: + return null; + } + } on Object { + return null; + } +} + +/// EXIF stores each axis as three unsigned rationals (degrees, minutes, +/// seconds) in the GPS IFD, with the hemisphere in a separate ASCII tag. +/// A reader that ignores the ref tag puts every western-hemisphere dive in +/// the wrong ocean, so both are required here. +GpsFix? gpsFromExif(img.ExifData exif) { + final gps = exif.gpsIfd; + final lat = _degrees(gps[0x0002]); + final lon = _degrees(gps[0x0004]); + if (lat == null || lon == null) return null; + final latRef = gps[0x0001]?.toString().trim().toUpperCase(); + final lonRef = gps[0x0003]?.toString().trim().toUpperCase(); + final signedLat = latRef == 'S' ? -lat : lat; + final signedLon = lonRef == 'W' ? -lon : lon; + if (!isPlausibleFix(signedLat, signedLon)) return null; + return (latitude: signedLat, longitude: signedLon); +} + +/// Sums a (deg, min, sec) rational triple; tolerates a single decimal-degree +/// rational, which some writers emit instead of the triple. +double? _degrees(img.IfdValue? value) { + if (value == null) return null; + if (value is! img.IfdValueRational) return null; + final parts = value.value; + if (parts.isEmpty) return null; + double part(int i) { + if (i >= parts.length) return 0; + final r = parts[i]; + if (r.denominator == 0) return double.nan; + return r.numerator / r.denominator; + } + final degrees = part(0) + part(1) / 60 + part(2) / 3600; + return degrees.isFinite ? degrees : null; +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `flutter test test/features/media/data/services/local_gps_reader_test.dart` +Expected: PASS. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format lib/features/media/data/services test/features/media/data/services +flutter analyze lib/features/media/data/services test/features/media/data/services +git add lib/features/media/data/services/gps_fix.dart lib/features/media/data/services/local_gps_reader.dart test/features/media/data/services/local_gps_reader_test.dart +git commit -m "feat(media): read EXIF GPS from JPEG and HEIC with pure Dart" +``` + +--- + +### Task 4: QuickTime location reader (ISO 6709) + +**Files:** +- Create: `lib/features/media/data/services/quicktime_location_reader.dart` +- Test: `test/features/media/data/services/quicktime_location_reader_test.dart` + +**Interfaces:** +- Consumes: `findBox`, `findBoxInBytes`, `beU16`, `beU32`, `readBytesAt`, `fourCC` (Task 1); `GpsFix`, `isPlausibleFix` (Task 3). +- Produces: + - `GpsFix? parseIso6709(String value)`: decimal-degree form `+DD.DDDD+DDD.DDDD[+alt]/`. + - `GpsFix? readQuickTimeLocation(File file)`: `moov > udta > ©xyz` first, then `moov > meta > keys/ilst` `com.apple.quicktime.location.ISO6709`. + +Format notes for the implementer: +- `©xyz` (bytes `A9 78 79 7A`) in `udta` is an international-text atom: `uint16 textLength`, `uint16 languageCode`, then `textLength` bytes of text. +- QuickTime's `moov > meta` is NOT a FullBox in Apple files (its first child `hdlr` starts at offset 0) but IS a FullBox in ISO files (children start at offset 4). Detect: if `fourCC(meta, 4) == 'hdlr'` the children start at 0, otherwise at 4. +- `keys` is a FullBox: `uint32 entryCount`, then entries `{uint32 size, 'mdta', bytes name}`; the key index is 1-based. +- `ilst` children are boxes whose 4-byte "type" is the big-endian key index; each holds a `data` box: `uint32 typeIndicator` (1 = UTF-8), `uint32 locale`, then the value bytes. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/media/data/services/quicktime_location_reader_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/quicktime_location_reader.dart'; + +import '../../../../helpers/media_container_fixtures.dart'; + +const _iso = '+37.3323-122.0312+023.456/'; + +/// `©xyz` international-text atom inside udta. +List _xyz(String text) => box('©xyz', [ + ...u16(text.length), + ...u16(0x15c7), // language: English + ...ascii.encode(text), +]); + +/// Apple-style moov > meta (no version/flags) with keys + ilst. +List _appleMeta(String text, {bool fullBox = false}) { + final hdlr = fullBox + ? fullBox_('hdlr') + : box('hdlr', [0, 0, 0, 0, 0, 0, 0, 0, ...'mdta'.codeUnits, ...List.filled(12, 0)]); + final keyName = ascii.encode('com.apple.quicktime.location.ISO6709'); + final keys = fullBox_('keys', [ + ...u32(1), + ...u32(8 + keyName.length), + ...'mdta'.codeUnits, + ...keyName, + ]); + final data = box('data', [...u32(1), ...u32(0), ...ascii.encode(text)]); + final entry = [...u32(8 + data.length), ...u32(1), ...data]; + final ilst = box('ilst', entry); + final payload = [...hdlr, ...keys, ...ilst]; + return box('meta', fullBox ? [0, 0, 0, 0, ...payload] : payload); +} + +List fullBox_(String type, [List payload = const []]) => + box(type, [0, 0, 0, 0, ...payload]); + +void main() { + group('parseIso6709', () { + test('parses the Apple decimal-degree form with altitude', () { + final fix = parseIso6709(_iso); + expect(fix?.latitude, closeTo(37.3323, 1e-6)); + expect(fix?.longitude, closeTo(-122.0312, 1e-6)); + }); + test('parses without altitude or trailing slash', () { + expect(parseIso6709('-33.8688+151.2093'), (latitude: -33.8688, longitude: 151.2093)); + }); + test('rejects garbage, (0,0), and out-of-range', () { + expect(parseIso6709('hello'), isNull); + expect(parseIso6709('+00.0000+000.0000/'), isNull); + expect(parseIso6709('+95.0000+010.0000/'), isNull); + expect(parseIso6709(''), isNull); + }); + }); + + group('readQuickTimeLocation', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('qt_loc_'); + }); + tearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + File write(String name, List bytes) => + File('${tempDir.path}/$name')..writeAsBytesSync(bytes); + + test('reads udta > xyz with moov after mdat', () { + final f = write('a.mov', [ + ...box('ftyp', 'qt '.codeUnits), + ...box('mdat', List.filled(64, 0)), + ...box('moov', [...box('mvhd', List.filled(100, 0)), ...box('udta', _xyz(_iso))]), + ]); + final fix = readQuickTimeLocation(f); + expect(fix?.latitude, closeTo(37.3323, 1e-6)); + expect(fix?.longitude, closeTo(-122.0312, 1e-6)); + }); + + test('reads meta > keys/ilst in the Apple (non-FullBox) layout', () { + final f = write('b.mov', [ + ...box('ftyp', 'qt '.codeUnits), + ...box('moov', _appleMeta(_iso)), + ]); + expect(readQuickTimeLocation(f)?.longitude, closeTo(-122.0312, 1e-6)); + }); + + test('reads meta > keys/ilst in the ISO (FullBox) layout', () { + final f = write('c.mp4', [ + ...box('ftyp', 'isom'.codeUnits), + ...box('moov', _appleMeta(_iso, fullBox: true)), + ]); + expect(readQuickTimeLocation(f)?.latitude, closeTo(37.3323, 1e-6)); + }); + + test('returns null when neither atom is present or the text is bad', () { + final none = write('d.mp4', [...box('ftyp', 'isom'.codeUnits), ...box('moov', box('mvhd', []))]); + expect(readQuickTimeLocation(none), isNull); + final bad = write('e.mov', [...box('moov', box('udta', _xyz('not a location')))]); + expect(readQuickTimeLocation(bad), isNull); + expect(readQuickTimeLocation(File('${tempDir.path}/none.mov')), isNull); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/media/data/services/quicktime_location_reader_test.dart` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create `quicktime_location_reader.dart`** + +```dart +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:submersion/features/media/data/services/gps_fix.dart'; +import 'package:submersion/features/media/data/services/isobmff_boxes.dart'; + +/// Reads the recording location iPhones and most cameras write into a +/// QuickTime/MP4 container: the classic `moov > udta > ©xyz` atom, then the +/// newer `moov > meta > keys/ilst` entry +/// `com.apple.quicktime.location.ISO6709`. Both hold an ISO 6709 string. +GpsFix? readQuickTimeLocation(File file) { + RandomAccessFile? raf; + try { + raf = file.openSync(); + final end = raf.lengthSync(); + final moov = findBox(raf, 0, end, 'moov'); + if (moov == null) return null; + return _fromUdta(raf, moov) ?? _fromMeta(raf, moov); + } on Object { + return null; + } finally { + raf?.closeSync(); + } +} + +const _maxTextBytes = 4096; +const _maxMetaBytes = 1024 * 1024; +const _locationKey = 'com.apple.quicktime.location.ISO6709'; + +GpsFix? _fromUdta(RandomAccessFile raf, BoxRange moov) { + final udta = findBox(raf, moov.start, moov.end, 'udta'); + if (udta == null) return null; + final xyz = findBox(raf, udta.start, udta.end, '©xyz'); + if (xyz == null || xyz.length < 4) return null; + final header = readBytesAt(raf, xyz.start, 4); + final textLen = beU16(header, 0); + if (textLen <= 0 || textLen > _maxTextBytes || 4 + textLen > xyz.length) { + return null; + } + final text = readBytesAt(raf, xyz.start + 4, textLen); + return parseIso6709(latin1.decode(text, allowInvalid: true)); +} + +GpsFix? _fromMeta(RandomAccessFile raf, BoxRange moov) { + final meta = findBox(raf, moov.start, moov.end, 'meta'); + if (meta == null || meta.length > _maxMetaBytes || meta.length < 8) { + return null; + } + final b = readBytesAt(raf, meta.start, meta.length); + // Apple writes meta as a plain box (hdlr at offset 0); ISO files make it a + // FullBox (4 bytes of version/flags first). Detect rather than assume. + final childStart = fourCC(b, 4) == 'hdlr' ? 0 : 4; + final keys = findBoxInBytes(b, childStart, b.length, 'keys'); + final ilst = findBoxInBytes(b, childStart, b.length, 'ilst'); + if (keys == null || ilst == null) return null; + + final index = _keyIndex(b, keys, _locationKey); + if (index == null) return null; + + for (final entry in _walkEntries(b, ilst)) { + if (entry.index != index) continue; + final data = findBoxInBytes(b, entry.range.start, entry.range.end, 'data'); + if (data == null || data.length <= 8) continue; + final text = b.sublist(data.start + 8, data.end); + return parseIso6709(utf8.decode(text, allowMalformed: true)); + } + return null; +} + +/// 1-based index of [name] in a `keys` FullBox, or null. +int? _keyIndex(Uint8List b, BoxRange keys, String name) { + var p = keys.start + 4; // version + flags + if (p + 4 > keys.end) return null; + final count = beU32(b, p); + p += 4; + for (var i = 1; i <= count && p + 8 <= keys.end; i++) { + final size = beU32(b, p); + if (size < 8 || p + size > keys.end) return null; + final key = utf8.decode(b.sublist(p + 8, p + size), allowMalformed: true); + if (key == name) return i; + p += size; + } + return null; +} + +typedef _IlstEntry = ({int index, BoxRange range}); + +/// `ilst` children are boxes whose type field is the key index. +Iterable<_IlstEntry> _walkEntries(Uint8List b, BoxRange ilst) sync* { + var pos = ilst.start; + while (pos + 8 <= ilst.end) { + final size = beU32(b, pos); + if (size < 8 || pos + size > ilst.end) return; + yield (index: beU32(b, pos + 4), range: BoxRange(pos + 8, pos + size)); + pos += size; + } +} + +final _iso6709 = RegExp(r'^([+-]\d{1,2}(?:\.\d+)?)([+-]\d{1,3}(?:\.\d+)?)'); + +/// Parses the decimal-degree ISO 6709 form (`+DD.DDDD+DDD.DDDD[+ALT]/`) +/// that Apple and camera firmware write. Returns null for anything else. +GpsFix? parseIso6709(String value) { + final m = _iso6709.firstMatch(value.trim()); + if (m == null) return null; + final lat = double.tryParse(m.group(1)!); + final lon = double.tryParse(m.group(2)!); + if (lat == null || lon == null || !isPlausibleFix(lat, lon)) return null; + return (latitude: lat, longitude: lon); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/media/data/services/quicktime_location_reader_test.dart` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/media/data/services test/features/media/data/services +flutter analyze lib/features/media/data/services test/features/media/data/services +git add lib/features/media/data/services/quicktime_location_reader.dart test/features/media/data/services/quicktime_location_reader_test.dart +git commit -m "feat(media): read QuickTime ISO 6709 recording location" +``` + +--- + +### Task 5: GoPro GPMF GPS reader + +**Files:** +- Create: `lib/features/media/data/services/gpmf_gps_reader.dart` +- Test: `test/features/media/data/services/gpmf_gps_reader_test.dart` + +**Interfaces:** +- Consumes: Task 1 walker and readers; `GpsFix`, `isPlausibleFix`. +- Produces: + - `GpsFix? gpsFromGpmfSample(Uint8List sample)`: parses one `gpmd` sample (a KLV stream); prefers `GPS9`, falls back to `GPS5` with `GPSF >= 2`. + - `GpsFix? readGpmfGps(File file, {int maxSamples = 30})`: locates the `gpmd` track, walks up to [maxSamples] samples, returns the first plausible fix. + +GPMF format notes for the implementer: +- Item: `key` (4 ASCII), `type` (1 byte), `structSize` (1 byte), `repeat` (uint16 BE). Payload is `structSize * repeat` bytes, padded to a multiple of 4. Type `0` means the payload is a nested KLV stream (`DEVC`, `STRM`). +- Type chars used here: `b` int8, `B` uint8, `s` int16, `S` uint16, `l` int32, `L` uint32, `f` float32, `?` complex (layout given by a sibling `TYPE` item, e.g. `lllllllSS` for `GPS9`). +- `GPS5`: type `l`, structSize 20 (lat, lon, alt, speed2d, speed3d), scaled by sibling `SCAL` (five values). Sibling `GPSF` (uint32) is the fix: 0 none, 2 = 2D, 3 = 3D. +- `GPS9`: type `?`, `TYPE` = `lllllllSS`, structSize 32 (lat, lon, alt, speed2d, speed3d, daysSince2000, secsSinceMidnight, dop, fix); `SCAL` has nine values; `fix` is the last field of each sample. +- Track locating: `moov > trak*`; pick the one whose `mdia > hdlr` handler type (bytes 8..12 of the hdlr payload) is `meta` and whose `mdia > minf > stbl > stsd` first entry type (bytes 12..16 of the stsd payload) is `gpmd`. Sample sizes from `stsz` (payload: version/flags, `sampleSize`, `sampleCount`, then per-sample sizes when `sampleSize == 0`), chunk offsets from `stco` (uint32) or `co64` (uint64), samples-per-chunk from `stsc` entries `{firstChunk, samplesPerChunk, sampleDescriptionIndex}`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/media/data/services/gpmf_gps_reader_test.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/gpmf_gps_reader.dart'; + +import '../../../../helpers/media_container_fixtures.dart'; + +List _s32(int v) => u32(v.toUnsigned(32)); + +/// One GPMF KLV item: key, type char, struct size, repeat, payload padded +/// to 4 bytes. [type] 0 nests a KLV stream. +List klv(String key, int type, int structSize, int repeat, List data) { + final padded = [...data, ...List.filled((4 - data.length % 4) % 4, 0)]; + return [...key.codeUnits, type, structSize, ...u16(repeat), ...padded]; +} + +int t(String c) => c.codeUnitAt(0); + +List _gps5Stream({required int fix, required List> samples}) { + final scal = klv('SCAL', t('l'), 4, 5, [ + ..._s32(10000000), ..._s32(10000000), ..._s32(1000), ..._s32(1000), ..._s32(100), + ]); + final gpsf = klv('GPSF', t('L'), 4, 1, u32(fix)); + final gps5 = klv('GPS5', t('l'), 20, samples.length, [ + for (final s in samples) ...[for (final v in s) ..._s32(v)], + ]); + final strm = [...klv('STNM', t('c'), 1, 4, 'GPS '.codeUnits), ...scal, ...gpsf, ...gps5]; + return klv('DEVC', 0, 1, strm.length, klv('STRM', 0, 1, strm.length, strm)); +} + +List _gps9Stream(List<({int lat, int lon, int fix})> samples) { + final type = klv('TYPE', t('c'), 1, 9, 'lllllllSS'.codeUnits); + final scal = klv('SCAL', t('l'), 4, 9, [ + ..._s32(10000000), ..._s32(10000000), ..._s32(1000), ..._s32(1000), + ..._s32(100), ..._s32(1), ..._s32(1000), ..._s32(100), ..._s32(1), + ]); + final gps9 = klv('GPS9', t('?'), 32, samples.length, [ + for (final s in samples) ...[ + ..._s32(s.lat), ..._s32(s.lon), ..._s32(0), ..._s32(0), ..._s32(0), + ..._s32(9000), ..._s32(0), ...u16(150), ...u16(s.fix), + ], + ]); + final strm = [...type, ...scal, ...gps9]; + return klv('DEVC', 0, 1, strm.length, klv('STRM', 0, 1, strm.length, strm)); +} + +/// GoPro-shaped MP4: ftyp, mdat holding [samples] back to back, moov last +/// with a single meta/gpmd track whose sample tables point into mdat. +List gpmfMp4(List> samples) { + final ftyp = box('ftyp', 'mp41'.codeUnits); + final mdatPayload = [for (final s in samples) ...s]; + final mdat = box('mdat', mdatPayload); + var offset = ftyp.length + 8; + final offsets = []; + for (final s in samples) { + offsets.add(offset); + offset += s.length; + } + final hdlr = fullBox('hdlr', [0, 0, 0, 0, ...'meta'.codeUnits, ...List.filled(12, 0), 0]); + final stsd = fullBox('stsd', [...u32(1), ...u32(16), ...'gpmd'.codeUnits, ...List.filled(8, 0)]); + final stsz = fullBox('stsz', [...u32(0), ...u32(samples.length), for (final s in samples) ...u32(s.length)]); + final stsc = fullBox('stsc', [...u32(1), ...u32(1), ...u32(1), ...u32(1)]); + final stco = fullBox('stco', [...u32(offsets.length), for (final o in offsets) ...u32(o)]); + final stbl = box('stbl', [...stsd, ...stsz, ...stsc, ...stco]); + final minf = box('minf', stbl); + final mdia = box('mdia', [...hdlr, ...minf]); + final videoTrak = box('trak', box('mdia', fullBox('hdlr', [0, 0, 0, 0, ...'vide'.codeUnits, ...List.filled(13, 0)]))); + final moov = box('moov', [...box('mvhd', List.filled(100, 0)), ...videoTrak, ...box('trak', mdia)]); + return [...ftyp, ...mdat, ...moov]; +} + +void main() { + group('gpsFromGpmfSample', () { + test('GPS5 with a 3D fix scales lat/lon by SCAL', () { + final sample = Uint8List.fromList( + _gps5Stream(fix: 3, samples: [[123456789, -987654321, 5000, 100, 100]]), + ); + final fix = gpsFromGpmfSample(sample); + expect(fix?.latitude, closeTo(12.3456789, 1e-7)); + expect(fix?.longitude, closeTo(-98.7654321, 1e-7)); + }); + + test('GPS5 without a fix yields null', () { + final sample = Uint8List.fromList( + _gps5Stream(fix: 0, samples: [[123456789, -987654321, 0, 0, 0]]), + ); + expect(gpsFromGpmfSample(sample), isNull); + }); + + test('GPS9 takes the first sample whose own fix field is 2D or better', () { + final sample = Uint8List.fromList(_gps9Stream([ + (lat: 0, lon: 0, fix: 0), + (lat: 205000000, lon: -872500000, fix: 2), + ])); + final fix = gpsFromGpmfSample(sample); + expect(fix?.latitude, closeTo(20.5, 1e-7)); + expect(fix?.longitude, closeTo(-87.25, 1e-7)); + }); + + test('a truncated or garbage stream yields null', () { + expect(gpsFromGpmfSample(Uint8List.fromList([1, 2, 3])), isNull); + final full = _gps5Stream(fix: 3, samples: [[1, 2, 3, 4, 5]]); + expect(gpsFromGpmfSample(Uint8List.fromList(full.sublist(0, full.length ~/ 2))), isNull); + }); + }); + + group('readGpmfGps', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('gpmf_'); + }); + tearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + File write(String name, List bytes) => + File('${tempDir.path}/$name')..writeAsBytesSync(bytes); + + test('finds the gpmd track behind a video track and reads the first fix', () { + final f = write('a.mp4', gpmfMp4([ + _gps5Stream(fix: 3, samples: [[123456789, -987654321, 0, 0, 0]]), + ])); + final fix = readGpmfGps(f); + expect(fix?.latitude, closeTo(12.3456789, 1e-7)); + }); + + test('walks forward past cold-start samples without a fix', () { + final cold = _gps5Stream(fix: 0, samples: [[0, 0, 0, 0, 0]]); + final warm = _gps5Stream(fix: 2, samples: [[100000000, 200000000, 0, 0, 0]]); + final f = write('b.mp4', gpmfMp4([cold, cold, cold, warm])); + expect(readGpmfGps(f)?.latitude, closeTo(10.0, 1e-7)); + }); + + test('gives up after maxSamples', () { + final cold = _gps5Stream(fix: 0, samples: [[0, 0, 0, 0, 0]]); + final warm = _gps5Stream(fix: 3, samples: [[100000000, 200000000, 0, 0, 0]]); + final f = write('c.mp4', gpmfMp4([...List.filled(30, cold), warm])); + expect(readGpmfGps(f, maxSamples: 30), isNull); + expect(readGpmfGps(f, maxSamples: 31), isNotNull); + }); + + test('returns null without a gpmd track or for a missing file', () { + final f = write('d.mp4', [...box('ftyp', 'isom'.codeUnits), ...box('moov', box('mvhd', []))]); + expect(readGpmfGps(f), isNull); + expect(readGpmfGps(File('${tempDir.path}/none.mp4')), isNull); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/media/data/services/gpmf_gps_reader_test.dart` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create `gpmf_gps_reader.dart`** + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:submersion/features/media/data/services/gps_fix.dart'; +import 'package:submersion/features/media/data/services/isobmff_boxes.dart'; + +/// Reads the first GPS fix from a GoPro clip's GPMF telemetry. +/// +/// GoPro writes telemetry as samples of a `gpmd` metadata track (one sample +/// per second). Each sample is a KLV stream; the GPS lives in a `STRM` +/// holding `GPS9` (HERO11 and later, per-sample fix) or `GPS5` (older, with a +/// sibling `GPSF` fix flag). The reader walks the sample tables and reads one +/// sample at a time, so the multi-GB `mdat` is never loaded. Cold-start clips +/// have no fix for the first seconds, so up to [maxSamples] samples are +/// tried before giving up. +GpsFix? readGpmfGps(File file, {int maxSamples = 30}) { + RandomAccessFile? raf; + try { + raf = file.openSync(); + final end = raf.lengthSync(); + final moov = findBox(raf, 0, end, 'moov'); + if (moov == null) return null; + final stbl = _findGpmdSampleTable(raf, moov); + if (stbl == null) return null; + for (final s in _sampleLocations(raf, stbl, maxSamples)) { + if (s.size <= 0 || s.size > _maxSampleBytes || s.offset + s.size > end) { + return null; + } + final fix = gpsFromGpmfSample(readBytesAt(raf, s.offset, s.size)); + if (fix != null) return fix; + } + return null; + } on Object { + return null; + } finally { + raf?.closeSync(); + } +} + +const _maxSampleBytes = 1024 * 1024; +const _maxTableBytes = 4 * 1024 * 1024; + +/// The `stbl` of the track whose handler is `meta` and whose sample entry is +/// `gpmd`, or null when the file has no GoPro telemetry track. +BoxRange? _findGpmdSampleTable(RandomAccessFile raf, BoxRange moov) { + for (final trak in findBoxes(raf, moov.start, moov.end, 'trak')) { + final mdia = findBox(raf, trak.start, trak.end, 'mdia'); + if (mdia == null) continue; + final hdlr = findBox(raf, mdia.start, mdia.end, 'hdlr'); + if (hdlr == null || hdlr.length < 12) continue; + if (fourCC(readBytesAt(raf, hdlr.start + 8, 4), 0) != 'meta') continue; + final minf = findBox(raf, mdia.start, mdia.end, 'minf'); + if (minf == null) continue; + final stbl = findBox(raf, minf.start, minf.end, 'stbl'); + if (stbl == null) continue; + final stsd = findBox(raf, stbl.start, stbl.end, 'stsd'); + if (stsd == null || stsd.length < 16) continue; + if (fourCC(readBytesAt(raf, stsd.start + 12, 4), 0) == 'gpmd') return stbl; + } + return null; +} + +typedef _SampleLocation = ({int offset, int size}); + +/// Absolute (offset, size) of the first [limit] samples, derived from +/// `stsz` (sizes), `stsc` (samples per chunk) and `stco`/`co64` (chunk +/// offsets). Samples within a chunk are contiguous. +Iterable<_SampleLocation> _sampleLocations( + RandomAccessFile raf, + BoxRange stbl, + int limit, +) sync* { + final stsz = _payload(raf, stbl, 'stsz'); + final stsc = _payload(raf, stbl, 'stsc'); + final stco = _payload(raf, stbl, 'stco'); + final co64 = stco == null ? _payload(raf, stbl, 'co64') : null; + if (stsz == null || stsc == null || (stco == null && co64 == null)) return; + + final constantSize = beU32(stsz, 4); + final sampleCount = beU32(stsz, 8); + int sizeOf(int i) { + if (constantSize != 0) return constantSize; + final p = 12 + i * 4; + return p + 4 <= stsz.length ? beU32(stsz, p) : -1; + } + + final chunkCount = beU32(stco ?? co64!, 4); + int chunkOffset(int c) => stco != null + ? beU32(stco, 8 + c * 4) + : beU64(co64!, 8 + c * 8); + + final stscCount = beU32(stsc, 4); + int samplesPerChunk(int chunkIndex1) { + var result = 1; + for (var e = 0; e < stscCount; e++) { + final p = 8 + e * 12; + if (p + 12 > stsc.length) break; + if (beU32(stsc, p) <= chunkIndex1) result = beU32(stsc, p + 4); + } + return result; + } + + var sample = 0; + for (var c = 0; c < chunkCount && sample < sampleCount && sample < limit; c++) { + final entryLen = stco != null ? 4 : 8; + if (8 + (c + 1) * entryLen > (stco ?? co64!).length) return; + var offset = chunkOffset(c); + final perChunk = samplesPerChunk(c + 1); + for (var k = 0; k < perChunk && sample < sampleCount && sample < limit; k++) { + final size = sizeOf(sample); + if (size < 0) return; + yield (offset: offset, size: size); + offset += size; + sample++; + } + } +} + +/// Payload bytes of the first [type] box inside [parent], bounded. +Uint8List? _payload(RandomAccessFile raf, BoxRange parent, String type) { + final r = findBox(raf, parent.start, parent.end, type); + if (r == null || r.length < 8 || r.length > _maxTableBytes) return null; + return readBytesAt(raf, r.start, r.length); +} + +/// Parses one `gpmd` sample. Public for unit tests and the real-sample gate. +GpsFix? gpsFromGpmfSample(Uint8List sample) { + try { + return _searchStreams(sample, 0, sample.length); + } on Object { + return null; + } +} + +typedef _Item = ({String key, int type, int structSize, int repeat, int dataStart, int dataEnd}); + +/// Yields the items of one KLV stream; stops at the first malformed header. +Iterable<_Item> _items(Uint8List b, int start, int end) sync* { + var p = start; + while (p + 8 <= end) { + final key = fourCC(b, p); + final type = b[p + 4]; + final structSize = b[p + 5]; + final repeat = beU16(b, p + 6); + final len = structSize * repeat; + final padded = (len + 3) & ~3; + if (p + 8 + padded > end) return; + yield ( + key: key, + type: type, + structSize: structSize, + repeat: repeat, + dataStart: p + 8, + dataEnd: p + 8 + len, + ); + p += 8 + padded; + } +} + +/// Depth-first search for a `STRM` carrying GPS9 or GPS5. +GpsFix? _searchStreams(Uint8List b, int start, int end) { + for (final item in _items(b, start, end)) { + if (item.key == 'STRM') { + final fix = _gpsFromStream(b, item.dataStart, item.dataEnd); + if (fix != null) return fix; + } else if (item.type == 0) { + final fix = _searchStreams(b, item.dataStart, item.dataEnd); + if (fix != null) return fix; + } + } + return null; +} + +GpsFix? _gpsFromStream(Uint8List b, int start, int end) { + _Item? gps9, gps5, scal, gpsf, type; + for (final item in _items(b, start, end)) { + switch (item.key) { + case 'GPS9': + gps9 = item; + case 'GPS5': + gps5 = item; + case 'SCAL': + scal = item; + case 'GPSF': + gpsf = item; + case 'TYPE': + type = item; + } + } + if (scal == null) return null; + final scale = _numbers(b, scal); + if (scale.length < 2 || scale[0] == 0 || scale[1] == 0) return null; + + if (gps9 != null && type != null) { + final layout = String.fromCharCodes(b, type.dataStart, type.dataEnd); + final fieldSizes = layout.split('').map(_sizeOfType).toList(); + if (fieldSizes.contains(0)) return null; + final structSize = fieldSizes.fold(0, (a, s) => a + s); + if (structSize != gps9.structSize || layout.length < 9) return null; + final fixIndex = layout.length - 1; + for (var i = 0; i < gps9.repeat; i++) { + final base = gps9.dataStart + i * structSize; + final lat = _readTyped(b, base, layout[0]) / scale[0]; + final lon = _readTyped(b, base + fieldSizes[0], layout[1]) / scale[1]; + var fixOffset = base; + for (var f = 0; f < fixIndex; f++) { + fixOffset += fieldSizes[f]; + } + final fix = _readTyped(b, fixOffset, layout[fixIndex]); + if (fix >= 2 && isPlausibleFix(lat, lon)) { + return (latitude: lat, longitude: lon); + } + } + return null; + } + + if (gps5 != null) { + if (gpsf == null) return null; + final fixFlag = _numbers(b, gpsf); + if (fixFlag.isEmpty || fixFlag.first < 2) return null; + if (gps5.structSize < 8 || gps5.repeat < 1) return null; + for (var i = 0; i < gps5.repeat; i++) { + final base = gps5.dataStart + i * gps5.structSize; + final lat = beS32(b, base) / scale[0]; + final lon = beS32(b, base + 4) / scale[1]; + if (isPlausibleFix(lat, lon)) return (latitude: lat, longitude: lon); + } + } + return null; +} + +/// Decodes a numeric item (SCAL, GPSF) of any integer type into doubles. +List _numbers(Uint8List b, _Item item) { + final c = String.fromCharCode(item.type); + final size = _sizeOfType(c); + if (size == 0) return const []; + final out = []; + for (var p = item.dataStart; p + size <= item.dataEnd; p += size) { + out.add(_readTyped(b, p, c)); + } + return out; +} + +int _sizeOfType(String c) => switch (c) { + 'b' || 'B' || 'c' => 1, + 's' || 'S' => 2, + 'l' || 'L' || 'f' => 4, + 'j' || 'J' || 'd' => 8, + _ => 0, +}; + +double _readTyped(Uint8List b, int p, String c) => switch (c) { + 'b' => b[p].toSigned(8).toDouble(), + 'B' || 'c' => b[p].toDouble(), + 's' => beS16(b, p).toDouble(), + 'S' => beU16(b, p).toDouble(), + 'l' => beS32(b, p).toDouble(), + 'L' => beU32(b, p).toDouble(), + 'f' => ByteData.sublistView(b, p, p + 4).getFloat32(0), + 'd' => ByteData.sublistView(b, p, p + 8).getFloat64(0), + 'j' => ByteData.sublistView(b, p, p + 8).getInt64(0).toDouble(), + 'J' => ByteData.sublistView(b, p, p + 8).getUint64(0).toDouble(), + _ => double.nan, +}; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/media/data/services/gpmf_gps_reader_test.dart` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/media/data/services test/features/media/data/services +flutter analyze lib/features/media/data/services test/features/media/data/services +git add lib/features/media/data/services/gpmf_gps_reader.dart test/features/media/data/services/gpmf_gps_reader_test.dart +git commit -m "feat(media): read the first GPS fix from GoPro GPMF telemetry" +``` + +--- + +### Task 6: Video dispatch and wiring into the extractor and desktop picker + +**Files:** +- Modify: `lib/features/media/data/services/local_gps_reader.dart` +- Modify: `lib/features/media/data/services/exif_extractor.dart:97-112` +- Modify: `lib/features/media/data/services/photo_picker_service_desktop.dart:180-195` +- Test: `test/features/media/data/services/local_gps_reader_test.dart` (video group), `test/features/media/data/services/exif_extractor_test.dart` (pure-Dart fallback group), `test/features/media/data/services/photo_picker_service_desktop_test.dart` + +**Interfaces:** +- Consumes: `readQuickTimeLocation` (Task 4), `readGpmfGps` (Task 5). +- Produces: `readLocalGps` now handles `video/mp4`, `video/quicktime`, `video/x-m4v`: QuickTime location first, then GPMF. + +- [ ] **Step 1: Add the failing video tests to `local_gps_reader_test.dart`** + +Append inside `main()`: + +```dart + group('readLocalGps for video', () { + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('local_gps_video_'); + }); + tearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + + List xyz(String text) => box('©xyz', [ + ...u16(text.length), + ...u16(0), + ...text.codeUnits, + ]); + + test('QuickTime location wins for a MOV', () { + final f = File('${tempDir.path}/a.mov') + ..writeAsBytesSync([ + ...box('ftyp', 'qt '.codeUnits), + ...box('moov', box('udta', xyz('+37.3323-122.0312/'))), + ]); + expect(readLocalGps(f, 'video/quicktime')?.latitude, closeTo(37.3323, 1e-6)); + }); + + test('a video with neither location atom nor telemetry yields null', () { + final f = File('${tempDir.path}/b.mp4') + ..writeAsBytesSync([...box('ftyp', 'isom'.codeUnits), ...box('moov', box('mvhd', []))]); + expect(readLocalGps(f, 'video/mp4'), isNull); + }); + }); +``` + +(The GPMF path through `readLocalGps` is exercised by the gated real-sample test in Task 7 and by `gpmf_gps_reader_test`; a synthetic GoPro file here would duplicate that fixture builder.) + +- [ ] **Step 2: Add the failing extractor test** + +In `exif_extractor_test.dart`, inside the `pure-Dart image fallback (native_exif unavailable)` group, add: + +```dart + test('reads GPS from JPEG bytes when native_exif is absent', () async { + final image = img.Image(width: 4, height: 4); + image.exif.gpsIfd.gpsLatitude = 20.5; + image.exif.gpsIfd.gpsLatitudeRef = 'N'; + image.exif.gpsIfd.gpsLongitude = 87.25; + image.exif.gpsIfd.gpsLongitudeRef = 'W'; + final f = File('${tempDir.path}/gps.jpg') + ..writeAsBytesSync(img.encodeJpg(image)); + final meta = await ExifExtractor().extract(f); + expect(meta?.latitude, closeTo(20.5, 1e-6)); + expect(meta?.longitude, closeTo(-87.25, 1e-6)); + }); + + test('leaves GPS null for a JPEG without a GPS IFD', () async { + final f = File('${tempDir.path}/nogps.jpg') + ..writeAsBytesSync(jpegWithDateTimeOriginal('2025:12:27 12:08:19')); + final meta = await ExifExtractor().extract(f); + expect(meta?.latitude, isNull); + expect(meta?.longitude, isNull); + }); +``` + +- [ ] **Step 3: Add the failing desktop picker test** + +In `photo_picker_service_desktop_test.dart`, inside the `assetInfoForFile` group: + +```dart + test('carries EXIF GPS onto the asset', () { + final image = img.Image(width: 4, height: 4); + image.exif.gpsIfd.gpsLatitude = 20.5; + image.exif.gpsIfd.gpsLatitudeRef = 'N'; + image.exif.gpsIfd.gpsLongitude = 87.25; + image.exif.gpsIfd.gpsLongitudeRef = 'W'; + final file = File('${tempDir.path}/gps.jpg') + ..writeAsBytesSync(img.encodeJpg(image)); + + final asset = service.assetInfoForFile(file); + + expect(asset?.latitude, closeTo(20.5, 1e-6)); + expect(asset?.longitude, closeTo(-87.25, 1e-6)); + }); + + test('leaves GPS null when the file has none', () { + final file = _jpegWithExifDate(tempDir, 'nogps.jpg', '2025:07:14 17:22:31'); + final asset = service.assetInfoForFile(file); + expect(asset?.latitude, isNull); + expect(asset?.longitude, isNull); + }); +``` + +- [ ] **Step 4: Run the three test files to verify the new cases fail** + +Run: `flutter test test/features/media/data/services/local_gps_reader_test.dart` +Expected: the two video tests FAIL (null for the MOV). +Run: `flutter test test/features/media/data/services/exif_extractor_test.dart` +Expected: "reads GPS from JPEG bytes" FAILS (latitude null). +Run: `flutter test test/features/media/data/services/photo_picker_service_desktop_test.dart` +Expected: "carries EXIF GPS" FAILS. + +- [ ] **Step 5: Add the video branch to `readLocalGps`** + +In `local_gps_reader.dart`, add the imports and the case: + +```dart +import 'package:submersion/features/media/data/services/gpmf_gps_reader.dart'; +import 'package:submersion/features/media/data/services/quicktime_location_reader.dart'; +``` + +```dart + case 'video/mp4': + case 'video/quicktime': + case 'video/x-m4v': + // Phones and most cameras write a location atom; GoPro writes + // telemetry instead. Try the cheap atom first. + return readQuickTimeLocation(file) ?? readGpmfGps(file); +``` + +- [ ] **Step 6: Wire the extractor** + +In `exif_extractor.dart`, add `import 'package:submersion/features/media/data/services/local_gps_reader.dart';` and, directly after `takenAt ??= readLocalCaptureTime(file, mime);`, add: + +```dart + // Same fallback for position: native_exif filled lat/lon on mobile stills; + // everything else (desktop stills, video on every platform) reads the + // file's own GPS IFD, QuickTime location atom, or GoPro telemetry. + if (lat == null || lon == null) { + final fix = readLocalGps(file, mime); + if (fix != null) { + lat = fix.latitude; + lon = fix.longitude; + } + } +``` + +Also update the class doc comment's second paragraph to mention position: "the extractor then reads the capture time and position straight from the file's own container metadata with pure-Dart parsers ([readLocalCaptureTime], [readLocalGps])". + +- [ ] **Step 7: Wire the desktop picker** + +In `photo_picker_service_desktop.dart`, add `import 'package:submersion/features/media/data/services/local_gps_reader.dart';`. In `_assetInfoForPath`, after `final size = _dimensionsOf(ioFile, mime);` add `final fix = readLocalGps(ioFile, mime);` and replace the two hardcoded lines with: + +```dart + latitude: fix?.latitude, + longitude: fix?.longitude, +``` + +Update the `assetInfoForFile` doc comment: after the capture-time paragraph add "Position comes from the same container metadata via [readLocalGps] (EXIF GPS IFD for stills, the QuickTime location atom or GoPro telemetry for video)." + +- [ ] **Step 8: Run the three test files to verify they pass** + +Run: `flutter test test/features/media/data/services/local_gps_reader_test.dart` +Expected: PASS. +Run: `flutter test test/features/media/data/services/exif_extractor_test.dart` +Expected: PASS. +Run: `flutter test test/features/media/data/services/photo_picker_service_desktop_test.dart` +Expected: PASS. + +- [ ] **Step 9: Format, analyze, commit** + +```bash +dart format lib/features/media test/features/media +flutter analyze lib/features/media test/features/media +git add lib/features/media/data/services/local_gps_reader.dart lib/features/media/data/services/exif_extractor.dart lib/features/media/data/services/photo_picker_service_desktop.dart test/features/media/data/services/local_gps_reader_test.dart test/features/media/data/services/exif_extractor_test.dart test/features/media/data/services/photo_picker_service_desktop_test.dart +git commit -m "feat(media): populate GPS on desktop imports from stills and video" +``` + +--- + +### Task 7: Gated real-sample tests (GoPro and iPhone) + +**Files:** +- Create: `test/features/media/data/services/video_gps_real_sample_test.dart` + +**Interfaces:** +- Consumes: `readLocalGps` (Task 6), `readGpmfGps`, `readQuickTimeLocation`. +- Env vars: `GOPRO_MP4_SAMPLE`, `IPHONE_MOV_SAMPLE`, and the expected coordinates `GOPRO_EXPECTED_LAT`, `GOPRO_EXPECTED_LON`, `IPHONE_EXPECTED_LAT`, `IPHONE_EXPECTED_LON` (decimal degrees, checked to 3 decimals, about 100 m). + +- [ ] **Step 1: Write the gated test** + +```dart +/// Real-sample regression for the video GPS readers. +/// +/// GPMF is a binary telemetry format and the QuickTime meta layout differs +/// between writers, so synthetic fixtures alone do not prove the readers. +/// Point these env vars at real clips (kept OUTSIDE the repo, e.g. in the +/// "submersion data" samples folder) and pass their expected positions: +/// +/// flutter test \ +/// --dart-define=GOPRO_MP4_SAMPLE=/path/GX010001.MP4 \ +/// --dart-define=GOPRO_EXPECTED_LAT=20.5123 \ +/// --dart-define=GOPRO_EXPECTED_LON=-87.2512 \ +/// --dart-define=IPHONE_MOV_SAMPLE=/path/IMG_0001.MOV \ +/// --dart-define=IPHONE_EXPECTED_LAT=20.5123 \ +/// --dart-define=IPHONE_EXPECTED_LON=-87.2512 \ +/// --run-skipped --tags=real-data \ +/// test/features/media/data/services/video_gps_real_sample_test.dart +/// +/// Without the env vars (or when a file is missing) every test skips so CI +/// and fresh clones stay green. +@Tags(['real-data']) +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/gpmf_gps_reader.dart'; +import 'package:submersion/features/media/data/services/local_gps_reader.dart'; +import 'package:submersion/features/media/data/services/quicktime_location_reader.dart'; + +const _goPro = String.fromEnvironment('GOPRO_MP4_SAMPLE'); +const _goProLat = String.fromEnvironment('GOPRO_EXPECTED_LAT'); +const _goProLon = String.fromEnvironment('GOPRO_EXPECTED_LON'); +const _iphone = String.fromEnvironment('IPHONE_MOV_SAMPLE'); +const _iphoneLat = String.fromEnvironment('IPHONE_EXPECTED_LAT'); +const _iphoneLon = String.fromEnvironment('IPHONE_EXPECTED_LON'); + +bool _skipUnless(String path, String lat, String lon, String name) { + if (path.isNotEmpty && File(path).existsSync() && lat.isNotEmpty && lon.isNotEmpty) { + return false; + } + markTestSkipped( + '$name sample not available. Set the *_SAMPLE and *_EXPECTED_* ' + 'dart-defines and pass --run-skipped --tags=real-data to run.', + ); + return true; +} + +void main() { + group('GoPro GPMF real sample', () { + test('readGpmfGps and readLocalGps agree with the expected position', () { + if (_skipUnless(_goPro, _goProLat, _goProLon, 'GoPro')) return; + final file = File(_goPro); + final direct = readGpmfGps(file); + final viaDispatch = readLocalGps(file, 'video/mp4'); + expect(direct, isNotNull); + expect(direct!.latitude, closeTo(double.parse(_goProLat), 0.001)); + expect(direct.longitude, closeTo(double.parse(_goProLon), 0.001)); + expect(viaDispatch, direct); + }); + }); + + group('iPhone MOV real sample', () { + test('readQuickTimeLocation and readLocalGps agree with the expected position', () { + if (_skipUnless(_iphone, _iphoneLat, _iphoneLon, 'iPhone')) return; + final file = File(_iphone); + final direct = readQuickTimeLocation(file); + final viaDispatch = readLocalGps(file, 'video/quicktime'); + expect(direct, isNotNull); + expect(direct!.latitude, closeTo(double.parse(_iphoneLat), 0.001)); + expect(direct.longitude, closeTo(double.parse(_iphoneLon), 0.001)); + expect(viaDispatch, direct); + }); + }); +} +``` + +- [ ] **Step 2: Run without samples to verify it skips cleanly** + +Run: `flutter test test/features/media/data/services/video_gps_real_sample_test.dart` +Expected: 2 tests skipped, 0 failed. + +- [ ] **Step 3: Run with real samples (BLOCKING GATE for this plan)** + +Ask the user for one GoPro clip and one iPhone `.MOV` with location, plus their expected coordinates (from the phone's Photos "Info" panel or GoPro Quik). Run the command from the file's doc comment. Expected: 2 passed. If GPMF fails on the real clip, capture the first `gpmd` sample with `readBytesAt` and inspect the key list before changing the parser; the likely culprits are a `GPS9` `TYPE` layout that differs from `lllllllSS` or a `SCAL` typed `s` rather than `l`, both of which the parser already tolerates through `_sizeOfType`/`_readTyped`. + +- [ ] **Step 4: Format, analyze, commit** + +```bash +dart format test/features/media/data/services +flutter analyze test/features/media/data/services +git add test/features/media/data/services/video_gps_real_sample_test.dart +git commit -m "test(media): gated real-sample checks for GoPro and iPhone video GPS" +``` + +--- + +### Task 8: Whole-project verification + +**Files:** none new. + +- [ ] **Step 1: Format and analyze the whole project** + +```bash +dart format . +flutter analyze +``` +Expected: no changes from format, "No issues found!". + +- [ ] **Step 2: Run the media test tree** + +Run: `flutter test test/features/media` +Expected: all pass. + +- [ ] **Step 3: Full suite once** + +Run: `flutter test` +Expected: all pass. If a known-flaky file fails (see the project memory list), rerun that file alone before treating it as a regression. + +- [ ] **Step 4: Commit any formatting fallout** + +```bash +git add -A lib test +git commit -m "chore: format after the GPS reader work" +``` +(Skip if `git status` is clean.) + +--- + +## Self-review notes + +- Spec Section 2 coverage: shared plumbing (Tasks 1-2), stills (Task 3), QuickTime (Task 4), GPMF with the 30-sample cap (Task 5), dispatch and both wiring seams (Task 6), real-sample gate (Task 7). +- Type consistency: `GpsFix` is the single record type across readers; `readLocalGps(File, String)` is the only entry point the extractor and picker call. +- Out of scope (per spec): RAW formats, `getAssetMetadata` on the desktop picker (still returns null; the picker's `AssetInfo` carries the fix instead). diff --git a/docs/superpowers/plans/2026-08-26-photo-gps-site-suggestions.md b/docs/superpowers/plans/2026-08-26-photo-gps-site-suggestions.md new file mode 100644 index 0000000000..aff3aefdc6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-photo-gps-site-suggestions.md @@ -0,0 +1,3445 @@ +# Photo GPS Site Suggestions (Plan B) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the existing edit-page-only "GPS found in photos" banner into a site suggestion that matches nearby existing and bundled sites before offering to create one, appears on the dive detail page and after multi-dive photo imports, picks the photo nearest the dive's entry time, and remembers a dismissal across devices. + +**Architecture:** Photo GPS becomes a second point source for the existing `SiteMatchingService` (`entry ?? exit ?? photo`), which also learns a third apply mode (write coordinates onto a dive's coordinate-less current site) and a `createAndLink` path. One eligibility predicate on `DiveRepository` feeds the banner, the import-wizard button, and a new post-import prompt. Dismissal is a nullable synced column on `dives`. All UI actions route through the service so the banner and the batch review page share one write path. + +**Tech Stack:** Flutter, Riverpod (`package:submersion/core/providers/provider.dart` barrel), Drift, mockito codegen for repository mocks, `flutter gen-l10n` with 11 ARB locales. + +**Spec:** `docs/superpowers/specs/2026-08-26-photo-gps-site-suggestions-design.md` (Sections 1, 3, 4, 5). Plan A (`2026-08-26-photo-gps-desktop-readers.md`) covers Section 2 and is independent of this plan. + +## Global Constraints + +- Worktree: `.claude/worktrees/photo-gps-site-suggestions`, branch `worktree-photo-gps-site-suggestions`. Run every command from that directory. +- No em-dashes anywhere (code, comments, commits, ARB strings). No emojis in code or docs. +- Files stay under 800 lines; target 200-400. `dive_edit_page.dart` and `dive_detail_page.dart` are already far over; touch them only at the seams named below and do not add logic to them. +- TDD: failing test, run, implement, run, commit. `dart format .` before every commit; `flutter analyze` clean (infos are fatal in CI). +- Every new user-facing string goes into ALL 11 ARB files (`lib/l10n/arb/app_{en,de,es,fr,it,nl,pt,zh,ar,he,hu}.arb`), then `flutter gen-l10n`, and the regenerated `lib/l10n/arb/app_localizations*.dart` files are committed with the ARBs. +- Schema rung: **v172** (main is at 166; 167 through 171 are claimed by open PRs as of 2026-08-26). Task 1 re-verifies before anything is written. +- Timestamps: `media.taken_at` and `dives.entry_time` are wall-clock-UTC epoch millis; always read them with `isUtc: true`. +- Site writes that touch only coordinates or altitude use the narrow column patches `SiteRepository.updateSiteCoordinates` / `updateSiteAltitude`, never `updateSite(site.copyWith(...))` on a possibly partial entity (issue #1187). +- Network calls (reverse geocode, elevation) never run inside a DB transaction and never block a write from completing. +- Run tests one file at a time as shown; do not overlap `flutter test` runs. + +--- + +## File map + +New: + +| File | Responsibility | +| --- | --- | +| `lib/features/media/domain/services/photo_gps_point_selector.dart` | `PhotoGpsPoint` record and `selectBestPhotoGps` (pure) | +| `lib/features/dive_sites/presentation/providers/site_suggestion_providers.dart` | `siteMatchingServiceFactoryProvider`, `SiteSuggestion`, `siteSuggestionForDiveProvider` | +| `lib/features/dive_log/presentation/helpers/site_suggestion_actions.dart` | `SiteSuggestionActions`: assign / addLocation / createSite / chooseNearby / dismiss, one write path | +| `lib/features/dive_log/presentation/widgets/site_suggestion_banner.dart` | Presentational banner (five action cases) | +| `lib/features/dive_log/presentation/widgets/site_suggestion_card.dart` | Consumer wrapper: watches the provider, wires the actions, used by edit and detail pages | +| `lib/features/media/presentation/helpers/offer_site_review_after_import.dart` | Post-import snackbar with "Review sites" action | +| `test/core/database/migration_v172_site_suggestion_dismissed_test.dart` | Migration test | + +Modified: + +| File | Change | +| --- | --- | +| `lib/core/database/database.dart` | `dives.site_suggestion_dismissed_at`, v172 rung, backstop | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart` | `setSiteSuggestionDismissed`, unified `getDivesNeedingSiteMatch` | +| `lib/features/media/data/repositories/media_repository.dart` | `getBestPhotoGpsForDives`, `isUtc` fix | +| `lib/features/dive_sites/data/services/site_matching_service.dart` | `PointSource`, photo fallback, current-site mode, `createAndLink`, `sitesLocated`, altitude pass | +| `lib/features/dive_sites/presentation/providers/site_match_review_notifier.dart` | Factory provider, `createSiteHere` | +| `lib/features/dive_sites/presentation/pages/site_match_review_page.dart` | Source chip, current-site card, "Create site here", snackbar counts, photo point on the map | +| `lib/features/media/presentation/widgets/quick_site_from_gps_dialog.dart` | Geocode-prefilled Country / Region / City | +| `lib/features/dive_log/presentation/pages/dive_edit_page.dart` | Swap banner, delete the two handlers | +| `lib/features/dive_log/presentation/pages/dive_detail_page.dart` | Card under both site headers | +| `lib/features/trips/presentation/helpers/trip_scan_actions.dart`, `lib/features/media/presentation/widgets/files_tab.dart`, `lib/features/media/presentation/helpers/lightroom_scan_helper.dart`, `lib/features/media/presentation/pages/media_import_view.dart` | Post-import prompt | +| `lib/l10n/arb/*.arb` | New keys (Task 9 lists every string) | +| `docs/FEATURE_ROADMAP.md`, `docs/REMAINING_TASKS.md` | Tick the item | + +Deleted: `lib/features/media/presentation/widgets/photo_gps_suggestion_banner.dart`. + +--- + +### Task 1: Rebase and claim the schema rung + +**Files:** none edited. + +- [ ] **Step 1: Rebase the branch onto current main** + +```bash +git fetch origin main +git rebase origin/main +git submodule update --init --recursive +flutter pub get +dart run build_runner build --delete-conflicting-outputs +``` +Expected: rebase applies the two docs commits cleanly. If `database.dart` on main now differs, re-read its ladder tail before Task 2. + +- [ ] **Step 2: Re-scan the ladder** + +```bash +git grep -h -o 'currentSchemaVersion = [0-9]*' origin/main -- lib/core/database/database.dart +for n in $(gh pr list --state open --json number --jq '.[].number'); do echo "PR $n: $(gh pr diff $n 2>/dev/null | grep -E '^\+\s*static const int currentSchemaVersion' | head -1)"; done +``` +Expected: main reports 166 and no open PR claims 172. If either changed, take the smallest number above both main and every open claim, and use that number everywhere this plan says 172 (six places: scalar, ladder entry, helper docstring, `onUpgrade` guard pair, `beforeOpen` backstop comment, the test filename and its two asserts). + +- [ ] **Step 3: Run the existing migration tests as a baseline** + +Run: `flutter test test/core/database/migration_v161_o2_cell_mv_default_test.dart` +Expected: PASS. + +--- + +### Task 2: `dives.site_suggestion_dismissed_at` at v172 + +**Files:** +- Modify: `lib/core/database/database.dart` (the `Dives` table near line 764, the `currentSchemaVersion` scalar near line 3182, the `migrationVersions` tail near line 3479, `onUpgrade` after the v164 rung near line 8616, `beforeOpen` backstops near line 8820, and a new `_assertSiteSuggestionDismissedAtColumn` helper beside `_assertO2CellMvDefaultColumn` near line 4958) +- Test: `test/core/database/migration_v172_site_suggestion_dismissed_test.dart` + +**Interfaces:** +- Produces: Drift column `Dives.siteSuggestionDismissedAt` (`IntColumn`, nullable, SQL `site_suggestion_dismissed_at`), `DivesCompanion.siteSuggestionDismissedAt`. + +- [ ] **Step 1: Write the failing migration test** + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v172 shape: a dives table without the dismissal column, +/// stamped at v171 so the 171 to 172 upgrade runs. +NativeDatabase _dbAt171() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 171'); + rawDb.execute(''' + CREATE TABLE dives ( + id TEXT NOT NULL PRIMARY KEY, + dive_datetime INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + rawDb.execute( + "INSERT INTO dives (id, dive_datetime, created_at, updated_at) " + "VALUES ('d1', 0, 0, 0)", + ); + }, + ); +} + +void main() { + test('v172 adds site_suggestion_dismissed_at defaulting to NULL', () async { + final db = AppDatabase(_dbAt171()); + addTearDown(() => db.close()); + + final cols = await db.customSelect("PRAGMA table_info('dives')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('site_suggestion_dismissed_at')); + + final row = await db + .customSelect('SELECT site_suggestion_dismissed_at AS v FROM dives') + .getSingle(); + expect(row.readNullable('v'), isNull); + }); + + test('fresh databases get the column', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + final cols = await db.customSelect("PRAGMA table_info('dives')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('site_suggestion_dismissed_at')); + }); + + test('the helper no-ops when dives is absent', () async { + final native = NativeDatabase.memory( + setup: (rawDb) => rawDb.execute('PRAGMA user_version = 171'), + ); + final db = AppDatabase(native); + addTearDown(db.close); + await expectLater(db.customSelect('SELECT 1').get(), completes); + }); + + test('v172 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(172)); + expect(AppDatabase.migrationVersions, contains(172)); + }); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/core/database/migration_v172_site_suggestion_dismissed_test.dart` +Expected: FAIL on the column assertions and the ladder assertion. + +- [ ] **Step 3: Add the column to the `Dives` table** + +Directly after `TextColumn get hlc => text().nullable()();` in `class Dives`: + +```dart + /// When the diver dismissed the site suggestion for this dive (photo GPS or + /// dive-computer GPS). Null = never dismissed. Synced with the row. + IntColumn get siteSuggestionDismissedAt => integer().nullable()(); +``` + +- [ ] **Step 4: Bump the scalar and the ladder** + +Change `static const int currentSchemaVersion = 164;` (or whatever main now holds) to `172`. Append to the `migrationVersions` list after the last entry: + +```dart + // v172: dives.site_suggestion_dismissed_at, the synced per-dive dismissal + // of the photo / dive-computer site suggestion. 167 through 171 are + // reserved by PRs that were open when this branch was cut, so this rung is + // deliberately non-contiguous, not missing. + 172, +``` + +- [ ] **Step 5: Add the helper, the `onUpgrade` pair, and the backstop** + +Helper, beside `_assertO2CellMvDefaultColumn`: + +```dart + /// v172: site_suggestion_dismissed_at on dives. Null means the site + /// suggestion (from photo GPS or dive-computer GPS) was never dismissed. + Future _assertSiteSuggestionDismissedAtColumn() async { + final cols = await customSelect("PRAGMA table_info('dives')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('site_suggestion_dismissed_at')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN site_suggestion_dismissed_at INTEGER', + ); + } + } +``` + +In `onUpgrade`, after the v164 pair (and after any rung main has since added): + +```dart + // v172: dives.site_suggestion_dismissed_at (site suggestion dismissal). + if (from < 172) { + await _assertSiteSuggestionDismissedAtColumn(); + } + if (from < 172) await reportProgress(); +``` + +In `beforeOpen`, after the last existing backstop: + +```dart + // v172 backstop: re-assert dives.site_suggestion_dismissed_at (same + // parallel-branch version-collision self-heal). + await _assertSiteSuggestionDismissedAtColumn(); +``` + +- [ ] **Step 6: Regenerate Drift code and run the test** + +```bash +dart run build_runner build --delete-conflicting-outputs +flutter test test/core/database/migration_v172_site_suggestion_dismissed_test.dart +``` +Expected: PASS (4 tests). + +- [ ] **Step 7: Run the schema-adjacent suites** + +Run: `flutter test test/core/database` +Expected: PASS. +Run: `flutter test test/core/services/sync/export_changeset_test.dart` +Expected: PASS (the dives export is table-generic; the new nullable column rides along). + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format lib/core/database test/core/database +flutter analyze lib/core/database test/core/database +git add lib/core/database/database.dart lib/core/database/database.g.dart test/core/database/migration_v172_site_suggestion_dismissed_test.dart +git commit -m "feat(db): add dives.site_suggestion_dismissed_at at v172" +``` + +--- + +### Task 3: `DiveRepository.setSiteSuggestionDismissed` (synced) + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (beside `setSite`, near line 408) +- Test: `test/features/dive_log/data/repositories/dive_repository_site_match_test.dart` + +**Interfaces:** +- Produces: `Future setSiteSuggestionDismissed(String diveId, bool dismissed)`; writes epoch millis or null, bumps the dive HLC through `markRecordPending('dives')`, notifies `SyncEventBus`. + +- [ ] **Step 1: Write the failing tests** + +Add to `dive_repository_site_match_test.dart` (imports to add: `package:submersion/core/data/repositories/sync_repository.dart`, `package:submersion/core/services/sync/sync_data_serializer.dart`): + +```dart + test('setSiteSuggestionDismissed writes and clears the timestamp', () async { + await insertDive('d1', lat: 1, lng: 2); + + await repo.setSiteSuggestionDismissed('d1', true); + var row = await db + .customSelect( + "SELECT site_suggestion_dismissed_at AS v FROM dives WHERE id = 'd1'", + ) + .getSingle(); + expect(row.readNullable('v'), isNotNull); + + await repo.setSiteSuggestionDismissed('d1', false); + row = await db + .customSelect( + "SELECT site_suggestion_dismissed_at AS v FROM dives WHERE id = 'd1'", + ) + .getSingle(); + expect(row.readNullable('v'), isNull); + }); + + test('a dismissal advances the dive HLC so it exports', () async { + await insertDive('d1', lat: 1, lng: 2); + final sync = SyncRepository(); + await sync.markRecordPending( + entityType: 'dives', + recordId: 'd1', + localUpdatedAt: DateTime.now().millisecondsSinceEpoch, + ); + final watermark = + (await db + .customSelect("SELECT hlc FROM dives WHERE id = 'd1'") + .getSingle()) + .read('hlc'); + final serializer = SyncDataSerializer(); + final deviceId = await sync.getDeviceId(); + + final before = await serializer.exportChangeset( + deviceId: deviceId, + hlcWatermark: watermark, + deletions: const [], + ); + expect(before.data.dives.map((d) => d['id']), isNot(contains('d1'))); + + await repo.setSiteSuggestionDismissed('d1', true); + + final after = await serializer.exportChangeset( + deviceId: deviceId, + hlcWatermark: watermark, + deletions: const [], + ); + final exported = after.data.dives.firstWhere((d) => d['id'] == 'd1'); + expect( + exported['siteSuggestionDismissedAt'], + isNotNull, + reason: 'the dismissal must ride the dive row to other devices', + ); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_site_match_test.dart` +Expected: FAIL, "The method 'setSiteSuggestionDismissed' isn't defined". + +- [ ] **Step 3: Implement** + +Directly after `setSite` in `dive_repository_impl.dart`: + +```dart + /// Records (or clears, when [dismissed] is false) the diver's dismissal of + /// the site suggestion for this dive. Single-column update; the dive row + /// carries its own HLC, so marking it pending is what syncs the flag. + Future setSiteSuggestionDismissed(String diveId, bool dismissed) async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + await (_db.update(_db.dives)..where((t) => t.id.equals(diveId))).write( + DivesCompanion( + siteSuggestionDismissedAt: Value(dismissed ? now : null), + updatedAt: Value(now), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'dives', + recordId: diveId, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to set site suggestion dismissal on dive: $diveId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_site_match_test.dart` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/dive_log/data test/features/dive_log/data +flutter analyze lib/features/dive_log/data test/features/dive_log/data +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart test/features/dive_log/data/repositories/dive_repository_site_match_test.dart +git commit -m "feat(dives): synced site suggestion dismissal" +``` + +--- + +### Task 4: Nearest-to-entry photo point and the join query + +**Files:** +- Create: `lib/features/media/domain/services/photo_gps_point_selector.dart` +- Modify: `lib/features/media/data/repositories/media_repository.dart:1043-1104` +- Test: `test/features/media/domain/services/photo_gps_point_selector_test.dart`, `test/features/media/data/repositories/media_repository_test.dart` + +**Interfaces:** +- Produces: + - `typedef PhotoGpsPoint = ({String mediaId, GeoPoint location, DateTime takenAt});` + - `PhotoGpsPoint? selectBestPhotoGps(List samples, DateTime entryTime)` + - `Future> MediaRepository.getBestPhotoGpsForDives(List diveIds)` + - `MediaRepository.getBestGpsFromDiveMedia(diveId)` keeps its signature and delegates to the above (so `divePhotoGpsProvider` is unchanged). + - `getGpsFromDiveMedia` returns `takenAt` with `isUtc: true`. + +- [ ] **Step 1: Write the failing selector test** + +`test/features/media/domain/services/photo_gps_point_selector_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/media/domain/services/photo_gps_point_selector.dart'; + +PhotoGpsPoint _p(String id, DateTime at) => + (mediaId: id, location: GeoPoint(1, 1), takenAt: at); + +void main() { + final entry = DateTime.utc(2025, 12, 27, 11, 26); + + test('picks the sample nearest the entry time, before or after', () { + final best = selectBestPhotoGps([ + _p('hotel', DateTime.utc(2025, 12, 27, 7, 0)), + _p('boat', DateTime.utc(2025, 12, 27, 11, 20)), + _p('after', DateTime.utc(2025, 12, 27, 12, 30)), + ], entry); + expect(best?.mediaId, 'boat'); + }); + + test('ties go to the earlier sample', () { + final best = selectBestPhotoGps([ + _p('later', DateTime.utc(2025, 12, 27, 11, 36)), + _p('earlier', DateTime.utc(2025, 12, 27, 11, 16)), + ], entry); + expect(best?.mediaId, 'earlier'); + }); + + test('empty input yields null', () { + expect(selectBestPhotoGps(const [], entry), isNull); + }); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/media/domain/services/photo_gps_point_selector_test.dart` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create the selector** + +```dart +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +/// One GPS-tagged media row linked to a dive. +typedef PhotoGpsPoint = ({String mediaId, GeoPoint location, DateTime takenAt}); + +/// The photo most likely shot at the site: the one whose capture time is +/// nearest the dive's entry time. The earliest photo (the previous rule) is +/// often a hotel or car-park shot; the one nearest entry is on the boat or +/// the shore. Every linked photo is eligible, because a manual link is the +/// diver asserting the photo belongs to this dive. Ties go to the earlier +/// sample. Both times are wall-clock-UTC, so they compare directly. +PhotoGpsPoint? selectBestPhotoGps( + List samples, + DateTime entryTime, +) { + PhotoGpsPoint? best; + Duration? bestGap; + for (final s in samples) { + final gap = (s.takenAt.difference(entryTime)).abs(); + final better = + bestGap == null || + gap < bestGap || + (gap == bestGap && s.takenAt.isBefore(best!.takenAt)); + if (better) { + best = s; + bestGap = gap; + } + } + return best; +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/media/domain/services/photo_gps_point_selector_test.dart` +Expected: PASS. + +- [ ] **Step 5: Write the failing repository tests** + +Append a group to `test/features/media/data/repositories/media_repository_test.dart` (the file already has `createTestDiveInDb` and `createTestMediaItem`; `createTestDiveInDb` needs an optional `DateTime? entryTime` parameter, so add `DateTime? entryTime` to its signature and pass `entryTime: entryTime` into the `Dive(...)` it builds): + +```dart + group('getBestPhotoGpsForDives', () { + test('selects the photo nearest entry per dive and skips dives without GPS', () async { + final entry = DateTime.utc(2025, 12, 27, 11, 26); + await createTestDiveInDb(id: 'd1', diveNumber: 1, entryTime: entry); + await createTestDiveInDb(id: 'd2', diveNumber: 2, entryTime: entry); + await repository.createMedia( + createTestMediaItem( + id: 'hotel', + diveId: 'd1', + latitude: 10, + longitude: 10, + takenAt: DateTime.utc(2025, 12, 27, 7), + ), + ); + await repository.createMedia( + createTestMediaItem( + id: 'boat', + diveId: 'd1', + latitude: 20.5, + longitude: -87.25, + takenAt: DateTime.utc(2025, 12, 27, 11, 20), + ), + ); + await repository.createMedia( + createTestMediaItem(id: 'nogps', diveId: 'd2', takenAt: entry), + ); + + final best = await repository.getBestPhotoGpsForDives(['d1', 'd2']); + + expect(best.keys, ['d1']); + expect(best['d1']!.mediaId, 'boat'); + expect(best['d1']!.location.latitude, 20.5); + expect(best['d1']!.takenAt.isUtc, isTrue); + }); + + test('ignores (0,0) fixes and an empty id list', () async { + await createTestDiveInDb(id: 'd1', diveNumber: 1); + await repository.createMedia( + createTestMediaItem(id: 'zero', diveId: 'd1', latitude: 0, longitude: 0), + ); + expect(await repository.getBestPhotoGpsForDives(['d1']), isEmpty); + expect(await repository.getBestPhotoGpsForDives(const []), isEmpty); + }); + + test('getBestGpsFromDiveMedia delegates and getGpsFromDiveMedia is UTC', () async { + final entry = DateTime.utc(2025, 12, 27, 11, 26); + await createTestDiveInDb(id: 'd1', diveNumber: 1, entryTime: entry); + await repository.createMedia( + createTestMediaItem( + id: 'm', + diveId: 'd1', + latitude: 1.5, + longitude: 2.5, + takenAt: DateTime.utc(2025, 12, 27, 11, 30), + ), + ); + final best = await repository.getBestGpsFromDiveMedia('d1'); + expect(best, (latitude: 1.5, longitude: 2.5)); + final all = await repository.getGpsFromDiveMedia('d1'); + expect(all.single.takenAt.isUtc, isTrue); + expect(all.single.takenAt, DateTime.utc(2025, 12, 27, 11, 30)); + }); + }); +``` + +- [ ] **Step 6: Run to verify failure** + +Run: `flutter test test/features/media/data/repositories/media_repository_test.dart` +Expected: FAIL, "The method 'getBestPhotoGpsForDives' isn't defined". + +- [ ] **Step 7: Implement in `media_repository.dart`** + +Add imports: + +```dart +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/media/domain/services/photo_gps_point_selector.dart'; +``` + +In `getGpsFromDiveMedia`, change the `takenAt` mapping to `DateTime.fromMillisecondsSinceEpoch(row.data['taken_at'] as int, isUtc: true)` and change the SQL guard `AND latitude != 0 AND longitude != 0` to `AND NOT (latitude = 0 AND longitude = 0)`. + +Replace `getBestGpsFromDiveMedia` and add the batch query: + +```dart + /// The best photo fix for each of [diveIds]: the GPS-tagged media row whose + /// capture time is nearest the dive's entry time (see + /// [selectBestPhotoGps]). One join query, no dive hydration. Dives with no + /// usable fix are absent from the map. + Future> getBestPhotoGpsForDives( + List diveIds, + ) async { + if (diveIds.isEmpty) return const {}; + try { + final samplesByDive = >{}; + final entryByDive = {}; + // SQLite caps bound variables; chunk generously below the limit. + for (var i = 0; i < diveIds.length; i += 500) { + final chunk = diveIds.sublist( + i, + i + 500 > diveIds.length ? diveIds.length : i + 500, + ); + final m = _db.media; + final d = _db.dives; + final query = _db.select(m).join([innerJoin(d, d.id.equalsExp(m.diveId))]) + ..where( + m.diveId.isIn(chunk) & + m.latitude.isNotNull() & + m.longitude.isNotNull() & + m.takenAt.isNotNull() & + (m.latitude.equals(0) & m.longitude.equals(0)).not(), + ); + for (final row in await query.get()) { + final media = row.readTable(m); + final dive = row.readTable(d); + final diveId = media.diveId!; + entryByDive[diveId] = DateTime.fromMillisecondsSinceEpoch( + dive.entryTime ?? dive.diveDateTime, + isUtc: true, + ); + samplesByDive.putIfAbsent(diveId, () => []).add(( + mediaId: media.id, + location: GeoPoint(media.latitude!, media.longitude!), + takenAt: DateTime.fromMillisecondsSinceEpoch( + media.takenAt!, + isUtc: true, + ), + )); + } + } + return { + for (final e in samplesByDive.entries) + if (selectBestPhotoGps(e.value, entryByDive[e.key]!) case final best?) + e.key: best, + }; + } catch (e, stackTrace) { + _log.error( + 'Failed to get best photo GPS for dives', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + /// The best photo fix for one dive, or null. See [getBestPhotoGpsForDives]. + Future<({double latitude, double longitude})?> getBestGpsFromDiveMedia( + String diveId, + ) async { + final best = (await getBestPhotoGpsForDives([diveId]))[diveId]; + if (best == null) return null; + return ( + latitude: best.location.latitude, + longitude: best.location.longitude, + ); + } +``` + +If the Drift `Dive` row field for the entry time is not `entryTime`, look it up in `class Dives` in `database.dart` (it is the column paired with `exitTime`) and use that name. + +- [ ] **Step 8: Run to verify pass** + +Run: `flutter test test/features/media/data/repositories/media_repository_test.dart` +Expected: PASS. +Run: `flutter test test/features/media/presentation/providers` +Expected: PASS (`divePhotoGpsProvider` is unchanged). + +- [ ] **Step 9: Format, analyze, commit** + +```bash +dart format lib/features/media test/features/media +flutter analyze lib/features/media test/features/media +git add lib/features/media/domain/services/photo_gps_point_selector.dart lib/features/media/data/repositories/media_repository.dart test/features/media/domain/services/photo_gps_point_selector_test.dart test/features/media/data/repositories/media_repository_test.dart +git commit -m "feat(media): nearest-to-entry photo GPS point per dive" +``` + +--- + +### Task 5: One eligibility predicate for site suggestions + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart:531-560` (`getDivesNeedingSiteMatch`) +- Test: `test/features/dive_log/data/repositories/dive_repository_site_match_test.dart` + +**Interfaces:** +- Produces: `getDivesNeedingSiteMatch({String? diverId, List? limitToIds})` now returns dives where `(site_id IS NULL OR the site has no coordinates) AND (dive GPS OR a GPS-tagged media row exists) AND site_suggestion_dismissed_at IS NULL`. Same signature, so the import wizard and the review notifier need no change. + +- [ ] **Step 1: Write the failing tests** + +In `dive_repository_site_match_test.dart`, extend `insertSite` to accept coordinates and add a media helper (imports to add: `package:submersion/features/media/data/repositories/media_repository.dart`, `package:submersion/features/media/domain/entities/media_item.dart`): + +```dart + Future insertSite(String id, {double? lat, double? lng}) async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diveSites) + .insert( + DiveSitesCompanion( + id: Value(id), + name: Value('Site $id'), + latitude: Value(lat), + longitude: Value(lng), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + return id; + } + + Future insertPhotoWithGps(String id, String diveId) async { + final now = DateTime.now(); + await MediaRepository().createMedia( + MediaItem( + id: id, + diveId: diveId, + filePath: '/photos/$id.jpg', + mediaType: MediaType.photo, + latitude: 20.5, + longitude: -87.25, + takenAt: now, + createdAt: now, + updatedAt: now, + ), + ); + } +``` + +Then the tests: + +```dart + group('getDivesNeedingSiteMatch (unified predicate)', () { + test('includes a siteless dive whose only GPS is a photo', () async { + await insertDive('photoOnly'); + await insertPhotoWithGps('p1', 'photoOnly'); + final ids = (await repo.getDivesNeedingSiteMatch()).map((d) => d.id); + expect(ids, contains('photoOnly')); + }); + + test('includes a dive whose site lacks coordinates', () async { + final bare = await insertSite('bare'); + await insertDive('sitedNoCoords', lat: 1, lng: 2, siteId: bare); + final ids = (await repo.getDivesNeedingSiteMatch()).map((d) => d.id); + expect(ids, contains('sitedNoCoords')); + }); + + test('excludes a dive whose site has coordinates', () async { + final located = await insertSite('located', lat: 5, lng: 6); + await insertDive('sited', lat: 1, lng: 2, siteId: located); + final ids = (await repo.getDivesNeedingSiteMatch()).map((d) => d.id); + expect(ids, isNot(contains('sited'))); + }); + + test('excludes a dismissed dive and a dive with no point at all', () async { + await insertDive('dismissed', lat: 1, lng: 2); + await repo.setSiteSuggestionDismissed('dismissed', true); + await insertDive('nothing'); + final ids = (await repo.getDivesNeedingSiteMatch()).map((d) => d.id); + expect(ids, isNot(contains('dismissed'))); + expect(ids, isNot(contains('nothing'))); + }); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_site_match_test.dart` +Expected: the "photo only", "site lacks coordinates", and "dismissed" cases FAIL. + +- [ ] **Step 3: Implement the predicate** + +Replace the `where` in `getDivesNeedingSiteMatch` and its doc comment: + +```dart + /// Dives that could be given a site from a GPS point (dive-computer entry + /// or exit fix, or a GPS-tagged photo) and still need one: no site, or a + /// site without coordinates. Dives whose suggestion was dismissed are + /// excluded. When [limitToIds] is provided, restricts to that id set. + Future> getDivesNeedingSiteMatch({ + String? diverId, + List? limitToIds, + }) async { + if (limitToIds != null && limitToIds.isEmpty) return []; + try { + final query = _db.select(_db.dives) + ..where((t) { + final hasDiveGps = + (t.entryLatitude.isNotNull() & t.entryLongitude.isNotNull()) | + (t.exitLatitude.isNotNull() & t.exitLongitude.isNotNull()); + final hasPhotoGps = existsQuery( + _db.select(_db.media)..where( + (m) => + m.diveId.equalsExp(t.id) & + m.latitude.isNotNull() & + m.longitude.isNotNull() & + (m.latitude.equals(0) & m.longitude.equals(0)).not(), + ), + ); + final siteLacksCoordinates = existsQuery( + _db.select(_db.diveSites)..where( + (s) => + s.id.equalsExp(t.siteId) & + (s.latitude.isNull() | s.longitude.isNull()), + ), + ); + var cond = + (t.siteId.isNull() | siteLacksCoordinates) & + (hasDiveGps | hasPhotoGps) & + t.siteSuggestionDismissedAt.isNull(); + if (diverId != null) cond = cond & t.diverId.equals(diverId); + if (limitToIds != null) cond = cond & t.id.isIn(limitToIds); + return cond; + }) + ..orderBy([(t) => OrderingTerm.desc(t.diveDateTime)]); + final rows = await query.get(); + if (rows.isEmpty) return []; + return await Future.wait(rows.map(_mapRowToDive)); + } catch (e, stackTrace) { + _log.error( + 'Failed to get dives needing site match', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } +``` + +`existsQuery` comes from `package:drift/drift.dart`, already imported. + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_site_match_test.dart` +Expected: PASS (all old and new tests). +Run: `flutter test test/features/dive_sites/presentation/providers/site_match_review_notifier_test.dart` +Expected: PASS (it mocks the repository). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/dive_log/data test/features/dive_log/data +flutter analyze lib/features/dive_log/data test/features/dive_log/data +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart test/features/dive_log/data/repositories/dive_repository_site_match_test.dart +git commit -m "feat(dives): unified eligibility for site suggestions" +``` + +--- + +### Task 6: Photo GPS as a second point source in `SiteMatchingService` + +**Files:** +- Modify: `lib/features/dive_sites/data/services/site_matching_service.dart` +- Modify: `lib/features/dive_sites/presentation/providers/site_match_review_notifier.dart:85-98` (constructor call) +- Modify: `lib/features/dive_sites/presentation/pages/site_match_review_page.dart:170-186` (`_MapPanel` reads the proposal point) +- Test: `test/features/dive_sites/data/services/site_matching_service_test.dart` (+ regenerated `.mocks.dart`) + +**Interfaces:** +- Consumes: `MediaRepository.getBestPhotoGpsForDives` (Task 4). +- Produces: + - `enum PointSource { diveComputer, photo }` + - `MatchProposal` gains `final GeoPoint? point;` and `final PointSource pointSource;` (default `PointSource.diveComputer`). + - `SiteMatchingService` constructor gains `required MediaRepository mediaRepository`. + +- [ ] **Step 1: Write the failing tests** + +In `site_matching_service_test.dart`: add `MediaRepository` to `@GenerateMocks`, add `import 'package:submersion/features/media/data/repositories/media_repository.dart';`, a `late MockMediaRepository media;` beside the others, `media = MockMediaRepository();` in `setUp` with the default stub `when(media.getBestPhotoGpsForDives(any)).thenAnswer((_) async => const {});`, and pass `mediaRepository: media` in the `service()` helper. Add a siteless-dive helper and tests: + +```dart +Dive _diveWithoutGps(String id) => + Dive(id: id, diveNumber: 1, dateTime: DateTime(2026, 1, 1), maxDepth: 18); +``` + +```dart + group('photo point source', () { + test('falls back to the photo fix when the dive has no GPS', () async { + const existing = DiveSite( + id: 's1', + name: 'Blue Hole', + location: GeoPoint(0, 0), + ); + when( + sites.getAllSites(diverId: anyNamed('diverId')), + ).thenAnswer((_) async => const [existing]); + when(media.getBestPhotoGpsForDives(['d1'])).thenAnswer( + (_) async => { + 'd1': ( + mediaId: 'm1', + location: _eastMeters(33), + takenAt: DateTime.utc(2026, 1, 1), + ), + }, + ); + + final proposals = await service().computeProposals([ + _diveWithoutGps('d1'), + ]); + + expect(proposals.single.status, ProposalStatus.clear); + expect(proposals.single.recommendedCandidateId, 's1'); + expect(proposals.single.pointSource, PointSource.photo); + expect(proposals.single.point, _eastMeters(33)); + }); + + test('dive-computer GPS wins over a photo fix', () async { + when(media.getBestPhotoGpsForDives(['d1'])).thenAnswer( + (_) async => { + 'd1': ( + mediaId: 'm1', + location: const GeoPoint(10, 10), + takenAt: DateTime.utc(2026, 1, 1), + ), + }, + ); + final proposals = await service().computeProposals([ + _diveAt('d1', _eastMeters(33)), + ]); + expect(proposals.single.pointSource, PointSource.diveComputer); + expect(proposals.single.point, _eastMeters(33)); + }); + + test('a dive with neither point is skipped', () async { + final proposals = await service().computeProposals([ + _diveWithoutGps('d1'), + ]); + expect(proposals, isEmpty); + }); + + test('a failing photo query degrades to no photo point', () async { + when( + media.getBestPhotoGpsForDives(any), + ).thenAnswer((_) async => throw StateError('db')); + final proposals = await service().computeProposals([ + _diveAt('d1', _eastMeters(33)), + _diveWithoutGps('d2'), + ]); + expect(proposals.map((p) => p.dive.id), ['d1']); + }); + }); +``` + +- [ ] **Step 2: Regenerate mocks and run to verify failure** + +```bash +dart run build_runner build --delete-conflicting-outputs +flutter test test/features/dive_sites/data/services/site_matching_service_test.dart +``` +Expected: compile FAIL on `mediaRepository:`, `PointSource`, `pointSource`, `point`. + +- [ ] **Step 3: Implement** + +In `site_matching_service.dart`: + +Add imports: + +```dart +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +``` + +Add the enum above `MatchProposal` and the two fields: + +```dart +/// Where a proposal's point came from. Dive-computer fixes are measured +/// entry/exit positions; photo fixes are the surface position of the photo +/// nearest the dive's entry time, so the review page labels them. +enum PointSource { diveComputer, photo } + +class MatchProposal { + final Dive dive; + final ProposalStatus status; + final List candidates; // distance-sorted + final String? recommendedCandidateId; // matcher's pick (clear only) + final GeoPoint? point; + final PointSource pointSource; + + const MatchProposal({ + required this.dive, + required this.status, + this.candidates = const [], + this.recommendedCandidateId, + this.point, + this.pointSource = PointSource.diveComputer, + }); +} +``` + +Constructor and fields: + +```dart + SiteMatchingService({ + required SiteRepository siteRepository, + required DiveSiteApiService apiService, + required DiveRepository diveRepository, + required MediaRepository mediaRepository, + required this.diverId, + required this.thresholds, + TransactionRunner? runInTransaction, + }) : _siteRepository = siteRepository, + _apiService = apiService, + _diveRepository = diveRepository, + _mediaRepository = mediaRepository, + _runInTransaction = + runInTransaction ?? + ((body) => DatabaseService.instance.database.transaction(body)); + + final MediaRepository _mediaRepository; + final _log = LoggerService.forClass(SiteMatchingService); +``` + +Replace `_pointFor` and the head of `computeProposals`: + +```dart + typedef _ResolvedPoint = ({GeoPoint point, PointSource source}); + + /// Dive-computer fixes win; the photo fix is the fallback. + _ResolvedPoint? _pointFor(Dive dive, Map photoFixes) { + final measured = dive.entryLocation ?? dive.exitLocation; + if (measured != null) { + return (point: measured, source: PointSource.diveComputer); + } + final photo = photoFixes[dive.id]; + if (photo != null) return (point: photo.location, source: PointSource.photo); + return null; + } + + /// Computes proposals for [dives]. Performs NO database writes. + Future> computeProposals(List dives) async { + _userSites = (await _siteRepository.getAllSites( + diverId: diverId, + )).where((s) => s.location != null).toList(); + + Map photoFixes = const {}; + try { + photoFixes = await _mediaRepository.getBestPhotoGpsForDives([ + for (final d in dives) d.id, + ]); + } catch (e, stackTrace) { + // Dive-computer points still match; only photo-only dives drop out. + _log.error('Photo GPS lookup failed', error: e, stackTrace: stackTrace); + } + + final proposals = []; + for (final dive in dives) { + final resolved = _pointFor(dive, photoFixes); + if (resolved == null) continue; + final point = resolved.point; + // ... existing body unchanged from `final bundled = await _apiService.searchNearby(` ... +``` + +(`typedef` cannot sit inside a class in Dart; put `typedef _ResolvedPoint = ...` at file top level next to `TransactionRunner`. Import `photo_gps_point_selector.dart` for `PhotoGpsPoint`.) + +In the `proposals.add(switch (outcome) { ... })` expression add `point: point, pointSource: resolved.source,` to all three `MatchProposal(...)` constructions. + +In `site_match_review_notifier.dart` `_init`, add `mediaRepository: _ref.read(mediaRepositoryProvider),` to the `SiteMatchingService(` call and import `package:submersion/features/media/presentation/providers/media_providers.dart`. + +In `site_match_review_page.dart` `_MapPanel.build`, replace `final point = p?.dive.entryLocation ?? p?.dive.exitLocation;` with `final point = p?.point;` so photo-sourced proposals get a map. + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/dive_sites/data/services/site_matching_service_test.dart` +Expected: PASS. +Run: `flutter test test/features/dive_sites/presentation/providers/site_match_review_notifier_test.dart` +Expected: PASS (the real `MediaRepository` runs against the test DB and returns an empty map). +Run: `flutter test test/features/dive_sites/presentation/pages/site_match_review_page_test.dart` +Expected: PASS. If the map-panel tests assert on a rendered map for `_dive(7)`, update `_dive` in that test to also pass `point: const GeoPoint(0, 0)` on the seeded `MatchProposal`s. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/dive_sites test/features/dive_sites +flutter analyze lib/features/dive_sites test/features/dive_sites +git add lib/features/dive_sites test/features/dive_sites +git commit -m "feat(sites): photo GPS as a fallback point source for site matching" +``` + +--- + +### Task 7: Locate the current site (third apply mode) and the altitude pass + +**Files:** +- Modify: `lib/features/dive_sites/data/services/site_matching_service.dart` +- Test: `test/features/dive_sites/data/services/site_matching_service_test.dart` + +**Interfaces:** +- Produces: + - `MatchCandidateView.isCurrentSite` (`bool`, default false); the current-site candidate id is `'current:'` and its `distanceMeters` is 0. + - `ApplyResult.sitesLocated` (int). + - Constructor gains `Future Function(GeoPoint point)? fetchElevation` (null = no altitude pass). + - `_applyOne` for a current-site candidate calls `SiteRepository.updateSiteCoordinates(siteId, point)`. + - After the transaction, every site that gained coordinates in this pass and has `altitude == null` gets `updateSiteAltitude` with the fetched value; failures are ignored. + +- [ ] **Step 1: Write the failing tests** + +```dart +Dive _diveWithBareSite(String id, DiveSite site, {GeoPoint? gps}) => Dive( + id: id, + diveNumber: 1, + dateTime: DateTime(2026, 1, 1), + maxDepth: 18, + entryLocation: gps, + site: site, +); + +const _bareSite = DiveSite(id: 'bare', name: 'Typed Twice'); +``` + +```dart + group('current site without coordinates', () { + setUp(() { + when( + sites.updateSiteCoordinates(any, any, altitude: anyNamed('altitude')), + ).thenAnswer((_) async {}); + when(sites.updateSiteAltitude(any, any)).thenAnswer((_) async {}); + }); + + test('is the clear recommendation when no located site is nearby', () async { + final proposals = await service().computeProposals([ + _diveWithBareSite('d1', _bareSite, gps: _eastMeters(0)), + ]); + final p = proposals.single; + expect(p.status, ProposalStatus.clear); + expect(p.recommendedCandidateId, 'current:bare'); + expect(p.candidates.first.isCurrentSite, isTrue); + expect(p.candidates.first.name, 'Typed Twice'); + }); + + test('goes to review when a located user site is within the inner radius', () async { + const neighbour = DiveSite( + id: 's1', + name: 'Blue Hole', + location: GeoPoint(0, 0), + ); + when( + sites.getAllSites(diverId: anyNamed('diverId')), + ).thenAnswer((_) async => const [neighbour]); + final proposals = await service().computeProposals([ + _diveWithBareSite('d1', _bareSite, gps: _eastMeters(40)), + ]); + final p = proposals.single; + expect(p.status, ProposalStatus.review); + expect(p.candidates.map((c) => c.id), ['current:bare', 's1']); + }); + + test('applying the current-site candidate patches coordinates only', () async { + final s = service(); + await s.computeProposals([ + _diveWithBareSite('d1', _bareSite, gps: _eastMeters(0)), + ]); + final result = await s.applyConfirmed([ + const ConfirmedMatch('d1', 'current:bare'), + ]); + expect(result.sitesLocated, 1); + expect(result.divesLinked, 1); + verify(sites.updateSiteCoordinates('bare', _eastMeters(0))).called(1); + verifyNever(sites.updateSite(any)); + verifyNever(dives.setSite(any, any)); + }); + + test('the altitude pass runs after apply for sites that gained coordinates', () async { + final s = SiteMatchingService( + siteRepository: sites, + apiService: api, + diveRepository: dives, + mediaRepository: media, + diverId: 'diver-1', + thresholds: SiteMatchSensitivity.balanced.thresholds, + runInTransaction: (body) => body(), + fetchElevation: (_) async => 12.0, + ); + await s.computeProposals([ + _diveWithBareSite('d1', _bareSite, gps: _eastMeters(0)), + ]); + await s.applyConfirmed([const ConfirmedMatch('d1', 'current:bare')]); + verify(sites.updateSiteAltitude('bare', 12.0)).called(1); + }); + + test('an elevation failure does not fail the apply', () async { + final s = SiteMatchingService( + siteRepository: sites, + apiService: api, + diveRepository: dives, + mediaRepository: media, + diverId: 'diver-1', + thresholds: SiteMatchSensitivity.balanced.thresholds, + runInTransaction: (body) => body(), + fetchElevation: (_) async => throw StateError('offline'), + ); + await s.computeProposals([ + _diveWithBareSite('d1', _bareSite, gps: _eastMeters(0)), + ]); + final result = await s.applyConfirmed([ + const ConfirmedMatch('d1', 'current:bare'), + ]); + expect(result.sitesLocated, 1); + verifyNever(sites.updateSiteAltitude(any, any)); + }); + }); +``` + +Also update the existing `applyConfirmed` group's expectations where `ApplyResult` is constructed or compared (none construct it directly; `sitesLocated` is additive). + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/dive_sites/data/services/site_matching_service_test.dart` +Expected: compile FAIL on `isCurrentSite`, `sitesLocated`, `fetchElevation`. + +- [ ] **Step 3: Implement** + +`MatchCandidateView`: add `final bool isCurrentSite;` with `this.isCurrentSite = false,` in the constructor. + +`ApplyResult`: + +```dart +class ApplyResult { + final int divesLinked; + final int sitesCreated; + final int sitesLocated; + const ApplyResult({ + required this.divesLinked, + required this.sitesCreated, + this.sitesLocated = 0, + }); +} +``` + +`_CandidateRef`: + +```dart +class _CandidateRef { + final DiveSite? existing; // link the dive to this site + final ExternalDiveSite? bundled; // materialise, then link + final DiveSite? currentSite; // keep the dive's site, give it coordinates + const _CandidateRef.existing(this.existing) + : bundled = null, + currentSite = null; + const _CandidateRef.bundled(this.bundled) + : existing = null, + currentSite = null; + const _CandidateRef.currentSite(this.currentSite) + : existing = null, + bundled = null; +} +``` + +Constructor: add `this.fetchElevation,` and the field `final Future Function(GeoPoint point)? fetchElevation;`. Add `static String currentSiteCandidateId(String siteId) => 'current:$siteId';` and per-apply bookkeeping `final Map _locatedThisPass = {};` (site id to point). + +In `computeProposals`, after `_refsByDive[dive.id] = refs;` and the ranking, insert the current-site branch before the `switch (outcome)`: + +```dart + final bareSite = dive.site; + if (bareSite != null && !bareSite.hasCoordinates) { + final currentId = currentSiteCandidateId(bareSite.id); + refs[currentId] = _CandidateRef.currentSite(bareSite); + final currentView = MatchCandidateView( + id: currentId, + name: bareSite.name, + isExisting: true, + isCurrentSite: true, + distanceMeters: 0, + location: point, + country: bareSite.country, + region: bareSite.region, + ); + // A located user site within the inner radius is a probable + // duplicate: let the diver choose between locating this site and + // relinking the dive. Otherwise locating the current site is clear. + final duplicateNearby = ranked.any( + (r) => + r.candidate.isExisting && + r.distanceMeters <= thresholds.innerRadiusMeters, + ); + proposals.add( + MatchProposal( + dive: dive, + status: duplicateNearby + ? ProposalStatus.review + : ProposalStatus.clear, + candidates: [currentView, ...views], + recommendedCandidateId: duplicateNearby ? null : currentId, + point: point, + pointSource: resolved.source, + ), + ); + continue; + } +``` + +`applyConfirmed` and `_applyOne`: + +```dart + Future applyConfirmed(List confirmed) async { + _createdByExternalId.clear(); + _locatedThisPass.clear(); + var linked = 0; + var created = 0; + var located = 0; + await _runInTransaction(() async { + for (final c in confirmed) { + final ref = _refsByDive[c.diveId]?[c.candidateId]; + if (ref == null) continue; + final outcome = await _applyOne(c.diveId, ref); + linked++; + if (outcome == _ApplyOutcome.created) created++; + if (outcome == _ApplyOutcome.located) located++; + } + }); + await _fillAltitudes(); + return ApplyResult( + divesLinked: linked, + sitesCreated: created, + sitesLocated: located, + ); + } + + /// Best-effort altitude for every site that gained coordinates in this + /// pass. Runs after the transaction commits so a network stall can never + /// hold a DB lock, and never throws. + Future _fillAltitudes() async { + final fetch = fetchElevation; + if (fetch == null) return; + for (final entry in _locatedThisPass.entries) { + try { + final meters = await fetch(entry.value); + if (meters != null) { + await _siteRepository.updateSiteAltitude(entry.key, meters); + } + } catch (e, stackTrace) { + _log.warning( + 'Altitude lookup failed for site ${entry.key}', + error: e, + stackTrace: stackTrace, + ); + } + } + } + + Future<_ApplyOutcome> _applyOne(String diveId, _CandidateRef ref) async { + if (ref.currentSite != null) { + final site = ref.currentSite!; + final point = _refsByDive[diveId] == null + ? null + : _pointByDive[diveId]; + if (point == null) return _ApplyOutcome.linked; + await _siteRepository.updateSiteCoordinates(site.id, point); + if (site.altitude == null) _locatedThisPass[site.id] = point; + return _ApplyOutcome.located; + } + if (ref.existing != null) { + await _diveRepository.setSite(diveId, ref.existing!.id); + return _ApplyOutcome.linked; + } + // ... bundled branch as before; on the materialise path add + // if (createdSite.altitude == null) _locatedThisPass[createdSite.id] = point; + // and return _ApplyOutcome.created / _ApplyOutcome.linked instead of true / false. + } +``` + +Add `enum _ApplyOutcome { linked, created, located }` at file top level, and a `final Map _pointByDive = {};` that `computeProposals` fills with `_pointByDive[dive.id] = point;` right after resolving the point (the current-site apply needs the dive's point, and the ref only carries the site). If `LoggerService` has no `warning`, use whatever level it exposes between info and error. + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/dive_sites/data/services/site_matching_service_test.dart` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/dive_sites test/features/dive_sites +flutter analyze lib/features/dive_sites test/features/dive_sites +git add lib/features/dive_sites/data/services/site_matching_service.dart test/features/dive_sites/data/services/site_matching_service_test.dart +git commit -m "feat(sites): locate a coordinate-less current site from the review flow" +``` + +--- + +### Task 8: `createAndLink` and the notifier's `createSiteHere` + +**Files:** +- Modify: `lib/features/dive_sites/data/services/site_matching_service.dart` +- Modify: `lib/features/dive_sites/presentation/providers/site_match_review_notifier.dart` +- Test: `test/features/dive_sites/data/services/site_matching_service_test.dart`, `test/features/dive_sites/presentation/providers/site_match_review_notifier_test.dart` + +**Interfaces:** +- Produces: + - `Future SiteMatchingService.createAndLink(String diveId, DiveSite site)`: `createSite(site.copyWith(diverId: diverId))`, `setSite(diveId, created.id)`, best-effort altitude, returns the created site. A previous coordinate-less site on the dive is left untouched. + - `Future SiteMatchReviewNotifier.createSiteHere(String diveId, DiveSite site)`: calls the above, drops that dive's proposal and selection from state, refreshes the dive and site lists; returns null on failure. + +- [ ] **Step 1: Write the failing service test** + +```dart + group('createAndLink', () { + setUp(() { + when(sites.createSite(any)).thenAnswer((inv) async { + final s = inv.positionalArguments.first as DiveSite; + return s.copyWith(id: 'new-${s.name}'); + }); + when(sites.updateSiteAltitude(any, any)).thenAnswer((_) async {}); + }); + + test('creates the site under the diver, links the dive, fills altitude', () async { + final s = SiteMatchingService( + siteRepository: sites, + apiService: api, + diveRepository: dives, + mediaRepository: media, + diverId: 'diver-1', + thresholds: SiteMatchSensitivity.balanced.thresholds, + runInTransaction: (body) => body(), + fetchElevation: (_) async => 3.0, + ); + final created = await s.createAndLink( + 'd1', + const DiveSite(id: 'x', name: 'Wall', location: GeoPoint(1, 2)), + ); + expect(created.id, 'new-Wall'); + final saved = verify(sites.createSite(captureAny)).captured.single as DiveSite; + expect(saved.diverId, 'diver-1'); + verify(dives.setSite('d1', 'new-Wall')).called(1); + verify(sites.updateSiteAltitude('new-Wall', 3.0)).called(1); + }); + }); +``` + +- [ ] **Step 2: Write the failing notifier test** + +In `site_match_review_notifier_test.dart`, inside `main()` after the existing tests (the `makeContainer` helper already stubs everything `confirm()` needs; add `when(sites.createSite(any)).thenAnswer((inv) async => (inv.positionalArguments.first as DiveSite).copyWith(id: 'created'));` and `when(sites.updateSiteAltitude(any, any)).thenAnswer((_) async {});` inside `makeContainer` beside the other `sites` stubs): + +```dart + test('createSiteHere links the dive and drops its proposal', () async { + final container = makeContainer([_dive('d1', _eastMeters(0))]); + await _settle(); + final notifier = container.read(siteMatchReviewProvider(null).notifier); + expect(container.read(siteMatchReviewProvider(null)).proposals, hasLength(1)); + + final created = await notifier.createSiteHere( + 'd1', + const DiveSite(id: '', name: 'Wall', location: GeoPoint(0, 0)), + ); + + expect(created?.id, 'created'); + verify(dives.setSite('d1', 'created')).called(1); + final state = container.read(siteMatchReviewProvider(null)); + expect(state.proposals, isEmpty); + expect(state.selections, isEmpty); + }); +``` + +- [ ] **Step 3: Run both to verify failure** + +Run: `flutter test test/features/dive_sites/data/services/site_matching_service_test.dart` +Expected: compile FAIL on `createAndLink`. +Run: `flutter test test/features/dive_sites/presentation/providers/site_match_review_notifier_test.dart` +Expected: compile FAIL on `createSiteHere`. + +- [ ] **Step 4: Implement the service method** + +```dart + /// Creates [site] for this diver, links [diveId] to it, and fills the + /// altitude best-effort. The single write path behind both the banner's + /// "Create site" and the review page's "Create site here". A previous + /// coordinate-less site on the dive is left as it was. + Future createAndLink(String diveId, DiveSite site) async { + late DiveSite created; + await _runInTransaction(() async { + created = await _siteRepository.createSite( + site.copyWith(diverId: diverId), + ); + await _diveRepository.setSite(diveId, created.id); + }); + _locatedThisPass.clear(); + final point = created.location; + if (point != null && created.altitude == null) { + _locatedThisPass[created.id] = point; + } + await _fillAltitudes(); + return created; + } +``` + +- [ ] **Step 5: Implement the notifier method** + +In `SiteMatchReviewNotifier`: + +```dart + /// Creates a brand-new site at the dive's point and links the dive right + /// away (a created site is a named user object, so it is not held as a + /// pending selection). Returns null on failure; the page shows a snackbar. + Future createSiteHere(String diveId, DiveSite site) async { + final service = _service; + if (service == null) return null; + try { + final created = await service.createAndLink(diveId, site); + if (!mounted) return created; + final selections = Map.from(state.selections) + ..remove(diveId); + state = state.copyWith( + proposals: [ + for (final p in state.proposals) + if (p.dive.id != diveId) p, + ], + selections: selections, + focusedDiveId: state.focusedDiveId == diveId + ? null + : state.focusedDiveId, + ); + await _ref.read(diveListNotifierProvider.notifier).refresh(); + await _ref.read(paginatedDiveListProvider.notifier).refresh(); + _ref.invalidate(diveProvider(diveId)); + await _ref.read(siteListNotifierProvider.notifier).refresh(); + return created; + } catch (e) { + return null; + } + } +``` + +Import `package:submersion/features/dive_sites/domain/entities/dive_site.dart`. `SiteMatchReviewState.copyWith` currently cannot clear `focusedDiveId` (it uses `??`); pass the existing value when the focused dive is not the created one and accept that clearing is a no-op, or add a `bool clearFocus = false` parameter to `copyWith` that sets `focusedDiveId` to null when true. Use the parameter; it is three lines. + +- [ ] **Step 6: Run to verify pass** + +Run: `flutter test test/features/dive_sites/data/services/site_matching_service_test.dart` +Expected: PASS. +Run: `flutter test test/features/dive_sites/presentation/providers/site_match_review_notifier_test.dart` +Expected: PASS. + +- [ ] **Step 7: Format, analyze, commit** + +```bash +dart format lib/features/dive_sites test/features/dive_sites +flutter analyze lib/features/dive_sites test/features/dive_sites +git add lib/features/dive_sites test/features/dive_sites +git commit -m "feat(sites): create-and-link a new site from a dive's point" +``` + +--- + +### Task 9: Localized strings (all 11 locales) + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb` plus `app_{de,es,fr,it,nl,pt,zh,ar,he,hu}.arb` +- Generated: `lib/l10n/arb/app_localizations*.dart` (committed) + +**Interfaces:** +- Produces the `AppLocalizations` getters used by Tasks 11 through 16. Existing keys reused unchanged: `media_gpsBanner_createSiteButton`, `media_gpsBanner_dismissTooltip`, `media_gpsBanner_coordinates`, `diveLog_edit_createdSite`, `diveLog_edit_addedGps`, `siteMatchReview_awayMeters`, `siteMatchReview_applyError`, `diveSites_edit_field_country_label`, `diveSites_edit_field_region_label`, `diveSites_edit_field_city_label`. + +- [ ] **Step 1: Add the keys to `app_en.arb`** + +Place each key in the alphabetical neighbourhood of its prefix; `@` metadata entries go only in `app_en.arb` (the template), beside the file's other metadata: + +```json + "siteSuggestion_titlePhoto": "Location found in photos", + "siteSuggestion_titleDiveComputer": "Location from dive computer", + "siteSuggestion_assignButton": "Assign {name}", + "@siteSuggestion_assignButton": { "placeholders": { "name": { "type": "Object" } } }, + "siteSuggestion_chooseNearbyButton": "Choose nearby site ({count})", + "@siteSuggestion_chooseNearbyButton": { "placeholders": { "count": { "type": "int" } } }, + "siteSuggestion_addLocationButton": "Add location to {name}", + "@siteSuggestion_addLocationButton": { "placeholders": { "name": { "type": "Object" } } }, + "siteSuggestion_assignedSnack": "Assigned {name}", + "@siteSuggestion_assignedSnack": { "placeholders": { "name": { "type": "Object" } } }, + "siteMatchReview_sourcePhoto": "from photo", + "siteMatchReview_sourceDiveComputer": "from dive computer", + "siteMatchReview_currentSiteCard": "Add location to this site", + "siteMatchReview_createHereButton": "Create site here", + "mediaImport_offerSiteReview": "{count} dives could get a site from their photos", + "@mediaImport_offerSiteReview": { "placeholders": { "count": { "type": "int" } } }, + "mediaImport_reviewSitesAction": "Review sites", + "filesTab_linkedItems": "{count, plural, =1{Linked 1 item} other{Linked {count} items}}", + "@filesTab_linkedItems": { "placeholders": { "count": { "type": "int" } } }, + "filesTab_attachedToSite": "{count, plural, =1{Attached 1 item to this site} other{Attached {count} items to this site}}", + "@filesTab_attachedToSite": { "placeholders": { "count": { "type": "int" } } }, + "filesTab_undo": "Undo", +``` + +And change the existing `siteMatchReview_appliedSnack` to three placeholders: + +```json + "siteMatchReview_appliedSnack": "Linked {dives} dives · added {sites} sites · located {located} sites", + "@siteMatchReview_appliedSnack": { + "placeholders": { + "dives": { "type": "int" }, + "sites": { "type": "int" }, + "located": { "type": "int" } + } + }, +``` + +- [ ] **Step 2: Add the translations to the other ten ARBs** + +For `siteMatchReview_appliedSnack`, keep each locale's existing text and append the suffix shown. Every other key is the full value. + +`siteSuggestion_titlePhoto` +- de: `Standort in Fotos gefunden` · es: `Ubicación encontrada en las fotos` · fr: `Position trouvée dans les photos` · it: `Posizione trovata nelle foto` · nl: `Locatie gevonden in foto's` · pt: `Localização encontrada nas fotos` · zh: `在照片中找到位置` · ar: `تم العثور على الموقع في الصور` · he: `נמצא מיקום בתמונות` · hu: `Helyszín található a fotókban` + +`siteSuggestion_titleDiveComputer` +- de: `Standort vom Tauchcomputer` · es: `Ubicación del ordenador de buceo` · fr: `Position de l'ordinateur de plongée` · it: `Posizione dal computer subacqueo` · nl: `Locatie van duikcomputer` · pt: `Localização do computador de mergulho` · zh: `来自潜水电脑的位置` · ar: `الموقع من كمبيوتر الغوص` · he: `מיקום ממחשב הצלילה` · hu: `Helyszín a búvárkomputerből` + +`siteSuggestion_assignButton` +- de: `{name} zuweisen` · es: `Asignar {name}` · fr: `Attribuer {name}` · it: `Assegna {name}` · nl: `{name} toewijzen` · pt: `Atribuir {name}` · zh: `指定 {name}` · ar: `تعيين {name}` · he: `שיוך {name}` · hu: `{name} hozzárendelése` + +`siteSuggestion_chooseNearbyButton` +- de: `Nahegelegenen Platz wählen ({count})` · es: `Elegir sitio cercano ({count})` · fr: `Choisir un site proche ({count})` · it: `Scegli sito vicino ({count})` · nl: `Nabijgelegen stek kiezen ({count})` · pt: `Escolher local próximo ({count})` · zh: `选择附近潜点 ({count})` · ar: `اختيار موقع قريب ({count})` · he: `בחירת אתר קרוב ({count})` · hu: `Közeli helyszín választása ({count})` + +`siteSuggestion_addLocationButton` +- de: `Standort zu {name} hinzufügen` · es: `Añadir ubicación a {name}` · fr: `Ajouter la position à {name}` · it: `Aggiungi posizione a {name}` · nl: `Locatie toevoegen aan {name}` · pt: `Adicionar localização a {name}` · zh: `为 {name} 添加位置` · ar: `إضافة الموقع إلى {name}` · he: `הוספת מיקום ל-{name}` · hu: `Helyszín hozzáadása: {name}` + +`siteSuggestion_assignedSnack` +- de: `{name} zugewiesen` · es: `Asignado {name}` · fr: `{name} attribué` · it: `Assegnato {name}` · nl: `{name} toegewezen` · pt: `{name} atribuído` · zh: `已指定 {name}` · ar: `تم تعيين {name}` · he: `{name} שויך` · hu: `{name} hozzárendelve` + +`siteMatchReview_sourcePhoto` +- de: `aus Foto` · es: `de foto` · fr: `depuis photo` · it: `da foto` · nl: `uit foto` · pt: `da foto` · zh: `来自照片` · ar: `من صورة` · he: `מתמונה` · hu: `fotóból` + +`siteMatchReview_sourceDiveComputer` +- de: `vom Tauchcomputer` · es: `del ordenador de buceo` · fr: `de l'ordinateur de plongée` · it: `dal computer subacqueo` · nl: `van duikcomputer` · pt: `do computador de mergulho` · zh: `来自潜水电脑` · ar: `من كمبيوتر الغوص` · he: `ממחשב הצלילה` · hu: `búvárkomputerből` + +`siteMatchReview_currentSiteCard` +- de: `Standort zu diesem Platz hinzufügen` · es: `Añadir ubicación a este sitio` · fr: `Ajouter la position à ce site` · it: `Aggiungi posizione a questo sito` · nl: `Locatie toevoegen aan deze stek` · pt: `Adicionar localização a este local` · zh: `为此潜点添加位置` · ar: `إضافة الموقع إلى هذا الموقع` · he: `הוספת מיקום לאתר זה` · hu: `Helyszín hozzáadása ehhez a helyhez` + +`siteMatchReview_createHereButton` +- de: `Platz hier erstellen` · es: `Crear sitio aquí` · fr: `Créer un site ici` · it: `Crea sito qui` · nl: `Stek hier aanmaken` · pt: `Criar local aqui` · zh: `在此创建潜点` · ar: `إنشاء موقع هنا` · he: `יצירת אתר כאן` · hu: `Helyszín létrehozása itt` + +`siteMatchReview_appliedSnack` (append to the existing value) +- de: ` · {located} Plätze verortet` · es: ` · {located} sitios ubicados` · fr: ` · {located} sites localisés` · it: ` · {located} siti localizzati` · nl: ` · {located} stekken gelokaliseerd` · pt: ` · {located} locais localizados` · zh: ` · 已定位 {located} 个潜点` · ar: ` · تم تحديد موقع {located} مواقع` · he: ` · {located} אתרים מוקמו` · hu: ` · {located} helyszín elhelyezve` + +`mediaImport_offerSiteReview` +- de: `{count} Tauchgänge könnten aus ihren Fotos einen Platz erhalten` · es: `{count} inmersiones podrían obtener un sitio a partir de sus fotos` · fr: `{count} plongées pourraient obtenir un site à partir de leurs photos` · it: `{count} immersioni potrebbero ottenere un sito dalle foto` · nl: `{count} duiken kunnen een stek krijgen uit hun foto's` · pt: `{count} mergulhos podem obter um local a partir das fotos` · zh: `{count} 次潜水可根据照片获得潜点` · ar: `يمكن لـ {count} غوصات الحصول على موقع من صورها` · he: `{count} צלילות יכולות לקבל אתר מהתמונות שלהן` · hu: `{count} merülés kaphat helyszínt a fotóiból` + +`mediaImport_reviewSitesAction` +- de: `Plätze prüfen` · es: `Revisar sitios` · fr: `Vérifier les sites` · it: `Rivedi siti` · nl: `Stekken bekijken` · pt: `Rever locais` · zh: `查看潜点` · ar: `مراجعة المواقع` · he: `סקירת אתרים` · hu: `Helyszínek áttekintése` + +`filesTab_linkedItems` +- de: `{count, plural, =1{1 Element verknüpft} other{{count} Elemente verknüpft}}` +- es: `{count, plural, =1{1 elemento vinculado} other{{count} elementos vinculados}}` +- fr: `{count, plural, =1{1 élément lié} other{{count} éléments liés}}` +- it: `{count, plural, =1{1 elemento collegato} other{{count} elementi collegati}}` +- nl: `{count, plural, =1{1 item gekoppeld} other{{count} items gekoppeld}}` +- pt: `{count, plural, =1{1 item vinculado} other{{count} itens vinculados}}` +- zh: `{count, plural, other{已关联 {count} 个项目}}` +- ar: `{count, plural, =1{تم ربط عنصر واحد} other{تم ربط {count} عناصر}}` +- he: `{count, plural, =1{פריט אחד קושר} other{{count} פריטים קושרו}}` +- hu: `{count, plural, =1{1 elem összekapcsolva} other{{count} elem összekapcsolva}}` + +`filesTab_attachedToSite` +- de: `{count, plural, =1{1 Element an diesen Platz angehängt} other{{count} Elemente an diesen Platz angehängt}}` +- es: `{count, plural, =1{1 elemento adjuntado a este sitio} other{{count} elementos adjuntados a este sitio}}` +- fr: `{count, plural, =1{1 élément joint à ce site} other{{count} éléments joints à ce site}}` +- it: `{count, plural, =1{1 elemento allegato a questo sito} other{{count} elementi allegati a questo sito}}` +- nl: `{count, plural, =1{1 item aan deze stek gekoppeld} other{{count} items aan deze stek gekoppeld}}` +- pt: `{count, plural, =1{1 item anexado a este local} other{{count} itens anexados a este local}}` +- zh: `{count, plural, other{已将 {count} 个项目附加到此潜点}}` +- ar: `{count, plural, =1{تم إرفاق عنصر واحد بهذا الموقع} other{تم إرفاق {count} عناصر بهذا الموقع}}` +- he: `{count, plural, =1{פריט אחד צורף לאתר זה} other{{count} פריטים צורפו לאתר זה}}` +- hu: `{count, plural, =1{1 elem csatolva ehhez a helyhez} other{{count} elem csatolva ehhez a helyhez}}` + +`filesTab_undo` +- de: `Rückgängig` · es: `Deshacer` · fr: `Annuler` · it: `Annulla` · nl: `Ongedaan maken` · pt: `Desfazer` · zh: `撤销` · ar: `تراجع` · he: `ביטול` · hu: `Visszavonás` + +- [ ] **Step 3: Regenerate and fix the one existing caller** + +```bash +flutter gen-l10n +``` +`site_match_review_page.dart` line 33 calls `siteMatchReview_appliedSnack(result.divesLinked, result.sitesCreated)`; add the third argument `result.sitesLocated`. Then: + +```bash +flutter analyze lib/l10n lib/features/dive_sites +``` +Expected: no issues. If `gen-l10n` reports a key present in the template but missing from a locale, add it; no key may be missing from any locale. + +- [ ] **Step 4: Run the review page test and commit** + +Run: `flutter test test/features/dive_sites/presentation/pages/site_match_review_page_test.dart` +Expected: PASS (`Linked 2 dives` is still a substring of the new snackbar). + +```bash +dart format lib/l10n lib/features/dive_sites +git add lib/l10n/arb lib/features/dive_sites/presentation/pages/site_match_review_page.dart +git commit -m "l10n: site suggestion, review page, import prompt, and files tab strings" +``` + +--- + +### Task 10: Suggestion providers + +**Files:** +- Create: `lib/features/dive_sites/presentation/providers/site_suggestion_providers.dart` +- Modify: `lib/features/dive_sites/presentation/providers/site_match_review_notifier.dart:85-98` (use the factory) +- Test: `test/features/dive_sites/presentation/providers/site_suggestion_providers_test.dart`, `test/features/dive_sites/presentation/providers/site_match_review_notifier_test.dart` + +**Interfaces:** +- Produces: + - `final siteMatchingServiceFactoryProvider = Provider`: builds the service from `siteRepositoryProvider`, `diveSiteApiServiceProvider`, `diveRepositoryProvider`, `mediaRepositoryProvider`, `settingsProvider.siteMatchSensitivity.thresholds`, and `elevationServiceProvider` as `fetchElevation`. + - `class SiteSuggestion { final MatchProposal proposal; final SiteMatchingService service; GeoPoint get point; PointSource get pointSource; }` (the service instance is carried because `applyConfirmed` needs the refs `computeProposals` cached). + - `final siteSuggestionForDiveProvider = FutureProvider.autoDispose.family`: null when the dive is not eligible (located site, dismissed, no point); re-evaluates on media, dive, and site table ticks. + +- [ ] **Step 1: Write the failing provider test** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/data/services/dive_site_api_service.dart'; +import 'package:submersion/features/dive_sites/data/services/site_matching_service.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_suggestion_providers.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/weather/presentation/providers/weather_providers.dart'; + +import '../../../../helpers/mock_providers.dart'; +import 'site_suggestion_providers_test.mocks.dart'; + +@GenerateMocks([SiteRepository, DiveSiteApiService, DiveRepository, MediaRepository]) +void main() { + late MockSiteRepository sites; + late MockDiveSiteApiService api; + late MockDiveRepository dives; + late MockMediaRepository media; + + ProviderContainer makeContainer({required List eligible}) { + sites = MockSiteRepository(); + api = MockDiveSiteApiService(); + dives = MockDiveRepository(); + media = MockMediaRepository(); + when( + dives.getDivesNeedingSiteMatch( + diverId: anyNamed('diverId'), + limitToIds: anyNamed('limitToIds'), + ), + ).thenAnswer((_) async => eligible); + when(dives.watchDivesChanges()).thenAnswer((_) => const Stream.empty()); + when(sites.watchSitesChanges()).thenAnswer((_) => const Stream.empty()); + when(media.watchMediaChanges()).thenAnswer((_) => const Stream.empty()); + when(media.getBestPhotoGpsForDives(any)).thenAnswer((_) async => const {}); + when(sites.getAllSites(diverId: anyNamed('diverId'))).thenAnswer((_) async => const []); + when( + api.searchNearby( + latitude: anyNamed('latitude'), + longitude: anyNamed('longitude'), + radiusKm: anyNamed('radiusKm'), + ), + ).thenAnswer((_) async => const DiveSiteSearchResult(sites: [])); + final container = ProviderContainer( + overrides: [ + diveRepositoryProvider.overrideWithValue(dives), + siteRepositoryProvider.overrideWithValue(sites), + diveSiteApiServiceProvider.overrideWithValue(api), + mediaRepositoryProvider.overrideWithValue(media), + validatedCurrentDiverIdProvider.overrideWith((ref) => 'diver-1'), + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + weatherHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('', 500)), + ), + ], + ); + addTearDown(container.dispose); + return container; + } + + test('returns null when the dive is not eligible', () async { + final container = makeContainer(eligible: const []); + expect(await container.read(siteSuggestionForDiveProvider('d1').future), isNull); + }); + + test('returns a proposal with its service when eligible', () async { + final container = makeContainer( + eligible: [ + Dive( + id: 'd1', + diveNumber: 1, + dateTime: DateTime(2026, 1, 1), + maxDepth: 18, + entryLocation: const GeoPoint(0, 0), + ), + ], + ); + final s = await container.read(siteSuggestionForDiveProvider('d1').future); + expect(s, isNotNull); + expect(s!.proposal.status, ProposalStatus.none); + expect(s.point, const GeoPoint(0, 0)); + expect(s.pointSource, PointSource.diveComputer); + expect(s.service, isA()); + }); + + test('the factory wires the sensitivity thresholds', () { + final container = makeContainer(eligible: const []); + final service = container.read(siteMatchingServiceFactoryProvider)('diver-1'); + expect(service.thresholds, MockSettingsNotifier().state.siteMatchSensitivity.thresholds); + }); +} +``` + +- [ ] **Step 2: Generate mocks and run to verify failure** + +```bash +dart run build_runner build --delete-conflicting-outputs +flutter test test/features/dive_sites/presentation/providers/site_suggestion_providers_test.dart +``` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create the providers file** + +```dart +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/dive_sites/data/services/site_matching_service.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/weather/presentation/providers/weather_providers.dart'; + +/// Builds a [SiteMatchingService] for [diverId] from the app's providers. +/// The review notifier and the per-dive suggestion share it, so a widget +/// test can override one provider to fake the whole matching pipeline. +final siteMatchingServiceFactoryProvider = + Provider((ref) { + return (diverId) => SiteMatchingService( + siteRepository: ref.read(siteRepositoryProvider), + apiService: ref.read(diveSiteApiServiceProvider), + diveRepository: ref.read(diveRepositoryProvider), + mediaRepository: ref.read(mediaRepositoryProvider), + diverId: diverId, + thresholds: ref.read(settingsProvider).siteMatchSensitivity.thresholds, + fetchElevation: (point) => ref + .read(elevationServiceProvider) + .fetchElevation( + latitude: point.latitude, + longitude: point.longitude, + ), + ); + }); + +/// One dive's site suggestion, with the service instance that computed it +/// (its apply methods rely on refs cached during computeProposals). +class SiteSuggestion { + const SiteSuggestion({required this.proposal, required this.service}); + final MatchProposal proposal; + final SiteMatchingService service; + GeoPoint get point => proposal.point!; + PointSource get pointSource => proposal.pointSource; +} + +/// The suggestion for [diveId], or null when the dive needs none: it has a +/// located site, the diver dismissed it, or there is no point to match. +/// Eligibility is the same repository predicate the batch review uses. +final siteSuggestionForDiveProvider = FutureProvider.autoDispose + .family((ref, diveId) async { + final diveRepo = ref.watch(diveRepositoryProvider); + ref.invalidateSelfWhen(diveRepo.watchDivesChanges()); + ref.invalidateSelfWhen(ref.watch(siteRepositoryProvider).watchSitesChanges()); + ref.invalidateSelfWhen(ref.watch(mediaRepositoryProvider).watchMediaChanges()); + final diverId = await ref.watch(validatedCurrentDiverIdProvider.future); + final dives = await diveRepo.getDivesNeedingSiteMatch( + diverId: diverId, + limitToIds: [diveId], + ); + if (dives.isEmpty) return null; + final service = ref.read(siteMatchingServiceFactoryProvider)(diverId); + final proposals = await service.computeProposals(dives); + if (proposals.isEmpty || proposals.single.point == null) return null; + return SiteSuggestion(proposal: proposals.single, service: service); + }); +``` + +`invalidateSelfWhen` is the project's `Ref` extension used by `media_providers.dart`; import whatever file declares it if the barrel does not export it (grep `invalidateSelfWhen` in `lib/core/providers`). + +- [ ] **Step 4: Point the notifier at the factory** + +In `SiteMatchReviewNotifier._init`, replace the inline `SiteMatchingService(...)` construction with `_service = _ref.read(siteMatchingServiceFactoryProvider)(diverId);` and drop the now-unused imports. In `site_match_review_notifier_test.dart` `makeContainer`, add `weatherHttpClientProvider.overrideWithValue(MockClient((_) async => http.Response('', 500)))` (imports: `package:http/http.dart as http`, `package:http/testing.dart`, `.../weather/presentation/providers/weather_providers.dart`) so the elevation client is never real. + +- [ ] **Step 5: Run to verify pass** + +Run: `flutter test test/features/dive_sites/presentation/providers/site_suggestion_providers_test.dart` +Expected: PASS. +Run: `flutter test test/features/dive_sites/presentation/providers/site_match_review_notifier_test.dart` +Expected: PASS. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format lib/features/dive_sites test/features/dive_sites +flutter analyze lib/features/dive_sites test/features/dive_sites +git add lib/features/dive_sites test/features/dive_sites +git commit -m "feat(sites): per-dive site suggestion provider and service factory" +``` + +--- + +### Task 11: `SiteSuggestionBanner` (presentational) + +**Files:** +- Create: `lib/features/dive_log/presentation/widgets/site_suggestion_banner.dart` +- Test: `test/features/dive_log/presentation/widgets/site_suggestion_banner_test.dart` + +**Interfaces:** +- Produces: + +```dart +class SiteSuggestionBanner extends StatelessWidget { + const SiteSuggestionBanner({ + super.key, + required this.pointSource, // PointSource + required this.coordinates, // pre-formatted by the caller's UnitFormatter + required this.status, // ProposalStatus + required this.hasSite, // dive.site != null (site without coordinates) + required this.siteName, // current site name, or the recommended candidate's name + required this.candidateCount, // candidates.length + required this.recommendedDistanceMeters, // null unless status == clear && !hasSite + required this.onAssign, // VoidCallback? + required this.onChooseNearby, // VoidCallback? + required this.onCreate, // VoidCallback? + required this.onAddLocation, // VoidCallback? + required this.onDismiss, // VoidCallback + }); +} +``` + +Rendering table (primary button is `FilledButton.icon`, secondary is `OutlinedButton`, both inside an `OverflowBar` with `spacing: 8`): + +| hasSite | status | Primary | Secondary | +| --- | --- | --- | --- | +| false | clear | `siteSuggestion_assignButton(siteName)` + ` · ` + `siteMatchReview_awayMeters(distance)` | `media_gpsBanner_createSiteButton` | +| false | review | `siteSuggestion_chooseNearbyButton(candidateCount)` | `media_gpsBanner_createSiteButton` | +| false | none | `media_gpsBanner_createSiteButton` | none | +| true | clear | `siteSuggestion_addLocationButton(siteName)` | none | +| true | review | `siteSuggestion_addLocationButton(siteName)` | `siteSuggestion_chooseNearbyButton(candidateCount - 1)` | + +Title is `siteSuggestion_titlePhoto` or `siteSuggestion_titleDiveComputer`; the second line is `media_gpsBanner_coordinates(coordinates)`; the X uses `media_gpsBanner_dismissTooltip`. + +- [ ] **Step 1: Write the failing widget test** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/site_suggestion_banner.dart'; +import 'package:submersion/features/dive_sites/data/services/site_matching_service.dart'; + +import '../../../../helpers/test_app.dart'; + +void main() { + Widget banner({ + required ProposalStatus status, + required bool hasSite, + PointSource source = PointSource.photo, + int candidateCount = 1, + double? distance, + VoidCallback? onAssign, + VoidCallback? onChooseNearby, + VoidCallback? onCreate, + VoidCallback? onAddLocation, + VoidCallback? onDismiss, + Locale? locale, + }) => testApp( + locale: locale ?? const Locale('en'), + child: SiteSuggestionBanner( + pointSource: source, + coordinates: '20.5000, -87.2500', + status: status, + hasSite: hasSite, + siteName: 'Blue Hole', + candidateCount: candidateCount, + recommendedDistanceMeters: distance, + onAssign: onAssign, + onChooseNearby: onChooseNearby, + onCreate: onCreate, + onAddLocation: onAddLocation, + onDismiss: onDismiss ?? () {}, + ), + ); + + testWidgets('siteless clear: Assign primary, Create secondary', (tester) async { + var assigned = false; + var created = false; + await tester.pumpWidget(banner( + status: ProposalStatus.clear, + hasSite: false, + distance: 40, + onAssign: () => assigned = true, + onCreate: () => created = true, + )); + expect(find.text('Location found in photos'), findsOneWidget); + expect(find.textContaining('Assign Blue Hole'), findsOneWidget); + expect(find.textContaining('40 m away'), findsOneWidget); + await tester.tap(find.textContaining('Assign Blue Hole')); + expect(assigned, isTrue); + await tester.tap(find.text('Create Site')); + expect(created, isTrue); + }); + + testWidgets('siteless review: Choose nearby primary, Create secondary', (tester) async { + var chose = false; + await tester.pumpWidget(banner( + status: ProposalStatus.review, + hasSite: false, + candidateCount: 3, + onChooseNearby: () => chose = true, + onCreate: () {}, + )); + await tester.tap(find.text('Choose nearby site (3)')); + expect(chose, isTrue); + expect(find.text('Create Site'), findsOneWidget); + }); + + testWidgets('siteless none: Create only', (tester) async { + await tester.pumpWidget(banner(status: ProposalStatus.none, hasSite: false, onCreate: () {})); + expect(find.text('Create Site'), findsOneWidget); + expect(find.byType(OutlinedButton), findsNothing); + }); + + testWidgets('site without coordinates, clear: Add location only', (tester) async { + var added = false; + await tester.pumpWidget(banner( + status: ProposalStatus.clear, + hasSite: true, + source: PointSource.diveComputer, + onAddLocation: () => added = true, + )); + expect(find.text('Location from dive computer'), findsOneWidget); + await tester.tap(find.text('Add location to Blue Hole')); + expect(added, isTrue); + expect(find.byType(OutlinedButton), findsNothing); + }); + + testWidgets('site without coordinates, review: Add location + Choose nearby', (tester) async { + await tester.pumpWidget(banner( + status: ProposalStatus.review, + hasSite: true, + candidateCount: 3, + onAddLocation: () {}, + onChooseNearby: () {}, + )); + expect(find.text('Add location to Blue Hole'), findsOneWidget); + expect(find.text('Choose nearby site (2)'), findsOneWidget); + }); + + testWidgets('dismiss fires', (tester) async { + var dismissed = false; + await tester.pumpWidget(banner( + status: ProposalStatus.none, + hasSite: false, + onCreate: () {}, + onDismiss: () => dismissed = true, + )); + await tester.tap(find.byIcon(Icons.close)); + expect(dismissed, isTrue); + }); + + testWidgets('German at 360 dp does not overflow', (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget(banner( + status: ProposalStatus.review, + hasSite: true, + candidateCount: 3, + onAddLocation: () {}, + onChooseNearby: () {}, + locale: const Locale('de'), + )); + expect(tester.takeException(), isNull); + expect(find.textContaining('Blue Hole'), findsWidgets); + }); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/dive_log/presentation/widgets/site_suggestion_banner_test.dart` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create the banner** + +```dart +import 'package:flutter/material.dart'; + +import 'package:submersion/features/dive_sites/data/services/site_matching_service.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Suggests a site for a dive from a GPS point (photo or dive computer). +/// Purely presentational: the caller resolves the proposal and wires the +/// actions (see SiteSuggestionCard). Which buttons appear depends on whether +/// the dive already has a (coordinate-less) site and on the match status. +class SiteSuggestionBanner extends StatelessWidget { + const SiteSuggestionBanner({ + super.key, + required this.pointSource, + required this.coordinates, + required this.status, + required this.hasSite, + required this.siteName, + required this.candidateCount, + required this.recommendedDistanceMeters, + required this.onAssign, + required this.onChooseNearby, + required this.onCreate, + required this.onAddLocation, + required this.onDismiss, + }); + + final PointSource pointSource; + final String coordinates; + final ProposalStatus status; + final bool hasSite; + final String siteName; + final int candidateCount; + final double? recommendedDistanceMeters; + final VoidCallback? onAssign; + final VoidCallback? onChooseNearby; + final VoidCallback? onCreate; + final VoidCallback? onAddLocation; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final l10n = context.l10n; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: scheme.primary.withValues(alpha: 0.3)), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.location_on, color: scheme.primary, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + pointSource == PointSource.photo + ? l10n.siteSuggestion_titlePhoto + : l10n.siteSuggestion_titleDiveComputer, + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: Icon(Icons.close, size: 18, color: scheme.onSurfaceVariant), + tooltip: l10n.media_gpsBanner_dismissTooltip, + onPressed: onDismiss, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + ), + ], + ), + const SizedBox(height: 4), + Text( + l10n.media_gpsBanner_coordinates(coordinates), + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 12), + OverflowBar( + spacing: 8, + overflowSpacing: 8, + children: _actions(l10n), + ), + ], + ), + ); + } + + List _actions(dynamic l10n) { + final create = OutlinedButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add_location_alt, size: 18), + label: Text(l10n.media_gpsBanner_createSiteButton), + ); + Widget choose(int count) => OutlinedButton.icon( + onPressed: onChooseNearby, + icon: const Icon(Icons.map_outlined, size: 18), + label: Text(l10n.siteSuggestion_chooseNearbyButton(count)), + ); + if (hasSite) { + final add = FilledButton.icon( + onPressed: onAddLocation, + icon: const Icon(Icons.edit_location_alt, size: 18), + label: Text(l10n.siteSuggestion_addLocationButton(siteName)), + ); + return switch (status) { + ProposalStatus.review => [add, choose(candidateCount - 1)], + _ => [add], + }; + } + return switch (status) { + ProposalStatus.clear => [ + FilledButton.icon( + onPressed: onAssign, + icon: const Icon(Icons.push_pin_outlined, size: 18), + label: Text( + '${l10n.siteSuggestion_assignButton(siteName)} · ' + '${l10n.siteMatchReview_awayMeters((recommendedDistanceMeters ?? 0).round())}', + ), + ), + create, + ], + ProposalStatus.review => [ + FilledButton.icon( + onPressed: onChooseNearby, + icon: const Icon(Icons.map_outlined, size: 18), + label: Text(l10n.siteSuggestion_chooseNearbyButton(candidateCount)), + ), + create, + ], + ProposalStatus.none => [ + FilledButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add_location_alt, size: 18), + label: Text(l10n.media_gpsBanner_createSiteButton), + ), + ], + }; + } +} +``` + +Replace `dynamic l10n` with the concrete `AppLocalizations` type (import `package:submersion/l10n/arb/app_localizations.dart`); `dynamic` is shown only to keep the snippet short and must not be committed. + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/dive_log/presentation/widgets/site_suggestion_banner_test.dart` +Expected: PASS (7 tests). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/dive_log/presentation/widgets test/features/dive_log/presentation/widgets +flutter analyze lib/features/dive_log/presentation/widgets test/features/dive_log/presentation/widgets +git add lib/features/dive_log/presentation/widgets/site_suggestion_banner.dart test/features/dive_log/presentation/widgets/site_suggestion_banner_test.dart +git commit -m "feat(dives): source-agnostic site suggestion banner" +``` + +--- + +### Task 12: `SiteSuggestionActions` and `SiteSuggestionCard` + +**Files:** +- Create: `lib/features/dive_log/presentation/helpers/site_suggestion_actions.dart` +- Create: `lib/features/dive_log/presentation/widgets/site_suggestion_card.dart` +- Test: `test/features/dive_log/presentation/helpers/site_suggestion_actions_test.dart`, `test/features/dive_log/presentation/widgets/site_suggestion_card_test.dart` + +**Interfaces:** +- Consumes: `SiteSuggestion`, `siteSuggestionForDiveProvider` (Task 10); `SiteMatchingService.applyConfirmed`, `createAndLink`, `currentSiteCandidateId` (Tasks 7-8); `DiveRepository.setSiteSuggestionDismissed` (Task 3); `QuickSiteFromGpsDialog.show` (Task 14 extends it, the current signature already works). +- Produces: + +```dart +/// One write path for every banner action; no BuildContext, no snackbars. +class SiteSuggestionActions { + SiteSuggestionActions({ + required this.diveId, + required this.suggestion, + required this.diveRepository, + }); + /// Links the dive to [candidateId] (existing or bundled). Returns the + /// resulting site when the candidate was an existing site, else null. + Future assign(String candidateId); + /// Locates the dive's coordinate-less current site at the point. + Future addLocation(); + /// Creates [site] and links the dive to it. + Future create(DiveSite site); + Future dismiss(); +} + +/// Watches the suggestion for [diveId] and renders SiteSuggestionBanner. +/// [onSiteChanged] fires with the site the dive now has after assign / +/// addLocation / create, so an edit form can update its unsaved state. +class SiteSuggestionCard extends ConsumerWidget { + const SiteSuggestionCard({ + super.key, + required this.diveId, + required this.currentSite, + this.onSiteChanged, + this.refreshLists, // test seam; defaults to refreshing the dive and site list notifiers + }); +} +``` + +- [ ] **Step 1: Write the failing actions test** + +`test/features/dive_log/presentation/helpers/site_suggestion_actions_test.dart` (generate `MockDiveRepository` here with `@GenerateMocks([DiveRepository])`): + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/helpers/site_suggestion_actions.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/data/services/dive_site_api_service.dart'; +import 'package:submersion/features/dive_sites/data/services/site_matching_service.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/domain/matching/site_match_sensitivity.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_suggestion_providers.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; + +import 'site_suggestion_actions_test.mocks.dart'; + +/// Records apply calls instead of touching a database. The base constructor +/// only stores its dependencies, so zero-arg repositories are safe here. +class FakeMatchingService extends SiteMatchingService { + FakeMatchingService() + : super( + siteRepository: SiteRepository(), + apiService: DiveSiteApiService(), + diveRepository: DiveRepository(), + mediaRepository: MediaRepository(), + diverId: 'diver-1', + thresholds: SiteMatchSensitivity.balanced.thresholds, + runInTransaction: (body) => body(), + ); + + final applied = []; + final created = []; + + @override + Future applyConfirmed(List confirmed) async { + applied.addAll(confirmed); + return const ApplyResult(divesLinked: 1, sitesCreated: 0); + } + + @override + Future createAndLink(String diveId, DiveSite site) async { + created.add(site); + return site.copyWith(id: 'created'); + } +} + +SiteSuggestion suggestionFor( + FakeMatchingService service, { + DiveSite? site, + ProposalStatus status = ProposalStatus.clear, + String? recommended, +}) => SiteSuggestion( + proposal: MatchProposal( + dive: Dive( + id: 'd1', + diveNumber: 1, + dateTime: DateTime(2026, 1, 1), + maxDepth: 18, + site: site, + ), + status: status, + recommendedCandidateId: recommended, + point: const GeoPoint(20.5, -87.25), + pointSource: PointSource.photo, + ), + service: service, +); + +@GenerateMocks([DiveRepository]) +void main() { + late FakeMatchingService service; + late MockDiveRepository dives; + + setUp(() { + service = FakeMatchingService(); + dives = MockDiveRepository(); + when(dives.setSiteSuggestionDismissed(any, any)).thenAnswer((_) async {}); + }); + + test('assign applies the chosen candidate through the service', () async { + final actions = SiteSuggestionActions( + diveId: 'd1', + suggestion: suggestionFor(service, recommended: 's1'), + diveRepository: dives, + ); + await actions.assign('s1'); + expect(service.applied.single.diveId, 'd1'); + expect(service.applied.single.candidateId, 's1'); + }); + + test('addLocation applies the current-site candidate', () async { + const bare = DiveSite(id: 'bare', name: 'Typed Twice'); + final actions = SiteSuggestionActions( + diveId: 'd1', + suggestion: suggestionFor(service, site: bare), + diveRepository: dives, + ); + await actions.addLocation(); + expect( + service.applied.single.candidateId, + SiteMatchingService.currentSiteCandidateId('bare'), + ); + }); + + test('create routes through createAndLink', () async { + final actions = SiteSuggestionActions( + diveId: 'd1', + suggestion: suggestionFor(service, status: ProposalStatus.none), + diveRepository: dives, + ); + final created = await actions.create( + const DiveSite(id: '', name: 'Wall', location: GeoPoint(20.5, -87.25)), + ); + expect(created.id, 'created'); + expect(service.created.single.name, 'Wall'); + }); + + test('dismiss writes the synced flag', () async { + final actions = SiteSuggestionActions( + diveId: 'd1', + suggestion: suggestionFor(service), + diveRepository: dives, + ); + await actions.dismiss(); + verify(dives.setSiteSuggestionDismissed('d1', true)).called(1); + }); +} +``` + +If `DiveSiteApiService()` needs constructor arguments, pass the same ones `diveSiteApiServiceProvider` passes (`site_providers.dart:528`). + +- [ ] **Step 2: Generate mocks and run to verify failure** + +```bash +dart run build_runner build --delete-conflicting-outputs +flutter test test/features/dive_log/presentation/helpers/site_suggestion_actions_test.dart +``` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create the actions helper** + +```dart +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_sites/data/services/site_matching_service.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_suggestion_providers.dart'; + +/// The write side of a site suggestion. Every action goes through the +/// [SiteMatchingService] that computed the suggestion, so the banner and the +/// batch review page share the coincidence guard, bundled-site +/// materialisation, and the post-commit altitude pass. UI concerns (dialogs, +/// snackbars, navigation, provider refreshes) live in SiteSuggestionCard. +class SiteSuggestionActions { + SiteSuggestionActions({ + required this.diveId, + required this.suggestion, + required this.diveRepository, + }); + + final String diveId; + final SiteSuggestion suggestion; + final DiveRepository diveRepository; + + /// Links the dive to [candidateId] (an existing site id or a bundled + /// site's external id). + Future assign(String candidateId) => + suggestion.service.applyConfirmed([ConfirmedMatch(diveId, candidateId)]); + + /// Writes the point onto the dive's coordinate-less current site. + Future addLocation() { + final site = suggestion.proposal.dive.site; + if (site == null) { + throw StateError('addLocation needs a dive with a current site'); + } + return suggestion.service.applyConfirmed([ + ConfirmedMatch(diveId, SiteMatchingService.currentSiteCandidateId(site.id)), + ]); + } + + /// Creates [site] at the point and links the dive to it. + Future create(DiveSite site) => + suggestion.service.createAndLink(diveId, site); + + /// Hides the suggestion for this dive on every device. + Future dismiss() => + diveRepository.setSiteSuggestionDismissed(diveId, true); +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/dive_log/presentation/helpers/site_suggestion_actions_test.dart` +Expected: PASS. + +- [ ] **Step 5: Write the failing card test** + +`test/features/dive_log/presentation/widgets/site_suggestion_card_test.dart` (reuse `FakeMatchingService` and `suggestionFor` by importing the actions test file's helpers; move them into `test/features/dive_log/presentation/support/fake_matching_service.dart` first, and import that from both tests): + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/site_suggestion_card.dart'; +import 'package:submersion/features/dive_sites/data/services/site_matching_service.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_suggestion_providers.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../media/presentation/support/media_widget_harness.dart'; +import '../support/fake_matching_service.dart'; + +class _StubDiveRepository extends DiveRepository { + final dismissed = []; + @override + Future setSiteSuggestionDismissed(String diveId, bool value) async { + if (value) dismissed.add(diveId); + } +} + +void main() { + late FakeMatchingService service; + late _StubDiveRepository dives; + var refreshed = 0; + + setUp(() { + service = FakeMatchingService(); + dives = _StubDiveRepository(); + refreshed = 0; + }); + + Future host( + SiteSuggestion? suggestion, { + DiveSite? currentSite, + void Function(DiveSite?)? onSiteChanged, + }) => mediaTestApp( + overrides: [ + siteSuggestionForDiveProvider('d1').overrideWith((ref) async => suggestion), + diveRepositoryProvider.overrideWithValue(dives), + ], + home: Scaffold( + body: SiteSuggestionCard( + diveId: 'd1', + currentSite: currentSite, + onSiteChanged: onSiteChanged, + refreshLists: () async => refreshed++, + ), + ), + ); + + testWidgets('renders nothing when there is no suggestion', (tester) async { + await tester.pumpWidget(await host(null)); + await tester.pumpAndSettle(); + expect(find.byType(SiteSuggestionCard), findsOneWidget); + expect(find.textContaining('Location'), findsNothing); + }); + + testWidgets('assign applies and reports the assigned site', (tester) async { + const blueHole = DiveSite(id: 's1', name: 'Blue Hole', location: GeoPoint(0, 0)); + final s = suggestionFor( + service, + recommended: 's1', + candidates: [ + const MatchCandidateView( + id: 's1', + name: 'Blue Hole', + isExisting: true, + distanceMeters: 40, + location: GeoPoint(0, 0), + ), + ], + ); + DiveSite? reported; + await tester.pumpWidget(await host(s, onSiteChanged: (site) => reported = site)); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('Assign Blue Hole')); + await tester.pumpAndSettle(); + expect(service.applied.single.candidateId, 's1'); + expect(reported?.id, blueHole.id); + expect(refreshed, 1); + expect(find.text('Assigned Blue Hole'), findsOneWidget); + }); + + testWidgets('dismiss writes the flag', (tester) async { + await tester.pumpWidget(await host(suggestionFor(service, status: ProposalStatus.none))); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(dives.dismissed, ['d1']); + }); + + testWidgets('create opens the quick dialog and links the new site', (tester) async { + DiveSite? reported; + await tester.pumpWidget(await host( + suggestionFor(service, status: ProposalStatus.none), + onSiteChanged: (site) => reported = site, + )); + await tester.pumpAndSettle(); + await tester.tap(find.text('Create Site')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, 'Wall'); + await tester.tap(find.widgetWithText(FilledButton, 'Create Site')); + await tester.pumpAndSettle(); + expect(service.created.single.name, 'Wall'); + expect(reported?.id, 'created'); + expect(find.textContaining('Created site: Wall'), findsOneWidget); + }); +} +``` + +`suggestionFor` in the shared support file gains an optional `List candidates = const []` parameter passed into `MatchProposal.candidates`. For the assign test, `onSiteChanged` receives the existing candidate resolved to a `DiveSite` by id from `proposal.candidates` (name and location from the view); `_StubDiveRepository` must also override `getDiveById` to return null so the card does not hit the database when re-reading the dive (see Step 6). + +- [ ] **Step 6: Create the card** + +```dart +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_log/presentation/helpers/site_suggestion_actions.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/site_suggestion_banner.dart'; +import 'package:submersion/features/dive_sites/data/services/site_matching_service.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_suggestion_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/quick_site_from_gps_dialog.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Watches the site suggestion for [diveId] and renders the banner with its +/// actions wired. Renders nothing when there is no suggestion, so callers +/// place it unconditionally. Used by the dive edit and detail pages. +class SiteSuggestionCard extends ConsumerWidget { + const SiteSuggestionCard({ + super.key, + required this.diveId, + required this.currentSite, + this.onSiteChanged, + this.refreshLists, + }); + + final String diveId; + final DiveSite? currentSite; + final void Function(DiveSite? site)? onSiteChanged; + + /// Refreshes the dive and site lists after a write. Injectable for tests. + final Future Function()? refreshLists; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(siteSuggestionForDiveProvider(diveId)); + final suggestion = async.value; + if (suggestion == null) return const SizedBox.shrink(); + + final proposal = suggestion.proposal; + final units = UnitFormatter(ref.watch(settingsProvider)); + final hasSite = currentSite != null || proposal.dive.site != null; + final recommended = proposal.recommendedCandidateId == null + ? null + : proposal.candidates + .where((c) => c.id == proposal.recommendedCandidateId) + .firstOrNull; + final siteName = + currentSite?.name ?? proposal.dive.site?.name ?? recommended?.name ?? ''; + + final actions = SiteSuggestionActions( + diveId: diveId, + suggestion: suggestion, + diveRepository: ref.read(diveRepositoryProvider), + ); + + return SiteSuggestionBanner( + pointSource: suggestion.pointSource, + coordinates: units.formatCoordinates( + suggestion.point.latitude, + suggestion.point.longitude, + ), + status: proposal.status, + hasSite: hasSite, + siteName: siteName, + candidateCount: proposal.candidates.length, + recommendedDistanceMeters: recommended?.distanceMeters, + onAssign: recommended == null + ? null + : () => _run(context, ref, () async { + await actions.assign(recommended.id); + final site = DiveSite( + id: recommended.id, + name: recommended.name, + location: recommended.location, + country: recommended.country, + region: recommended.region, + ); + return (site, context.l10n.siteSuggestion_assignedSnack(site.name)); + }), + onChooseNearby: () => context.push('/dives/match-sites', extra: [diveId]), + onCreate: () => _create(context, ref, actions, suggestion), + onAddLocation: !hasSite + ? null + : () => _run(context, ref, () async { + await actions.addLocation(); + final base = currentSite ?? proposal.dive.site!; + final site = base.copyWith(location: suggestion.point); + return (site, context.l10n.diveLog_edit_addedGps(site.name)); + }), + onDismiss: () => _run(context, ref, () async { + await actions.dismiss(); + return null; + }), + ); + } + + Future _create( + BuildContext context, + WidgetRef ref, + SiteSuggestionActions actions, + SiteSuggestion suggestion, + ) async { + final draft = await QuickSiteFromGpsDialog.show( + context, + latitude: suggestion.point.latitude, + longitude: suggestion.point.longitude, + ); + if (draft == null || !context.mounted) return; + await _run(context, ref, () async { + final created = await actions.create(draft); + return (created, context.l10n.diveLog_edit_createdSite(created.name)); + }); + } + + /// Runs a write, then refreshes what depends on it and reports the result. + /// A failure keeps the banner up and shows the shared apply error. + Future _run( + BuildContext context, + WidgetRef ref, + Future<(DiveSite, String)?> Function() write, + ) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + try { + final result = await write(); + ref.invalidate(siteSuggestionForDiveProvider(diveId)); + ref.invalidate(diveProvider(diveId)); + await (refreshLists ?? () => _defaultRefresh(ref))(); + if (result != null) { + onSiteChanged?.call(result.$1); + messenger.showSnackBar( + SnackBar(content: Text(result.$2), behavior: SnackBarBehavior.floating), + ); + } + } catch (_) { + messenger.showSnackBar(SnackBar(content: Text(l10n.siteMatchReview_applyError))); + } + } + + Future _defaultRefresh(WidgetRef ref) async { + await ref.read(diveListNotifierProvider.notifier).refresh(); + await ref.read(paginatedDiveListProvider.notifier).refresh(); + await ref.read(siteListNotifierProvider.notifier).refresh(); + } +} +``` + +- [ ] **Step 7: Run both tests to verify pass** + +Run: `flutter test test/features/dive_log/presentation/helpers/site_suggestion_actions_test.dart` +Expected: PASS. +Run: `flutter test test/features/dive_log/presentation/widgets/site_suggestion_card_test.dart` +Expected: PASS. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format lib/features/dive_log test/features/dive_log +flutter analyze lib/features/dive_log test/features/dive_log +git add lib/features/dive_log/presentation/helpers/site_suggestion_actions.dart lib/features/dive_log/presentation/widgets/site_suggestion_card.dart test/features/dive_log/presentation +git commit -m "feat(dives): site suggestion card with one write path" +``` + +--- + +### Task 13: Wire the card into the edit and detail pages; delete the old banner + +**Files:** +- Modify: `lib/features/dive_log/presentation/pages/dive_edit_page.dart` (lines 92-93 imports, 297 `_gpsSuggestionDismissed`, 2191-2200 banner, 2212-2285 handlers) +- Modify: `lib/features/dive_log/presentation/pages/dive_detail_page.dart` (`_buildEmbeddedHeader` around line 1000, `_buildHeaderSection` around line 1210) +- Delete: `lib/features/media/presentation/widgets/photo_gps_suggestion_banner.dart` +- Test: existing `test/features/dive_log/presentation/pages/dive_edit_page_test.dart` and `dive_detail_page_test.dart` (run; add the override below where they pump a saved dive) + +- [ ] **Step 1: Edit page** + +Replace the `PhotoGpsSuggestionBanner(...)` block with: + +```dart + if (widget.diveId != null) { + children.add( + SiteSuggestionCard( + diveId: widget.diveId!, + currentSite: _selectedSite, + onSiteChanged: (site) => setState(() => _assignSite(site)), + ), + ); + } +``` + +Delete `_createSiteFromPhotoGps`, `_updateSiteWithPhotoGps`, the `_gpsSuggestionDismissed` field, and the imports of `photo_gps_suggestion_banner.dart`, `quick_site_from_gps_dialog.dart`, and `media_providers.dart` if nothing else in the file uses them (grep for `divePhotoGpsProvider` and `elevationServiceProvider` first; `elevationServiceProvider` is still used at line ~4103). Add `import 'package:submersion/features/dive_log/presentation/widgets/site_suggestion_card.dart';`. + +- [ ] **Step 2: Detail page** + +In `_buildEmbeddedHeader`, the site column is `Expanded(child: Column(...children: [...]))`; the card does not belong inside a one-line header row, so wrap the header: change the method's returned `Container(...)` into a `Column(mainAxisSize: MainAxisSize.min, children: [ , Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: SiteSuggestionCard(diveId: dive.id, currentSite: dive.site)) ])`. + +In `_buildHeaderSection`, inside the outer `Column` under the `Row(...)` that holds the avatar and the site column (directly after that `Row`'s closing `),`), add: + +```dart + SiteSuggestionCard(diveId: dive.id, currentSite: dive.site), +``` + +Add the import. Both placements are unconditional; the card renders nothing when there is no suggestion. + +- [ ] **Step 3: Delete the old banner and fix references** + +```bash +git rm lib/features/media/presentation/widgets/photo_gps_suggestion_banner.dart +grep -rn "PhotoGpsSuggestionBanner\|photo_gps_suggestion_banner" lib test +``` +Expected: no matches. (`divePhotoGpsProvider` and `allDivePhotoGpsProvider` stay; other code invalidates them.) + +- [ ] **Step 4: Run the page tests** + +Run: `flutter test test/features/dive_log/presentation/pages/dive_edit_page_test.dart` +Run: `flutter test test/features/dive_log/presentation/pages/dive_detail_page_test.dart` +Expected: PASS. If a test fails because `siteSuggestionForDiveProvider` reaches the database, add `siteSuggestionForDiveProvider().overrideWith((ref) async => null)` to that test's overrides (or a `siteSuggestionForDiveProvider` family-wide `overrideWith((ref, id) async => null)`), and add the same family override to `getBaseOverrides` in `test/helpers/mock_providers.dart` so every page test gets it by default. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib test +flutter analyze +git add -A lib test +git commit -m "feat(dives): site suggestion on the edit and detail pages" +``` + +--- + +### Task 14: Geocode-prefilled quick-create dialog + +**Files:** +- Modify: `lib/features/media/presentation/widgets/quick_site_from_gps_dialog.dart` +- Test: `test/features/media/presentation/widgets/quick_site_from_gps_dialog_test.dart` + +**Interfaces:** +- `QuickSiteFromGpsDialog.show(context, {latitude, longitude})` unchanged. The returned `DiveSite` now carries `country`, `region`, `city` from editable fields prefilled by `locationServiceProvider.reverseGeocode` (best-effort; empty when it fails). The name hint shows the locality when known. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/media/presentation/widgets/quick_site_from_gps_dialog.dart'; + +import '../support/media_widget_harness.dart'; + +class _FakeLocationService extends LocationService { + _FakeLocationService(this.result, {this.fail = false}); + final ({String? country, String? region, String? locality}) result; + final bool fail; + @override + Future<({String? country, String? region, String? locality})> reverseGeocode( + double latitude, + double longitude, + ) async { + if (fail) throw StateError('offline'); + return result; + } +} + +void main() { + Future open(WidgetTester tester, LocationService geocoder) async { + DiveSite? result; + await tester.pumpWidget( + await mediaTestApp( + overrides: [locationServiceProvider.overrideWithValue(geocoder)], + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await QuickSiteFromGpsDialog.show( + context, + latitude: 20.5, + longitude: -87.25, + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return result; + } + + testWidgets('prefills country, region and city from the geocoder', (tester) async { + final geocoder = _FakeLocationService( + (country: 'Mexico', region: 'Quintana Roo', locality: 'Tulum'), + ); + final pending = open(tester, geocoder); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextFormField, 'Mexico'), findsOneWidget); + expect(find.widgetWithText(TextFormField, 'Quintana Roo'), findsOneWidget); + expect(find.widgetWithText(TextFormField, 'Tulum'), findsOneWidget); + await tester.enterText(find.byType(TextFormField).first, 'Cenote Wall'); + await tester.tap(find.widgetWithText(FilledButton, 'Create Site')); + await tester.pumpAndSettle(); + final site = await pending; + expect(site?.name, 'Cenote Wall'); + expect(site?.country, 'Mexico'); + expect(site?.region, 'Quintana Roo'); + expect(site?.city, 'Tulum'); + expect(site?.location, const GeoPoint(20.5, -87.25)); + }); + + testWidgets('a failed geocode leaves the fields empty and still creates', (tester) async { + final geocoder = _FakeLocationService( + (country: null, region: null, locality: null), + fail: true, + ); + final pending = open(tester, geocoder); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, 'Somewhere'); + await tester.tap(find.widgetWithText(FilledButton, 'Create Site')); + await tester.pumpAndSettle(); + final site = await pending; + expect(site?.name, 'Somewhere'); + expect(site?.country, isNull); + }); +} +``` + +`LocationService` is a singleton with a private constructor in production; if `extends LocationService` is not possible, check how `site_edit_page_test.dart` fakes it (memory: it uses a fake through `locationServiceProvider`) and copy that fake's shape. + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/media/presentation/widgets/quick_site_from_gps_dialog_test.dart` +Expected: FAIL (no Country field; the returned site has no country). + +- [ ] **Step 3: Implement** + +In `_QuickSiteFromGpsDialogState`: add `_countryController`, `_regionController`, `_cityController`, `String? _locality`, and in `initState` kick off the lookup: + +```dart + @override + void initState() { + super.initState(); + unawaited(_prefill()); + } + + /// Fill-empty geocoding at explicit capture time (the same rule as the + /// site edit page's "Use my location"); never at save time. + Future _prefill() async { + try { + final place = await ref + .read(locationServiceProvider) + .reverseGeocode(widget.latitude, widget.longitude); + if (!mounted) return; + setState(() { + if (_countryController.text.isEmpty) { + _countryController.text = place.country ?? ''; + } + if (_regionController.text.isEmpty) { + _regionController.text = place.region ?? ''; + } + if (_cityController.text.isEmpty) { + _cityController.text = place.locality ?? ''; + } + _locality = place.locality; + }); + } catch (_) { + // Offline or unsupported platform: the diver types what they know. + } + } +``` + +Below the name field add three `TextFormField`s with `labelText: context.l10n.diveSites_edit_field_country_label` / `..._region_label` / `..._city_label` (no validators). Use `hintText: _locality ?? context.l10n.media_quickSiteDialog_siteNameHint` on the name field. In `_createSite`, build the site with the extra fields, mapping empty strings to null: + +```dart + String? orNull(TextEditingController c) => + c.text.trim().isEmpty ? null : c.text.trim(); + final site = DiveSite( + id: _uuid.v4(), + name: _nameController.text.trim(), + location: GeoPoint(widget.latitude, widget.longitude), + country: orNull(_countryController), + region: orNull(_regionController), + city: orNull(_cityController), + ); +``` + +Dispose the new controllers. Wrap the dialog content in a `SingleChildScrollView` so four fields fit on a phone. Imports: `dart:async`, `package:submersion/core/providers/location_service_provider.dart`. + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/media/presentation/widgets/quick_site_from_gps_dialog_test.dart` +Expected: PASS. +Run: `flutter test test/features/dive_log/presentation/widgets/site_suggestion_card_test.dart` +Expected: PASS (the card test's `mediaTestApp` has no `locationServiceProvider` override; the real singleton's geocode fails fast in tests and the catch swallows it; if it instead hangs, add `locationServiceProvider.overrideWithValue(_FakeLocationService(...))` to that test too, moving the fake into `test/features/media/presentation/support/fake_location_service.dart`). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/media test/features/media +flutter analyze lib/features/media test/features/media +git add lib/features/media/presentation/widgets/quick_site_from_gps_dialog.dart test/features/media/presentation +git commit -m "feat(media): prefill country, region and city in the quick site dialog" +``` + +--- + +### Task 15: Review page: source chip, current-site card, "Create site here" + +**Files:** +- Modify: `lib/features/dive_sites/presentation/pages/site_match_review_page.dart` (`_DiveRow` around line 196, `_CandidateCard` around line 272, `onConfirm` snackbar at line 33) +- Test: `test/features/dive_sites/presentation/pages/site_match_review_page_test.dart` + +**Interfaces:** +- Consumes: `MatchProposal.pointSource`, `MatchCandidateView.isCurrentSite`, `ApplyResult.sitesLocated`, `SiteMatchReviewNotifier.createSiteHere` (Tasks 6-8), l10n keys (Task 9), `QuickSiteFromGpsDialog` (Task 14). +- Produces: `_DiveRow` gains `required VoidCallback onCreateHere`. + +- [ ] **Step 1: Write the failing tests** + +Extend `_SeededNotifier` in the test with a recorded `createSiteHere`: + +```dart + final createdHere = <(String, DiveSite)>[]; + @override + Future createSiteHere(String diveId, DiveSite site) async { + createdHere.add((diveId, site)); + state = state.copyWith( + proposals: [for (final p in state.proposals) if (p.dive.id != diveId) p], + ); + return site.copyWith(id: 'created'); + } +``` + +Add `import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart';` if missing, and make `_harness` return the notifier too (or keep a `late _SeededNotifier notifier` assigned inside the `overrideWith` closure). Then: + +```dart + testWidgets('photo-sourced proposals show the source chip', (tester) async { + final seeded = SiteMatchReviewState( + isLoading: false, + proposals: [ + MatchProposal( + dive: _dive(7), + status: ProposalStatus.clear, + candidates: [_cand('s1')], + recommendedCandidateId: 's1', + point: const GeoPoint(0, 0), + pointSource: PointSource.photo, + ), + ], + focusedDiveId: 'd7', + selections: const {'d7': 's1'}, + ); + await tester.pumpWidget(_harness(seeded)); + await tester.pump(); + expect(find.text('from photo'), findsOneWidget); + }); + + testWidgets('the current-site candidate is labelled and preselected', (tester) async { + final current = MatchCandidateView( + id: SiteMatchingService.currentSiteCandidateId('bare'), + name: 'Typed Twice', + isExisting: true, + isCurrentSite: true, + distanceMeters: 0, + location: const GeoPoint(0, 0), + ); + final seeded = SiteMatchReviewState( + isLoading: false, + proposals: [ + MatchProposal( + dive: _dive(7), + status: ProposalStatus.clear, + candidates: [current], + recommendedCandidateId: current.id, + point: const GeoPoint(0, 0), + ), + ], + focusedDiveId: 'd7', + selections: {'d7': current.id}, + ); + tester.view.physicalSize = const Size(400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget(_harness(seeded)); + await tester.pump(); + expect(find.text('Add location to this site'), findsOneWidget); + expect(find.text('Typed Twice'), findsWidgets); + expect(find.textContaining('0 m away'), findsNothing); + }); + + testWidgets('Create site here opens the dialog and links immediately', (tester) async { + final seeded = SiteMatchReviewState( + isLoading: false, + proposals: [ + MatchProposal( + dive: _dive(7), + status: ProposalStatus.none, + point: const GeoPoint(0, 0), + ), + ], + focusedDiveId: 'd7', + ); + await tester.pumpWidget(_harness(seeded)); + await tester.pump(); + await tester.tap(find.text('Create site here')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, 'Wall'); + await tester.tap(find.widgetWithText(FilledButton, 'Create Site')); + await tester.pumpAndSettle(); + expect(notifier.createdHere.single.$1, 'd7'); + expect(notifier.createdHere.single.$2.name, 'Wall'); + expect(find.textContaining('Created site: Wall'), findsOneWidget); + expect(find.text('Nothing to match.'), findsOneWidget); + }); + + testWidgets('confirm snackbar reports located sites', (tester) async { + final seeded = SiteMatchReviewState( + isLoading: false, + proposals: [ + MatchProposal( + dive: _dive(7), + status: ProposalStatus.clear, + candidates: [_cand('s1')], + recommendedCandidateId: 's1', + point: const GeoPoint(0, 0), + ), + ], + focusedDiveId: 'd7', + selections: const {'d7': 's1'}, + ); + await tester.pumpWidget( + _harness(seeded, confirmResult: const ApplyResult(divesLinked: 1, sitesCreated: 0, sitesLocated: 1)), + ); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, 'Confirm 1 matches')); + await tester.pump(); + expect(find.textContaining('located 1 sites'), findsOneWidget); + }); +``` + +The `QuickSiteFromGpsDialog` inside the harness needs `locationServiceProvider` overridden with the fake from Task 14 (move it to `test/features/media/presentation/support/fake_location_service.dart` and add `locationServiceProvider.overrideWithValue(FakeLocationService((country: null, region: null, locality: null)))` to `_harness`'s overrides). + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/dive_sites/presentation/pages/site_match_review_page_test.dart` +Expected: the four new tests FAIL. + +- [ ] **Step 3: Implement** + +`onConfirm` snackbar: pass `result.sitesLocated` as the third argument (done in Task 9 if not already). + +`_DiveRow`: add `required this.onCreateHere` and render, inside the `Column` after the `ListTile`, a trailing action row for every focused proposal: + +```dart + if (focused) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Row( + children: [ + if (proposal.pointSource == PointSource.photo) + Chip( + avatar: const Icon(Icons.photo_camera_outlined, size: 16), + label: Text(l10n.siteMatchReview_sourcePhoto), + visualDensity: VisualDensity.compact, + ) + else + Chip( + avatar: const Icon(Icons.watch_outlined, size: 16), + label: Text(l10n.siteMatchReview_sourceDiveComputer), + visualDensity: VisualDensity.compact, + ), + const Spacer(), + TextButton.icon( + onPressed: onCreateHere, + icon: const Icon(Icons.add_location_alt_outlined, size: 18), + label: Text(l10n.siteMatchReview_createHereButton), + ), + ], + ), + ), +``` + +For `ProposalStatus.none` rows the same row renders (the chip plus the button), which is the primary action for that case. + +`_CandidateCard`: when `c.isCurrentSite`, the trailing source label becomes `l10n.siteMatchReview_currentSiteCard` and the `meta` list omits `awayMeters` (start `meta` as `[if (!c.isCurrentSite) l10n.siteMatchReview_awayMeters(...), ...]`). + +Page: build `onCreateHere` per row: + +```dart + Future createHere(MatchProposal p) async { + final point = p.point; + if (point == null) return; + final draft = await QuickSiteFromGpsDialog.show( + context, + latitude: point.latitude, + longitude: point.longitude, + ); + if (draft == null || !context.mounted) return; + final created = await notifier.createSiteHere(p.dive.id, draft); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + created == null + ? l10n.siteMatchReview_applyError + : l10n.diveLog_edit_createdSite(created.name), + ), + ), + ); + } +``` + +and pass `onCreateHere: () => createHere(p)` into each `_DiveRow`. Import `quick_site_from_gps_dialog.dart`. + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/dive_sites/presentation/pages/site_match_review_page_test.dart` +Expected: PASS (15 tests). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/features/dive_sites test/features/dive_sites +flutter analyze lib/features/dive_sites test/features/dive_sites +git add lib/features/dive_sites test/features/dive_sites test/features/media/presentation/support +git commit -m "feat(sites): source chip, current-site card and create-here on the review page" +``` + +--- + +### Task 16: Post-import prompt and the Files-tab snackbar + +**Files:** +- Create: `lib/features/media/presentation/helpers/offer_site_review_after_import.dart` +- Modify: `lib/features/trips/presentation/helpers/trip_scan_actions.dart:168-178`, `lib/features/media/presentation/widgets/files_tab.dart:326-356`, `lib/features/media/presentation/helpers/lightroom_scan_helper.dart:35-48`, `lib/features/media/presentation/pages/media_import_view.dart` (where `importResolved`'s result snackbar is shown) +- Test: `test/features/media/presentation/helpers/offer_site_review_after_import_test.dart` + +**Interfaces:** +- Produces: `Future offerSiteReviewAfterImport(BuildContext context, WidgetRef ref, Iterable diveIds, {ScaffoldMessengerState? messenger})`: reads `eligibleImportedDivesProvider(ImportedDiveIds(ids))`; when non-empty shows a snackbar `mediaImport_offerSiteReview(n)` with action `mediaImport_reviewSitesAction` that pushes `/dives/match-sites` with the eligible ids. No-op for an empty id list or when nothing is eligible. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_match_review_notifier.dart'; +import 'package:submersion/features/media/presentation/helpers/offer_site_review_after_import.dart'; + +import '../../../../helpers/test_app.dart'; + +void main() { + Future<(WidgetTester, List)> pump( + WidgetTester tester, + List eligible, { + required List imported, + }) async { + final pushed = []; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => offerSiteReviewAfterImport(context, ref, imported), + child: const Text('done'), + ), + ), + ), + ), + GoRoute( + path: '/dives/match-sites', + builder: (context, state) { + pushed.add(state.extra); + return const Scaffold(body: Text('review page')); + }, + ), + ], + ); + await tester.pumpWidget( + testAppRouter( + router: router, + overrides: [ + eligibleImportedDivesProvider(ImportedDiveIds(imported)).overrideWith((ref) async => eligible), + ], + ), + ); + return (tester, pushed); + } + + testWidgets('offers a review with the eligible count and navigates', (tester) async { + final (_, pushed) = await pump(tester, ['d1', 'd2'], imported: ['d1', 'd2', 'd3']); + await tester.tap(find.text('done')); + await tester.pumpAndSettle(); + expect(find.text('2 dives could get a site from their photos'), findsOneWidget); + await tester.tap(find.text('Review sites')); + await tester.pumpAndSettle(); + expect(find.text('review page'), findsOneWidget); + expect(pushed.single, ['d1', 'd2']); + }); + + testWidgets('stays silent when nothing is eligible or nothing was imported', (tester) async { + await pump(tester, const [], imported: ['d1']); + await tester.tap(find.text('done')); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + }); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/media/presentation/helpers/offer_site_review_after_import_test.dart` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Create the helper** + +```dart +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_match_review_notifier.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// After a multi-dive photo import, offers the batch site review for the +/// dives that just became eligible (siteless or coordinate-less site, now +/// with a GPS point, not dismissed). Silent when there is nothing to offer. +/// Pass [messenger] when the calling page is about to pop, so the snackbar +/// lands on the page underneath. +Future offerSiteReviewAfterImport( + BuildContext context, + WidgetRef ref, + Iterable diveIds, { + ScaffoldMessengerState? messenger, +}) async { + final ids = diveIds.toSet().toList(); + if (ids.isEmpty) return; + final l10n = context.l10n; + final scaffold = messenger ?? ScaffoldMessenger.of(context); + final router = GoRouter.of(context); + final eligible = await ref.read( + eligibleImportedDivesProvider(ImportedDiveIds(ids)).future, + ); + if (eligible.isEmpty) return; + scaffold.showSnackBar( + SnackBar( + content: Text(l10n.mediaImport_offerSiteReview(eligible.length)), + duration: const Duration(seconds: 8), + action: SnackBarAction( + label: l10n.mediaImport_reviewSitesAction, + onPressed: () => router.push('/dives/match-sites', extra: eligible), + ), + ), + ); +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `flutter test test/features/media/presentation/helpers/offer_site_review_after_import_test.dart` +Expected: PASS. + +- [ ] **Step 5: Wire the four call sites** + +1. `trip_scan_actions.dart` `_importPhotos`: after the `trips_detail_scan_linkedPhotos` snackbar, add + `await offerSiteReviewAfterImport(context, ref, photosByDive.keys.map((d) => d.id));` guarded by `if (context.mounted)`. +2. `files_tab.dart` `_commit`: capture `final diveIds = ref.read(filesTabNotifierProvider).match.matched.keys.toList();` BEFORE `notifier.commit(...)` (commit clears the state); replace the hardcoded snackbar text with `_isSiteSession ? context.l10n.filesTab_attachedToSite(created.length) : context.l10n.filesTab_linkedItems(created.length)` and the action label with `context.l10n.filesTab_undo` (read `l10n` before the pop); after showing it, `if (!_isSiteSession) await offerSiteReviewAfterImport(context, ref, diveIds, messenger: messenger);` (the page has popped, so pass the captured root messenger; the helper's `GoRouter.of(context)` still resolves through the popped route's context, and `context.mounted` is false, so call it with the messenger BEFORE `navigator.pop()` instead, and reorder: show the linked snackbar, offer the review, then pop). Remove the `TODO(media): l10n` comments those strings carried. +3. `lightroom_scan_helper.dart` `runLightroomScan`: after the summary snackbar and the invalidation loop, `if (context.mounted) await offerSiteReviewAfterImport(context, ref, dives.map((d) => d.id));`. +4. `media_import_view.dart`: `importResolved` returns `ImportReviewResult`; add `linkedDiveIds: byDive.keys.toList()` to that result (new `final List linkedDiveIds;` field, default `const []`), and where `_launch` shows its result snackbar, follow it with `await offerSiteReviewAfterImport(context, ref, result.linkedDiveIds);`. + +- [ ] **Step 6: Run the touched suites** + +Run: `flutter test test/features/media/presentation` +Run: `flutter test test/features/trips` +Expected: PASS. If a Files-tab test asserts the old hardcoded snackbar text, update it to the localized string. + +- [ ] **Step 7: Format, analyze, commit** + +```bash +dart format lib test +flutter analyze +git add -A lib test +git commit -m "feat(media): offer the site review after multi-dive photo imports" +``` + +--- + +### Task 17: Roadmap, docs, whole-project verification + +**Files:** +- Modify: `docs/FEATURE_ROADMAP.md:851`, `docs/REMAINING_TASKS.md:151` + +- [ ] **Step 1: Tick the roadmap item** + +Change `- [ ] GPS extraction from photos (suggest site creation)` to `- [x] GPS extraction from photos (suggest site creation): desktop and video readers, nearest-site matching, detail-page and post-import surfacing, synced dismissal` in both files. + +- [ ] **Step 2: Format and analyze everything** + +```bash +dart format . +flutter analyze +``` +Expected: no changes, "No issues found!". + +- [ ] **Step 3: Full test suite once** + +Run: `flutter test` +Expected: PASS. Rerun any known-flaky file alone before treating a failure as a regression. + +- [ ] **Step 4: Manual smoke on macOS** + +Run `flutter run -d macos`. Open a dive without a site that has a GPS-tagged photo (after Plan A, a desktop JPEG import works; before it, use a dive synced from a phone). Confirm: the banner appears on the detail page; "Assign" links the nearest site; dismiss hides it and survives reopening the dive; a Files-tab folder import over several dives ends with the "Review sites" snackbar; the review page shows the "from photo" chip and can create a site. + +- [ ] **Step 5: Commit** + +```bash +git add docs/FEATURE_ROADMAP.md docs/REMAINING_TASKS.md +git commit -m "docs: tick the photo GPS site suggestion roadmap item" +``` + +--- + +## Self-review notes + +- Spec coverage: Section 1 (Tasks 4-8), Section 3 (Tasks 10-14), Section 4 (Tasks 15-16), Section 5 (Tasks 2-3 schema and sync, error handling inside Tasks 6-8 and 12, testing throughout). Section 2 is Plan A. +- Type consistency: `PhotoGpsPoint` (Task 4) is what `getBestPhotoGpsForDives` returns and what `_pointFor` consumes (Task 6); `SiteMatchingService.currentSiteCandidateId` (Task 7) is used by Tasks 12 and 15; `ApplyResult.sitesLocated` (Task 7) is read by Task 9's snackbar and Task 15; `SiteSuggestion` (Task 10) is consumed by Tasks 12-13; `createSiteHere` (Task 8) by Task 15. +- Deliberate deviations from the spec text: the current-site apply uses `updateSiteCoordinates` (column patch) rather than `updateSite(site.copyWith(...))`, because `Dive.site` may be a partially hydrated entity (issue #1187); the dismissal column name is `site_suggestion_dismissed_at` as approved in Section 1 v2; the rung is v172, not v169, because main moved. diff --git a/docs/superpowers/plans/2026-08-27-species-catalog-growth.md b/docs/superpowers/plans/2026-08-27-species-catalog-growth.md new file mode 100644 index 0000000000..8f398979ca --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-species-catalog-growth.md @@ -0,0 +1,1327 @@ +# Species Catalog Growth Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the bundled species catalog versioned (corrections reach existing installs without touching diver edits), generate the two id-keyed lookup switches from the asset with a coverage test, and add about 150 curated freshwater species with iNaturalist-localized names and hand-written descriptions in all 11 locales. + +**Architecture:** `species.json`'s `version` drives a one-time upsert pass guarded by `hlc IS NULL`, with the applied version in SharedPreferences (cleared by restore). Three `tool/` scripts share pure functions in `tool/src/species_tool_support.dart`: a lookup-switch renderer, an ARB key inserter, and an iNaturalist name merger; the freshwater seed lives in `tool/data/` and every generated artifact is committed. No schema change. + +**Tech Stack:** Flutter, Drift 2.34 (`DoUpdate(where:)`), SharedPreferences, `dart:io` HttpClient in tools, `flutter gen-l10n`, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-08-27-species-catalog-growth-design.md` + +## Global Constraints + +- Work only in `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-catalog` on branch `worktree-species-catalog` (cut from origin/main 929d7130860). `flutter analyze` prints `Analyzing species-catalog...` when it runs in the right tree. +- No schema change. No em-dashes anywhere. No emojis in code or docs. Immutable entities. Tests first. Single-file test runs with exit codes, never through a pipe. +- Every new ARB key exists in all 11 locales (`ar de en es fr he hu it nl pt zh`); generated l10n Dart is committed with the ARB change. +- Tools are run with `dart run tool/.dart` from the worktree root. Network tools (`generate_freshwater_species`, `generate_species_gbif_keys`) are manual and never part of tests or CI. +- Species ids are `sp_`; ARB keys are `species__name` and `_desc`. +- `dart format .` before every commit. Commit per task; do not push. + +--- + +### Task 1: `BundledSpeciesCatalog` and a pure catalog parser + +**Files:** +- Create: `lib/features/marine_life/domain/entities/bundled_species_catalog.dart` +- Modify: `lib/features/marine_life/data/services/species_seed_service.dart` +- Test: `test/features/marine_life/data/services/species_seed_service_test.dart` + +**Interfaces:** +- Produces: + ```dart + class BundledSpeciesCatalog { const BundledSpeciesCatalog({required this.version, required this.species}); final int version; final List species; } + // SpeciesSeedService + static BundledSpeciesCatalog parseCatalog(String jsonString); // pure, missing version reads as 1 + static Future loadBundledCatalog(); // rootBundle + cache + static Future> loadBundledSpecies(); // unchanged signature, returns catalog.species + static void overrideCatalog(BundledSpeciesCatalog catalog); // @visibleForTesting, seeds the cache + static void clearCache(); + ``` + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/data/services/species_seed_service.dart'; + +void main() { + const twoRows = ''' +{ + "version": 2, + "species": [ + {"id": "sp_a", "commonName": "A", "scientificName": "Aus aus", + "category": "fish", "taxonomyClass": "Actinopterygii", "description": "d"}, + {"id": "sp_b", "commonName": "B", "scientificName": null, + "category": "nonsense", "taxonomyClass": null, "description": null} + ] +} +'''; + + test('parseCatalog reads the version and every row as built-in', () { + final catalog = SpeciesSeedService.parseCatalog(twoRows); + + expect(catalog.version, 2); + expect(catalog.species.map((s) => s.id), ['sp_a', 'sp_b']); + expect(catalog.species.first.isBuiltIn, isTrue); + expect(catalog.species.first.category, SpeciesCategory.fish); + expect(catalog.species.last.category, SpeciesCategory.other); + expect(catalog.species.last.scientificName, isNull); + }); + + test('a catalog without a version is version 1', () { + final catalog = SpeciesSeedService.parseCatalog('{"species": []}'); + + expect(catalog.version, 1); + expect(catalog.species, isEmpty); + }); + + test('overrideCatalog feeds loadBundledSpecies until the cache clears', () async { + addTearDown(SpeciesSeedService.clearCache); + SpeciesSeedService.overrideCatalog(SpeciesSeedService.parseCatalog(twoRows)); + + final species = await SpeciesSeedService.loadBundledSpecies(); + final catalog = await SpeciesSeedService.loadBundledCatalog(); + + expect(species.map((s) => s.id), ['sp_a', 'sp_b']); + expect(catalog.version, 2); + }); +} +``` + +- [ ] **Step 2: Run it red**: `flutter test test/features/marine_life/data/services/species_seed_service_test.dart` fails to compile (`parseCatalog` undefined). + +- [ ] **Step 3: Implement** + +`bundled_species_catalog.dart`: + +```dart +import 'package:submersion/features/marine_life/domain/entities/species.dart'; + +/// The bundled species asset: its rows plus the catalog version that gates +/// the re-seed upgrade pass (see SpeciesRepository.seedBuiltInSpecies). +class BundledSpeciesCatalog { + const BundledSpeciesCatalog({required this.version, required this.species}); + + final int version; + final List species; +} +``` + +`species_seed_service.dart` becomes: + +```dart +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/services.dart' show rootBundle; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/domain/entities/bundled_species_catalog.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; + +class SpeciesSeedService { + static BundledSpeciesCatalog? _cached; + + /// Decodes the asset text. A catalog without a `version` is version 1: the + /// value shipped before versioning meant anything. + static BundledSpeciesCatalog parseCatalog(String jsonString) { + final data = json.decode(jsonString) as Map; + final version = data['version'] is int ? data['version'] as int : 1; + final speciesList = data['species'] as List; + final species = speciesList.map((item) { + final map = item as Map; + return Species( + id: map['id'] as String, + commonName: map['commonName'] as String, + scientificName: map['scientificName'] as String?, + category: SpeciesCategory.values.firstWhere( + (c) => c.name == map['category'], + orElse: () => SpeciesCategory.other, + ), + taxonomyClass: map['taxonomyClass'] as String?, + description: map['description'] as String?, + isBuiltIn: true, + ); + }).toList(); + return BundledSpeciesCatalog(version: version, species: species); + } + + /// Load the bundled catalog, cached after the first read (the reset and + /// the seed both use it). + static Future loadBundledCatalog() async { + final cached = _cached; + if (cached != null) return cached; + final jsonString = await rootBundle.loadString('assets/data/species.json'); + return _cached = parseCatalog(jsonString); + } + + /// Load built-in species from the bundled JSON asset. + static Future> loadBundledSpecies() async => + (await loadBundledCatalog()).species; + + /// Seeds the cache so tests can run the seed against a small catalog. + @visibleForTesting + static void overrideCatalog(BundledSpeciesCatalog catalog) { + _cached = catalog; + } + + /// Clear the cached catalog (useful for testing). + static void clearCache() { + _cached = null; + } +} +``` + +- [ ] **Step 4: Run it green**, then `flutter test test/features/marine_life/` to confirm nothing else depended on the old cache field. +- [ ] **Step 5: Commit** `feat(marine-life): expose the bundled catalog version`. + +--- + +### Task 2: `BuiltInSpeciesSeedVersionStore` + +**Files:** +- Create: `lib/features/marine_life/data/services/builtin_species_seed_version_store.dart` +- Create: `test/helpers/in_memory_seed_version_store.dart` +- Test: `test/features/marine_life/data/services/builtin_species_seed_version_store_test.dart` + +**Interfaces:** +```dart +abstract class BuiltInSpeciesSeedVersionStore { + Future appliedVersion(); // 0 when unset + Future markApplied(int version); + Future clear(); +} +class PrefsBuiltInSpeciesSeedVersionStore implements BuiltInSpeciesSeedVersionStore { + PrefsBuiltInSpeciesSeedVersionStore(SharedPreferences prefs); + static const key = 'builtin_species_seed_version'; +} +// test helper +class InMemorySeedVersionStore implements BuiltInSpeciesSeedVersionStore { int? version; int clearCalls = 0; } +``` + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/marine_life/data/services/builtin_species_seed_version_store.dart'; + +void main() { + test('reads 0 when nothing was applied, then round-trips', () async { + SharedPreferences.setMockInitialValues({}); + final store = PrefsBuiltInSpeciesSeedVersionStore( + await SharedPreferences.getInstance(), + ); + + expect(await store.appliedVersion(), 0); + await store.markApplied(2); + expect(await store.appliedVersion(), 2); + await store.clear(); + expect(await store.appliedVersion(), 0); + }); + + test('a malformed stored value reads as 0', () async { + SharedPreferences.setMockInitialValues({ + PrefsBuiltInSpeciesSeedVersionStore.key: 'two', + }); + final store = PrefsBuiltInSpeciesSeedVersionStore( + await SharedPreferences.getInstance(), + ); + + expect(await store.appliedVersion(), 0); + }); +} +``` + +- [ ] **Step 2: Run it red** (compile failure). +- [ ] **Step 3: Implement** + +```dart +import 'package:shared_preferences/shared_preferences.dart'; + +/// Which bundled catalog version this device has applied. Device-local by +/// design: the catalog is re-seeded on every install, so nothing about it +/// syncs. Restore-from-backup clears it (BackupService) so a restored older +/// database is upgraded again on the next launch. +abstract class BuiltInSpeciesSeedVersionStore { + Future appliedVersion(); + Future markApplied(int version); + Future clear(); +} + +class PrefsBuiltInSpeciesSeedVersionStore + implements BuiltInSpeciesSeedVersionStore { + PrefsBuiltInSpeciesSeedVersionStore(this._prefs); + + static const key = 'builtin_species_seed_version'; + + final SharedPreferences _prefs; + + @override + Future appliedVersion() async { + try { + return _prefs.getInt(key) ?? 0; + } catch (_) { + // A value of another type reads as never applied. + return 0; + } + } + + @override + Future markApplied(int version) => _prefs.setInt(key, version); + + @override + Future clear() => _prefs.remove(key); +} +``` + +Test helper `test/helpers/in_memory_seed_version_store.dart`: + +```dart +import 'package:submersion/features/marine_life/data/services/builtin_species_seed_version_store.dart'; + +class InMemorySeedVersionStore implements BuiltInSpeciesSeedVersionStore { + InMemorySeedVersionStore([this.version]); + + int? version; + int clearCalls = 0; + + @override + Future appliedVersion() async => version ?? 0; + + @override + Future markApplied(int applied) async => version = applied; + + @override + Future clear() async { + clearCalls++; + version = null; + } +} +``` + +- [ ] **Step 4: Run it green.** +- [ ] **Step 5: Commit** `feat(marine-life): add the built-in species seed version store`. + +--- + +### Task 3: Versioned re-seed in `SpeciesRepository` and the startup wiring + +**Files:** +- Modify: `lib/features/marine_life/data/repositories/species_repository.dart` (`seedBuiltInSpecies`, `resetBuiltInSpecies`) +- Modify: `lib/core/presentation/pages/startup_page.dart:673-676` +- Test: `test/features/marine_life/data/repositories/species_repository_seed_test.dart` + +**Interfaces:** +```dart +Future seedBuiltInSpecies({BuiltInSpeciesSeedVersionStore? versionStore}); +``` +`versionStore == null` keeps today's behaviour (INSERT OR IGNORE only). Startup passes a prefs store; `resetBuiltInSpecies` calls it without one. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/marine_life/data/services/species_seed_service.dart'; +import 'package:submersion/features/marine_life/domain/entities/bundled_species_catalog.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart' + as domain; + +import '../../../../helpers/in_memory_seed_version_store.dart'; +import '../../../../helpers/test_database.dart'; + +domain.Species _row(String id, String name, {String? description}) => + domain.Species( + id: id, + commonName: name, + scientificName: '$name sci', + category: SpeciesCategory.fish, + description: description ?? '$name description', + isBuiltIn: true, + ); + +BundledSpeciesCatalog _catalog(int version, List rows) => + BundledSpeciesCatalog(version: version, species: rows); + +void main() { + late AppDatabase db; + late SpeciesRepository repository; + + setUp(() async { + db = await setUpTestDatabase(); + repository = SpeciesRepository(); + }); + + tearDown(() async { + SpeciesSeedService.clearCache(); + await tearDownTestDatabase(); + }); + + Future read(String id) => + (db.select(db.species)..where((t) => t.id.equals(id))).getSingle(); + + test('first launch inserts every row and records the version', () async { + SpeciesSeedService.overrideCatalog(_catalog(2, [_row('sp_a', 'A')])); + final store = InMemorySeedVersionStore(); + + await repository.seedBuiltInSpecies(versionStore: store); + + expect((await read('sp_a')).commonName, 'A'); + expect((await read('sp_a')).hlc, isNull); + expect(store.version, 2); + }); + + test('a newer catalog updates untouched rows and keeps edited ones', () async { + SpeciesSeedService.overrideCatalog( + _catalog(1, [_row('sp_a', 'A'), _row('sp_b', 'B')]), + ); + final store = InMemorySeedVersionStore(); + await repository.seedBuiltInSpecies(versionStore: store); + // The diver renames B: every edit path stamps an hlc. + await repository.updateSpecies( + (await repository.getSpeciesById('sp_b'))!.copyWith(commonName: 'Mine'), + ); + expect((await read('sp_b')).hlc, isNotNull); + + SpeciesSeedService.overrideCatalog( + _catalog(2, [_row('sp_a', 'A2'), _row('sp_b', 'B2'), _row('sp_c', 'C')]), + ); + await repository.seedBuiltInSpecies(versionStore: store); + + expect((await read('sp_a')).commonName, 'A2'); + expect((await read('sp_b')).commonName, 'Mine'); + expect((await read('sp_c')).commonName, 'C'); + expect(store.version, 2); + }); + + test('the same version only fills in missing rows', () async { + SpeciesSeedService.overrideCatalog(_catalog(2, [_row('sp_a', 'A')])); + final store = InMemorySeedVersionStore(2); + await db + .into(db.species) + .insert( + SpeciesCompanion( + id: const Value('sp_a'), + commonName: const Value('Old'), + category: const Value('fish'), + isBuiltIn: const Value(true), + ), + ); + + await repository.seedBuiltInSpecies(versionStore: store); + + expect((await read('sp_a')).commonName, 'Old'); + }); + + test('a deleted built-in row comes back at the next version', () async { + SpeciesSeedService.overrideCatalog(_catalog(1, [_row('sp_a', 'A')])); + final store = InMemorySeedVersionStore(); + await repository.seedBuiltInSpecies(versionStore: store); + await repository.deleteSpecies('sp_a'); + + SpeciesSeedService.overrideCatalog(_catalog(2, [_row('sp_a', 'A')])); + await repository.seedBuiltInSpecies(versionStore: store); + + expect((await read('sp_a')).commonName, 'A'); + }); + + test('without a store the seed is the plain INSERT OR IGNORE', () async { + SpeciesSeedService.overrideCatalog(_catalog(2, [_row('sp_a', 'A')])); + await db + .into(db.species) + .insert( + SpeciesCompanion( + id: const Value('sp_a'), + commonName: const Value('Old'), + category: const Value('fish'), + isBuiltIn: const Value(true), + ), + ); + + await repository.seedBuiltInSpecies(); + + expect((await read('sp_a')).commonName, 'Old'); + }); + + test('resetBuiltInSpecies clears the hlc and pending record of a restored row', + () async { + SpeciesSeedService.overrideCatalog(_catalog(1, [_row('sp_a', 'A')])); + await repository.seedBuiltInSpecies(versionStore: InMemorySeedVersionStore()); + await repository.updateSpecies( + (await repository.getSpeciesById('sp_a'))!.copyWith(commonName: 'Mine'), + ); + // In use, so the reset restores rather than deletes it. + final now = DateTime.now().millisecondsSinceEpoch; + await db.into(db.dives).insert( + DivesCompanion( + id: const Value('d1'), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + await repository.addSighting(diveId: 'd1', speciesId: 'sp_a'); + + await repository.resetBuiltInSpecies(); + + final row = await read('sp_a'); + expect(row.commonName, 'A'); + expect(row.hlc, isNull); + final pending = await db + .customSelect( + "SELECT record_id FROM sync_records " + "WHERE entity_type = 'species' AND record_id = 'sp_a'", + ) + .get(); + expect(pending, isEmpty); + }); +} +``` + +Check `getSpeciesById` and `addSighting` names against `species_repository.dart` before running (`grep -n "Future get\|Future<.*> addSighting" lib/features/marine_life/data/repositories/species_repository.dart`) and use the real names. + +- [ ] **Step 2: Run it red**: the named parameter does not exist. +- [ ] **Step 3: Implement** + +Replace `seedBuiltInSpecies` with: + +```dart + /// Seed built-in species from the bundled JSON asset. + /// + /// Every launch runs the cheap INSERT OR IGNORE batch keyed on the stable + /// sp_ ids, which refills a wiped table. When [versionStore] reports a + /// version below the asset's, the batch becomes an upsert that also + /// rewrites rows the diver never touched (`hlc IS NULL`: every edit path + /// stamps an hlc, the seed never does), so catalog corrections reach + /// existing installs while edits survive. A built-in row the diver deleted + /// comes back at the next catalog version. + Future seedBuiltInSpecies({ + BuiltInSpeciesSeedVersionStore? versionStore, + }) async { + final catalog = await SpeciesSeedService.loadBundledCatalog(); + final applied = await versionStore?.appliedVersion(); + final upgrade = applied != null && applied < catalog.version; + + await _db.batch((batch) { + for (final species in catalog.species) { + final companion = SpeciesCompanion( + id: Value(species.id), + commonName: Value(species.commonName), + scientificName: Value(species.scientificName), + category: Value(species.category.name), + taxonomyClass: Value(species.taxonomyClass), + description: Value(species.description), + isBuiltIn: const Value(true), + ); + if (upgrade) { + batch.insert( + _db.species, + companion, + onConflict: DoUpdate( + (old) => companion, + where: (old) => old.hlc.isNull(), + ), + ); + } else { + batch.insert(_db.species, companion, mode: InsertMode.insertOrIgnore); + } + } + }); + + if (upgrade) { + await versionStore.markApplied(catalog.version); + } + } +``` + +In `resetBuiltInSpecies`, change the in-use update to also clear the hlc and drop the pending record: + +```dart + for (final species in builtInSpecies) { + if (inUseIds.contains(species.id)) { + await (_db.update( + _db.species, + )..where((t) => t.id.equals(species.id))).write( + SpeciesCompanion( + commonName: Value(species.commonName), + scientificName: Value(species.scientificName), + category: Value(species.category.name), + taxonomyClass: Value(species.taxonomyClass), + description: Value(species.description), + isBuiltIn: const Value(true), + // Restored to the bundle means untouched again, so the next + // catalog version may update it. + hlc: const Value(null), + ), + ); + await (_db.delete(_db.syncRecords)..where( + (t) => t.entityType.equals('species') & t.recordId.equals(species.id), + )) + .go(); + } + } +``` + +Add the import `package:submersion/features/marine_life/data/services/builtin_species_seed_version_store.dart`. + +`startup_page.dart:673-676` becomes: + +```dart + await timeStartupStep('speciesSeed', () async { + final speciesRepository = SpeciesRepository(); + await speciesRepository.seedBuiltInSpecies( + versionStore: PrefsBuiltInSpeciesSeedVersionStore( + await SharedPreferences.getInstance(), + ), + ); + }); +``` + +with the store import. `SharedPreferences` is already imported there (line 652 uses it). + +- [ ] **Step 4: Run green**: the seed test, then `flutter test test/features/marine_life/data/repositories/` and `test/core/presentation/` if a startup test exists (`ls test/core/presentation/pages/ | grep startup`). +- [ ] **Step 5: Commit** `feat(marine-life): re-seed untouched built-in species when the catalog version rises`. + +--- + +### Task 4: Restore clears the applied version + +**Files:** +- Modify: `lib/features/backup/data/services/backup_service.dart` (constructor, `_replaceDatabaseAndRebaselineSync`) +- Modify: `lib/features/backup/presentation/providers/backup_providers.dart:37-49` +- Test: `test/features/backup/data/services/backup_service_replace_test.dart` (add a case) + +- [ ] **Step 1: Write the failing test** (inside the existing `restore modes` group, using the file's `fakeDb`, `preferences`, `writeRestoreSource`, `_EpochSpySyncRepository`): + +```dart + test('a restore clears the applied catalog version', () async { + final seedStore = InMemorySeedVersionStore(2); + final service = BackupService( + dbAdapter: fakeDb, + preferences: preferences, + syncRepository: _EpochSpySyncRepository(), + seedVersionStore: seedStore, + ); + + await service.restoreFromFile(await writeRestoreSource()); + + expect(seedStore.version, isNull); + expect(seedStore.clearCalls, 1); + }); +``` + +Add `import '../../../../helpers/in_memory_seed_version_store.dart';`. + +- [ ] **Step 2: Run it red** (no named parameter `seedVersionStore`). +- [ ] **Step 3: Implement** + +Constructor: add `BuiltInSpeciesSeedVersionStore? seedVersionStore,` after `backupEncryptionKeyStore`, field `final BuiltInSpeciesSeedVersionStore? _seedVersionStore;`, initializer `_seedVersionStore = seedVersionStore`. In `_replaceDatabaseAndRebaselineSync`, directly after the `await _dbAdapter.restore(...)` call: + +```dart + // The restored file carries whatever built-in species rows its backup + // had; forgetting the applied catalog version makes the next launch run + // the upgrade pass again (diver-edited rows keep their hlc and are + // skipped by it). + try { + await _seedVersionStore?.clear(); + } catch (e, st) { + _log.warning( + 'Could not clear the built-in species seed version after restore', + error: e, + stackTrace: st, + ); + } +``` + +Provider: add `seedVersionStore: PrefsBuiltInSpeciesSeedVersionStore(ref.watch(sharedPreferencesProvider)),` to `backupServiceProvider`. The headless `buildScheduledBackupService` only backs up, so it stays as is. + +- [ ] **Step 4: Run green**: `flutter test test/features/backup/data/services/backup_service_replace_test.dart`. +- [ ] **Step 5: Commit** `feat(backup): forget the applied species catalog version on restore`. + +--- + +### Task 5: Tool support library (pure functions) with tests + +**Files:** +- Create: `tool/src/species_tool_support.dart` +- Test: `test/tool/species_tool_support_test.dart` (imports the library by relative path: `import '../../tool/src/species_tool_support.dart';`) +- Fixture (already saved): `test/fixtures/inaturalist/taxa_esox_lucius_all_names.json` + +**Interfaces:** +```dart +const appLocales = ['en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'hu', 'zh', 'ar', 'he']; +String speciesSlug(String id); // 'sp_x' -> 'x', throws on a bad id +String renderNameLookup(List> rows); // full Dart source of species_name_lookup.dart +String renderDescriptionLookup(List> rows); // full Dart source of species_description_lookup.dart +class ArbSpeciesEntry { final String id; final String name; final String description; } +String insertSpeciesArbKeys(String arbText, List entries); // after the last species_*_desc line; throws if a key exists +Map localizedNamesFromTaxon(Map taxon, {required String englishFallback}); // locale -> name for every appLocale +``` + +- [ ] **Step 1: Write the failing test** + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../tool/src/species_tool_support.dart'; + +void main() { + final rows = [ + {'id': 'sp_a_fish', 'commonName': 'A Fish', 'description': 'Desc A'}, + {'id': 'sp_b_ray', 'commonName': 'B Ray', 'description': 'Desc B'}, + ]; + + test('speciesSlug strips the prefix and rejects other ids', () { + expect(speciesSlug('sp_whale_shark'), 'whale_shark'); + expect(() => speciesSlug('whale_shark'), throwsFormatException); + expect(() => speciesSlug('sp_Whale'), throwsFormatException); + }); + + test('renderNameLookup emits one case per row inside the switch', () { + final source = renderNameLookup(rows); + + expect(source, contains('// GENERATED by tool/generate_species_lookups.dart')); + expect(source, contains("String? builtInSpeciesName(AppLocalizations l10n, String id) =>")); + expect(source, contains(" 'sp_a_fish' => l10n.species_a_fish_name,\n")); + expect(source, contains(" 'sp_b_ray' => l10n.species_b_ray_name,\n")); + expect(source, contains(' _ => null,\n};\n')); + expect('sp_'.allMatches(source).length, 2 + 1); // two cases plus the doc comment's example + }); + + test('renderDescriptionLookup mirrors the name renderer with _desc', () { + final source = renderDescriptionLookup(rows); + + expect(source, contains(" 'sp_a_fish' => l10n.species_a_fish_desc,\n")); + expect(source, contains('String? builtInSpeciesDescription(')); + }); + + test('insertSpeciesArbKeys lands after the last species_*_desc key', () { + const arb = ''' +{ + "@@locale": "en", + "species_whale_shark_name": "Whale Shark", + "species_whale_shark_desc": "Big.", + "common_action_done": "Done" +} +'''; + final out = insertSpeciesArbKeys(arb, [ + const ArbSpeciesEntry(id: 'sp_a_fish', name: 'A Fish', description: 'Desc "A"'), + ]); + + final lines = out.split('\n'); + final descAt = lines.indexWhere((l) => l.contains('"species_whale_shark_desc"')); + expect(lines[descAt + 1], ' "species_a_fish_name": "A Fish",'); + expect(lines[descAt + 2], ' "species_a_fish_desc": "Desc \\"A\\"",'); + expect(lines[descAt + 3], ' "common_action_done": "Done"'); + expect(jsonDecode(out), isA>()); + }); + + test('insertSpeciesArbKeys refuses an existing key', () { + const arb = '{\n "species_whale_shark_name": "W",\n "species_whale_shark_desc": "B"\n}\n'; + expect( + () => insertSpeciesArbKeys(arb, [ + const ArbSpeciesEntry(id: 'sp_whale_shark', name: 'x', description: 'y'), + ]), + throwsStateError, + ); + }); + + test('localizedNamesFromTaxon picks the top valid name per locale', () async { + final json = jsonDecode( + await File('test/fixtures/inaturalist/taxa_esox_lucius_all_names.json').readAsString(), + ) as Map; + final taxon = (json['results'] as List).first as Map; + + final names = localizedNamesFromTaxon(taxon, englishFallback: 'Pike'); + + expect(names['en'], 'Northern Pike'); + expect(names['de'], 'Hecht'); // position 0 beats Europaeischer Hecht at 56 + expect(names['zh'], '白斑狗鱼'); // zh-CN preferred over zh (traditional) + expect(names['pt'], 'Lúcio'); + expect(names['ar'], 'Northern Pike'); // absent: English name, not the fallback + expect(names.keys.toSet(), appLocales.toSet()); + }); + + test('localizedNamesFromTaxon falls back to the seed name without names', () { + final names = localizedNamesFromTaxon({'name': 'Esox lucius'}, englishFallback: 'Pike'); + + expect(names['en'], 'Pike'); + expect(names['he'], 'Pike'); + }); +} +``` + +- [ ] **Step 2: Run it red.** +- [ ] **Step 3: Implement** `tool/src/species_tool_support.dart`: + +```dart +// Pure helpers shared by the species catalog tools. Kept free of dart:io and +// the network so test/tool/species_tool_support_test.dart can cover them. + +/// App locales, in the order the ARB files are listed. +const List appLocales = [ + 'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'hu', 'zh', 'ar', 'he', +]; + +final RegExp _idPattern = RegExp(r'^sp_[a-z0-9_]+$'); + +/// `sp_whale_shark` -> `whale_shark`, the part the ARB keys are built from. +String speciesSlug(String id) { + if (!_idPattern.hasMatch(id)) { + throw FormatException('Species id must match ${_idPattern.pattern}: $id'); + } + return id.substring(3); +} + +const String _generatedHeader = + '// GENERATED by tool/generate_species_lookups.dart from\n' + '// assets/data/species.json. Do not edit; rerun the tool.\n'; + +String renderNameLookup(List> rows) { + final buffer = StringBuffer(_generatedHeader) + ..write(''' +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Localized common names for the built-in species catalog. +/// +/// The rows in `assets/data/species.json` are seeded into the `species` +/// table as English literals, and the stored `common_name` is what UDDF export +/// writes out, so that column deliberately stays English. Rendering it +/// directly is what left every species list in English under a translated +/// locale; on-screen callers resolve through here instead. +/// +/// Keyed on the stable seed id rather than the name, matching +/// `builtInDiveTypeName`. Custom species carry a UUID id, so they can never +/// collide with an `sp_` slug and always fall through to null. +/// +/// Returns null for anything that is not a known built-in id, and callers +/// fall back to the stored English name. +String? builtInSpeciesName(AppLocalizations l10n, String id) => switch (id) { +'''); + for (final row in rows) { + final id = row['id'] as String; + buffer.writeln(" '$id' => l10n.species_${speciesSlug(id)}_name,"); + } + buffer.write(' _ => null,\n};\n'); + return buffer.toString(); +} + +String renderDescriptionLookup(List> rows) { + final buffer = StringBuffer(_generatedHeader) + ..write(''' +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Localized descriptions for the built-in species catalog. +/// +/// Companion to `builtInSpeciesName`; see that file for why the stored column +/// stays English. Split out purely to keep each generated file inside the +/// project's file-size budget. +/// +/// Returns null for anything that is not a known built-in id, and callers +/// fall back to the stored English description. +String? builtInSpeciesDescription( + AppLocalizations l10n, + String id, +) => switch (id) { +'''); + for (final row in rows) { + final id = row['id'] as String; + buffer.writeln(" '$id' => l10n.species_${speciesSlug(id)}_desc,"); + } + buffer.write(' _ => null,\n};\n'); + return buffer.toString(); +} + +class ArbSpeciesEntry { + const ArbSpeciesEntry({ + required this.id, + required this.name, + required this.description, + }); + + final String id; + final String name; + final String description; +} + +String _jsonString(String value) { + final escaped = value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\n', '\\n'); + return '"$escaped"'; +} + +/// Inserts `species__name` and `_desc` lines after the last existing +/// `species_*_desc` line. The ARB files are feature blocks, not sorted, so +/// the anchor is positional. Throws if any key is already present. +String insertSpeciesArbKeys(String arbText, List entries) { + final lines = arbText.split('\n'); + final anchorPattern = RegExp(r'^ "species_[a-z0-9_]+_desc": '); + var anchor = -1; + for (var i = 0; i < lines.length; i++) { + if (anchorPattern.hasMatch(lines[i])) anchor = i; + } + if (anchor == -1) { + throw StateError('No species_*_desc key to anchor on'); + } + final inserted = []; + for (final entry in entries) { + final slug = speciesSlug(entry.id); + for (final key in ['species_${slug}_name', 'species_${slug}_desc']) { + if (arbText.contains('"$key"')) { + throw StateError('$key already exists in the ARB file'); + } + } + inserted + ..add(' "species_${slug}_name": ${_jsonString(entry.name)},') + ..add(' "species_${slug}_desc": ${_jsonString(entry.description)},'); + } + // The anchor line keeps its trailing comma; the inserted lines all carry + // one, and the line after the anchor already existed, so the object stays + // well-formed. + lines.insertAll(anchor + 1, inserted); + return lines.join('\n'); +} + +/// Which iNaturalist name locales serve each app locale, best first. +const Map> _localeCandidates = { + 'en': ['en'], + 'de': ['de'], + 'es': ['es'], + 'fr': ['fr'], + 'it': ['it'], + 'pt': ['pt', 'pt-BR', 'pt-PT'], + 'nl': ['nl'], + 'hu': ['hu'], + 'zh': ['zh-CN', 'zh'], + 'ar': ['ar'], + 'he': ['he'], +}; + +/// One name per app locale from a `/v1/taxa?...&all_names=true` taxon: the +/// valid name with the lowest `position` for the best candidate locale, +/// falling back to the taxon's English name, then to [englishFallback]. +Map localizedNamesFromTaxon( + Map taxon, { + required String englishFallback, +}) { + final names = (taxon['names'] as List? ?? const []) + .cast>() + .where((n) => n['is_valid'] != false && n['name'] is String) + .toList(); + + String? best(List candidates) { + for (final locale in candidates) { + final matches = names.where((n) => n['locale'] == locale).toList() + ..sort( + (a, b) => ((a['position'] as int?) ?? 1 << 30).compareTo( + (b['position'] as int?) ?? 1 << 30, + ), + ); + if (matches.isNotEmpty) return matches.first['name'] as String; + } + return null; + } + + final english = + best(['en']) ?? taxon['preferred_common_name'] as String? ?? englishFallback; + return { + for (final locale in appLocales) + locale: locale == 'en' ? english : best(_localeCandidates[locale]!) ?? english, + }; +} +``` + +- [ ] **Step 4: Run green.** Also `flutter analyze tool test/tool`. +- [ ] **Step 5: Commit** `chore(tool): add the species catalog tool support library`. + +--- + +### Task 6: Lookup generator, regeneration, coverage test + +**Files:** +- Create: `tool/generate_species_lookups.dart` +- Regenerate: `lib/features/marine_life/presentation/species_name_lookup.dart`, `species_description_lookup.dart` +- Test: `test/features/marine_life/presentation/species_lookup_coverage_test.dart` + +- [ ] **Step 1: Write the coverage test** (it passes today; it is the regression net for Task 11): + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/marine_life/presentation/species_description_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/species_name_lookup.dart'; +import 'package:submersion/l10n/arb/app_localizations_en.dart'; + +void main() { + late Map raw; + late List> catalog; + + setUpAll(() async { + raw = + jsonDecode(await File('assets/data/species.json').readAsString()) + as Map; + catalog = (raw['species'] as List).cast>(); + }); + + test('the catalog carries a version of at least 2', () { + expect(raw['version'], isA()); + expect(raw['version'] as int, greaterThanOrEqualTo(2)); + }); + + test('ids are unique and slug-shaped', () { + final ids = catalog.map((r) => r['id'] as String).toList(); + expect(ids.toSet().length, ids.length); + for (final id in ids) { + expect(id, matches(RegExp(r'^sp_[a-z0-9_]+$'))); + } + }); + + test('every row resolves through the English ARB to its own text', () { + final l10n = AppLocalizationsEn(); + for (final row in catalog) { + final id = row['id'] as String; + expect(builtInSpeciesName(l10n, id), row['commonName'], reason: id); + expect(builtInSpeciesDescription(l10n, id), row['description'], reason: id); + } + }); + + test('the switches carry no case the catalog lacks', () async { + final ids = catalog.map((r) => r['id'] as String).toSet(); + for (final path in [ + 'lib/features/marine_life/presentation/species_name_lookup.dart', + 'lib/features/marine_life/presentation/species_description_lookup.dart', + ]) { + final cases = RegExp(r"^ '(sp_[a-z0-9_]+)' =>", multiLine: true) + .allMatches(await File(path).readAsString()) + .map((m) => m.group(1)!) + .toList(); + expect(cases.length, ids.length, reason: path); + expect(cases.toSet().difference(ids), isEmpty, reason: path); + } + }); +} +``` + +The version assertion fails until Task 11 bumps the asset; until then run the file expecting exactly that one failure, and note it in the commit message. + +- [ ] **Step 2: Write the generator** `tool/generate_species_lookups.dart`: + +```dart +// Regenerates the two id-keyed lookup switches from assets/data/species.json. +// +// dart run tool/generate_species_lookups.dart +// +// No network. Run after any change to the asset, then dart format. +import 'dart:convert'; +import 'dart:io'; + +import 'src/species_tool_support.dart'; + +Future main() async { + final catalog = + jsonDecode(await File('assets/data/species.json').readAsString()) + as Map; + final rows = (catalog['species'] as List).cast>(); + + const dir = 'lib/features/marine_life/presentation'; + await File('$dir/species_name_lookup.dart').writeAsString( + renderNameLookup(rows), + ); + await File('$dir/species_description_lookup.dart').writeAsString( + renderDescriptionLookup(rows), + ); + final format = await Process.run('dart', [ + 'format', + '$dir/species_name_lookup.dart', + '$dir/species_description_lookup.dart', + ]); + stdout.write(format.stdout); + stderr.write(format.stderr); + stdout.writeln('Wrote ${rows.length} cases into both lookups'); +} +``` + +- [ ] **Step 3: Regenerate and prove equivalence**: `dart run tool/generate_species_lookups.dart`, then `git diff --stat lib/features/marine_life/presentation/` must show only header and doc-comment changes (no case lines added or removed: `git diff lib/features/marine_life/presentation/ | grep "^[-+] 'sp_" | wc -l` prints 0). +- [ ] **Step 4: Run** the coverage test (3 of 4 pass; the version test fails as expected), `test/features/marine_life/presentation/`, and `flutter analyze`. +- [ ] **Step 5: Commit** `chore(marine-life): generate the species lookup switches from the asset`. + +--- + +### Task 7: ARB key tool and freshwater generator (network client injected) + +**Files:** +- Create: `tool/generate_species_arb_keys.dart` +- Create: `tool/generate_freshwater_species.dart` +- Create: `tool/src/inaturalist_names_client.dart` + +`generate_species_arb_keys.dart`: + +```dart +// Inserts species name and description keys for new catalog rows into every +// ARB file, from the localized file the freshwater generator writes. +// +// dart run tool/generate_species_arb_keys.dart [tool/data/freshwater_species_localized.json] +// +// No network. Follow with: flutter gen-l10n +import 'dart:convert'; +import 'dart:io'; + +import 'src/species_tool_support.dart'; + +Future main(List args) async { + final source = args.isEmpty + ? 'tool/data/freshwater_species_localized.json' + : args.first; + final localized = + (jsonDecode(await File(source).readAsString()) as List) + .cast>(); + + for (final locale in appLocales) { + final path = 'lib/l10n/arb/app_$locale.arb'; + final entries = [ + for (final row in localized) + ArbSpeciesEntry( + id: row['id'] as String, + name: (row['names'] as Map)[locale] as String, + description: + (row['descriptions'] as Map)[locale] as String, + ), + ]; + final text = await File(path).readAsString(); + final updated = insertSpeciesArbKeys(text, entries); + jsonDecode(updated); // refuse to write anything that does not parse + await File(path).writeAsString(updated); + stdout.writeln('$locale: added ${entries.length * 2} keys'); + } +} +``` + +`tool/src/inaturalist_names_client.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +const String inaturalistUserAgent = 'Submersion/1.0 (https://submersion.app)'; + +/// Resolves a scientific name to its iNaturalist taxon with every name +/// attached (`all_names=true`). One request per species; polite pacing. +class InaturalistNamesClient { + InaturalistNamesClient({HttpClient? client}) : _client = client ?? HttpClient(); + + final HttpClient _client; + + Future?> taxonByScientificName( + String scientificName, { + int? taxonId, + }) async { + final uri = taxonId != null + ? Uri.parse('https://api.inaturalist.org/v1/taxa/$taxonId?all_names=true') + : Uri.parse( + 'https://api.inaturalist.org/v1/taxa?q=${Uri.encodeQueryComponent(scientificName)}' + '&rank=species&all_names=true&per_page=5', + ); + for (var attempt = 1; attempt <= 3; attempt++) { + final request = await _client.getUrl(uri); + request.headers.set(HttpHeaders.userAgentHeader, inaturalistUserAgent); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + if (response.statusCode == 200) { + final results = ((jsonDecode(body) as Map)['results'] as List) + .cast>(); + for (final r in results) { + if (taxonId != null || r['name'] == scientificName) return r; + } + return null; + } + await Future.delayed(Duration(seconds: 2 * attempt)); + } + throw HttpException('iNaturalist failed for $scientificName'); + } + + void close() => _client.close(); +} +``` + +`generate_freshwater_species.dart`: + +```dart +// Turns tool/data/freshwater_species_seed.json into catalog rows. +// +// dart run tool/generate_freshwater_species.dart +// +// Network (iNaturalist, one request per species, one per second). Writes +// assets/data/species.json (appended rows, version bumped) and +// tool/data/freshwater_species_localized.json, then prints the follow-ups. +import 'dart:convert'; +import 'dart:io'; + +import 'src/inaturalist_names_client.dart'; +import 'src/species_tool_support.dart'; + +Future main() async { + final seed = (jsonDecode( + await File('tool/data/freshwater_species_seed.json').readAsString(), + ) as List).cast>(); + final catalogFile = File('assets/data/species.json'); + final catalog = jsonDecode(await catalogFile.readAsString()) as Map; + final rows = (catalog['species'] as List).cast>(); + final existingIds = rows.map((r) => r['id']).toSet(); + final existingSci = rows.map((r) => r['scientificName']).whereType().toSet(); + + for (final entry in seed) { + final id = entry['id'] as String; + speciesSlug(id); + if (existingIds.contains(id)) throw StateError('$id already in the catalog'); + if (existingSci.contains(entry['scientificName'])) { + throw StateError('${entry['scientificName']} already in the catalog'); + } + final descriptions = entry['description'] as Map; + for (final locale in appLocales) { + if (descriptions[locale] is! String) { + throw StateError('$id lacks a $locale description'); + } + } + } + + final client = InaturalistNamesClient(); + final localized = >[]; + final added = >[]; + try { + for (final entry in seed) { + final scientific = entry['scientificName'] as String; + final taxon = await client.taxonByScientificName( + scientific, + taxonId: entry['inaturalistTaxonId'] as int?, + ); + if (taxon == null) stderr.writeln('No iNaturalist taxon for $scientific'); + final names = localizedNamesFromTaxon( + taxon ?? const {}, + englishFallback: entry['commonName'] as String, + ); + added.add({ + 'id': entry['id'], + 'commonName': names['en'], + 'scientificName': scientific, + 'category': entry['category'], + 'taxonomyClass': entry['taxonomyClass'], + 'description': (entry['description'] as Map)['en'], + }); + localized.add({ + 'id': entry['id'], + 'names': names, + 'descriptions': entry['description'], + }); + stdout.writeln('${entry['id']}: ${names['en']} (${taxon?['id'] ?? 'no taxon'})'); + await Future.delayed(const Duration(seconds: 1)); + } + } finally { + client.close(); + } + + const encoder = JsonEncoder.withIndent(' '); + await catalogFile.writeAsString( + '${encoder.convert({'version': (catalog['version'] as int? ?? 1) + 1, 'species': [...rows, ...added]})}\n', + ); + await File('tool/data/freshwater_species_localized.json').writeAsString( + '${encoder.convert(localized)}\n', + ); + stdout.writeln('Added ${added.length} rows. Now run:'); + stdout.writeln(' dart run tool/generate_species_arb_keys.dart'); + stdout.writeln(' flutter gen-l10n'); + stdout.writeln(' dart run tool/generate_species_lookups.dart'); + stdout.writeln(' dart run tool/generate_species_gbif_keys.dart'); +} +``` + +- [ ] **Step 1: Write the files**, `flutter analyze tool`, and a dry run of the ARB tool against a throwaway copy: `cp lib/l10n/arb/app_en.arb /tmp/x.arb` is not needed; instead run `dart run tool/generate_species_arb_keys.dart test/fixtures/species_localized_sample.json` only after Task 11 produces real input. For now the analyzer is the check. +- [ ] **Step 2: Commit** `chore(tool): add the ARB key and freshwater species generators`. + +--- + +### Task 8: Freshwater seed, part 1 (about 50 North American and European fish) + +**Files:** +- Create: `tool/data/freshwater_species_seed.json` (a JSON array) + +Each entry has `id`, `scientificName`, `category`, `taxonomyClass`, `commonName`, `description` with all 11 locales. `inaturalistTaxonId` is optional and omitted (the tool resolves by scientific name). Descriptions are one sentence, 10 to 20 words, about what a diver sees (shape, markings, habitat, behaviour), written natively in each language, not word-for-word. + +Species (ids follow the English name): northern pike, muskellunge, chain pickerel, walleye, sauger, yellow perch, European perch, zander, ruffe, largemouth bass, smallmouth bass, rock bass, bluegill, pumpkinseed, black crappie, white crappie, brown trout, rainbow trout, brook trout, lake trout, Arctic char, Atlantic salmon, chinook salmon, sockeye salmon, coho salmon, kokanee, lake whitefish, cisco, European grayling, common carp, grass carp, tench, common bream, roach, rudd, chub, barbel, European eel, American eel, burbot, channel catfish, flathead catfish, brown bullhead, wels catfish, white sturgeon, lake sturgeon, European sturgeon, alligator gar, longnose gar, bowfin, American paddlefish, sea lamprey, freshwater drum, white sucker, common minnow, three-spined stickleback, alewife. + +- [ ] **Step 1: Write the entries** (a python script in the scratchpad that assembles the list and dumps it with `ensure_ascii=False, indent=2`). +- [ ] **Step 2: Validate**: a scratchpad check that ids are unique, match the pattern, every entry has all 11 description locales as non-empty strings, and no scientific name already appears in `assets/data/species.json`. +- [ ] **Step 3: Commit** `data(marine-life): curate freshwater fish of North America and Europe`. + +### Task 9: Freshwater seed, part 2 (about 50 fish from Africa, South America, Asia, Australia) + +Species: Nile perch, Nile tilapia, African tigerfish, marbled lungfish, electric catfish, Lake Malawi cichlids (blue mbuna `Pseudotropheus zebra`... choose clearly valid names: `Maylandia zebra`, `Aulonocara jacobfreibergi`, `Labeotropheus fuelleborni`), Tanganyika: `Neolamprologus brichardi`, `Cyphotilapia frontosa`, `Tropheus moorii`; Amazon: arapaima, silver arowana, red-bellied piranha, black piranha, pacu (`Piaractus brachypomus`), tambaqui, electric eel, ocellate river stingray (`Potamotrygon motoro`), redtail catfish, tiger shovelnose catfish, peacock bass (`Cichla ocellaris`), oscar, angelfish (`Pterophyllum scalare`), discus, armored catfish (`Pterygoplichthys pardalis`), cardinal tetra, Mexican tetra (blind cave form), Asian: Mekong giant catfish, giant barb (`Catlocarpio siamensis`), Asian arowana, snakehead (`Channa striata`), giant snakehead, climbing perch, mahseer (`Tor putitora`), koi (`Cyprinus rubrofuscus`), goldfish, giant gourami, clown knifefish, walking catfish, Japanese eel, ayu, Baikal omul, Baikal oilfish (golomyanka `Comephorus baikalensis`), Australian: Murray cod, golden perch, Australian bass, barramundi, silver perch, saratoga (`Scleropages jardinii`), sooty grunter, freshwater sawfish (`Pristis pristis`), bull shark (skip if already marine; check), eel-tailed catfish, spangled perch, rainbowfish (`Melanotaenia splendida`). + +- [ ] Steps as Task 8 (write, validate, commit `data(marine-life): curate freshwater fish of Africa, the Americas, Asia and Australia`). + +### Task 10: Freshwater seed, part 3 (about 50 non-fish: invertebrates, reptiles, amphibians, mammals, plants) + +Species: signal crayfish, red swamp crayfish, noble crayfish, white-clawed crayfish, Tasmanian giant freshwater crayfish, zebra mussel, quagga mussel, freshwater pearl mussel, Chinese pond mussel, swan mussel, freshwater sponge (`Spongilla lacustris`), freshwater jellyfish (`Craspedacusta sowerbii`), great pond snail, ramshorn snail, apple snail (`Pomacea canaliculata`), freshwater bryozoan (`Pectinatella magnifica`), Chinese mitten crab, giant freshwater prawn, water boatman? (skip insects); reptiles: common snapping turtle, alligator snapping turtle, painted turtle, red-eared slider, common map turtle, spiny softshell, Florida softshell, pig-nosed turtle, Mary River turtle, yellow-spotted river turtle, European pond turtle, American alligator, spectacled caiman, black caiman, freshwater crocodile (`Crocodylus johnstoni`), Nile crocodile (check not marine-listed), northern water snake, anaconda (`Eunectes murinus`); amphibians (`other`, class Amphibia): hellbender, mudpuppy, axolotl, Chinese giant salamander, smooth newt, great crested newt, American bullfrog, common frog; mammals: North American river otter, Eurasian otter, giant otter, North American beaver, Eurasian beaver, muskrat, platypus, Florida manatee (`Trichechus manatus latirostris` if the marine catalog lacks it, else skip), Amazonian manatee, Amazon river dolphin, Baikal seal, capybara, hippopotamus; plants: white water lily, yellow pond lily, spatterdock, American eelgrass (`Vallisneria americana`), coontail, Eurasian watermilfoil, muskgrass (`Chara vulgaris`), Canadian waterweed, curly-leaf pondweed, water hyacinth, hornwort, common reed. + +Every mammal, reptile and amphibian gets the right `taxonomyClass` (`Mammalia`, `Reptilia`, `Amphibia`); invertebrates `Malacostraca`, `Bivalvia`, `Gastropoda`, `Demospongiae`, `Hydrozoa`, `Phylactolaemata`; plants `Magnoliopsida`/`Liliopsida`/`Charophyceae`. + +- [ ] Steps as Task 8 (commit `data(marine-life): curate freshwater invertebrates, reptiles, amphibians, mammals and plants`). Before writing, `grep -i "manatee\|crocodile\|otter\|hippo\|bull shark\|sawfish" assets/data/species.json` and drop anything already present. + +--- + +### Task 11: Generate the catalog (network) and refresh every artifact + +- [ ] **Step 1**: `dart run tool/generate_freshwater_species.dart > scratch/freshwater_gen.log 2>&1` (about 3 minutes). Read the log: every line names a taxon id; "No iNaturalist taxon" lines mean a scientific name iNat spells differently; fix the seed (or add `inaturalistTaxonId`) and rerun after `git checkout assets/data/species.json`. +- [ ] **Step 2**: `dart run tool/generate_species_arb_keys.dart`, `flutter gen-l10n`, `dart run tool/generate_species_lookups.dart`. +- [ ] **Step 3**: extend `_straddlingTaxa` in `tool/generate_species_gbif_keys.dart` with the freshwater families that hold terrestrial relatives, each with a one-line comment: `Cricetidae` (muskrat, with voles and hamsters), `Emydidae` (pond turtles, with box turtles), `Colubridae` (water snakes, with most land snakes), `Salamandridae` (newts, with land salamanders), `Ambystomatidae` (axolotl, with mole salamanders), `Rodentia` (beaver and capybara, with every rat and squirrel), `Caviidae` (capybara, with guinea pigs), `Boidae` (anaconda, with boas), `Haloragaceae` (milfoil, with land Gunnera relatives), `Poaceae` (common reed, with every grass). Then `dart run tool/generate_species_gbif_keys.dart > scratch/gbif_gen.log 2>&1` and read the unmatched list; tighten `species_gbif_keys_asset_test.dart:27` to `0.9`. +- [ ] **Step 4**: run `test/features/marine_life/presentation/species_lookup_coverage_test.dart`, `test/l10n/arb_parity_test.dart`, `test/features/reef/domain/services/species_gbif_keys_asset_test.dart`, `test/features/marine_life/`, `test/features/reef/`; then `flutter analyze --fatal-infos`. +- [ ] **Step 5**: Commit in two parts: `data(marine-life): add the freshwater species to the bundled catalog (v2)` (asset, ARBs, generated l10n, lookups, localized json) and `chore(reef): refresh GBIF keys for the freshwater catalog` (tool change, asset, test threshold). + +--- + +### Task 12: Docs, format, full suite + +- [ ] `docs/features/marine-life.md`: under "## Species Database" add a sentence that the catalog covers freshwater species (lakes, rivers, springs and cenotes) and that catalog corrections reach existing installs without touching species the diver edited; add a new section before "## Identification Tips": + +``` +## Growing the Catalog (maintainers) + +The bundled catalog is `assets/data/species.json`; its `version` gates a +one-time upgrade pass on each device that rewrites rows the diver never +edited. To add species: + +1. Add entries to `tool/data/freshwater_species_seed.json` (or a sibling seed) + with descriptions in all 11 locales. +2. `dart run tool/generate_freshwater_species.dart` (network) writes the + catalog rows and the localized names file and bumps the version. +3. `dart run tool/generate_species_arb_keys.dart`, `flutter gen-l10n`, + `dart run tool/generate_species_lookups.dart`, + `dart run tool/generate_species_gbif_keys.dart` (network). +4. Run `test/features/marine_life/presentation/species_lookup_coverage_test.dart`. + +Suggestions filed through the in-app "Suggest for the catalog" action arrive +as GitHub issues labelled `species-suggestion`; they land in the seed the same +way. +``` + +- [ ] `dart format .`, `flutter analyze --fatal-infos`, full `flutter test` detached with the exit line checked, commit `docs: describe the versioned species catalog and its tools`. Do not push. + +## Self-review notes + +- Spec 4.1: Tasks 1 to 4. Spec 4.2: Tasks 5 to 7 (generators, coverage test). Spec 4.3: Tasks 8 to 11. Spec 4.4 error handling: Task 2 (malformed prefs), Task 4 (clear failure logged), Task 7 (retries, validate-before-write, refuse existing keys). Spec 4.5 tests: Tasks 1 to 6 and 11. Docs: Task 12. +- The spec listed `tool/data/freshwater_species_localized.json` as generated output; Task 7 writes it and Task 11 commits it. +- `seedBuiltInSpecies` takes the store per call rather than through the constructor so the dozens of `SpeciesRepository()` call sites and tests stay untouched; the spec's "optional constructor parameter" wording is superseded by this. diff --git a/docs/superpowers/plans/2026-08-27-species-online-lookup.md b/docs/superpowers/plans/2026-08-27-species-online-lookup.md new file mode 100644 index 0000000000..7271295921 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-species-online-lookup.md @@ -0,0 +1,3090 @@ +# Species Online Lookup and Catalog Suggestions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a diver adds a species, let them look it up on iNaturalist so the custom species gets a localized common name, scientific name, taxonomy class and category; and let them suggest a custom species for the bundled catalog through a prefilled GitHub issue. + +**Architecture:** A small `http`-backed `INaturalistSpeciesLookupService` (autocomplete, then one taxon fetch on selection) behind an abstract `SpeciesLookupService` so widgets are tested with a fake; a pure category mapper over the taxon's named ancestry; one modal `SpeciesLookupSheet` reused by three entry points (species edit page, dive species picker, reef tier's unmatched chips); a pure suggestion-URL builder and a launcher with the Settings page's clipboard fallback. No schema change, nothing stored from iNaturalist. + +**Tech Stack:** Flutter, Riverpod, `package:http` (+ `package:http/testing.dart` `MockClient`), `url_launcher`, `flutter gen-l10n`, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-08-26-species-online-lookup-design.md` + +## Global Constraints + +- Work only in `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup` on branch `worktree-species-online-lookup` (cut from `origin/main` dd6b435f62a, codegen already run). Paths are relative to that root; `flutter analyze` prints `Analyzing species-online-lookup...` when it runs in the right tree. +- **No schema change**, and nothing from iNaturalist is stored: no taxon ids, no photo URLs, no summaries. Photos are shown in lookup results only, and only when `license_code` is non-null, with their attribution. +- **Explicit lookups only**: a request is sent when the diver taps "Look up" (or submits the field). Never per keystroke. No background retries. +- Source endpoints: `https://api.inaturalist.org/v1/taxa/autocomplete` and `https://api.inaturalist.org/v1/taxa/{id}`, user agent `Submersion/1.0 (https://submersion.app)`, 10 second timeout. +- The contribution channel is a prefilled GitHub issue URL opened in the external browser; the app sends nothing itself. +- No em-dashes anywhere; no emojis in code or docs. Immutable `Equatable` entities with `copyWith`. Imports grouped dart, flutter, packages, local. Files under 800 lines. +- Tests first; run single files with `flutter test ` and check the exit code, never through a pipe. +- Localization: every user-visible string is an ARB key in all 11 locales (`ar de en es fr he hu it nl pt zh`); regenerated `lib/l10n/arb/app_localizations*.dart` files are committed with the ARB change. The GitHub issue title and body stay English. +- `dart format .` before every commit. +- Fixtures `test/fixtures/inaturalist/autocomplete_whale_shark_de.json` and `taxon_52188_de.json` are real API responses captured on 2026-08-26 and are already in the worktree (untracked; the first task commits them). + +--- + +### Task 1: Lookup entities and iNaturalist response parsers + +**Files:** +- Create: `lib/features/marine_life/domain/entities/species_lookup.dart` +- Create: `lib/features/marine_life/data/services/inaturalist_parsers.dart` +- Commit: `test/fixtures/inaturalist/autocomplete_whale_shark_de.json`, `test/fixtures/inaturalist/taxon_52188_de.json` +- Test: `test/features/marine_life/data/services/inaturalist_parsers_test.dart` + +**Interfaces:** +- Produces: + ```dart + class SpeciesLookupPhoto { squareUrl, attribution } + class SpeciesLookupHit { taxonId, scientificName, rank, rankLevel, commonName?, matchedTerm?, observationCount, photo?, bool get isResolvable } + class TaxonAncestor { rank, name } + class TaxonDetail { taxonId, scientificName, commonName?, rank, ancestors } + class SpeciesLookupResult { taxonId, commonName, scientificName, category, taxonomyClass? } + enum SpeciesLookupErrorKind { offline, timeout, server, malformed } + class SpeciesLookupException implements Exception { kind, detail? } + List parseAutocomplete(String body); + TaxonDetail parseTaxonDetail(String body); + ``` + +Background (verified against the fixtures): autocomplete `results[]` carry `id`, `name` (scientific), `rank`, `rank_level` (species = 10; anything above species has a larger level), `preferred_common_name` (localized by the `locale` query parameter; "Walhai" under `de`), `english_common_name`, `matched_term`, `observations_count`, `is_active`, and `default_photo` with `license_code` (null means all rights reserved), `attribution` and `square_url`. The taxon endpoint returns `results[0].ancestors[]` with `rank` and `name`. + +- [ ] **Step 1: Write the failing parser tests** + +`test/features/marine_life/data/services/inaturalist_parsers_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/marine_life/data/services/inaturalist_parsers.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; + +String _fixture(String name) => + File('test/fixtures/inaturalist/$name').readAsStringSync(); + +void main() { + group('parseAutocomplete', () { + test('maps the whale shark hit with its German common name', () { + final hits = parseAutocomplete( + _fixture('autocomplete_whale_shark_de.json'), + ); + + final hit = hits.single; + expect(hit.taxonId, 52188); + expect(hit.scientificName, 'Rhincodon typus'); + expect(hit.rank, 'species'); + expect(hit.commonName, 'Walhai'); + expect(hit.matchedTerm, 'Whale Shark'); + expect(hit.observationCount, greaterThan(0)); + expect(hit.isResolvable, isTrue); + }); + + test('keeps a licensed photo with its attribution', () { + final hit = parseAutocomplete( + _fixture('autocomplete_whale_shark_de.json'), + ).single; + + expect(hit.photo, isNotNull); + expect(hit.photo!.squareUrl, startsWith('https://')); + expect(hit.photo!.attribution, contains('CC BY-NC')); + }); + + test('drops an unlicensed photo and falls back to the English name', () { + const body = ''' + {"results": [{ + "id": 1, "name": "Genus one", "rank": "genus", "rank_level": 20, + "english_common_name": "Fallback", "observations_count": 3, + "default_photo": {"license_code": null, "attribution": "(c) x", + "square_url": "https://x/1.jpg"} + }]}'''; + + final hit = parseAutocomplete(body).single; + + expect(hit.photo, isNull); + expect(hit.commonName, 'Fallback'); + expect(hit.isResolvable, isFalse); + }); + + test('tolerates a hit without any common name or photo', () { + const body = + '{"results": [{"id": 2, "name": "Nomen nudum", "rank": "species", ' + '"rank_level": 10, "observations_count": 0}]}'; + + final hit = parseAutocomplete(body).single; + + expect(hit.commonName, isNull); + expect(hit.photo, isNull); + expect(hit.observationCount, 0); + }); + + test('throws a malformed exception for a body that is not JSON', () { + expect( + () => parseAutocomplete(''), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + SpeciesLookupErrorKind.malformed, + ), + ), + ); + }); + }); + + group('parseTaxonDetail', () { + test('reads the ancestry in order with ranks', () { + final detail = parseTaxonDetail(_fixture('taxon_52188_de.json')); + + expect(detail.taxonId, 52188); + expect(detail.scientificName, 'Rhincodon typus'); + expect(detail.commonName, 'Walhai'); + expect(detail.rank, 'species'); + expect(detail.ancestors.first, const TaxonAncestor('kingdom', 'Animalia')); + expect( + detail.ancestors, + contains(const TaxonAncestor('class', 'Chondrichthyes')), + ); + expect(detail.ancestors.last.rank, 'genus'); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/marine_life/data/services/inaturalist_parsers_test.dart` +Expected: compilation errors, both source files missing. + +- [ ] **Step 3: Write the entities and parsers** + +`lib/features/marine_life/domain/entities/species_lookup.dart`: + +```dart +import 'package:equatable/equatable.dart'; + +import 'package:submersion/core/constants/enums.dart'; + +/// A licensed photo shown next to a lookup hit, never stored. +class SpeciesLookupPhoto extends Equatable { + final String squareUrl; + final String attribution; + + const SpeciesLookupPhoto({required this.squareUrl, required this.attribution}); + + @override + List get props => [squareUrl, attribution]; +} + +/// One autocomplete result. +class SpeciesLookupHit extends Equatable { + final int taxonId; + final String scientificName; + final String rank; + + /// iNaturalist's numeric rank; species is 10, coarser ranks are larger. + final int rankLevel; + + /// The common name in the requested locale, else English, else null. + final String? commonName; + final String? matchedTerm; + final int observationCount; + final SpeciesLookupPhoto? photo; + + const SpeciesLookupHit({ + required this.taxonId, + required this.scientificName, + required this.rank, + required this.rankLevel, + this.commonName, + this.matchedTerm, + required this.observationCount, + this.photo, + }); + + /// Only species-rank (or finer) hits become a species row. + bool get isResolvable => rankLevel <= 10; + + @override + List get props => [ + taxonId, + scientificName, + rank, + rankLevel, + commonName, + matchedTerm, + observationCount, + photo, + ]; +} + +class TaxonAncestor extends Equatable { + final String rank; + final String name; + + const TaxonAncestor(this.rank, this.name); + + @override + List get props => [rank, name]; +} + +/// The taxon endpoint's answer for one hit: what the category mapper and +/// the species row need. +class TaxonDetail extends Equatable { + final int taxonId; + final String scientificName; + final String? commonName; + final String rank; + final List ancestors; + + const TaxonDetail({ + required this.taxonId, + required this.scientificName, + this.commonName, + required this.rank, + required this.ancestors, + }); + + @override + List get props => [taxonId, scientificName, commonName, rank, ancestors]; +} + +/// What a lookup hands back to the caller that creates the species. +class SpeciesLookupResult extends Equatable { + final int taxonId; + final String commonName; + final String scientificName; + final SpeciesCategory category; + final String? taxonomyClass; + + const SpeciesLookupResult({ + required this.taxonId, + required this.commonName, + required this.scientificName, + required this.category, + this.taxonomyClass, + }); + + SpeciesLookupResult copyWith({ + int? taxonId, + String? commonName, + String? scientificName, + SpeciesCategory? category, + String? taxonomyClass, + }) { + return SpeciesLookupResult( + taxonId: taxonId ?? this.taxonId, + commonName: commonName ?? this.commonName, + scientificName: scientificName ?? this.scientificName, + category: category ?? this.category, + taxonomyClass: taxonomyClass ?? this.taxonomyClass, + ); + } + + @override + List get props => [ + taxonId, + commonName, + scientificName, + category, + taxonomyClass, + ]; +} + +enum SpeciesLookupErrorKind { offline, timeout, server, malformed } + +/// One message per kind in the sheet; nothing retries silently. +class SpeciesLookupException implements Exception { + final SpeciesLookupErrorKind kind; + final String? detail; + + const SpeciesLookupException(this.kind, [this.detail]); + + @override + String toString() => 'SpeciesLookupException(${kind.name}' + '${detail == null ? '' : ': $detail'})'; +} +``` + +`lib/features/marine_life/data/services/inaturalist_parsers.dart`: + +```dart +import 'dart:convert'; + +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; + +/// Parses `GET /v1/taxa/autocomplete`. Every field the UI shows is read +/// leniently: a hit missing a common name or photo is still a hit. +List parseAutocomplete(String body) { + final results = _results(body); + return [for (final r in results) _hit(r)]; +} + +/// Parses `GET /v1/taxa/{id}`: the first result with its ancestry. +TaxonDetail parseTaxonDetail(String body) { + final results = _results(body); + if (results.isEmpty) { + throw const SpeciesLookupException( + SpeciesLookupErrorKind.malformed, + 'taxon response has no results', + ); + } + final r = results.first; + final ancestors = [ + for (final a in (r['ancestors'] as List?) ?? const []) + if (a is Map && a['rank'] is String && a['name'] is String) + TaxonAncestor(a['rank'] as String, a['name'] as String), + ]; + return TaxonDetail( + taxonId: _int(r['id']), + scientificName: r['name'] as String, + commonName: _commonName(r), + rank: r['rank'] as String, + ancestors: ancestors, + ); +} + +List> _results(String body) { + try { + final decoded = jsonDecode(body); + final results = (decoded as Map)['results'] as List?; + return [ + for (final r in results ?? const []) + if (r is Map) r, + ]; + } on FormatException catch (e) { + throw SpeciesLookupException(SpeciesLookupErrorKind.malformed, e.message); + } on TypeError catch (e) { + throw SpeciesLookupException( + SpeciesLookupErrorKind.malformed, + e.toString(), + ); + } +} + +SpeciesLookupHit _hit(Map r) { + try { + return SpeciesLookupHit( + taxonId: _int(r['id']), + scientificName: r['name'] as String, + rank: r['rank'] as String, + rankLevel: _int(r['rank_level']), + commonName: _commonName(r), + matchedTerm: r['matched_term'] as String?, + observationCount: _int(r['observations_count'] ?? 0), + photo: _photo(r['default_photo']), + ); + } on TypeError catch (e) { + throw SpeciesLookupException( + SpeciesLookupErrorKind.malformed, + e.toString(), + ); + } +} + +String? _commonName(Map r) { + final preferred = r['preferred_common_name'] as String?; + if (preferred != null && preferred.isNotEmpty) return preferred; + final english = r['english_common_name'] as String?; + if (english != null && english.isNotEmpty) return english; + return null; +} + +/// Only a photo with a licence is shown; null `license_code` means all +/// rights reserved. +SpeciesLookupPhoto? _photo(Object? raw) { + if (raw is! Map) return null; + if (raw['license_code'] == null) return null; + final url = raw['square_url'] as String?; + if (url == null) return null; + return SpeciesLookupPhoto( + squareUrl: url, + attribution: (raw['attribution'] as String?) ?? '', + ); +} + +int _int(Object? value) => switch (value) { + final int i => i, + final double d => d.round(), + final String s => int.parse(s), + _ => throw TypeError(), +}; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/data/services/inaturalist_parsers_test.dart` +Expected: `All tests passed!` (6 tests), exit code 0. + +- [ ] **Step 5: Format and commit (fixtures included)** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/marine_life/domain/entities/species_lookup.dart lib/features/marine_life/data/services/inaturalist_parsers.dart test/features/marine_life/data/services/inaturalist_parsers_test.dart +git add lib/features/marine_life/domain/entities/species_lookup.dart lib/features/marine_life/data/services/inaturalist_parsers.dart test/features/marine_life/data/services/inaturalist_parsers_test.dart test/fixtures/inaturalist/ +git commit -m "feat(marine-life): add iNaturalist lookup entities and response parsers" +``` + +--- + +### Task 2: Category mapper from taxon ancestry + +**Files:** +- Create: `lib/features/marine_life/domain/services/species_category_mapper.dart` +- Test: `test/features/marine_life/domain/services/species_category_mapper_test.dart` + +**Interfaces:** +- Consumes: `TaxonAncestor` (Task 1), `SpeciesCategory`. +- Produces: `SpeciesCategory speciesCategoryFromAncestry(List ancestors)`. + +- [ ] **Step 1: Write the failing mapper test** + +`test/features/marine_life/domain/services/species_category_mapper_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/domain/services/species_category_mapper.dart'; + +List _animal(List<(String, String)> rest) => [ + const TaxonAncestor('kingdom', 'Animalia'), + const TaxonAncestor('phylum', 'Chordata'), + for (final (rank, name) in rest) TaxonAncestor(rank, name), +]; + +void main() { + test('whale shark: Chondrichthyes without a batoid order is a shark', () { + final ancestry = _animal([ + ('class', 'Chondrichthyes'), + ('subclass', 'Elasmobranchii'), + ('order', 'Orectolobiformes'), + ]); + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.shark); + }); + + test('manta ray: Chondrichthyes with Myliobatiformes is a ray', () { + final ancestry = _animal([ + ('class', 'Chondrichthyes'), + ('order', 'Myliobatiformes'), + ]); + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.ray); + }); + + test('clownfish: Actinopterygii is a fish', () { + final ancestry = _animal([('class', 'Actinopterygii')]); + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.fish); + }); + + test('green sea turtle: Testudines is a turtle', () { + final ancestry = _animal([ + ('class', 'Reptilia'), + ('order', 'Testudines'), + ]); + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.turtle); + }); + + test('dolphin: Mammalia is a mammal', () { + final ancestry = _animal([('class', 'Mammalia')]); + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.mammal); + }); + + test('staghorn coral: Anthozoa is coral', () { + const ancestry = [ + TaxonAncestor('kingdom', 'Animalia'), + TaxonAncestor('phylum', 'Cnidaria'), + TaxonAncestor('class', 'Anthozoa'), + TaxonAncestor('order', 'Scleractinia'), + ]; + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.coral); + }); + + test('magnificent anemone: Anthozoa with Actiniaria is an invertebrate', () { + const ancestry = [ + TaxonAncestor('kingdom', 'Animalia'), + TaxonAncestor('class', 'Anthozoa'), + TaxonAncestor('order', 'Actiniaria'), + ]; + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.invertebrate); + }); + + test('giant kelp (Chromista) and seagrass (Plantae) are plants', () { + expect( + speciesCategoryFromAncestry(const [TaxonAncestor('kingdom', 'Chromista')]), + SpeciesCategory.plant, + ); + expect( + speciesCategoryFromAncestry(const [TaxonAncestor('kingdom', 'Plantae')]), + SpeciesCategory.plant, + ); + }); + + test('sea snake: non-turtle Reptilia is other', () { + final ancestry = _animal([('class', 'Reptilia'), ('order', 'Squamata')]); + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.other); + }); + + test('nudibranch: any other animal is an invertebrate', () { + const ancestry = [ + TaxonAncestor('kingdom', 'Animalia'), + TaxonAncestor('phylum', 'Mollusca'), + TaxonAncestor('class', 'Gastropoda'), + ]; + expect(speciesCategoryFromAncestry(ancestry), SpeciesCategory.invertebrate); + }); + + test('unknown ancestry is other', () { + expect(speciesCategoryFromAncestry(const []), SpeciesCategory.other); + expect( + speciesCategoryFromAncestry(const [TaxonAncestor('kingdom', 'Fungi')]), + SpeciesCategory.other, + ); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/domain/services/species_category_mapper_test.dart` +Expected: compilation error, `species_category_mapper.dart` not found. + +- [ ] **Step 3: Write the mapper** + +`lib/features/marine_life/domain/services/species_category_mapper.dart`: + +```dart +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; + +/// Batoid orders under Chondrichthyes; everything else in the class is a +/// shark (or a chimaera, which divers file with the sharks). +const Set _batoidOrders = { + 'Rajiformes', + 'Myliobatiformes', + 'Torpediniformes', + 'Rhinopristiformes', +}; + +/// Maps a taxon's named ancestry to the app's category. First rule wins. +/// +/// iNaturalist's `iconic_taxon_name` is too coarse (a shark is "Animalia"), +/// which is why the caller fetches the taxon's ancestors before mapping. +SpeciesCategory speciesCategoryFromAncestry(List ancestors) { + bool has(String rank, String name) => + ancestors.any((a) => a.rank == rank && a.name == name); + bool hasName(String name) => ancestors.any((a) => a.name == name); + + if (has('class', 'Chondrichthyes')) { + return _batoidOrders.any(hasName) ? SpeciesCategory.ray : SpeciesCategory.shark; + } + if (has('class', 'Actinopterygii')) return SpeciesCategory.fish; + if (has('class', 'Mammalia')) return SpeciesCategory.mammal; + if (has('order', 'Testudines')) return SpeciesCategory.turtle; + if (has('class', 'Anthozoa')) { + return has('order', 'Actiniaria') + ? SpeciesCategory.invertebrate + : SpeciesCategory.coral; + } + if (has('kingdom', 'Plantae') || has('kingdom', 'Chromista')) { + return SpeciesCategory.plant; + } + if (has('class', 'Aves') || has('class', 'Reptilia') || has('class', 'Amphibia')) { + return SpeciesCategory.other; + } + if (has('kingdom', 'Animalia')) return SpeciesCategory.invertebrate; + return SpeciesCategory.other; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/domain/services/species_category_mapper_test.dart` +Expected: `All tests passed!` (11 tests), exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/marine_life/domain/services test/features/marine_life/domain/services +git add lib/features/marine_life/domain/services/species_category_mapper.dart test/features/marine_life/domain/services/species_category_mapper_test.dart +git commit -m "feat(marine-life): map a taxon's ancestry to a species category" +``` + +--- + +### Task 3: `INaturalistSpeciesLookupService` + +**Files:** +- Create: `lib/features/marine_life/data/services/species_lookup_service.dart` (the abstract interface) +- Create: `lib/features/marine_life/data/services/inaturalist_species_lookup_service.dart` +- Test: `test/features/marine_life/data/services/inaturalist_species_lookup_service_test.dart` + +**Interfaces:** +- Consumes: Tasks 1 and 2; `package:http`. +- Produces: + ```dart + abstract class SpeciesLookupService { + Future> search(String query, {required String locale}); + Future resolve(int taxonId, {required String locale}); + } + class INaturalistSpeciesLookupService implements SpeciesLookupService { + INaturalistSpeciesLookupService({http.Client? client, Duration timeout = const Duration(seconds: 10)}); + } + ``` + +Background: the GBIF service (`lib/features/reef/data/services/nearby_species_service.dart`) is the network pattern to follow: optional `http.Client` injection, `Uri.https`, a `User-Agent` header. It swallows errors into a result wrapper; this service throws `SpeciesLookupException` instead because the sheet renders one message per failure kind. + +- [ ] **Step 1: Write the failing service test** + +`test/features/marine_life/data/services/inaturalist_species_lookup_service_test.dart`: + +```dart +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/data/services/inaturalist_species_lookup_service.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; + +String _fixture(String name) => + File('test/fixtures/inaturalist/$name').readAsStringSync(); + +void main() { + test('search hits the autocomplete endpoint with query, locale and agent', + () async { + late http.Request captured; + final client = MockClient((request) async { + captured = request; + return http.Response(_fixture('autocomplete_whale_shark_de.json'), 200); + }); + final service = INaturalistSpeciesLookupService(client: client); + + final hits = await service.search('whale shark', locale: 'de'); + + expect(captured.url.host, 'api.inaturalist.org'); + expect(captured.url.path, '/v1/taxa/autocomplete'); + expect(captured.url.queryParameters['q'], 'whale shark'); + expect(captured.url.queryParameters['locale'], 'de'); + expect(captured.url.queryParameters['per_page'], '10'); + expect(captured.url.queryParameters['is_active'], 'true'); + expect(captured.headers['user-agent'], contains('Submersion')); + expect(hits.single.commonName, 'Walhai'); + }); + + test('resolve fetches the taxon and maps category and class', () async { + late http.Request captured; + final client = MockClient((request) async { + captured = request; + return http.Response(_fixture('taxon_52188_de.json'), 200); + }); + final service = INaturalistSpeciesLookupService(client: client); + + final result = await service.resolve(52188, locale: 'de'); + + expect(captured.url.path, '/v1/taxa/52188'); + expect(captured.url.queryParameters['locale'], 'de'); + expect(result.taxonId, 52188); + expect(result.commonName, 'Walhai'); + expect(result.scientificName, 'Rhincodon typus'); + expect(result.category, SpeciesCategory.shark); + expect(result.taxonomyClass, 'Chondrichthyes'); + }); + + test('resolve falls back to the scientific name without a common name', + () async { + final client = MockClient( + (_) async => http.Response( + '{"results":[{"id":9,"name":"Nomen nudum","rank":"species",' + '"ancestors":[{"rank":"kingdom","name":"Animalia"}]}]}', + 200, + ), + ); + + final result = await INaturalistSpeciesLookupService( + client: client, + ).resolve(9, locale: 'en'); + + expect(result.commonName, 'Nomen nudum'); + expect(result.category, SpeciesCategory.invertebrate); + expect(result.taxonomyClass, isNull); + }); + + test('a non-200 answer is a server error', () async { + final client = MockClient((_) async => http.Response('nope', 503)); + + expect( + () => INaturalistSpeciesLookupService(client: client) + .search('x', locale: 'en'), + throwsA(_kind(SpeciesLookupErrorKind.server)), + ); + }); + + test('a body that is not JSON is malformed', () async { + final client = MockClient((_) async => http.Response('', 200)); + + expect( + () => INaturalistSpeciesLookupService(client: client) + .search('x', locale: 'en'), + throwsA(_kind(SpeciesLookupErrorKind.malformed)), + ); + }); + + test('a socket failure is offline', () async { + final client = MockClient((_) async => throw const SocketException('down')); + + expect( + () => INaturalistSpeciesLookupService(client: client) + .search('x', locale: 'en'), + throwsA(_kind(SpeciesLookupErrorKind.offline)), + ); + }); + + test('a slow answer is a timeout', () async { + final client = MockClient((_) async { + await Future.delayed(const Duration(milliseconds: 200)); + return http.Response('{"results":[]}', 200); + }); + final service = INaturalistSpeciesLookupService( + client: client, + timeout: const Duration(milliseconds: 20), + ); + + expect( + () => service.search('x', locale: 'en'), + throwsA(_kind(SpeciesLookupErrorKind.timeout)), + ); + }); + + test('a repeated search is served from the session cache', () async { + var calls = 0; + final client = MockClient((_) async { + calls += 1; + return http.Response(_fixture('autocomplete_whale_shark_de.json'), 200); + }); + final service = INaturalistSpeciesLookupService(client: client); + + await service.search('Whale shark', locale: 'de'); + await service.search(' whale shark ', locale: 'DE'); + await service.search('whale shark', locale: 'en'); + + expect(calls, 2); + }); +} + +Matcher _kind(SpeciesLookupErrorKind kind) => + isA().having((e) => e.kind, 'kind', kind); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/data/services/inaturalist_species_lookup_service_test.dart` +Expected: compilation error, service file not found. + +- [ ] **Step 3: Write the interface and the service** + +`lib/features/marine_life/data/services/species_lookup_service.dart`: + +```dart +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; + +/// Looks a species up online. The iNaturalist implementation is the only +/// production one; widgets depend on this interface so tests can stub it. +abstract class SpeciesLookupService { + /// Autocomplete hits for [query] with common names in [locale]. + Future> search(String query, {required String locale}); + + /// The fields a species row needs for one hit, category included. + Future resolve(int taxonId, {required String locale}); +} +``` + +`lib/features/marine_life/data/services/inaturalist_species_lookup_service.dart`: + +```dart +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import 'package:submersion/features/marine_life/data/services/inaturalist_parsers.dart'; +import 'package:submersion/features/marine_life/data/services/species_lookup_service.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/domain/services/species_category_mapper.dart'; + +/// iNaturalist's public taxa API. Free to read, no key. +/// +/// Explicit lookups only: callers send a request when the diver asks for +/// one, never per keystroke. Results are cached for the session so a +/// repeated query costs nothing. +class INaturalistSpeciesLookupService implements SpeciesLookupService { + static const String _host = 'api.inaturalist.org'; + static const String _autocompletePath = '/v1/taxa/autocomplete'; + static const String _taxonPath = '/v1/taxa'; + static const String _userAgent = 'Submersion/1.0 (https://submersion.app)'; + + final http.Client _client; + final Duration _timeout; + final Map> _searchCache = {}; + final Map _resolveCache = {}; + + INaturalistSpeciesLookupService({ + http.Client? client, + Duration timeout = const Duration(seconds: 10), + }) : _client = client ?? http.Client(), + _timeout = timeout; + + @override + Future> search( + String query, { + required String locale, + }) async { + final term = query.trim(); + final key = '${locale.toLowerCase()}|${term.toLowerCase()}'; + final cached = _searchCache[key]; + if (cached != null) return cached; + + final uri = Uri.https(_host, _autocompletePath, { + 'q': term, + 'locale': locale, + 'per_page': '10', + 'is_active': 'true', + }); + final hits = parseAutocomplete(await _get(uri)); + _searchCache[key] = hits; + return hits; + } + + @override + Future resolve( + int taxonId, { + required String locale, + }) async { + final key = '${locale.toLowerCase()}|$taxonId'; + final cached = _resolveCache[key]; + if (cached != null) return cached; + + final uri = Uri.https(_host, '$_taxonPath/$taxonId', {'locale': locale}); + final detail = parseTaxonDetail(await _get(uri)); + final taxonomyClass = detail.ancestors + .where((a) => a.rank == 'class') + .map((a) => a.name) + .firstOrNull; + final result = SpeciesLookupResult( + taxonId: detail.taxonId, + commonName: detail.commonName ?? detail.scientificName, + scientificName: detail.scientificName, + category: speciesCategoryFromAncestry(detail.ancestors), + taxonomyClass: taxonomyClass, + ); + _resolveCache[key] = result; + return result; + } + + Future _get(Uri uri) async { + final http.Response response; + try { + response = await _client + .get(uri, headers: const {'User-Agent': _userAgent}) + .timeout(_timeout); + } on TimeoutException { + throw const SpeciesLookupException(SpeciesLookupErrorKind.timeout); + } on SocketException catch (e) { + throw SpeciesLookupException(SpeciesLookupErrorKind.offline, e.message); + } on http.ClientException catch (e) { + throw SpeciesLookupException(SpeciesLookupErrorKind.offline, e.message); + } + if (response.statusCode != 200) { + throw SpeciesLookupException( + SpeciesLookupErrorKind.server, + 'HTTP ${response.statusCode}', + ); + } + return response.body; + } +} +``` + +`firstOrNull` comes from `package:collection` (already a dependency; add `import 'package:collection/collection.dart';` if the analyzer asks for it). + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/data/services/inaturalist_species_lookup_service_test.dart` +Expected: `All tests passed!` (8 tests), exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/marine_life/data/services test/features/marine_life/data/services +git add lib/features/marine_life/data/services/species_lookup_service.dart lib/features/marine_life/data/services/inaturalist_species_lookup_service.dart test/features/marine_life/data/services/inaturalist_species_lookup_service_test.dart +git commit -m "feat(marine-life): add the iNaturalist species lookup service" +``` + +--- + +### Task 4: Providers and the lookup locale + +**Files:** +- Create: `lib/features/marine_life/presentation/providers/species_lookup_providers.dart` +- Test: `test/features/marine_life/presentation/providers/species_lookup_providers_test.dart` + +**Interfaces:** +- Consumes: Task 3; `localeProvider` (`Provider`, `'system'` or a language tag) from `lib/features/settings/presentation/providers/settings_providers.dart`. +- Produces: + ```dart + String lookupLocaleCode(String localeTag, {String Function()? systemLanguageCode}); + final speciesLookupHttpClientProvider = Provider; + final speciesLookupServiceProvider = Provider; + final speciesLookupLocaleProvider = Provider; + ``` + +Background: `l10nForLocaleTag` in `lib/l10n/l10n_extension.dart` is the app's idiom for turning the persisted locale setting into a language code (`'system'` resolves through `PlatformDispatcher.instance.locale`); the lookup locale copies that resolution with the platform read injectable for tests. The reef feature's `reefHttpClientProvider` is the client-provider pattern (create, close on dispose, override with `MockClient` in tests). + +- [ ] **Step 1: Write the failing test** + +`test/features/marine_life/presentation/providers/species_lookup_providers_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_lookup_providers.dart'; + +void main() { + test('a language tag becomes its lowercase language code', () { + expect(lookupLocaleCode('de'), 'de'); + expect(lookupLocaleCode('pt-BR'), 'pt'); + expect(lookupLocaleCode('zh_Hans'), 'zh'); + expect(lookupLocaleCode('EN'), 'en'); + }); + + test('system resolves through the platform language', () { + expect( + lookupLocaleCode('system', systemLanguageCode: () => 'fr'), + 'fr', + ); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/marine_life/presentation/providers/species_lookup_providers_test.dart` +Expected: compilation error, file not found. + +- [ ] **Step 3: Write the providers** + +`lib/features/marine_life/presentation/providers/species_lookup_providers.dart`: + +```dart +import 'dart:ui'; + +import 'package:http/http.dart' as http; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/marine_life/data/services/inaturalist_species_lookup_service.dart'; +import 'package:submersion/features/marine_life/data/services/species_lookup_service.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +/// The language code sent as iNaturalist's `locale` parameter, from the +/// persisted locale setting. Mirrors `l10nForLocaleTag`: `system` follows +/// the platform, and a region or script suffix is dropped. +String lookupLocaleCode( + String localeTag, { + String Function()? systemLanguageCode, +}) { + final tag = localeTag == 'system' + ? (systemLanguageCode ?? _platformLanguageCode)() + : localeTag; + return tag.split(RegExp('[-_]')).first.toLowerCase(); +} + +String _platformLanguageCode() => PlatformDispatcher.instance.locale.languageCode; + +/// Shared client for species lookups. Overridden in tests with a MockClient. +final speciesLookupHttpClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +final speciesLookupServiceProvider = Provider((ref) { + return INaturalistSpeciesLookupService( + client: ref.watch(speciesLookupHttpClientProvider), + ); +}); + +final speciesLookupLocaleProvider = Provider((ref) { + return lookupLocaleCode(ref.watch(localeProvider)); +}); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/presentation/providers/species_lookup_providers_test.dart` +Expected: `All tests passed!` (2 tests), exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/marine_life/presentation/providers/species_lookup_providers.dart test/features/marine_life/presentation/providers/species_lookup_providers_test.dart +git add lib/features/marine_life/presentation/providers/species_lookup_providers.dart test/features/marine_life/presentation/providers/species_lookup_providers_test.dart +git commit -m "feat(marine-life): add species lookup providers and locale resolution" +``` + +--- + +### Task 5: Localization keys + +**Files:** +- Modify: `lib/l10n/arb/app_{ar,de,en,es,fr,he,hu,it,nl,pt,zh}.arb` +- Regenerate: `lib/l10n/arb/app_localizations*.dart` +- Test: `test/l10n/species_lookup_strings_test.dart` + +**Interfaces:** +- Produces `AppLocalizations` members: `marineLife_lookup_button`, `_title`, `_searchHint`, `_search`, `_createWithout`, `_attribution`, `_idle`, `_empty(String query)`, `_errorOffline`, `_errorTimeout`, `_errorServer`, `_errorMalformed`, `_retry`, `_resolving`, `_observations(int count)`, `_unresolvableRank(String rank)`; `marineLife_speciesDetail_suggestForCatalog`; `marineLife_suggest_couldNotOpen`, `marineLife_suggest_copyLink`; `reef_species_addFromLookup`. + +- [ ] **Step 1: Write the failing strings test** + +`test/l10n/species_lookup_strings_test.dart`: + +```dart +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + test('lookup strings exist in English', () async { + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + expect(l10n.marineLife_lookup_button, 'Look up online'); + expect(l10n.marineLife_lookup_search, 'Look up'); + expect(l10n.marineLife_lookup_empty('zzz'), 'No species found for "zzz"'); + expect(l10n.marineLife_lookup_observations(1), '1 observation'); + expect(l10n.marineLife_lookup_observations(42), '42 observations'); + expect(l10n.marineLife_speciesDetail_suggestForCatalog, 'Suggest for the catalog'); + expect(l10n.reef_species_addFromLookup, 'Look up and add to your species'); + }); + + test('lookup strings are translated in German', () async { + final l10n = await AppLocalizations.delegate.load(const Locale('de')); + + expect(l10n.marineLife_lookup_button, 'Online nachschlagen'); + expect(l10n.marineLife_lookup_observations(2), '2 Beobachtungen'); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/l10n/species_lookup_strings_test.dart` +Expected: compilation error, `marineLife_lookup_button` is not defined. + +- [ ] **Step 3: Insert the keys with the script** + +First confirm both anchors exist once in every locale: + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +grep -l '^ "marineLife_speciesManage_searchHint": ' lib/l10n/arb/app_*.arb | wc -l +grep -l '^ "reef_species_addToExpected": ' lib/l10n/arb/app_*.arb | wc -l +``` + +Expected: `11` and `11`. Then save this as `/private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/1a1e1ff7-4477-4b97-b07b-8de224f521ea/scratchpad/add_species_lookup_keys.py` (throwaway) and run it from the worktree root with `python3`: + +```python +import json, re, sys +from pathlib import Path + +ROOT = Path('lib/l10n/arb') +ANCHORS = { + 'marineLife_': 'marineLife_speciesManage_searchHint', + 'reef_': 'reef_species_addToExpected', +} + +KEYS = [ + ('marineLife_lookup_button', None), + ('marineLife_lookup_title', None), + ('marineLife_lookup_searchHint', None), + ('marineLife_lookup_search', None), + ('marineLife_lookup_createWithout', None), + ('marineLife_lookup_attribution', None), + ('marineLife_lookup_idle', None), + ('marineLife_lookup_empty', {'query': 'String'}), + ('marineLife_lookup_errorOffline', None), + ('marineLife_lookup_errorTimeout', None), + ('marineLife_lookup_errorServer', None), + ('marineLife_lookup_errorMalformed', None), + ('marineLife_lookup_retry', None), + ('marineLife_lookup_resolving', None), + ('marineLife_lookup_observations', {'count': 'int'}), + ('marineLife_lookup_unresolvableRank', {'rank': 'String'}), + ('marineLife_speciesDetail_suggestForCatalog', None), + ('marineLife_suggest_couldNotOpen', None), + ('marineLife_suggest_copyLink', None), + ('reef_species_addFromLookup', None), +] + +VALUES = { + 'en': [ + 'Look up online', 'Look up a species', 'Common or scientific name', + 'Look up', 'Create without lookup', + 'Species data and photos from iNaturalist', + 'Type a name and tap Look up.', 'No species found for "{query}"', + 'You appear to be offline.', 'The lookup timed out.', + 'iNaturalist returned an error. Try again later.', + 'Unexpected response from iNaturalist.', 'Retry', + 'Loading details...', + '{count, plural, =1{1 observation} other{{count} observations}}', + '{rank}: choose a species', 'Suggest for the catalog', + 'Could not open the browser', 'Copy link', + 'Look up and add to your species', + ], + 'de': [ + 'Online nachschlagen', 'Art nachschlagen', + 'Trivial- oder wissenschaftlicher Name', 'Nachschlagen', + 'Ohne Nachschlagen anlegen', 'Artdaten und Fotos von iNaturalist', + 'Geben Sie einen Namen ein und tippen Sie auf Nachschlagen.', + 'Keine Art für "{query}" gefunden', 'Sie scheinen offline zu sein.', + 'Zeitüberschreitung beim Nachschlagen.', + 'iNaturalist hat einen Fehler gemeldet. Versuchen Sie es später erneut.', + 'Unerwartete Antwort von iNaturalist.', 'Erneut versuchen', + 'Details werden geladen...', + '{count, plural, =1{1 Beobachtung} other{{count} Beobachtungen}}', + '{rank}: wählen Sie eine Art', 'Für den Katalog vorschlagen', + 'Browser konnte nicht geöffnet werden', 'Link kopieren', + 'Nachschlagen und zu Ihren Arten hinzufügen', + ], + 'es': [ + 'Buscar en línea', 'Buscar una especie', 'Nombre común o científico', + 'Buscar', 'Crear sin buscar', 'Datos y fotos de especies de iNaturalist', + 'Escribe un nombre y toca Buscar.', + 'No se encontraron especies para "{query}"', + 'Parece que no tienes conexión.', 'La búsqueda tardó demasiado.', + 'iNaturalist devolvió un error. Inténtalo más tarde.', + 'Respuesta inesperada de iNaturalist.', 'Reintentar', + 'Cargando detalles...', + '{count, plural, =1{1 observación} other{{count} observaciones}}', + '{rank}: elige una especie', 'Sugerir para el catálogo', + 'No se pudo abrir el navegador', 'Copiar enlace', + 'Buscar y añadir a tus especies', + ], + 'fr': [ + 'Rechercher en ligne', 'Rechercher une espèce', + 'Nom commun ou scientifique', 'Rechercher', 'Créer sans recherche', + "Données et photos d'espèces issues d'iNaturalist", + 'Saisissez un nom puis touchez Rechercher.', + 'Aucune espèce trouvée pour "{query}"', + 'Vous semblez être hors ligne.', 'La recherche a expiré.', + 'iNaturalist a renvoyé une erreur. Réessayez plus tard.', + "Réponse inattendue d'iNaturalist.", 'Réessayer', + 'Chargement des détails...', + '{count, plural, =1{1 observation} other{{count} observations}}', + '{rank} : choisissez une espèce', 'Proposer pour le catalogue', + "Impossible d'ouvrir le navigateur", 'Copier le lien', + 'Rechercher et ajouter à vos espèces', + ], + 'it': [ + 'Cerca online', 'Cerca una specie', 'Nome comune o scientifico', + 'Cerca', 'Crea senza ricerca', 'Dati e foto delle specie da iNaturalist', + 'Digita un nome e tocca Cerca.', 'Nessuna specie trovata per "{query}"', + 'Sembra che tu sia offline.', 'La ricerca è scaduta.', + 'iNaturalist ha restituito un errore. Riprova più tardi.', + 'Risposta inattesa da iNaturalist.', 'Riprova', + 'Caricamento dei dettagli...', + '{count, plural, =1{1 osservazione} other{{count} osservazioni}}', + '{rank}: scegli una specie', 'Suggerisci per il catalogo', + 'Impossibile aprire il browser', 'Copia link', + 'Cerca e aggiungi alle tue specie', + ], + 'pt': [ + 'Pesquisar online', 'Pesquisar uma espécie', 'Nome comum ou científico', + 'Pesquisar', 'Criar sem pesquisar', + 'Dados e fotos de espécies do iNaturalist', + 'Digite um nome e toque em Pesquisar.', + 'Nenhuma espécie encontrada para "{query}"', + 'Você parece estar offline.', 'A pesquisa expirou.', + 'O iNaturalist retornou um erro. Tente novamente mais tarde.', + 'Resposta inesperada do iNaturalist.', 'Tentar novamente', + 'Carregando detalhes...', + '{count, plural, =1{1 observação} other{{count} observações}}', + '{rank}: escolha uma espécie', 'Sugerir para o catálogo', + 'Não foi possível abrir o navegador', 'Copiar link', + 'Pesquisar e adicionar às suas espécies', + ], + 'nl': [ + 'Online opzoeken', 'Een soort opzoeken', + 'Gewone of wetenschappelijke naam', 'Opzoeken', + 'Aanmaken zonder opzoeken', "Soortgegevens en foto's van iNaturalist", + 'Typ een naam en tik op Opzoeken.', + 'Geen soorten gevonden voor "{query}"', 'Je lijkt offline te zijn.', + 'Het opzoeken duurde te lang.', + 'iNaturalist gaf een fout terug. Probeer het later opnieuw.', + 'Onverwacht antwoord van iNaturalist.', 'Opnieuw proberen', + 'Details laden...', + '{count, plural, =1{1 waarneming} other{{count} waarnemingen}}', + '{rank}: kies een soort', 'Voorstellen voor de catalogus', + 'Kon de browser niet openen', 'Link kopiëren', + 'Opzoeken en aan je soorten toevoegen', + ], + 'hu': [ + 'Keresés online', 'Faj keresése', 'Köznapi vagy tudományos név', + 'Keresés', 'Létrehozás keresés nélkül', + 'Fajadatok és fotók az iNaturalisttól', + 'Írj be egy nevet, és koppints a Keresés gombra.', + 'Nincs találat erre: "{query}"', + 'Úgy tűnik, nincs internetkapcsolat.', + 'A keresés túllépte az időkorlátot.', + 'Az iNaturalist hibát adott vissza. Próbáld újra később.', + 'Váratlan válasz az iNaturalisttól.', 'Újra', + 'Részletek betöltése...', + '{count, plural, =1{1 megfigyelés} other{{count} megfigyelés}}', + '{rank}: válassz egy fajt', 'Javaslat a katalógusba', + 'Nem sikerült megnyitni a böngészőt', 'Link másolása', + 'Keresés és hozzáadás a fajaidhoz', + ], + 'zh': [ + '在线查找', '查找物种', '常用名或学名', '查找', '不查找直接创建', + '物种数据和照片来自 iNaturalist', '输入名称,然后点按“查找”。', + '未找到与“{query}”匹配的物种', '你似乎处于离线状态。', '查找超时。', + 'iNaturalist 返回了错误。请稍后重试。', '来自 iNaturalist 的意外响应。', + '重试', '正在加载详情...', + '{count, plural, =1{1 条观察记录} other{{count} 条观察记录}}', + '{rank}:请选择一个物种', '推荐加入目录', '无法打开浏览器', '复制链接', + '查找并添加到你的物种', + ], + 'ar': [ + 'البحث عبر الإنترنت', 'البحث عن نوع', 'الاسم الشائع أو العلمي', 'بحث', + 'إنشاء بدون بحث', 'بيانات الأنواع والصور من iNaturalist', + 'اكتب اسمًا ثم اضغط بحث.', 'لم يُعثر على أنواع لـ "{query}"', + 'يبدو أنك غير متصل بالإنترنت.', 'انتهت مهلة البحث.', + 'أعاد iNaturalist خطأ. حاول مرة أخرى لاحقًا.', + 'استجابة غير متوقعة من iNaturalist.', 'إعادة المحاولة', + 'جارٍ تحميل التفاصيل...', + '{count, plural, =1{مشاهدة واحدة} other{{count} مشاهدات}}', + '{rank}: اختر نوعًا', 'اقتراح للفهرس', 'تعذر فتح المتصفح', 'نسخ الرابط', + 'البحث والإضافة إلى أنواعك', + ], + 'he': [ + 'חיפוש מקוון', 'חיפוש מין', 'שם עממי או מדעי', 'חיפוש', + 'יצירה ללא חיפוש', 'נתוני מינים ותמונות מ-iNaturalist', + 'הקלידו שם והקישו על חיפוש.', 'לא נמצאו מינים עבור "{query}"', + 'נראה שאין חיבור לאינטרנט.', 'תם הזמן המוקצב לחיפוש.', + 'iNaturalist החזיר שגיאה. נסו שוב מאוחר יותר.', + 'תגובה לא צפויה מ-iNaturalist.', 'ניסיון חוזר', 'טוען פרטים...', + '{count, plural, =1{תצפית אחת} other{{count} תצפיות}}', + '{rank}: בחרו מין', 'הצעה לקטלוג', 'לא ניתן לפתוח את הדפדפן', + 'העתקת קישור', 'חיפוש והוספה למינים שלכם', + ], +} + +def key_line(key, value): + return ' ' + json.dumps(key) + ': ' + json.dumps(value, ensure_ascii=False) + ',\n' + +def meta_lines(key, placeholders): + out = [' ' + json.dumps('@' + key) + ': {\n', ' "placeholders": {\n'] + items = list(placeholders.items()) + for i, (name, typ) in enumerate(items): + comma = ',' if i < len(items) - 1 else '' + out += [' ' + json.dumps(name) + ': {\n', + ' "type": ' + json.dumps(typ) + '\n', + ' }' + comma + '\n'] + out += [' }\n', ' },\n'] + return out + +def find_anchor(lines, anchor): + pattern = re.compile(r'^ "' + re.escape(anchor) + r'": ') + hits = [i for i, l in enumerate(lines) if pattern.match(l)] + if len(hits) != 1: + sys.exit(f'anchor {anchor} found {len(hits)} times') + return hits[0] + +for locale, values in VALUES.items(): + if len(values) != len(KEYS): + sys.exit(f'{locale}: {len(values)} values for {len(KEYS)} keys') + path = ROOT / f'app_{locale}.arb' + text = path.read_text(encoding='utf-8') + for key, _ in KEYS: + if f'"{key}"' in text: + sys.exit(f'{locale}: {key} already present') + lines = text.splitlines(keepends=True) + blocks = {anchor: [] for anchor in ANCHORS.values()} + for (key, placeholders), value in zip(KEYS, values): + block = [key_line(key, value)] + if locale == 'en' and placeholders: + block += meta_lines(key, placeholders) + prefix = next(p for p in ANCHORS if key.startswith(p)) + blocks[ANCHORS[prefix]].extend(block) + for anchor, block in sorted( + blocks.items(), key=lambda pair: find_anchor(lines, pair[0]), reverse=True + ): + at = find_anchor(lines, anchor) + 1 + lines[at:at] = block + new_text = ''.join(lines) + json.loads(new_text) + path.write_text(new_text, encoding='utf-8') + print(f'{locale}: added {len(KEYS)} keys') +``` + +Run: + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +python3 /private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/1a1e1ff7-4477-4b97-b07b-8de224f521ea/scratchpad/add_species_lookup_keys.py +flutter gen-l10n +``` + +Expected: eleven `added 20 keys` lines. + +- [ ] **Step 4: Run the strings test and the parity test** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/l10n/species_lookup_strings_test.dart test/l10n/arb_parity_test.dart` +Expected: `All tests passed!`, exit code 0. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +git add lib/l10n/arb test/l10n/species_lookup_strings_test.dart +git commit -m "feat(l10n): add species lookup and catalog suggestion strings" +``` + +--- + +### Task 6: `SpeciesLookupSheet` + +**Files:** +- Create: `lib/features/marine_life/presentation/widgets/species_lookup_sheet.dart` +- Test: `test/features/marine_life/presentation/widgets/species_lookup_sheet_test.dart` + +**Interfaces:** +- Consumes: `speciesLookupServiceProvider`, `speciesLookupLocaleProvider` (Task 4); entities (Task 1); Task 5 strings; `SpeciesCategoryDisplay.localizedName`, `iconForSpeciesCategory`. +- Produces: `Future showSpeciesLookupSheet(BuildContext context, {String initialQuery = ''})` and `SpeciesLookupSheet({required String initialQuery, ScrollController? scrollController})`. The sheet pops with a `SpeciesLookupResult` on selection and with null on dismiss or "Create without lookup". + +- [ ] **Step 1: Write the failing widget test** + +`test/features/marine_life/presentation/widgets/species_lookup_sheet_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/data/services/species_lookup_service.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_lookup_providers.dart'; +import 'package:submersion/features/marine_life/presentation/widgets/species_lookup_sheet.dart'; + +import '../../../../helpers/test_app.dart'; + +const _whale = SpeciesLookupHit( + taxonId: 52188, + scientificName: 'Rhincodon typus', + rank: 'species', + rankLevel: 10, + commonName: 'Whale Shark', + observationCount: 2662, +); + +const _genus = SpeciesLookupHit( + taxonId: 1, + scientificName: 'Rhincodon', + rank: 'genus', + rankLevel: 20, + observationCount: 3000, +); + +const _resolved = SpeciesLookupResult( + taxonId: 52188, + commonName: 'Whale Shark', + scientificName: 'Rhincodon typus', + category: SpeciesCategory.shark, + taxonomyClass: 'Chondrichthyes', +); + +class _FakeLookup implements SpeciesLookupService { + _FakeLookup({this.hits = const [], this.failWith}); + + final List hits; + final SpeciesLookupException? failWith; + final List queries = []; + final List resolved = []; + + @override + Future> search( + String query, { + required String locale, + }) async { + queries.add('$query@$locale'); + if (failWith != null) throw failWith!; + return hits; + } + + @override + Future resolve( + int taxonId, { + required String locale, + }) async { + resolved.add(taxonId); + return _resolved; + } +} + +/// Opens the sheet from a button so the popped value can be captured. +Future _open( + WidgetTester tester, + _FakeLookup service, { + String initialQuery = '', +}) async { + SpeciesLookupResult? popped; + var closed = false; + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + speciesLookupServiceProvider.overrideWithValue(service), + speciesLookupLocaleProvider.overrideWithValue('en'), + ], + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + popped = await showSpeciesLookupSheet( + context, + initialQuery: initialQuery, + ); + closed = true; + }, + child: const Text('OPEN'), + ), + ), + ), + ); + await tester.tap(find.text('OPEN')); + await tester.pumpAndSettle(); + return () => closed ? popped : throw StateError('sheet still open'); +} + +void main() { + testWidgets('starts idle and searches only when asked', (tester) async { + final service = _FakeLookup(hits: const [_whale, _genus]); + await _open(tester, service, initialQuery: 'whale'); + + expect(find.text('Type a name and tap Look up.'), findsOneWidget); + expect(service.queries, isEmpty); + + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + + expect(service.queries, ['whale@en']); + expect(find.text('Whale Shark'), findsOneWidget); + expect(find.text('Rhincodon typus'), findsOneWidget); + expect(find.text('2662 observations'), findsOneWidget); + expect(find.text('Species data and photos from iNaturalist'), findsOneWidget); + }); + + testWidgets('a coarser rank is listed but cannot be chosen', (tester) async { + final service = _FakeLookup(hits: const [_genus]); + await _open(tester, service, initialQuery: 'rhincodon'); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + + expect(find.text('genus: choose a species'), findsOneWidget); + final tile = tester.widget( + find.widgetWithText(ListTile, 'Rhincodon'), + ); + expect(tile.enabled, isFalse); + }); + + testWidgets('choosing a hit resolves it and pops with the result', ( + tester, + ) async { + final service = _FakeLookup(hits: const [_whale]); + final read = await _open(tester, service, initialQuery: 'whale'); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Whale Shark')); + await tester.pumpAndSettle(); + + expect(service.resolved, [52188]); + expect(read(), _resolved); + }); + + testWidgets('shows the empty state for a query with no hits', (tester) async { + final service = _FakeLookup(); + await _open(tester, service, initialQuery: 'zzz'); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + + expect(find.text('No species found for "zzz"'), findsOneWidget); + }); + + testWidgets('shows one message per failure kind with a retry', ( + tester, + ) async { + final service = _FakeLookup( + failWith: const SpeciesLookupException(SpeciesLookupErrorKind.offline), + ); + await _open(tester, service, initialQuery: 'whale'); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + + expect(find.text('You appear to be offline.'), findsOneWidget); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(service.queries, hasLength(2)); + }); + + testWidgets('Create without lookup pops with null', (tester) async { + final service = _FakeLookup(); + final read = await _open(tester, service, initialQuery: 'whale'); + + await tester.tap(find.text('Create without lookup')); + await tester.pumpAndSettle(); + + expect(read(), isNull); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/presentation/widgets/species_lookup_sheet_test.dart` +Expected: compilation error, sheet file not found. + +- [ ] **Step 3: Write the sheet** + +`lib/features/marine_life/presentation/widgets/species_lookup_sheet.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_lookup_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Opens the lookup sheet. Resolves to the chosen species' fields, or null +/// when the diver dismisses it or asks to create the species without a +/// lookup (callers keep their offline path for that). +Future showSpeciesLookupSheet( + BuildContext context, { + String initialQuery = '', +}) => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (_, controller) => SpeciesLookupSheet( + initialQuery: initialQuery, + scrollController: controller, + ), + ), +); + +/// Search iNaturalist for a species. Lookups are explicit: nothing is sent +/// until the diver taps Look up, so typing never leaks keystrokes and an +/// offline boat is a single clear message rather than a stream of errors. +class SpeciesLookupSheet extends ConsumerStatefulWidget { + final String initialQuery; + final ScrollController? scrollController; + + const SpeciesLookupSheet({ + super.key, + required this.initialQuery, + this.scrollController, + }); + + @override + ConsumerState createState() => _SpeciesLookupSheetState(); +} + +class _SpeciesLookupSheetState extends ConsumerState { + late final TextEditingController _controller; + List? _hits; + String _lastQuery = ''; + SpeciesLookupErrorKind? _error; + bool _searching = false; + int? _resolvingId; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialQuery); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _search() async { + final query = _controller.text.trim(); + if (query.isEmpty) return; + setState(() { + _searching = true; + _error = null; + _lastQuery = query; + }); + try { + final hits = await ref + .read(speciesLookupServiceProvider) + .search(query, locale: ref.read(speciesLookupLocaleProvider)); + if (mounted) setState(() => _hits = hits); + } on SpeciesLookupException catch (e) { + if (mounted) setState(() => _error = e.kind); + } finally { + if (mounted) setState(() => _searching = false); + } + } + + Future _choose(SpeciesLookupHit hit) async { + setState(() => _resolvingId = hit.taxonId); + try { + final result = await ref + .read(speciesLookupServiceProvider) + .resolve(hit.taxonId, locale: ref.read(speciesLookupLocaleProvider)); + if (mounted) Navigator.of(context).pop(result); + } on SpeciesLookupException catch (e) { + if (mounted) { + setState(() { + _error = e.kind; + _resolvingId = null; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return ListView( + controller: widget.scrollController, + padding: const EdgeInsets.all(16), + children: [ + Text(l10n.marineLife_lookup_title, style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + autofocus: widget.initialQuery.isEmpty, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _search(), + decoration: InputDecoration( + hintText: l10n.marineLife_lookup_searchHint, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + key: const ValueKey('lookup_search'), + onPressed: _searching ? null : _search, + child: Text(l10n.marineLife_lookup_search), + ), + ], + ), + const SizedBox(height: 16), + ..._body(l10n, theme), + const SizedBox(height: 16), + OutlinedButton( + key: const ValueKey('lookup_create_without'), + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.marineLife_lookup_createWithout), + ), + const SizedBox(height: 8), + Text( + l10n.marineLife_lookup_attribution, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + List _body(AppLocalizations l10n, ThemeData theme) { + if (_searching) { + return const [Center(child: CircularProgressIndicator())]; + } + final error = _error; + if (error != null) { + return [ + Text(_errorText(l10n, error), textAlign: TextAlign.center), + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: _search, + child: Text(l10n.marineLife_lookup_retry), + ), + ), + ]; + } + final hits = _hits; + if (hits == null) { + return [ + Text( + l10n.marineLife_lookup_idle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ]; + } + if (hits.isEmpty) { + return [ + Text(l10n.marineLife_lookup_empty(_lastQuery), textAlign: TextAlign.center), + ]; + } + return [for (final hit in hits) _HitTile(hit: hit, resolving: _resolvingId == hit.taxonId, onTap: hit.isResolvable && _resolvingId == null ? () => _choose(hit) : null)]; + } + + String _errorText(AppLocalizations l10n, SpeciesLookupErrorKind kind) => + switch (kind) { + SpeciesLookupErrorKind.offline => l10n.marineLife_lookup_errorOffline, + SpeciesLookupErrorKind.timeout => l10n.marineLife_lookup_errorTimeout, + SpeciesLookupErrorKind.server => l10n.marineLife_lookup_errorServer, + SpeciesLookupErrorKind.malformed => + l10n.marineLife_lookup_errorMalformed, + }; +} + +class _HitTile extends StatelessWidget { + final SpeciesLookupHit hit; + final bool resolving; + final VoidCallback? onTap; + + const _HitTile({required this.hit, required this.resolving, this.onTap}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final photo = hit.photo; + final title = hit.commonName ?? hit.scientificName; + final subtitle = hit.isResolvable + ? hit.scientificName + : l10n.marineLife_lookup_unresolvableRank(hit.rank); + return ListTile( + enabled: hit.isResolvable, + leading: SizedBox( + width: 48, + height: 48, + child: photo == null + ? const Icon(Icons.image_not_supported_outlined) + : Tooltip( + message: photo.attribution, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + photo.squareUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + const Icon(Icons.image_not_supported_outlined), + ), + ), + ), + ), + title: Text(title), + subtitle: Text( + subtitle, + style: hit.isResolvable + ? const TextStyle(fontStyle: FontStyle.italic) + : null, + ), + trailing: resolving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + l10n.marineLife_lookup_observations(hit.observationCount), + style: Theme.of(context).textTheme.bodySmall, + ), + onTap: onTap, + ); + } +} +``` + +If the test's "Loading details..." string is wanted, show it in place of the spinner text; the plan keeps the spinner only. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/presentation/widgets/species_lookup_sheet_test.dart` +Expected: `All tests passed!` (6 tests), exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/marine_life/presentation/widgets/species_lookup_sheet.dart test/features/marine_life/presentation/widgets/species_lookup_sheet_test.dart +git add lib/features/marine_life/presentation/widgets/species_lookup_sheet.dart test/features/marine_life/presentation/widgets/species_lookup_sheet_test.dart +git commit -m "feat(marine-life): add the species lookup sheet" +``` + +--- + +### Task 7: "Look up online" on the species edit page + +**Files:** +- Modify: `lib/features/marine_life/presentation/pages/species_edit_page.dart` (imports; the `DropdownButtonFormField` gets a key; a button after the common-name field; an `_applyLookup` method) +- Test: `test/features/marine_life/presentation/pages/species_edit_page_test.dart` (new; the page had no tests) + +**Interfaces:** +- Consumes: `showSpeciesLookupSheet` (Task 6), `SpeciesLookupResult` (Task 1), Task 5 strings. +- Produces: the page fills common name, scientific name, category and taxonomy class from a lookup; description is untouched. + +Background: `DropdownButtonFormField` takes `initialValue`, which only applies on first build, so a programmatic category change needs the field rebuilt: keying the dropdown on the category does that. + +- [ ] **Step 1: Write the failing widget test** + +`test/features/marine_life/presentation/pages/species_edit_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/data/services/species_lookup_service.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/pages/species_edit_page.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_lookup_providers.dart'; + +import '../../../../helpers/test_app.dart'; + +class _FakeLookup implements SpeciesLookupService { + @override + Future> search( + String query, { + required String locale, + }) async => const [ + SpeciesLookupHit( + taxonId: 52188, + scientificName: 'Rhincodon typus', + rank: 'species', + rankLevel: 10, + commonName: 'Whale Shark', + observationCount: 5, + ), + ]; + + @override + Future resolve( + int taxonId, { + required String locale, + }) async => const SpeciesLookupResult( + taxonId: 52188, + commonName: 'Whale Shark', + scientificName: 'Rhincodon typus', + category: SpeciesCategory.shark, + taxonomyClass: 'Chondrichthyes', + ); +} + +void main() { + testWidgets('a lookup fills name, scientific name, category and class', ( + tester, + ) async { + tester.view.physicalSize = const Size(900, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + speciesLookupServiceProvider.overrideWithValue(_FakeLookup()), + speciesLookupLocaleProvider.overrideWithValue('en'), + ], + child: const SpeciesEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).first, 'whale'); + await tester.tap(find.text('Look up online')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Whale Shark')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextFormField, 'Whale Shark'), findsOneWidget); + expect(find.widgetWithText(TextFormField, 'Rhincodon typus'), findsOneWidget); + expect(find.widgetWithText(TextFormField, 'Chondrichthyes'), findsOneWidget); + expect(find.text('Shark'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/marine_life/presentation/pages/species_edit_page_test.dart` +Expected: fails at `find.text('Look up online')` (no such button yet). + +- [ ] **Step 3: Add the button and the fill** + +In `species_edit_page.dart`: + +1. Add imports: +```dart +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/widgets/species_lookup_sheet.dart'; +``` +2. Directly after the common-name `TextFormField(...)` (the one bound to `_commonNameController`, before the `const SizedBox(height: 16),` that precedes the scientific-name field) add: +```dart + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton.icon( + key: const ValueKey('species_lookup_online'), + icon: const Icon(Icons.travel_explore), + label: Text(context.l10n.marineLife_lookup_button), + onPressed: _lookUpOnline, + ), + ), +``` +3. Give the category dropdown a key so a programmatic change rebuilds it: on `DropdownButtonFormField(` add `key: ValueKey('species_category_${_category.name}'),` as the first argument. +4. Add the methods to the state class: +```dart + Future _lookUpOnline() async { + final result = await showSpeciesLookupSheet( + context, + initialQuery: _commonNameController.text.trim(), + ); + if (result != null && mounted) _applyLookup(result); + } + + /// Fills what the lookup knows and leaves the description alone; the + /// diver can still edit any field before saving. + void _applyLookup(SpeciesLookupResult result) { + setState(() { + _commonNameController.text = result.commonName; + _scientificNameController.text = result.scientificName; + _taxonomyClassController.text = result.taxonomyClass ?? ''; + _category = result.category; + }); + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/marine_life/presentation/pages/species_edit_page_test.dart` +Expected: `All tests passed!`, exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/marine_life/presentation/pages/species_edit_page.dart test/features/marine_life/presentation/pages/species_edit_page_test.dart +git add lib/features/marine_life/presentation/pages/species_edit_page.dart test/features/marine_life/presentation/pages/species_edit_page_test.dart +git commit -m "feat(marine-life): look a species up online from the edit page" +``` + +--- + +### Task 8: Lookup from the dive species picker's "add custom species" + +**Files:** +- Modify: `lib/features/marine_life/data/repositories/species_repository.dart` (add `findSpeciesByScientificName`) +- Modify: `lib/features/dive_log/presentation/widgets/pickers/species_picker_sheet.dart` (`_addCustomSpecies`) +- Test: `test/features/marine_life/data/repositories/species_repository_lookup_test.dart` (new), `test/features/dive_log/presentation/widgets/pickers/species_picker_sheet_lookup_test.dart` (new) + +**Interfaces:** +- Consumes: Tasks 1, 4, 6; `SpeciesRepository.createSpecies({commonName, scientificName, category, taxonomyClass, description})` and `getOrCreateSpecies({commonName, scientificName, category})`. +- Produces: `Future SpeciesRepository.findSpeciesByScientificName(String scientificName)`; the picker's add path opens the lookup sheet, creates (or reuses) the species from the result, and falls back to today's name-only creation on null. + +- [ ] **Step 1: Write the failing repository test** + +`test/features/marine_life/data/repositories/species_repository_lookup_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late SpeciesRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = SpeciesRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('finds a species by scientific name, case-insensitively', () async { + final created = await repository.createSpecies( + commonName: 'Whale Shark', + scientificName: 'Rhincodon typus', + category: SpeciesCategory.shark, + ); + + final found = await repository.findSpeciesByScientificName( + 'rhincodon TYPUS', + ); + + expect(found?.id, created.id); + }); + + test('returns null for an unknown or empty scientific name', () async { + expect(await repository.findSpeciesByScientificName('Nomen nudum'), isNull); + expect(await repository.findSpeciesByScientificName(' '), isNull); + }); +} +``` + +- [ ] **Step 2: Write the failing picker test** + +`test/features/dive_log/presentation/widgets/pickers/species_picker_sheet_lookup_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/pickers/species_picker_sheet.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/marine_life/data/services/species_lookup_service.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_lookup_providers.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +const _hit = SpeciesLookupHit( + taxonId: 52188, + scientificName: 'Rhincodon typus', + rank: 'species', + rankLevel: 10, + commonName: 'Whale Shark', + observationCount: 5, +); + +const _resolved = SpeciesLookupResult( + taxonId: 52188, + commonName: 'Whale Shark', + scientificName: 'Rhincodon typus', + category: SpeciesCategory.shark, + taxonomyClass: 'Chondrichthyes', +); + +class _FakeLookup implements SpeciesLookupService { + @override + Future> search(String query, {required String locale}) async => + const [_hit]; + + @override + Future resolve(int taxonId, {required String locale}) async => + _resolved; +} + +/// Records creations; `existing` is what a scientific-name lookup returns. +class _RecordingRepository extends Fake implements SpeciesRepository { + _RecordingRepository({this.existing}); + + final Species? existing; + final List> created = []; + int nameOnlyCreations = 0; + + @override + Future findSpeciesByScientificName(String scientificName) async => + existing; + + @override + Future createSpecies({ + required String commonName, + String? scientificName, + required SpeciesCategory category, + String? taxonomyClass, + String? description, + }) async { + created.add({ + 'commonName': commonName, + 'scientificName': scientificName, + 'category': category, + 'taxonomyClass': taxonomyClass, + }); + return Species( + id: 'new-1', + commonName: commonName, + scientificName: scientificName, + category: category, + taxonomyClass: taxonomyClass, + ); + } + + @override + Future getOrCreateSpecies({ + required String commonName, + String? scientificName, + required SpeciesCategory category, + }) async { + nameOnlyCreations += 1; + return Species(id: 'plain-1', commonName: commonName, category: category); + } +} + +Future _pump( + WidgetTester tester, { + required _RecordingRepository repository, +}) async { + tester.view.physicalSize = const Size(900, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => const []), + speciesByCategoryProvider.overrideWith((ref, category) async => const []), + speciesSearchProvider.overrideWith((ref, query) async => const []), + speciesRepositoryProvider.overrideWithValue(repository), + speciesLookupServiceProvider.overrideWithValue(_FakeLookup()), + speciesLookupLocaleProvider.overrideWithValue('en'), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SpeciesPickerSheet( + scrollController: ScrollController(), + onSpeciesSelected: (_, _, _) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _typeAndAdd(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).first, 'whale'); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('whale').last); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a lookup result creates the species with its fields and ' + 'opens the sighting dialog', (tester) async { + final repository = _RecordingRepository(); + await _pump(tester, repository: repository); + + await _typeAndAdd(tester); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Whale Shark')); + await tester.pumpAndSettle(); + + expect(repository.created.single['scientificName'], 'Rhincodon typus'); + expect(repository.created.single['category'], SpeciesCategory.shark); + expect(repository.created.single['taxonomyClass'], 'Chondrichthyes'); + expect(repository.nameOnlyCreations, 0); + expect(find.byType(AlertDialog), findsOneWidget); + }); + + testWidgets('an existing species with that scientific name is reused', ( + tester, + ) async { + const existing = Species( + id: 'sp_whale_shark', + commonName: 'Whale Shark', + scientificName: 'Rhincodon typus', + category: SpeciesCategory.shark, + isBuiltIn: true, + ); + final repository = _RecordingRepository(existing: existing); + await _pump(tester, repository: repository); + + await _typeAndAdd(tester); + await tester.tap(find.text('Look up')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Whale Shark')); + await tester.pumpAndSettle(); + + expect(repository.created, isEmpty); + expect(find.byType(AlertDialog), findsOneWidget); + }); + + testWidgets('Create without lookup keeps the name-only path', ( + tester, + ) async { + final repository = _RecordingRepository(); + await _pump(tester, repository: repository); + + await _typeAndAdd(tester); + await tester.tap(find.text('Create without lookup')); + await tester.pumpAndSettle(); + + expect(repository.nameOnlyCreations, 1); + expect(repository.created, isEmpty); + expect(find.byType(AlertDialog), findsOneWidget); + }); +} +``` + +The empty-state add button's label is `diveLog_speciesPicker_addNew(query)`; `_typeAndAdd` finds it as the last widget whose text contains the query (the search field is the first). + +- [ ] **Step 3: Run both tests to verify they fail** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/data/repositories/species_repository_lookup_test.dart test/features/dive_log/presentation/widgets/pickers/species_picker_sheet_lookup_test.dart` +Expected: compilation errors (`findSpeciesByScientificName` undefined) and the picker tests failing on the missing sheet. + +- [ ] **Step 4: Implement** + +In `species_repository.dart`, after `getOrCreateSpecies` add: + +```dart + /// The species whose scientific name equals [scientificName], ignoring + /// case, or null. Lets a lookup select an existing row (built-in or + /// custom) instead of creating a twin. + Future findSpeciesByScientificName( + String scientificName, + ) async { + final needle = scientificName.trim().toLowerCase(); + if (needle.isEmpty) return null; + final row = await _db + .customSelect( + 'SELECT id FROM species WHERE LOWER(scientific_name) = ? LIMIT 1', + variables: [Variable.withString(needle)], + ) + .getSingleOrNull(); + if (row == null) return null; + return getSpeciesById(row.data['id'] as String); + } +``` + +In `species_picker_sheet.dart`, add the imports + +```dart +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/widgets/species_lookup_sheet.dart'; +``` + +and replace `_addCustomSpecies` with: + +```dart + /// The empty state's "add" path: look the name up first so a custom + /// species gets its scientific name, category and class; "Create without + /// lookup" (or a dismissed sheet) keeps the old name-only creation, so an + /// offline diver loses nothing. + Future _addCustomSpecies(String name) async { + final result = await showSpeciesLookupSheet(context, initialQuery: name); + if (!mounted) return; + final repository = ref.read(speciesRepositoryProvider); + final species = result == null + ? await repository.getOrCreateSpecies( + commonName: name, + category: SpeciesCategory.other, + ) + : await _speciesFromLookup(repository, result); + if (mounted) _showSightingDetails(species); + } + + Future _speciesFromLookup( + SpeciesRepository repository, + SpeciesLookupResult result, + ) async { + final existing = await repository.findSpeciesByScientificName( + result.scientificName, + ); + if (existing != null) return existing; + return repository.createSpecies( + commonName: result.commonName, + scientificName: result.scientificName, + category: result.category, + taxonomyClass: result.taxonomyClass, + ); + } +``` + +with `import 'package:submersion/features/marine_life/data/repositories/species_repository.dart';` for the type. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/data/repositories/species_repository_lookup_test.dart test/features/dive_log/presentation/widgets/pickers/` +Expected: `All tests passed!`, exit code 0 (the existing picker tests included). + +- [ ] **Step 6: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/marine_life/data/repositories/species_repository.dart lib/features/dive_log/presentation/widgets/pickers/species_picker_sheet.dart test/features/marine_life/data/repositories/species_repository_lookup_test.dart test/features/dive_log/presentation/widgets/pickers/species_picker_sheet_lookup_test.dart +git add lib/features/marine_life/data/repositories/species_repository.dart lib/features/dive_log/presentation/widgets/pickers/species_picker_sheet.dart test/features/marine_life/data/repositories/species_repository_lookup_test.dart test/features/dive_log/presentation/widgets/pickers/species_picker_sheet_lookup_test.dart +git commit -m "feat(dive-log): look a new species up online from the species picker" +``` + +--- + +### Task 9: Add a species from the reef tier's unmatched GBIF names + +**Files:** +- Modify: `lib/features/reef/presentation/widgets/nearby_species_tier.dart` (`_unmatchedChip` becomes an `ActionChip`; new `_addFromLookup`) +- Test: `test/features/reef/presentation/widgets/nearby_species_tier_lookup_test.dart` (new) + +**Interfaces:** +- Consumes: Tasks 4, 6, 8 (`findSpeciesByScientificName`, `createSpecies`); `siteExpectedSpeciesNotifierProvider(siteId).notifier.addSpecies(String)`; `reef_species_addFromLookup` string. +- Produces: tapping an unmatched chip creates (or reuses) the species and adds it to the site's expected list; when the lookup is not a single exact species match, the sheet opens prefilled. + +- [ ] **Step 1: Write the failing widget test** + +`test/features/reef/presentation/widgets/nearby_species_tier_lookup_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/marine_life/data/services/species_lookup_service.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_lookup_providers.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart'; +import 'package:submersion/features/reef/domain/entities/nearby_species.dart'; +import 'package:submersion/features/reef/domain/entities/reef_data_status.dart'; +import 'package:submersion/features/reef/domain/entities/reef_snapshot.dart'; +import 'package:submersion/features/reef/presentation/providers/reef_providers.dart'; +import 'package:submersion/features/reef/presentation/widgets/nearby_species_tier.dart'; + +import '../../../../helpers/l10n_test_helpers.dart'; + +const _location = GeoPoint(12.16, -68.28); + +ReefSnapshot _snapshotWithUnmatched(List names) => ReefSnapshot( + habitat: const ReefPart.empty(), + health: const ReefPart.empty(), + protection: const ReefPart.empty(), + species: ReefPart.ok(NearbySpecies(matched: const [], unmatchedNames: names)), +); + +class _FakeLookup implements SpeciesLookupService { + _FakeLookup(this.hits); + final List hits; + final List resolved = []; + + @override + Future> search(String query, {required String locale}) async => hits; + + @override + Future resolve(int taxonId, {required String locale}) async { + resolved.add(taxonId); + return const SpeciesLookupResult( + taxonId: 7, + commonName: 'Stove-pipe Sponge', + scientificName: 'Aplysina archeri', + category: SpeciesCategory.invertebrate, + taxonomyClass: 'Demospongiae', + ); + } +} + +class _RecordingRepository extends Fake implements SpeciesRepository { + final List created = []; + final List added = []; + + @override + Future> getExpectedSpeciesForSite(String siteId) async => + const []; + + @override + Future findSpeciesByScientificName(String scientificName) async => null; + + @override + Future createSpecies({ + required String commonName, + String? scientificName, + required SpeciesCategory category, + String? taxonomyClass, + String? description, + }) async { + created.add(scientificName ?? commonName); + return Species( + id: 'new-1', + commonName: commonName, + scientificName: scientificName, + category: category, + ); + } + + @override + Future addExpectedSpecies({ + required String siteId, + required String speciesId, + String notes = '', + }) async { + added.add(speciesId); + return SiteSpeciesEntry( + id: 'entry-1', + siteId: siteId, + speciesId: speciesId, + speciesName: 'Stove-pipe Sponge', + category: SpeciesCategory.invertebrate, + createdAt: DateTime(2026), + ); + } +} + +Widget _harness(_FakeLookup lookup, _RecordingRepository repository) => + ProviderScope( + overrides: [ + reefSnapshotProvider( + ReefSnapshotRequest(location: _location), + ).overrideWith((ref) async => _snapshotWithUnmatched(['Aplysina archeri'])), + allSpeciesProvider.overrideWith( + (ref) async => const [ + Species(id: 'sp_x', commonName: 'Filler', category: SpeciesCategory.fish), + ], + ), + speciesRepositoryProvider.overrideWithValue(repository), + speciesLookupServiceProvider.overrideWithValue(lookup), + speciesLookupLocaleProvider.overrideWithValue('en'), + ], + child: localizedMaterialApp( + locale: const Locale('en'), + home: Scaffold( + body: NearbySpeciesTier(siteId: 'site-1', location: _location), + ), + ), + ); + +const _exact = SpeciesLookupHit( + taxonId: 7, + scientificName: 'Aplysina archeri', + rank: 'species', + rankLevel: 10, + commonName: 'Stove-pipe Sponge', + observationCount: 12, +); + +void main() { + testWidgets('a single exact match creates the species and adds it to the ' + 'site without opening the sheet', (tester) async { + final lookup = _FakeLookup(const [_exact]); + final repository = _RecordingRepository(); + await tester.pumpWidget(_harness(lookup, repository)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Aplysina archeri')); + await tester.pumpAndSettle(); + + expect(lookup.resolved, [7]); + expect(repository.created, ['Aplysina archeri']); + expect(repository.added, ['new-1']); + expect(find.text('Look up a species'), findsNothing); + }); + + testWidgets('an ambiguous answer opens the sheet prefilled', (tester) async { + final lookup = _FakeLookup(const [ + _exact, + SpeciesLookupHit( + taxonId: 8, + scientificName: 'Aplysina archeri var. b', + rank: 'variety', + rankLevel: 5, + observationCount: 1, + ), + ]); + final repository = _RecordingRepository(); + await tester.pumpWidget(_harness(lookup, repository)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Aplysina archeri')); + await tester.pumpAndSettle(); + + expect(find.text('Look up a species'), findsOneWidget); + expect( + find.widgetWithText(TextField, 'Aplysina archeri'), + findsOneWidget, + ); + expect(repository.created, isEmpty); + }); +} +``` + +The `allSpeciesProvider` override is non-empty on purpose: the tier renders nothing when the catalog is empty. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/reef/presentation/widgets/nearby_species_tier_lookup_test.dart` +Expected: the first test fails (the chip is not tappable; nothing created). + +- [ ] **Step 3: Make the unmatched chip act** + +In `nearby_species_tier.dart`, add the imports + +```dart +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/marine_life/domain/entities/species_lookup.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_lookup_providers.dart'; +import 'package:submersion/features/marine_life/presentation/widgets/species_lookup_sheet.dart'; +``` + +replace `_unmatchedChip` with: + +```dart + /// A GBIF name the catalog lacks. The chip looks it up and adds it: this + /// list is, in effect, the species missing from the catalog at this site. + Widget _unmatchedChip(BuildContext context, String scientificName) { + final theme = Theme.of(context); + + return ActionChip( + avatar: const ExcludeSemantics(child: Icon(Icons.help_outline, size: 16)), + label: Text(scientificName, style: theme.textTheme.bodySmall), + tooltip: context.l10n.reef_species_addFromLookup, + onPressed: () => _addFromLookup(scientificName), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4), + ); + } +``` + +and add to the state class: + +```dart + /// One resolvable hit whose scientific name is the GBIF name goes straight + /// through; anything else (none, several, a lookup failure) opens the + /// sheet with the name prefilled so the diver decides. + Future _addFromLookup(String scientificName) async { + final lookup = ref.read(speciesLookupServiceProvider); + final locale = ref.read(speciesLookupLocaleProvider); + SpeciesLookupResult? result; + try { + final hits = await lookup.search(scientificName, locale: locale); + final exact = hits + .where( + (h) => + h.isResolvable && + h.scientificName.toLowerCase() == scientificName.toLowerCase(), + ) + .toList(); + if (exact.length == 1) { + result = await lookup.resolve(exact.single.taxonId, locale: locale); + } + } on SpeciesLookupException { + result = null; + } + if (!mounted) return; + result ??= await showSpeciesLookupSheet(context, initialQuery: scientificName); + if (result == null || !mounted) return; + + final repository = ref.read(speciesRepositoryProvider); + final species = + await repository.findSpeciesByScientificName(result.scientificName) ?? + await repository.createSpecies( + commonName: result.commonName, + scientificName: result.scientificName, + category: result.category, + taxonomyClass: result.taxonomyClass, + ); + if (!mounted) return; + await ref + .read(siteExpectedSpeciesNotifierProvider(widget.siteId).notifier) + .addSpecies(species.id); + } +``` + +`SpeciesRepository` is already reachable through `speciesRepositoryProvider` (imported via `species_providers.dart`); keep the explicit repository import only if the analyzer needs the type. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/reef/presentation/widgets/` +Expected: `All tests passed!`, exit code 0 (the existing tier tests count `RawChip`s; an `ActionChip` is still one `RawChip`). + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/reef/presentation/widgets/nearby_species_tier.dart test/features/reef/presentation/widgets/nearby_species_tier_lookup_test.dart +git add lib/features/reef/presentation/widgets/nearby_species_tier.dart test/features/reef/presentation/widgets/nearby_species_tier_lookup_test.dart +git commit -m "feat(reef): add an unmatched nearby species to the catalog through the lookup" +``` + +--- + +### Task 10: "Suggest for the catalog": URL builder, launcher, detail page menu + +**Files:** +- Create: `lib/features/marine_life/domain/services/species_suggestion_url.dart` +- Create: `lib/features/marine_life/presentation/helpers/species_suggestion_launcher.dart` +- Modify: `lib/features/marine_life/presentation/pages/species_detail_page.dart` (AppBar actions; imports) +- Test: `test/features/marine_life/domain/services/species_suggestion_url_test.dart`, `test/features/marine_life/presentation/pages/species_detail_page_suggest_test.dart` + +**Interfaces:** +- Consumes: `Species`; `packageInfoProvider` (`FutureProvider` in `settings_providers.dart`) and `formatAppVersion(PackageInfo)` from `lib/core/utils/app_version.dart`; `localeProvider`; `launchUrl` from `url_launcher`; `Clipboard`; Task 5 strings; `speciesProvider(id)`, `speciesStatisticsProvider(id)` and `speciesSightingsProvider(id)` for the page test. +- Produces: + ```dart + Uri buildSpeciesSuggestionUrl({required Species species, required String locale, required String appVersion}); + typedef UrlLauncher = Future Function(Uri uri); + final speciesSuggestionLaunchProvider = Provider; + Future launchSpeciesSuggestion(BuildContext context, Uri uri, {required UrlLauncher launch}); + ``` + +- [ ] **Step 1: Write the failing builder test** + +`test/features/marine_life/domain/services/species_suggestion_url_test.dart`: + +```dart +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; +import 'package:submersion/features/marine_life/domain/services/species_suggestion_url.dart'; + +const _species = Species( + id: 'c1', + commonName: 'Stove-pipe Sponge', + scientificName: 'Aplysina archeri', + category: SpeciesCategory.invertebrate, + taxonomyClass: 'Demospongiae', + description: 'Tall purple tubes on the wall.', +); + +Map _bodyJson(Uri uri) { + final body = uri.queryParameters['body']!; + final start = body.indexOf('```json') + '```json'.length; + final end = body.lastIndexOf('```'); + return jsonDecode(body.substring(start, end).trim()) as Map; +} + +void main() { + test('targets a new issue with the title, label and a JSON body', () { + final uri = buildSpeciesSuggestionUrl( + species: _species, + locale: 'de', + appVersion: '1.7.6.7001', + ); + + expect(uri.scheme, 'https'); + expect(uri.host, 'github.com'); + expect(uri.path, '/submersion-app/submersion/issues/new'); + expect(uri.queryParameters['title'], 'Species suggestion: Stove-pipe Sponge'); + expect(uri.queryParameters['labels'], 'species-suggestion'); + + final json = _bodyJson(uri); + expect(json['commonName'], 'Stove-pipe Sponge'); + expect(json['scientificName'], 'Aplysina archeri'); + expect(json['category'], 'invertebrate'); + expect(json['taxonomyClass'], 'Demospongiae'); + expect(json['description'], 'Tall purple tubes on the wall.'); + expect(json['locale'], 'de'); + expect(json['appVersion'], '1.7.6.7001'); + }); + + test('keeps the whole URL under the cap by truncating the description', () { + final long = _species.copyWith(description: 'x' * 20000); + + final uri = buildSpeciesSuggestionUrl( + species: long, + locale: 'en', + appVersion: '1.0.0.1', + ); + + expect(uri.toString().length, lessThanOrEqualTo(8000)); + expect(_bodyJson(uri)['scientificName'], 'Aplysina archeri'); + }); + + test('encodes non-ASCII names', () { + final uri = buildSpeciesSuggestionUrl( + species: _species.copyWith(commonName: 'Süßwasser Grundel'), + locale: 'de', + appVersion: '1.0.0.1', + ); + + expect(uri.queryParameters['title'], 'Species suggestion: Süßwasser Grundel'); + expect(uri.toString(), isNot(contains('ü'))); + }); +} +``` + +- [ ] **Step 2: Write the failing page test** + +`test/features/marine_life/presentation/pages/species_detail_page_suggest_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; +import 'package:submersion/features/marine_life/presentation/helpers/species_suggestion_launcher.dart'; +import 'package:submersion/features/marine_life/presentation/pages/species_detail_page.dart'; +import 'package:submersion/features/marine_life/presentation/providers/seen_species_providers.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/statistics/domain/entities/species_statistics.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_app.dart'; + +const _custom = Species( + id: 'c1', + commonName: 'Stove-pipe Sponge', + scientificName: 'Aplysina archeri', + category: SpeciesCategory.invertebrate, +); + +const _builtIn = Species( + id: 'sp_whale_shark', + commonName: 'Whale Shark', + category: SpeciesCategory.shark, + isBuiltIn: true, +); + +Future> _pump(WidgetTester tester, Species species) async { + final launched = []; + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + ...overrides, + speciesProvider(species.id).overrideWith((ref) async => species), + speciesStatisticsProvider(species.id).overrideWith( + (ref) async => SpeciesStatistics.empty, + ), + speciesSightingsProvider(species.id).overrideWith((ref) async => const []), + packageInfoProvider.overrideWith( + (ref) async => PackageInfo( + appName: 'Submersion', + packageName: 'app.submersion', + version: '1.7.6', + buildNumber: '7001', + ), + ), + localeProvider.overrideWithValue('en'), + speciesSuggestionLaunchProvider.overrideWithValue((uri) async { + launched.add(uri); + return true; + }), + ], + child: SpeciesDetailPage(speciesId: species.id), + ), + ); + await tester.pumpAndSettle(); + return launched; +} + +void main() { + testWidgets('a custom species offers Suggest for the catalog', ( + tester, + ) async { + final launched = await _pump(tester, _custom); + + await tester.tap(find.byKey(const ValueKey('species_detail_menu'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Suggest for the catalog')); + await tester.pumpAndSettle(); + + expect(launched, hasLength(1)); + expect(launched.single.host, 'github.com'); + expect( + launched.single.queryParameters['title'], + 'Species suggestion: Stove-pipe Sponge', + ); + }); + + testWidgets('a built-in species has no menu', (tester) async { + await _pump(tester, _builtIn); + + expect(find.byKey(const ValueKey('species_detail_menu')), findsNothing); + }); +} +``` + +- [ ] **Step 3: Run both tests to verify they fail** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/domain/services/species_suggestion_url_test.dart test/features/marine_life/presentation/pages/species_detail_page_suggest_test.dart` +Expected: compilation errors, the two new source files are missing. + +- [ ] **Step 4: Implement** + +`lib/features/marine_life/domain/services/species_suggestion_url.dart`: + +```dart +import 'dart:convert'; + +import 'package:submersion/features/marine_life/domain/entities/species.dart'; + +/// Where suggestions land: a prefilled new-issue page in the project's +/// GitHub repository. The app sends nothing itself; the diver posts the +/// issue from their own account in the browser. +const String speciesSuggestionRepository = 'submersion-app/submersion'; +const String speciesSuggestionLabel = 'species-suggestion'; + +/// Browsers and GitHub both accept URLs comfortably below this. +const int speciesSuggestionMaxUrlLength = 8000; + +/// Builds the prefilled issue URL for [species]. The body carries a JSON +/// block a maintainer (or a script) can lift straight into the catalog. +/// The description is the only free-text field and is truncated first when +/// the URL would exceed the cap. +Uri buildSpeciesSuggestionUrl({ + required Species species, + required String locale, + required String appVersion, +}) { + var description = species.description ?? ''; + Uri build() => Uri.https( + 'github.com', + '/$speciesSuggestionRepository/issues/new', + { + 'title': 'Species suggestion: ${species.commonName}', + 'labels': speciesSuggestionLabel, + 'body': _body(species, description, locale, appVersion), + }, + ); + + var uri = build(); + while (uri.toString().length > speciesSuggestionMaxUrlLength && + description.isNotEmpty) { + final over = uri.toString().length - speciesSuggestionMaxUrlLength; + // Encoded characters can be three bytes each; cut generously. + final cut = (over ~/ 3 + 1).clamp(1, description.length); + description = description.substring(0, description.length - cut); + uri = build(); + } + return uri; +} + +String _body( + Species species, + String description, + String locale, + String appVersion, +) { + final json = const JsonEncoder.withIndent(' ').convert({ + 'commonName': species.commonName, + 'scientificName': species.scientificName, + 'category': species.category.name, + 'taxonomyClass': species.taxonomyClass, + 'description': description, + 'locale': locale, + 'appVersion': appVersion, + }); + return 'Please consider adding this species to the bundled catalog.\n\n' + '```json\n$json\n```\n'; +} +``` + +`lib/features/marine_life/presentation/helpers/species_suggestion_launcher.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:submersion/l10n/l10n_extension.dart'; + +typedef UrlLauncher = Future Function(Uri uri); + +/// Opens URLs in the external browser. A provider so widget tests can +/// record the URL instead of leaving the app. +final speciesSuggestionLaunchProvider = Provider((ref) { + return (uri) => launchUrl(uri, mode: LaunchMode.externalApplication); +}); + +/// Same shape as Settings' report-issue action: no `canLaunchUrl` guard (it +/// false-negatives for https on Android 11+), external mode, and a copy-link +/// snackbar when the launch fails. +Future launchSpeciesSuggestion( + BuildContext context, + Uri uri, { + required UrlLauncher launch, +}) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + var didLaunch = false; + try { + didLaunch = await launch(uri); + } catch (_) { + didLaunch = false; + } + if (didLaunch) return; + messenger.showSnackBar( + SnackBar( + content: Text(l10n.marineLife_suggest_couldNotOpen), + action: SnackBarAction( + label: l10n.marineLife_suggest_copyLink, + onPressed: () => Clipboard.setData(ClipboardData(text: uri.toString())), + ), + ), + ); +} +``` + +In `species_detail_page.dart`, add the imports + +```dart +import 'package:submersion/core/utils/app_version.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; +import 'package:submersion/features/marine_life/domain/services/species_suggestion_url.dart'; +import 'package:submersion/features/marine_life/presentation/helpers/species_suggestion_launcher.dart'; +``` + +replace the `actions:` list with: + +```dart + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: context.l10n.marineLife_speciesDetail_editTooltip, + onPressed: () => context.push('/species/$speciesId/edit'), + ), + // Only a custom species can be suggested: built-ins already are + // the catalog. + if (speciesAsync.value case final species? when !species.isBuiltIn) + PopupMenuButton( + key: const ValueKey('species_detail_menu'), + onSelected: (value) { + if (value == 'suggest') _suggestForCatalog(context, ref, species); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'suggest', + child: Text( + context.l10n.marineLife_speciesDetail_suggestForCatalog, + ), + ), + ], + ), + ], +``` + +and add to the class: + +```dart + Future _suggestForCatalog( + BuildContext context, + WidgetRef ref, + Species species, + ) async { + final info = await ref.read(packageInfoProvider.future); + if (!context.mounted) return; + final uri = buildSpeciesSuggestionUrl( + species: species, + locale: ref.read(localeProvider), + appVersion: formatAppVersion(info), + ); + await launchSpeciesSuggestion( + context, + uri, + launch: ref.read(speciesSuggestionLaunchProvider), + ); + } +``` + +`packageInfoProvider` and `localeProvider` come from `settings_providers.dart`, which the page already imports. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup && flutter test test/features/marine_life/domain/services/species_suggestion_url_test.dart test/features/marine_life/presentation/pages/` +Expected: `All tests passed!`, exit code 0. + +- [ ] **Step 6: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format lib/features/marine_life test/features/marine_life +git add lib/features/marine_life/domain/services/species_suggestion_url.dart lib/features/marine_life/presentation/helpers/species_suggestion_launcher.dart lib/features/marine_life/presentation/pages/species_detail_page.dart test/features/marine_life/domain/services/species_suggestion_url_test.dart test/features/marine_life/presentation/pages/species_detail_page_suggest_test.dart +git commit -m "feat(marine-life): suggest a custom species for the catalog through a prefilled issue" +``` + +--- + +### Task 11: Docs, formatting, analysis, full test run + +**Files:** +- Modify: `docs/features/marine-life.md` + +- [ ] **Step 1: Update the feature doc** + +In `docs/features/marine-life.md`, in the `## Adding Custom Species` section, after the numbered steps add: + +```markdown +### Looking a species up online + +When you add a species, tap **Look up online** to search iNaturalist by common +or scientific name. Choosing a result fills the common name (in your language +where iNaturalist has it), the scientific name, the category and the taxonomy +class; you can still edit anything before saving. The same lookup is offered +when you add a species from the dive's marine life picker, and on a dive site's +"Recorded nearby" list for names the catalog does not know yet. + +Lookups happen only when you tap **Look up**; nothing is sent while you type, +and nothing from iNaturalist is stored except the fields you save. + +### Suggesting a species for the catalog + +On a custom species, the menu offers **Suggest for the catalog**. It opens a +prefilled GitHub issue in your browser with the species' details; posting it +is up to you. +``` + +- [ ] **Step 2: Format and confirm only intended files changed** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +dart format . +git status --short +``` + +Expected: only `docs/features/marine-life.md` (plus the untracked spec file copied in before the plan started). + +- [ ] **Step 3: Analyze the whole project** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +flutter analyze --fatal-infos +``` + +Expected: `Analyzing species-online-lookup...` then `No issues found!`. + +- [ ] **Step 4: Run the full test suite once** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +flutter test +``` + +Detach it to a log with the exit code appended and read the summary line yourself; check first that no other session's `flutter_tester` processes are running (`ps -axo command | grep flutter_tester | grep -o "packages=[^ ]*"`). Expected: `All tests passed!` with exit 0 and a summary line present; a run ending in `Bad state: Cannot close sink while adding stream` with no summary was killed and must be rerun. + +- [ ] **Step 5: Commit the docs** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-online-lookup +git add docs/features/marine-life.md +git commit -m "docs: describe the online species lookup and catalog suggestions" +``` + +Do not push. Report the branch state and stop. + +--- + +## Self-review notes + +- Spec coverage: section 3 API shape (Task 1 fixtures and parsers); 4 service (Task 3); 5 mapper (Task 2); 6 sheet (Task 6); 7 entry points (Tasks 7, 8, 9, with the scientific-name dedupe in Task 8); 8 contribution channel (Task 10); 9 l10n (Task 5); 10 tests across tasks; docs (Task 11). Locale resolution and DI (Task 4) serve sections 4 and 6. +- Deviations, deliberate: the spec's `SpeciesLookupException(kind)` gained an optional `detail` for logs; the "Loading details..." string exists but the sheet shows a spinner in the trailing slot instead of the text (the key stays for a later polish); the reef chip treats a lookup failure as "open the sheet" rather than an error toast, which keeps the tier's one-tap promise honest. +- Type consistency: `SpeciesLookupService` (Task 3) is what every fake implements in Tasks 6 to 9; `SpeciesLookupResult` fields used by Tasks 7 to 9 (`commonName`, `scientificName`, `category`, `taxonomyClass`) are all declared in Task 1; `findSpeciesByScientificName` is added in Task 8 before Task 9 calls it; `speciesSuggestionLaunchProvider` and `launchSpeciesSuggestion` are both in Task 10's helper file; `speciesSightingsProvider` comes from the merged Species page work (`seen_species_providers.dart`), which is on `origin/main`. diff --git a/docs/superpowers/plans/2026-08-27-species-photos-phase-a.md b/docs/superpowers/plans/2026-08-27-species-photos-phase-a.md new file mode 100644 index 0000000000..ddf5bf700a --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-species-photos-phase-a.md @@ -0,0 +1,4164 @@ +# Species Photos, Phase A, Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a diver tag the photos already on their dives with the species in them, see every photo of a species in one gallery on the species detail page, tag from the photo viewer, and import camera-roll photos into a species through the existing reviewed importer. + +**Architecture:** The dormant `media_species` table becomes live behind a new read/write `MediaSpeciesRepository`; a `SpeciesTaggingService` composes it with the media and species repositories so a tag adds a missing sighting. Sync registration copies `site_species` point for point (a clockless child exported by its parent's HLC). UI is additive: a Photos section on the species detail page, a purpose-built tag picker page, a viewer action with a bottom sheet, and a wrapper viewer page, all following the dive and site media sections' patterns. + +**Tech Stack:** Flutter, Riverpod (`package:submersion/core/providers/provider.dart`), Drift (typed selects with joins plus `customSelect` for aggregates), the shared `SelectionController` and `DragSelectGridView`, `flutter gen-l10n` ARB localization, `flutter_test` with the media widget harness. + +**Spec:** `docs/superpowers/specs/2026-08-26-species-photos-design.md` (sections 1 to 6 and 8 to 10 are phase A; section 7 is phase B and is NOT in this plan). + +## Global Constraints + +- Work only in `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos` on branch `worktree-species-photos` (cut from `origin/main` dd6b435f62a, codegen already run). Every path below is relative to that root; every shell command starts with `cd` into it. `flutter analyze` prints `Analyzing species-photos...` when it runs in the right tree; that line is the receipt. +- **No schema change.** `media_species` already exists (created in the `CREATE TABLE IF NOT EXISTS media_species` migration step and by `createAll()` on fresh databases). Do not touch `lib/core/database/database.dart`. Uniqueness of (media, species) is enforced in the repository; gallery queries are `DISTINCT`. +- The bounding-box and notes columns of `media_species` stay unused. +- "Attached or absent" is untouched: no new `MediaAttachTarget` case, no change to the orphan sweep, unlink or deletion cascades. A tag only ever sits on a photo that already has a dive or site. +- Tagging a species on a photo whose dive has no sighting of it adds the sighting (count 1, no notes). Untagging never removes a sighting. +- No em-dashes anywhere (code, comments, docs, ARB strings, commit messages). No emojis in code or docs. +- Immutability: entities are `const`-constructible `Equatable`s with `copyWith`; never mutate lists passed in. +- Imports grouped: dart, flutter, packages, local. Files snake_case, classes PascalCase, 200-400 lines typical, 800 max. +- Tests first. Run single test files with `flutter test ` and check the exit code; never pipe the command into `grep` or `tail` (the pipe replaces the exit code). +- Localization: every new user-visible string is an ARB key in all 11 locales (`ar de en es fr he hu it nl pt zh`); the `flutter gen-l10n` output under `lib/l10n/arb/` is tracked and committed with the ARB change. +- Dates and depths go through `UnitFormatter(ref.watch(settingsProvider))`. +- `dart format .` before every commit. +- Sync: a `FutureProvider` overridden to an error never delivers under `testWidgets` in this repo, so widget tests cover success paths; error branches stay trivial and analyzer-checked. + +--- + +### Task 1: `MediaSpeciesRepository` core (tags for a photo, add, remove, tick) and shared test fixtures + +**Files:** +- Create: `lib/features/media/data/repositories/media_species_repository.dart` +- Create: `test/features/media/data/repositories/species_photo_fixtures.dart` (test support, reused by Tasks 2, 4 and 5) +- Test: `test/features/media/data/repositories/media_species_repository_test.dart` + +**Interfaces:** +- Consumes: `AppDatabase` via `DatabaseService.instance.database`; `SyncRepository.markRecordPending({entityType, recordId, localUpdatedAt})` and `logDeletion({entityType, recordId})` from `lib/core/data/repositories/sync_repository.dart`; `SyncEventBus.notifyLocalChange()` from `lib/core/services/sync/sync_event_bus.dart`; `MediaSpeciesTag` from `lib/features/media/domain/entities/media_item.dart` (fields `id, mediaId, speciesId, sightingId, bboxX, bboxY, bboxWidth, bboxHeight, notes, createdAt`); the Drift row class `MediaSpecy` and companion `MediaSpeciesCompanion`. +- Produces: + ```dart + class MediaSpeciesRepository { + Stream watchTagChanges(); + Future> getTagsForMedia(String mediaId); + Future>> getTagsForMediaIds(List mediaIds); + Future addTag({required String mediaId, required String speciesId, String? sightingId}); + Future removeTag({required String mediaId, required String speciesId}); + } + ``` + and the fixture helpers `insertTestDiver(id)`, `insertTestSite(id, name)`, `insertTestDive({id, at, diverId, siteId, number})`, `insertTestSpecies({id, name, category, builtIn})`, `insertTestSighting({id, diveId, speciesId})`, `insertTestMedia({id, diveId, siteId, takenAt, fileType})`. + +Background: `media_species` has no `hlc` column and no `updatedAt`; it is a "clockless child" exactly like `site_species`, so add and remove follow `SpeciesRepository.addExpectedSpecies` / `removeExpectedSpecies` to the letter (insert, `markRecordPending`, notify; select id by pair, delete by id, `logDeletion`, notify). Test databases run with foreign keys off, so insert parents before children anyway. + +- [ ] **Step 1: Write the shared fixtures** + +`test/features/media/data/repositories/species_photo_fixtures.dart`: + +```dart +import 'package:drift/drift.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; + +/// Row-level fixtures for species-photo tests. Every helper inserts one row +/// into the test database and nothing else; compose them in the order +/// parent then child (divers, sites, dives, species, sightings, media). +Future insertTestDiver(String id) async { + final db = DatabaseService.instance.database; + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.divers) + .insertOnConflictUpdate( + DiversCompanion( + id: Value(id), + name: Value('Diver $id'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); +} + +Future insertTestSite(String id, String name) async { + final db = DatabaseService.instance.database; + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diveSites) + .insertOnConflictUpdate( + DiveSitesCompanion( + id: Value(id), + name: Value(name), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); +} + +Future insertTestDive({ + required String id, + required DateTime at, + String? diverId, + String? siteId, + int? number, +}) async { + final db = DatabaseService.instance.database; + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveNumber: Value(number), + diveDateTime: Value(at.millisecondsSinceEpoch), + diverId: Value(diverId), + siteId: Value(siteId), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); +} + +Future insertTestSpecies({ + required String id, + required String name, + SpeciesCategory category = SpeciesCategory.fish, + bool builtIn = false, +}) async { + final db = DatabaseService.instance.database; + await db + .into(db.species) + .insert( + SpeciesCompanion( + id: Value(id), + commonName: Value(name), + category: Value(category.name), + isBuiltIn: Value(builtIn), + ), + ); +} + +Future insertTestSighting({ + required String id, + required String diveId, + required String speciesId, +}) async { + final db = DatabaseService.instance.database; + await db + .into(db.sightings) + .insert( + SightingsCompanion( + id: Value(id), + diveId: Value(diveId), + speciesId: Value(speciesId), + ), + ); +} + +/// A photo row. [takenAt] orders galleries (newest first); it is stored as +/// epoch millis the way the importer stores wall-clock UTC. +Future insertTestMedia({ + required String id, + String? diveId, + String? siteId, + DateTime? takenAt, + String fileType = 'photo', +}) async { + final db = DatabaseService.instance.database; + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.media) + .insert( + MediaCompanion( + id: Value(id), + diveId: Value(diveId), + siteId: Value(siteId), + filePath: Value('/tmp/$id.jpg'), + fileType: Value(fileType), + takenAt: Value(takenAt?.millisecondsSinceEpoch), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); +} +``` + +- [ ] **Step 2: Write the failing repository test** + +`test/features/media/data/repositories/media_species_repository_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; + +import '../../../../helpers/test_database.dart'; +import 'species_photo_fixtures.dart'; + +void main() { + late MediaSpeciesRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = MediaSpeciesRepository(); + await insertTestDive(id: 'd1', at: DateTime(2024, 1, 10)); + await insertTestSpecies(id: 'sp_whale_shark', name: 'Whale Shark'); + await insertTestSpecies(id: 'c1', name: 'My Nudibranch'); + await insertTestSighting(id: 'sg1', diveId: 'd1', speciesId: 'sp_whale_shark'); + await insertTestMedia(id: 'm1', diveId: 'd1'); + await insertTestMedia(id: 'm2', diveId: 'd1'); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('a photo starts with no tags', () async { + expect(await repository.getTagsForMedia('m1'), isEmpty); + }); + + test('addTag inserts a row linked to the sighting', () async { + final tag = await repository.addTag( + mediaId: 'm1', + speciesId: 'sp_whale_shark', + sightingId: 'sg1', + ); + + expect(tag.mediaId, 'm1'); + expect(tag.speciesId, 'sp_whale_shark'); + expect(tag.sightingId, 'sg1'); + final tags = await repository.getTagsForMedia('m1'); + expect(tags.single.id, tag.id); + }); + + test('addTag is idempotent for the same photo and species', () async { + final first = await repository.addTag(mediaId: 'm1', speciesId: 'c1'); + final second = await repository.addTag(mediaId: 'm1', speciesId: 'c1'); + + expect(second.id, first.id); + expect(await repository.getTagsForMedia('m1'), hasLength(1)); + }); + + test('getTagsForMediaIds groups tags by photo and skips untagged ones', + () async { + await repository.addTag(mediaId: 'm1', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'm1', speciesId: 'c1'); + + final byMedia = await repository.getTagsForMediaIds(['m1', 'm2']); + + expect(byMedia.keys, ['m1']); + expect( + byMedia['m1']!.map((t) => t.speciesId).toSet(), + {'sp_whale_shark', 'c1'}, + ); + }); + + test('removeTag deletes the pair and is a no-op when absent', () async { + await repository.addTag(mediaId: 'm1', speciesId: 'c1'); + + await repository.removeTag(mediaId: 'm1', speciesId: 'c1'); + await repository.removeTag(mediaId: 'm1', speciesId: 'c1'); + + expect(await repository.getTagsForMedia('m1'), isEmpty); + }); + + test('watchTagChanges ticks when a tag is written', () async { + final ticks = []; + final sub = repository.watchTagChanges().listen(ticks.add); + addTearDown(sub.cancel); + + await repository.addTag(mediaId: 'm2', speciesId: 'c1'); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(ticks, isNotEmpty); + // The database is what ticked, not the repository: a sync writing the + // row directly must reach the same watchers. + final db = DatabaseService.instance.database; + expect(await db.select(db.mediaSpecies).get(), hasLength(1)); + }); +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `flutter test test/features/media/data/repositories/media_species_repository_test.dart` +Expected: compilation error, `media_species_repository.dart` not found. + +- [ ] **Step 4: Write the repository** + +`lib/features/media/data/repositories/media_species_repository.dart`: + +```dart +import 'dart:math'; + +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; + +/// Species tags on photos: the `media_species` link table. +/// +/// A tag sits on a photo that already belongs to a dive or a site; this +/// repository never creates media rows. The table has no `hlc` column, so +/// like `site_species` it syncs as a clockless child: add marks the row +/// pending, remove tombstones it by id, and the incremental export keys off +/// the parent `media.hlc`. +class MediaSpeciesRepository { + AppDatabase get _db => DatabaseService.instance.database; + final SyncRepository _syncRepository = SyncRepository(); + static const Uuid _uuid = Uuid(); + + /// Bound-variable budget per `isIn` chunk, well under SQLite's 999 limit. + static const int _chunkSize = 500; + + /// Ticks whenever `media_species` changes, from any writer. + Stream watchTagChanges() => + _db.tableUpdates(TableUpdateQuery.onTable(_db.mediaSpecies)); + + Future> getTagsForMedia(String mediaId) async { + final rows = + await (_db.select(_db.mediaSpecies) + ..where((t) => t.mediaId.equals(mediaId)) + ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) + .get(); + return rows.map(_tagFromRow).toList(); + } + + /// Tags for many photos in one pass, keyed by media id. Photos without a + /// tag are absent from the map. + Future>> getTagsForMediaIds( + List mediaIds, + ) async { + final result = >{}; + for (var i = 0; i < mediaIds.length; i += _chunkSize) { + final chunk = mediaIds.sublist(i, min(i + _chunkSize, mediaIds.length)); + final rows = await (_db.select( + _db.mediaSpecies, + )..where((t) => t.mediaId.isIn(chunk))).get(); + for (final row in rows) { + result.putIfAbsent(row.mediaId, () => []).add(_tagFromRow(row)); + } + } + return result; + } + + /// Tags [mediaId] with [speciesId]. Returns the existing tag when the pair + /// is already linked: uniqueness lives here, not in a schema constraint. + Future addTag({ + required String mediaId, + required String speciesId, + String? sightingId, + }) async { + final existing = await _findTag(mediaId, speciesId); + if (existing != null) return _tagFromRow(existing); + + final id = _uuid.v4(); + final now = DateTime.now().millisecondsSinceEpoch; + await _db + .into(_db.mediaSpecies) + .insert( + MediaSpeciesCompanion( + id: Value(id), + mediaId: Value(mediaId), + speciesId: Value(speciesId), + sightingId: Value(sightingId), + createdAt: Value(now), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'mediaSpecies', + recordId: id, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + + return MediaSpeciesTag( + id: id, + mediaId: mediaId, + speciesId: speciesId, + sightingId: sightingId, + createdAt: DateTime.fromMillisecondsSinceEpoch(now), + ); + } + + /// Removes the tag linking [mediaId] and [speciesId], tombstoning it by + /// row id so other devices drop the same row. No-op when absent. + Future removeTag({ + required String mediaId, + required String speciesId, + }) async { + final existing = await _findTag(mediaId, speciesId); + if (existing == null) return; + + await (_db.delete( + _db.mediaSpecies, + )..where((t) => t.id.equals(existing.id))).go(); + await _syncRepository.logDeletion( + entityType: 'mediaSpecies', + recordId: existing.id, + ); + SyncEventBus.notifyLocalChange(); + } + + Future _findTag(String mediaId, String speciesId) => + (_db.select(_db.mediaSpecies) + ..where( + (t) => t.mediaId.equals(mediaId) & t.speciesId.equals(speciesId), + ) + ..limit(1)) + .getSingleOrNull(); + + MediaSpeciesTag _tagFromRow(MediaSpecy row) => MediaSpeciesTag( + id: row.id, + mediaId: row.mediaId, + speciesId: row.speciesId, + sightingId: row.sightingId, + bboxX: row.bboxX, + bboxY: row.bboxY, + bboxWidth: row.bboxWidth, + bboxHeight: row.bboxHeight, + notes: row.notes, + createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), + ); +} +``` + +If the analyzer reports that `MediaSpecy` is not the generated row class name, open `lib/core/database/database.g.dart`, search for `class MediaSpec`, and use the name it declares; the companion is `MediaSpeciesCompanion` either way. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/data/repositories/media_species_repository_test.dart` +Expected: `All tests passed!` (6 tests), exit code 0. + +- [ ] **Step 6: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media/data/repositories/media_species_repository.dart test/features/media/data/repositories/ +git add lib/features/media/data/repositories/media_species_repository.dart test/features/media/data/repositories/species_photo_fixtures.dart test/features/media/data/repositories/media_species_repository_test.dart +git commit -m "feat(media): add MediaSpeciesRepository for species tags on photos" +``` + +--- + +### Task 2: Gallery, candidate, count and chip queries + +**Files:** +- Create: `lib/features/media/domain/entities/species_tag_candidate_group.dart` +- Create: `lib/features/media/domain/entities/species_tag_chip.dart` +- Modify: `lib/features/media/data/repositories/media_species_repository.dart` (add methods after `removeTag`) +- Test: `test/features/media/data/repositories/media_species_queries_test.dart` + +**Interfaces:** +- Consumes: Task 1; `mediaItemFromRow(MediaData row, [MediaEnrichmentData? enrichmentRow])` from `lib/features/media/data/repositories/media_row_mapper.dart`; `MediaItem.isDocument` from `media_item.dart`; `Species`, `SpeciesCategory`. +- Produces: + ```dart + class SpeciesTagCandidateGroup { diveId, diveNumber, diveDateTime, siteName, sightingId, List items } + class SpeciesTagChip { speciesId, storedName, category, isBuiltIn } + Future> getMediaForSpecies(String speciesId, {String? diverId}); + Future> getTagCandidatesForSpecies(String speciesId, {String? diverId}); + Future> getTagChipsForMedia(String mediaId); + Future> getPhotoCountsBySpeciesForDive(String diveId); + Future> getCoverMediaBySpecies({String? diverId}); + Future> tagCountsBySpecies(); + ``` + +Background: the library's diver rule is `media.dive_id IS NULL OR dives.diver_id = ?`, which keeps site-only photos; copy it. `getMediaForDive` in `MediaRepository` is the join shape to reproduce (`select(media).join([leftOuterJoin(mediaEnrichment, ...)])` then `mediaItemFromRow(row.readTable(media), row.readTableOrNull(mediaEnrichment))`). Drift's `useColumns: false` on a join keeps that table out of the selected columns, which is what makes `distinct: true` meaningful. `isNotInQuery` takes a `selectOnly` statement. + +- [ ] **Step 1: Write the entities** + +`lib/features/media/domain/entities/species_tag_candidate_group.dart`: + +```dart +import 'package:equatable/equatable.dart'; + +import 'package:submersion/features/media/domain/entities/media_item.dart'; + +/// One dive on which a species was sighted, with the photos on that dive +/// that are not yet tagged with it. Feeds the tag picker's grouped grid. +class SpeciesTagCandidateGroup extends Equatable { + final String diveId; + final int? diveNumber; + final DateTime diveDateTime; + final String? siteName; + + /// The dive's sighting of the species; a tag made from this group links + /// to it. + final String sightingId; + final List items; + + const SpeciesTagCandidateGroup({ + required this.diveId, + this.diveNumber, + required this.diveDateTime, + this.siteName, + required this.sightingId, + required this.items, + }); + + SpeciesTagCandidateGroup copyWith({ + String? diveId, + int? diveNumber, + DateTime? diveDateTime, + String? siteName, + String? sightingId, + List? items, + }) { + return SpeciesTagCandidateGroup( + diveId: diveId ?? this.diveId, + diveNumber: diveNumber ?? this.diveNumber, + diveDateTime: diveDateTime ?? this.diveDateTime, + siteName: siteName ?? this.siteName, + sightingId: sightingId ?? this.sightingId, + items: items ?? this.items, + ); + } + + @override + List get props => [ + diveId, + diveNumber, + diveDateTime, + siteName, + sightingId, + items, + ]; +} +``` + +`lib/features/media/domain/entities/species_tag_chip.dart`: + +```dart +import 'package:equatable/equatable.dart'; + +import 'package:submersion/core/constants/enums.dart'; + +/// A species tag as the viewer shows it: enough to render a chip and to +/// localize the name at display time (built-ins resolve through their id). +class SpeciesTagChip extends Equatable { + final String speciesId; + final String storedName; + final SpeciesCategory category; + final bool isBuiltIn; + + const SpeciesTagChip({ + required this.speciesId, + required this.storedName, + required this.category, + required this.isBuiltIn, + }); + + SpeciesTagChip copyWith({ + String? speciesId, + String? storedName, + SpeciesCategory? category, + bool? isBuiltIn, + }) { + return SpeciesTagChip( + speciesId: speciesId ?? this.speciesId, + storedName: storedName ?? this.storedName, + category: category ?? this.category, + isBuiltIn: isBuiltIn ?? this.isBuiltIn, + ); + } + + @override + List get props => [speciesId, storedName, category, isBuiltIn]; +} +``` + +- [ ] **Step 2: Write the failing query tests** + +`test/features/media/data/repositories/media_species_queries_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; + +import '../../../../helpers/test_database.dart'; +import 'species_photo_fixtures.dart'; + +/// diver-a: d1 (Blue Hole, Jan, #101) with photos p1, p2, p3 and a PDF doc1; +/// d2 (no site, Mar, #102) with photo p4. +/// diver-b: d3 (Shark Point, May) with photo p5. +/// site-only photo s1 (Blue Hole, no dive). +/// Whale shark sighted on d1 (sg1), d2 (sg2), d3 (sg3). Turtle on d1 (sg4). +Future seed() async { + await insertTestDiver('diver-a'); + await insertTestDiver('diver-b'); + await insertTestSite('s1', 'Blue Hole'); + await insertTestSite('s2', 'Shark Point'); + await insertTestDive( + id: 'd1', + at: DateTime(2024, 1, 10), + diverId: 'diver-a', + siteId: 's1', + number: 101, + ); + await insertTestDive( + id: 'd2', + at: DateTime(2024, 3, 5), + diverId: 'diver-a', + number: 102, + ); + await insertTestDive( + id: 'd3', + at: DateTime(2024, 5, 1), + diverId: 'diver-b', + siteId: 's2', + number: 7, + ); + await insertTestSpecies( + id: 'sp_whale_shark', + name: 'Whale Shark', + category: SpeciesCategory.shark, + builtIn: true, + ); + await insertTestSpecies( + id: 'sp_green_sea_turtle', + name: 'Green Sea Turtle', + category: SpeciesCategory.turtle, + builtIn: true, + ); + await insertTestSighting(id: 'sg1', diveId: 'd1', speciesId: 'sp_whale_shark'); + await insertTestSighting(id: 'sg2', diveId: 'd2', speciesId: 'sp_whale_shark'); + await insertTestSighting(id: 'sg3', diveId: 'd3', speciesId: 'sp_whale_shark'); + await insertTestSighting( + id: 'sg4', + diveId: 'd1', + speciesId: 'sp_green_sea_turtle', + ); + await insertTestMedia(id: 'p1', diveId: 'd1', takenAt: DateTime(2024, 1, 10, 9)); + await insertTestMedia(id: 'p2', diveId: 'd1', takenAt: DateTime(2024, 1, 10, 10)); + await insertTestMedia(id: 'p3', diveId: 'd1', takenAt: DateTime(2024, 1, 10, 11)); + await insertTestMedia(id: 'doc1', diveId: 'd1', fileType: 'document'); + await insertTestMedia(id: 'p4', diveId: 'd2', takenAt: DateTime(2024, 3, 5, 9)); + await insertTestMedia(id: 'p5', diveId: 'd3', takenAt: DateTime(2024, 5, 1, 9)); + await insertTestMedia(id: 's1', siteId: 's1', takenAt: DateTime(2024, 6, 1)); +} + +void main() { + late MediaSpeciesRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = MediaSpeciesRepository(); + await seed(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + group('getMediaForSpecies', () { + test('returns tagged photos newest first, once each, scoped to the diver', + () async { + await repository.addTag(mediaId: 'p1', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p4', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p5', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p2', speciesId: 'sp_green_sea_turtle'); + + final mine = await repository.getMediaForSpecies( + 'sp_whale_shark', + diverId: 'diver-a', + ); + final everyone = await repository.getMediaForSpecies('sp_whale_shark'); + + expect(mine.map((m) => m.id).toList(), ['p4', 'p1']); + expect(everyone.map((m) => m.id).toList(), ['p5', 'p4', 'p1']); + }); + + test('keeps a site-only photo under a diver scope', () async { + await repository.addTag(mediaId: 's1', speciesId: 'sp_whale_shark'); + + final mine = await repository.getMediaForSpecies( + 'sp_whale_shark', + diverId: 'diver-a', + ); + + expect(mine.map((m) => m.id).toList(), ['s1']); + }); + }); + + group('getTagCandidatesForSpecies', () { + test('groups untagged photos by dive with the sighting, newest dive first', + () async { + await repository.addTag( + mediaId: 'p1', + speciesId: 'sp_whale_shark', + sightingId: 'sg1', + ); + + final groups = await repository.getTagCandidatesForSpecies( + 'sp_whale_shark', + diverId: 'diver-a', + ); + + expect(groups.map((g) => g.diveId).toList(), ['d2', 'd1']); + final d1 = groups.last; + expect(d1.sightingId, 'sg1'); + expect(d1.diveNumber, 101); + expect(d1.siteName, 'Blue Hole'); + // p1 is already tagged and doc1 is a document: both excluded. + expect(d1.items.map((m) => m.id).toSet(), {'p2', 'p3'}); + expect(groups.first.siteName, isNull); + expect(groups.first.items.map((m) => m.id).toList(), ['p4']); + }); + + test('omits dives whose photos are all tagged and other divers\' dives', + () async { + await repository.addTag(mediaId: 'p4', speciesId: 'sp_whale_shark'); + + final groups = await repository.getTagCandidatesForSpecies( + 'sp_whale_shark', + diverId: 'diver-a', + ); + + expect(groups.map((g) => g.diveId).toList(), ['d1']); + }); + + test('is empty for a species never sighted', () async { + await insertTestSpecies(id: 'c9', name: 'Nobody'); + expect(await repository.getTagCandidatesForSpecies('c9'), isEmpty); + }); + }); + + test('getTagChipsForMedia joins the species row in tag order', () async { + await repository.addTag(mediaId: 'p1', speciesId: 'sp_green_sea_turtle'); + await repository.addTag(mediaId: 'p1', speciesId: 'sp_whale_shark'); + + final chips = await repository.getTagChipsForMedia('p1'); + + expect(chips.map((c) => c.speciesId).toList(), [ + 'sp_green_sea_turtle', + 'sp_whale_shark', + ]); + expect(chips.first.storedName, 'Green Sea Turtle'); + expect(chips.first.category, SpeciesCategory.turtle); + expect(chips.first.isBuiltIn, isTrue); + }); + + test('getPhotoCountsBySpeciesForDive counts distinct photos per species', + () async { + await repository.addTag(mediaId: 'p1', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p2', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p2', speciesId: 'sp_green_sea_turtle'); + await repository.addTag(mediaId: 'p4', speciesId: 'sp_whale_shark'); + + final counts = await repository.getPhotoCountsBySpeciesForDive('d1'); + + expect(counts, {'sp_whale_shark': 2, 'sp_green_sea_turtle': 1}); + }); + + test('getCoverMediaBySpecies picks the newest tagged photo per species', + () async { + await repository.addTag(mediaId: 'p1', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p4', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p5', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p2', speciesId: 'sp_green_sea_turtle'); + + final mine = await repository.getCoverMediaBySpecies(diverId: 'diver-a'); + final everyone = await repository.getCoverMediaBySpecies(); + + expect(mine['sp_whale_shark']!.id, 'p4'); + expect(mine['sp_green_sea_turtle']!.id, 'p2'); + expect(everyone['sp_whale_shark']!.id, 'p5'); + }); + + test('tagCountsBySpecies counts rows per species', () async { + await repository.addTag(mediaId: 'p1', speciesId: 'sp_whale_shark'); + await repository.addTag(mediaId: 'p2', speciesId: 'sp_whale_shark'); + + expect(await repository.tagCountsBySpecies(), {'sp_whale_shark': 2}); + }); +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/data/repositories/media_species_queries_test.dart` +Expected: compilation errors, `getMediaForSpecies` and friends are not defined. + +- [ ] **Step 4: Add the queries** + +Add these imports to `media_species_repository.dart`: + +```dart +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/media/data/repositories/media_row_mapper.dart'; +import 'package:submersion/features/media/domain/entities/species_tag_candidate_group.dart'; +import 'package:submersion/features/media/domain/entities/species_tag_chip.dart'; +``` + +Add these methods to the class after `removeTag`: + +```dart + /// Every photo tagged with [speciesId], newest first, once each. + /// + /// Scoped to [diverId] through the photo's dive while keeping site-only + /// photos, the library's own rule (`media.dive_id IS NULL OR + /// dives.diver_id = ?`). + Future> getMediaForSpecies( + String speciesId, { + String? diverId, + }) async { + final m = _db.media; + final query = + _db.select(m, distinct: true).join([ + innerJoin( + _db.mediaSpecies, + _db.mediaSpecies.mediaId.equalsExp(m.id), + useColumns: false, + ), + leftOuterJoin( + _db.mediaEnrichment, + _db.mediaEnrichment.mediaId.equalsExp(m.id), + ), + leftOuterJoin( + _db.dives, + _db.dives.id.equalsExp(m.diveId), + useColumns: false, + ), + ]) + ..where( + _db.mediaSpecies.speciesId.equals(speciesId) & + _diverScope(diverId), + ) + ..orderBy([OrderingTerm.desc(m.takenAt), OrderingTerm.asc(m.id)]); + final rows = await query.get(); + return rows + .map( + (row) => mediaItemFromRow( + row.readTable(m), + row.readTableOrNull(_db.mediaEnrichment), + ), + ) + .toList(); + } + + /// Photos the diver could tag with [speciesId]: the photos and videos on + /// dives with a sighting of it that carry no tag for it yet, grouped by + /// dive, newest dive first. Documents are not candidates. + Future> getTagCandidatesForSpecies( + String speciesId, { + String? diverId, + }) async { + final diverClause = diverId != null ? 'AND d.diver_id = ?' : ''; + final diveRows = await _db + .customSelect( + ''' + SELECT s.id AS sighting_id, d.id AS dive_id, d.dive_number, + d.dive_date_time, ds.name AS site_name + FROM sightings s + JOIN dives d ON d.id = s.dive_id + LEFT JOIN dive_sites ds ON ds.id = d.site_id + WHERE s.species_id = ? $diverClause + ORDER BY d.dive_date_time DESC, s.id ASC + ''', + variables: [ + Variable.withString(speciesId), + if (diverId != null) Variable.withString(diverId), + ], + ) + .get(); + if (diveRows.isEmpty) return const []; + + // One group per dive even if the dive logged the species twice. + final seenDives = {}; + final headers = diveRows.where( + (r) => seenDives.add(r.read('dive_id')), + ); + final diveIds = headers.map((r) => r.read('dive_id')).toList(); + + final taggedIds = _db.selectOnly(_db.mediaSpecies) + ..addColumns([_db.mediaSpecies.mediaId]) + ..where(_db.mediaSpecies.speciesId.equals(speciesId)); + final m = _db.media; + final mediaQuery = + _db.select(m).join([ + leftOuterJoin( + _db.mediaEnrichment, + _db.mediaEnrichment.mediaId.equalsExp(m.id), + ), + ]) + ..where(m.diveId.isIn(diveIds) & m.id.isNotInQuery(taggedIds)) + ..orderBy([OrderingTerm.asc(m.takenAt), OrderingTerm.asc(m.id)]); + final byDive = >{}; + for (final row in await mediaQuery.get()) { + final item = mediaItemFromRow( + row.readTable(m), + row.readTableOrNull(_db.mediaEnrichment), + ); + if (item.isDocument) continue; + byDive.putIfAbsent(item.diveId!, () => []).add(item); + } + + return [ + for (final r in headers) + if (byDive[r.read('dive_id')] case final items? + when items.isNotEmpty) + SpeciesTagCandidateGroup( + diveId: r.read('dive_id'), + diveNumber: r.read('dive_number'), + diveDateTime: DateTime.fromMillisecondsSinceEpoch( + r.read('dive_date_time'), + ), + siteName: r.read('site_name'), + sightingId: r.read('sighting_id'), + items: items, + ), + ]; + } + + /// The species tagged on one photo, in tag order, with what a chip needs. + Future> getTagChipsForMedia(String mediaId) async { + final rows = await _db + .customSelect( + ''' + SELECT sp.id, sp.common_name, sp.category, sp.is_built_in + FROM media_species ms + JOIN species sp ON sp.id = ms.species_id + WHERE ms.media_id = ? + ORDER BY ms.created_at ASC, ms.id ASC + ''', + variables: [Variable.withString(mediaId)], + ) + .get(); + return rows.map((row) { + final categoryName = row.read('category'); + return SpeciesTagChip( + speciesId: row.read('id'), + storedName: row.read('common_name'), + category: SpeciesCategory.values.firstWhere( + (c) => c.name == categoryName, + orElse: () => SpeciesCategory.other, + ), + isBuiltIn: row.read('is_built_in'), + ); + }).toList(); + } + + /// How many distinct photos on [diveId] are tagged with each species. + Future> getPhotoCountsBySpeciesForDive( + String diveId, + ) async { + final rows = await _db + .customSelect( + ''' + SELECT ms.species_id, COUNT(DISTINCT ms.media_id) AS n + FROM media_species ms + JOIN media m ON m.id = ms.media_id + WHERE m.dive_id = ? + GROUP BY ms.species_id + ''', + variables: [Variable.withString(diveId)], + ) + .get(); + return { + for (final row in rows) + row.read('species_id'): row.read('n'), + }; + } + + /// The newest tagged photo per species, for cover thumbnails. Derived, + /// never chosen: a chosen cover would live on the species row, and + /// built-in species rows never sync. + Future> getCoverMediaBySpecies({ + String? diverId, + }) async { + final diverClause = diverId != null + ? 'AND (m.dive_id IS NULL OR d.diver_id = ?)' + : ''; + final rows = await _db + .customSelect( + ''' + SELECT species_id, media_id FROM ( + SELECT ms.species_id, ms.media_id, + ROW_NUMBER() OVER ( + PARTITION BY ms.species_id + ORDER BY m.taken_at DESC, m.id ASC + ) AS rn + FROM media_species ms + JOIN media m ON m.id = ms.media_id + LEFT JOIN dives d ON d.id = m.dive_id + WHERE 1=1 $diverClause + ) WHERE rn = 1 + ''', + variables: [if (diverId != null) Variable.withString(diverId)], + ) + .get(); + if (rows.isEmpty) return const {}; + final coverIdBySpecies = { + for (final row in rows) + row.read('species_id'): row.read('media_id'), + }; + final items = await _getMediaByIds(coverIdBySpecies.values.toList()); + return { + for (final entry in coverIdBySpecies.entries) + if (items[entry.value] case final item?) entry.key: item, + }; + } + + /// Tag rows per species, the photo-side twin of + /// `SpeciesRepository.sightingCountsBySpecies`. + Future> tagCountsBySpecies() async { + final rows = await _db + .customSelect( + 'SELECT species_id, COUNT(*) AS n FROM media_species ' + 'GROUP BY species_id', + ) + .get(); + return { + for (final row in rows) + row.read('species_id'): row.read('n'), + }; + } + + Future> _getMediaByIds(List ids) async { + final m = _db.media; + final result = {}; + for (var i = 0; i < ids.length; i += _chunkSize) { + final chunk = ids.sublist(i, min(i + _chunkSize, ids.length)); + final query = _db.select(m).join([ + leftOuterJoin( + _db.mediaEnrichment, + _db.mediaEnrichment.mediaId.equalsExp(m.id), + ), + ])..where(m.id.isIn(chunk)); + for (final row in await query.get()) { + final item = mediaItemFromRow( + row.readTable(m), + row.readTableOrNull(_db.mediaEnrichment), + ); + result[item.id] = item; + } + } + return result; + } + + Expression _diverScope(String? diverId) { + if (diverId == null) return const Constant(true); + return _db.media.diveId.isNull() | _db.dives.diverId.equals(diverId); + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/data/repositories/media_species_queries_test.dart test/features/media/data/repositories/media_species_repository_test.dart` +Expected: `All tests passed!` (15 tests), exit code 0. If `getMediaForSpecies` returns a photo twice, the `distinct: true` is not reaching the generated SQL: check that both `useColumns: false` flags are present, since any selected column from the joined tables defeats `DISTINCT`. + +- [ ] **Step 6: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media/data/repositories/media_species_repository.dart lib/features/media/domain/entities test/features/media/data/repositories +git add lib/features/media/data/repositories/media_species_repository.dart lib/features/media/domain/entities/species_tag_candidate_group.dart lib/features/media/domain/entities/species_tag_chip.dart test/features/media/data/repositories/media_species_queries_test.dart +git commit -m "feat(media): add species gallery, tag candidate, count and chip queries" +``` + +--- + +### Task 3: Sync registration for `mediaSpecies` + +**Files:** +- Modify: `lib/core/services/sync/sync_data_serializer.dart` (the `SyncData` class field list, constructor, `toJson`, `fromJson`; `_baseTables`; the `_safeExport` block; the `fetchRecord`, `upsertRecord`, `upsertRecords`, `recordIdsFor`, `_syncTableFor` and `deleteRecord` switches; a new `_exportMediaSpecies`) +- Modify: `lib/core/services/sync/sync_service.dart` (`mergeOrder`, `entityHasUpdatedAt`, `parentRefs`, and the comment above the extra-entities block) +- Modify: `test/core/services/sync/sync_parent_refs_completeness_test.dart`, `sync_serializer_upsert_test.dart`, `sync_data_serializer_batch_coverage_test.dart`, `sync_serializer_fetch_record_test.dart` +- Test: `test/core/services/sync/sync_media_species_registration_test.dart` (new) + +**Interfaces:** +- Consumes: Task 1's `MediaSpeciesRepository.addTag` (for the registration test); the Drift row class `MediaSpecy` and table `_db.mediaSpecies`. +- Produces: the sync entity type string `'mediaSpecies'`, exported under the JSON key `mediaSpecies`, merged after `media` and `species`. + +Background: every `siteSpecies` touch point in these two files is the template; `mediaSpecies` is inserted **immediately after** each one so the ordering assertions (the streaming parity test compares `_baseTables` order with `SyncData.toJson` key order) keep holding. In `_exportSiteSpecies`, the incremental branch selects rows whose *parent* changed (`diveSites.hlc > hlcSince`) because the child has no clock; the media twin filters by `media.hlc`. + +- [ ] **Step 1: Write the failing registration test** + +`test/core/services/sync/sync_media_species_registration_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; +import 'package:submersion/core/services/sync/sync_service.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; + +import '../../../features/media/data/repositories/species_photo_fixtures.dart'; +import '../../../helpers/test_database.dart'; + +void main() { + late SyncDataSerializer serializer; + + setUp(() async { + await setUpTestDatabase(); + serializer = SyncDataSerializer(); + await insertTestDive(id: 'd1', at: DateTime(2024, 1, 10)); + await insertTestSpecies(id: 'c1', name: 'Grouper'); + await insertTestMedia(id: 'p1', diveId: 'd1'); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('a tag written through the repository is fetchable by sync type', + () async { + final tag = await MediaSpeciesRepository().addTag( + mediaId: 'p1', + speciesId: 'c1', + ); + + final record = await serializer.fetchRecord('mediaSpecies', tag.id); + + expect(record, isNotNull); + expect(record!['mediaId'], 'p1'); + expect(record['speciesId'], 'c1'); + }); + + test('upsertRecord and deleteRecord round-trip a remote tag', () async { + await serializer.upsertRecord('mediaSpecies', { + 'id': 'mst-remote', + 'mediaId': 'p1', + 'speciesId': 'c1', + 'createdAt': 1000, + }); + expect(await serializer.fetchRecord('mediaSpecies', 'mst-remote'), isNotNull); + + await serializer.deleteRecord('mediaSpecies', 'mst-remote'); + + expect(await serializer.fetchRecord('mediaSpecies', 'mst-remote'), isNull); + }); + + test('SyncData carries mediaSpecies through toJson and fromJson', () { + const data = SyncData( + mediaSpecies: [ + {'id': 'mst-1', 'mediaId': 'p1', 'speciesId': 'c1', 'createdAt': 1}, + ], + ); + + final restored = SyncData.fromJson(data.toJson()); + + expect(restored.mediaSpecies.single['id'], 'mst-1'); + }); + + test('mediaSpecies merges as a clockless child of media and species', () { + expect(SyncService.entityHasUpdatedAt['mediaSpecies'], isFalse); + final refs = SyncService.parentRefs['mediaSpecies']!; + expect( + refs.map((r) => (r.field, r.parent, r.nullable)).toSet(), + {('mediaId', 'media', false), ('speciesId', 'species', false)}, + ); + }); +} +``` + +If `SyncService.entityHasUpdatedAt` or `SyncService.parentRefs` are not accessible under those names, open `sync_service.dart` around the `'siteSpecies': false,` line and the `'siteSpecies': [` parentRefs entry to read their declared names and visibility, and adjust the last test to whatever the file exposes (an `@visibleForTesting` getter is acceptable to add if neither is reachable). + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/services/sync/sync_media_species_registration_test.dart` +Expected: compilation error (`mediaSpecies` is not a `SyncData` parameter) or runtime failures on the unknown entity type. + +- [ ] **Step 3: Register the entity in the serializer** + +In `lib/core/services/sync/sync_data_serializer.dart`, make these six edits, each directly after the `siteSpecies` line it names: + +1. Field (after `final List> siteSpecies;`): +```dart + final List> mediaSpecies; +``` +2. Constructor default (after `this.siteSpecies = const [],`): +```dart + this.mediaSpecies = const [], +``` +3. `toJson` (after `'siteSpecies': siteSpecies,`): +```dart + 'mediaSpecies': mediaSpecies, +``` +4. `fromJson` (after `siteSpecies: _parseList(json['siteSpecies']),`): +```dart + mediaSpecies: _parseList(json['mediaSpecies']), +``` +5. `_baseTables` (after `(key: 'siteSpecies', table: _db.siteSpecies, blob: false, full: null),`): +```dart + (key: 'mediaSpecies', table: _db.mediaSpecies, blob: false, full: null), +``` +6. The export block (after the `siteSpecies: await _safeExport('siteSpecies', ...)` entry): +```dart + mediaSpecies: await _safeExport( + 'mediaSpecies', + () => _exportMediaSpecies(hlcSince), + ), +``` + +Then add a `case 'mediaSpecies':` arm directly after each `case 'siteSpecies':` arm in the six switches: + +`fetchRecord`: +```dart + case 'mediaSpecies': + final row = await (_db.select( + _db.mediaSpecies, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(); +``` + +`upsertRecord`: +```dart + case 'mediaSpecies': + await _db + .into(_db.mediaSpecies) + .insertOnConflictUpdate( + MediaSpecy.fromJson(_withTimestampDefaults(data)), + ); + return; +``` + +`upsertRecords`: +```dart + case 'mediaSpecies': + await _db.batch( + (b) => b.insertAllOnConflictUpdate( + _db.mediaSpecies, + records + .map((r) => MediaSpecy.fromJson(_withTimestampDefaults(r))) + .toList(), + ), + ); + return; +``` + +`recordIdsFor`: +```dart + case 'mediaSpecies': + return plain(_db.mediaSpecies, _db.mediaSpecies.id); +``` + +`_syncTableFor`: +```dart + case 'mediaSpecies': + return _db.mediaSpecies; +``` + +`deleteRecord`: +```dart + case 'mediaSpecies': + await (_db.delete( + _db.mediaSpecies, + )..where((t) => t.id.equals(recordId))).go(); + return; +``` + +And add the export method directly after `_exportSiteSpecies`: + +```dart + /// `media_species` has no clock of its own, so an incremental export + /// ships the tags of every photo whose `media.hlc` advanced; a full export + /// ships the table. + Future>> _exportMediaSpecies( + String? hlcSince, + ) async { + if (hlcSince != null) { + final modifiedMedia = await (_db.select( + _db.media, + )..where((t) => t.hlc.isBiggerThanValue(hlcSince))).get(); + final mediaIds = modifiedMedia.map((m) => m.id).toSet(); + if (mediaIds.isEmpty) return []; + + final rows = await (_db.select( + _db.mediaSpecies, + )..where((t) => t.mediaId.isIn(mediaIds))).get(); + return rows.map((r) => r.toJson()).toList(); + } + final rows = await _db.select(_db.mediaSpecies).get(); + return rows.map((r) => r.toJson()).toList(); + } +``` + +- [ ] **Step 4: Register the entity in the sync service** + +In `lib/core/services/sync/sync_service.dart`: + +1. In `mergeOrder`, after `(type: 'siteSpecies', records: data.siteSpecies, hasUpdatedAt: false),` add: +```dart + ( + type: 'mediaSpecies', + records: data.mediaSpecies, + hasUpdatedAt: false, + ), +``` + and in the comment above that block change "Four are append-only ... (no updatedAt column: diveCustomFields, diveDataSources, siteSpecies, fieldPresets)" to "Five are append-only ... (no updatedAt column: diveCustomFields, diveDataSources, siteSpecies, mediaSpecies, fieldPresets)". +2. In the `entityHasUpdatedAt` map, after `'siteSpecies': false,` add: +```dart + 'mediaSpecies': false, +``` +3. In `parentRefs`, after the `'siteSpecies': [...]` entry add: +```dart + 'mediaSpecies': [ + (field: 'mediaId', parent: 'media', nullable: false), + (field: 'speciesId', parent: 'species', nullable: false), + ], +``` + `sightingId` gets no entry: `sightings` is not a deletable parent in that map, and the FK already sets it null. + +- [ ] **Step 5: Extend the enumerating tests** + +- `test/core/services/sync/sync_parent_refs_completeness_test.dart`: after ` 'site_species': 'siteSpecies',` add ` 'media_species': 'mediaSpecies',`. +- `test/core/services/sync/sync_serializer_upsert_test.dart`: after `(type: 'siteSpecies', table: db.siteSpecies.actualTableName),` add `(type: 'mediaSpecies', table: db.mediaSpecies.actualTableName),`. +- `test/core/services/sync/sync_data_serializer_batch_coverage_test.dart`: same addition after its `siteSpecies` tuple. +- `test/core/services/sync/sync_serializer_fetch_record_test.dart`: after ` 'siteSpecies',` in the string list add ` 'mediaSpecies',`, and after the `(type: 'siteSpecies', table: db.siteSpecies.actualTableName),` tuple add the `mediaSpecies` tuple. + +- [ ] **Step 6: Run the new test and the whole sync suite** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/core/services/sync/` +Expected: `All tests passed!`, exit code 0. If `sync_base_streaming_parity_test.dart` fails on key order, the `_baseTables` entry is not directly after `siteSpecies`; move it. If the parent-refs completeness test still fails, read its message: it names the table, field and expected nullability. + +- [ ] **Step 7: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/core/services/sync test/core/services/sync +git add lib/core/services/sync/sync_data_serializer.dart lib/core/services/sync/sync_service.dart test/core/services/sync/ +git commit -m "feat(sync): register media_species as a clockless child of media and species" +``` + +--- + +### Task 4: Integrity rules: tags count as "in use", unlink warnings, media watch stream + +**Files:** +- Modify: `lib/features/marine_life/data/repositories/species_repository.dart` (`isSpeciesInUse`, `deleteSpecies`) +- Modify: `lib/features/media/data/repositories/media_repository.dart` (`watchMediaChanges`, `idsWithUserMetadata` and its doc comment) +- Modify: `lib/features/marine_life/presentation/pages/species_manage_page.dart` (delete eligibility) +- Create: `lib/features/media/presentation/providers/species_media_providers.dart` (only `mediaSpeciesRepositoryProvider` and `speciesTagCountsProvider` for now; Task 6 adds the rest) +- Test: `test/features/marine_life/data/repositories/species_repository_tags_test.dart` (new), `test/features/media/data/repositories/media_repository_species_test.dart` (new) +- Regression: `test/features/marine_life/presentation/pages/species_manage_page_test.dart` (must stay green) + +**Interfaces:** +- Consumes: Tasks 1 and 2 (`MediaSpeciesRepository.tagCountsBySpecies`, `watchTagChanges`). +- Produces: `speciesTagCountsProvider: FutureProvider>`; `SpeciesRepository.isSpeciesInUse` true when tags exist; `deleteSpecies` removes the species' tags; `MediaRepository.idsWithUserMetadata` includes tagged photos; `watchMediaChanges` ticks on `media_species`. + +- [ ] **Step 1: Write the failing repository tests** + +`test/features/marine_life/data/repositories/species_repository_tags_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; + +import '../../../../helpers/test_database.dart'; +import '../../../media/data/repositories/species_photo_fixtures.dart'; + +void main() { + late SpeciesRepository species; + late MediaSpeciesRepository tags; + + setUp(() async { + await setUpTestDatabase(); + species = SpeciesRepository(); + tags = MediaSpeciesRepository(); + await insertTestDive(id: 'd1', at: DateTime(2024, 1, 10)); + await insertTestSpecies(id: 'c1', name: 'Grouper'); + await insertTestMedia(id: 'p1', diveId: 'd1'); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('a species with a photo tag but no sighting is in use', () async { + expect(await species.isSpeciesInUse('c1'), isFalse); + + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + + expect(await species.isSpeciesInUse('c1'), isTrue); + }); + + test('deleteSpecies refuses while a tag exists', () async { + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + + expect(() => species.deleteSpecies('c1'), throwsException); + }); + + test('deleteSpecies removes stale tag rows of an unused species', () async { + // Simulate a tag that outlived the in-use check (foreign keys are off + // in tests, so the cascade cannot do it for us). + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + await tags.removeTag(mediaId: 'p1', speciesId: 'c1'); + final db = DatabaseService.instance.database; + expect(await db.select(db.mediaSpecies).get(), isEmpty); + + await species.deleteSpecies('c1'); + + expect(await species.getSpeciesById('c1'), isNull); + }); +} +``` + +`test/features/media/data/repositories/media_repository_species_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; + +import '../../../../helpers/test_database.dart'; +import 'species_photo_fixtures.dart'; + +void main() { + late MediaRepository media; + late MediaSpeciesRepository tags; + + setUp(() async { + await setUpTestDatabase(); + media = MediaRepository(); + tags = MediaSpeciesRepository(); + await insertTestDive(id: 'd1', at: DateTime(2024, 1, 10)); + await insertTestSpecies(id: 'c1', name: 'Grouper'); + await insertTestMedia(id: 'p1', diveId: 'd1'); + await insertTestMedia(id: 'p2', diveId: 'd1'); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('a tagged photo counts as carrying user metadata', () async { + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + + expect(await media.idsWithUserMetadata(['p1', 'p2']), {'p1'}); + }); + + test('watchMediaChanges ticks when a tag is written', () async { + final ticks = []; + final sub = media.watchMediaChanges().listen(ticks.add); + addTearDown(sub.cancel); + + await tags.addTag(mediaId: 'p2', speciesId: 'c1'); + // The stream is debounced; give it time to flush. + await Future.delayed(const Duration(milliseconds: 400)); + + expect(ticks, isNotEmpty); + }); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/marine_life/data/repositories/species_repository_tags_test.dart test/features/media/data/repositories/media_repository_species_test.dart` +Expected: the "in use", "refuses" and "user metadata" tests fail; the watch test may fail. + +- [ ] **Step 3: Implement the rules** + +In `species_repository.dart`, replace `isSpeciesInUse` with: + +```dart + /// Whether anything references the species: a sighting on a dive or a + /// tag on a photo. Both are diver data, so both block deletion. + Future isSpeciesInUse(String id) async { + final result = await _db + .customSelect( + ''' + SELECT + (SELECT COUNT(*) FROM sightings WHERE species_id = ?) + + (SELECT COUNT(*) FROM media_species WHERE species_id = ?) AS count + ''', + variables: [Variable.withString(id), Variable.withString(id)], + ) + .getSingle(); + return (result.data['count'] as int) > 0; + } +``` + +In `deleteSpecies`, after the `site_species` delete block add: + +```dart + // Tags of an unused species are stale rows (the in-use check above + // refuses while any exist); clear them the way site_species is cleared, + // relying on the species tombstone downstream. + await (_db.delete( + _db.mediaSpecies, + )..where((t) => t.speciesId.equals(id))).go(); +``` + +and change the doc comment to `/// Delete a species. Throws if the species is referenced by sightings or photo tags.` and the exception text to `'Cannot delete species that is referenced by sightings or photo tags'`. + +In `media_repository.dart`, replace the `watchMediaChanges` table list with: + +```dart + TableUpdateQuery.onAllTables([ + _db.media, + _db.mediaEnrichment, + _db.mediaSpecies, + ]), +``` + +and replace `idsWithUserMetadata` and its doc comment with: + +```dart + /// Ids of [mediaIds] whose rows carry something the diver typed that the + /// source file does not hold: a caption, the favorite flag, or a species + /// tag. + /// + /// Used to decide whether an unlink needs to warn before it removes the + /// rows. + Future> idsWithUserMetadata(List mediaIds) async { + if (mediaIds.isEmpty) return {}; + final rows = + await (_db.select(_db.media)..where( + (t) => + t.id.isIn(mediaIds) & + (t.isFavorite.equals(true) | + (t.caption.isNotNull() & t.caption.equals('').not())), + )) + .get(); + final tagged = await (_db.selectOnly(_db.mediaSpecies) + ..addColumns([_db.mediaSpecies.mediaId]) + ..where(_db.mediaSpecies.mediaId.isIn(mediaIds))) + .get(); + return { + for (final row in rows) row.id, + for (final row in tagged) row.read(_db.mediaSpecies.mediaId)!, + }; + } +``` + +Create `lib/features/media/presentation/providers/species_media_providers.dart`: + +```dart +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; + +final mediaSpeciesRepositoryProvider = Provider((ref) { + return MediaSpeciesRepository(); +}); + +/// Photo-tag rows per species, the twin of `speciesSightingCountsProvider`: +/// the catalog manager refuses to delete a species that either one counts. +final speciesTagCountsProvider = FutureProvider>((ref) async { + final repository = ref.watch(mediaSpeciesRepositoryProvider); + ref.invalidateSelfWhen(repository.watchTagChanges()); + return repository.tagCountsBySpecies(); +}); +``` + +In `species_manage_page.dart`: add the import +`import 'package:submersion/features/media/presentation/providers/species_media_providers.dart';`, +add a field `Map _tagCounts = const {};` under `_sightingCounts`, change `_isSelectable` to + +```dart + bool _isSelectable(Species s) => + !s.isBuiltIn && + (_sightingCounts[s.id] ?? 0) == 0 && + (_tagCounts[s.id] ?? 0) == 0; +``` + +and in `build`, directly after the `_sightingCounts = ...` assignment add +`_tagCounts = ref.watch(speciesTagCountsProvider).value ?? const {};`. +Where the page invalidates `speciesSightingCountsProvider` after a delete, also `ref.invalidate(speciesTagCountsProvider);`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/marine_life/data/repositories/species_repository_tags_test.dart test/features/media/data/repositories/media_repository_species_test.dart test/features/marine_life/presentation/pages/species_manage_page_test.dart test/features/marine_life/data/repositories/species_repository_test.dart` +Expected: `All tests passed!`, exit code 0. The manage page test overrides `speciesSightingCountsProvider` but not the new provider; if it now reaches the database, add `speciesTagCountsProvider.overrideWith((ref) async => const {})` to that test's `host()` overrides next to the sighting-counts override. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/marine_life lib/features/media test/features/marine_life test/features/media +git add lib/features/marine_life/data/repositories/species_repository.dart lib/features/media/data/repositories/media_repository.dart lib/features/marine_life/presentation/pages/species_manage_page.dart lib/features/media/presentation/providers/species_media_providers.dart test/features/marine_life/data/repositories/species_repository_tags_test.dart test/features/media/data/repositories/media_repository_species_test.dart test/features/marine_life/presentation/pages/species_manage_page_test.dart +git commit -m "feat(marine-life): treat photo tags as species usage and user metadata" +``` + +--- + +### Task 5: `SpeciesTaggingService` + +**Files:** +- Create: `lib/features/media/data/services/species_tagging_service.dart` +- Test: `test/features/media/data/services/species_tagging_service_test.dart` + +**Interfaces:** +- Consumes: `MediaSpeciesRepository.addTag/removeTag` (Task 1); `MediaRepository.getMediaById(String) -> Future`; `SpeciesRepository.getSightingsForDive(String) -> Future>` and `addSighting({required String diveId, required String speciesId, int count = 1, String notes = ''}) -> Future`; `Sighting.id`, `Sighting.speciesId` from `lib/features/marine_life/domain/entities/species.dart`. +- Produces: + ```dart + class TagPhotosResult { final int tagged; final Map failures; } + class SpeciesTaggingService { + SpeciesTaggingService({required MediaSpeciesRepository tags, required MediaRepository media, required SpeciesRepository species}); + Future tagPhoto({required String mediaId, required String speciesId}); + Future tagPhotos({required List mediaIds, required String speciesId}); + Future untagPhoto({required String mediaId, required String speciesId}); + } + ``` + +- [ ] **Step 1: Write the failing service test** + +`test/features/media/data/services/species_tagging_service_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; +import 'package:submersion/features/media/data/services/species_tagging_service.dart'; + +import '../../../../helpers/test_database.dart'; +import '../repositories/species_photo_fixtures.dart'; + +void main() { + late SpeciesRepository species; + late MediaSpeciesRepository tags; + late SpeciesTaggingService service; + + setUp(() async { + await setUpTestDatabase(); + species = SpeciesRepository(); + tags = MediaSpeciesRepository(); + service = SpeciesTaggingService( + tags: tags, + media: MediaRepository(), + species: species, + ); + await insertTestSite('s1', 'Blue Hole'); + await insertTestDive(id: 'd1', at: DateTime(2024, 1, 10), siteId: 's1'); + await insertTestSpecies(id: 'sp_whale_shark', name: 'Whale Shark'); + await insertTestMedia(id: 'p1', diveId: 'd1'); + await insertTestMedia(id: 'p2', diveId: 'd1'); + await insertTestMedia(id: 'site-photo', siteId: 's1'); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('tagging adds the missing sighting and links the tag to it', () async { + expect(await species.getSightingsForDive('d1'), isEmpty); + + final tag = await service.tagPhoto( + mediaId: 'p1', + speciesId: 'sp_whale_shark', + ); + + final sightings = await species.getSightingsForDive('d1'); + expect(sightings.single.speciesId, 'sp_whale_shark'); + expect(sightings.single.count, 1); + expect(tag.sightingId, sightings.single.id); + }); + + test('a second photo on the same dive reuses the sighting', () async { + final first = await service.tagPhoto( + mediaId: 'p1', + speciesId: 'sp_whale_shark', + ); + final second = await service.tagPhoto( + mediaId: 'p2', + speciesId: 'sp_whale_shark', + ); + + expect(second.sightingId, first.sightingId); + expect(await species.getSightingsForDive('d1'), hasLength(1)); + }); + + test('an existing sighting is linked, not duplicated', () async { + final existing = await species.addSighting( + diveId: 'd1', + speciesId: 'sp_whale_shark', + count: 3, + ); + + final tag = await service.tagPhoto( + mediaId: 'p1', + speciesId: 'sp_whale_shark', + ); + + expect(tag.sightingId, existing.id); + expect((await species.getSightingsForDive('d1')).single.count, 3); + }); + + test('a site-only photo is tagged without a sighting', () async { + final tag = await service.tagPhoto( + mediaId: 'site-photo', + speciesId: 'sp_whale_shark', + ); + + expect(tag.sightingId, isNull); + expect(await species.getSightingsForDive('d1'), isEmpty); + }); + + test('untagging keeps the sighting', () async { + await service.tagPhoto(mediaId: 'p1', speciesId: 'sp_whale_shark'); + + await service.untagPhoto(mediaId: 'p1', speciesId: 'sp_whale_shark'); + + expect(await tags.getTagsForMedia('p1'), isEmpty); + expect(await species.getSightingsForDive('d1'), hasLength(1)); + }); + + test('tagPhotos tags what it can and reports the rest', () async { + final result = await service.tagPhotos( + mediaIds: ['p1', 'missing', 'p2'], + speciesId: 'sp_whale_shark', + ); + + expect(result.tagged, 2); + expect(result.failures.keys, ['missing']); + expect(await tags.getTagsForMedia('p1'), hasLength(1)); + expect(await tags.getTagsForMedia('p2'), hasLength(1)); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/data/services/species_tagging_service_test.dart` +Expected: compilation error, `species_tagging_service.dart` not found. + +- [ ] **Step 3: Write the service** + +`lib/features/media/data/services/species_tagging_service.dart`: + +```dart +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; + +/// What [SpeciesTaggingService.tagPhotos] managed to do. +class TagPhotosResult { + final int tagged; + + /// Media id to failure message, for the photos that could not be tagged. + final Map failures; + + const TagPhotosResult({required this.tagged, this.failures = const {}}); +} + +/// Tags photos with species and keeps the dive log consistent while doing +/// it: a photo is evidence, so a tag on a dive photo adds the dive's +/// sighting of that species when none exists yet. Untagging never removes +/// a sighting; the diver may have logged it independently. +class SpeciesTaggingService { + final MediaSpeciesRepository _tags; + final MediaRepository _media; + final SpeciesRepository _species; + + SpeciesTaggingService({ + required MediaSpeciesRepository tags, + required MediaRepository media, + required SpeciesRepository species, + }) : _tags = tags, + _media = media, + _species = species; + + Future tagPhoto({ + required String mediaId, + required String speciesId, + }) async { + final item = await _media.getMediaById(mediaId); + if (item == null) { + throw StateError('Media $mediaId does not exist'); + } + final diveId = item.diveId; + // A site-only photo has no dive to log the sighting on. + if (diveId == null) { + return _tags.addTag(mediaId: mediaId, speciesId: speciesId); + } + final sightingId = await _sightingIdFor(diveId, speciesId); + return _tags.addTag( + mediaId: mediaId, + speciesId: speciesId, + sightingId: sightingId, + ); + } + + /// [tagPhoto] over many photos; one failure never stops the rest. + Future tagPhotos({ + required List mediaIds, + required String speciesId, + }) async { + var tagged = 0; + final failures = {}; + for (final mediaId in mediaIds) { + try { + await tagPhoto(mediaId: mediaId, speciesId: speciesId); + tagged += 1; + } catch (e) { + failures[mediaId] = e.toString(); + } + } + return TagPhotosResult(tagged: tagged, failures: failures); + } + + Future untagPhoto({ + required String mediaId, + required String speciesId, + }) => _tags.removeTag(mediaId: mediaId, speciesId: speciesId); + + Future _sightingIdFor(String diveId, String speciesId) async { + final sightings = await _species.getSightingsForDive(diveId); + for (final sighting in sightings) { + if (sighting.speciesId == speciesId) return sighting.id; + } + final created = await _species.addSighting( + diveId: diveId, + speciesId: speciesId, + ); + return created.id; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/data/services/species_tagging_service_test.dart` +Expected: `All tests passed!` (6 tests), exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media/data/services/species_tagging_service.dart test/features/media/data/services/species_tagging_service_test.dart +git add lib/features/media/data/services/species_tagging_service.dart test/features/media/data/services/species_tagging_service_test.dart +git commit -m "feat(media): add SpeciesTaggingService that keeps sightings in step with tags" +``` + +--- + +### Task 6: Providers for species media + +**Files:** +- Modify: `lib/features/media/presentation/providers/species_media_providers.dart` (created in Task 4; add the rest) +- Test: `test/features/media/presentation/providers/species_media_providers_test.dart` + +**Interfaces:** +- Consumes: Tasks 1, 2, 5; `mediaRepositoryProvider` from `lib/features/media/presentation/providers/media_providers.dart`; `speciesRepositoryProvider` from `lib/features/marine_life/presentation/providers/species_providers.dart` (with `watchSpeciesChanges()` and `watchSightingChanges()`); `currentDiverIdProvider` from `lib/features/divers/presentation/providers/diver_providers.dart`. +- Produces: + ```dart + final speciesTaggingServiceProvider = Provider; + final mediaForSpeciesProvider = FutureProvider.family, String>; // speciesId + final speciesTagCandidatesProvider = FutureProvider.family, String>; // speciesId + final mediaTagChipsProvider = FutureProvider.family, String>; // mediaId + ``` + +- [ ] **Step 1: Write the failing provider test** + +`test/features/media/presentation/providers/species_media_providers_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../../helpers/test_database.dart'; +import '../../data/repositories/species_photo_fixtures.dart'; + +Future _eventually( + Future Function() read, + bool Function(T value) until, +) async { + late T value; + for (var i = 0; i < 50; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + value = await read(); + if (until(value)) break; + } + return value; +} + +void main() { + late SharedPreferences prefs; + late MediaSpeciesRepository tags; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + tags = MediaSpeciesRepository(); + await insertTestDive(id: 'd1', at: DateTime(2024, 1, 10)); + await insertTestSpecies(id: 'c1', name: 'Grouper'); + await insertTestSighting(id: 'sg1', diveId: 'd1', speciesId: 'c1'); + await insertTestMedia(id: 'p1', diveId: 'd1', takenAt: DateTime(2024, 1, 10, 9)); + await insertTestMedia(id: 'p2', diveId: 'd1', takenAt: DateTime(2024, 1, 10, 10)); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + ProviderContainer makeContainer() { + final container = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); + addTearDown(container.dispose); + return container; + } + + test('mediaForSpeciesProvider refreshes when a tag is added', () async { + final container = makeContainer(); + final sub = container.listen(mediaForSpeciesProvider('c1'), (_, _) {}); + addTearDown(sub.close); + expect(await container.read(mediaForSpeciesProvider('c1').future), isEmpty); + + await tags.addTag(mediaId: 'p2', speciesId: 'c1'); + + final items = await _eventually( + () => container.read(mediaForSpeciesProvider('c1').future), + (v) => v.isNotEmpty, + ); + expect(items.single.id, 'p2'); + }); + + test('speciesTagCandidatesProvider drops a photo once it is tagged', + () async { + final container = makeContainer(); + final sub = container.listen( + speciesTagCandidatesProvider('c1'), + (_, _) {}, + ); + addTearDown(sub.close); + final before = await container.read(speciesTagCandidatesProvider('c1').future); + expect(before.single.items.map((m) => m.id).toList(), ['p1', 'p2']); + + await tags.addTag(mediaId: 'p1', speciesId: 'c1', sightingId: 'sg1'); + + final after = await _eventually( + () => container.read(speciesTagCandidatesProvider('c1').future), + (v) => v.single.items.length == 1, + ); + expect(after.single.items.single.id, 'p2'); + }); + + test('mediaTagChipsProvider lists chips and refreshes on removal', () async { + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + final container = makeContainer(); + final sub = container.listen(mediaTagChipsProvider('p1'), (_, _) {}); + addTearDown(sub.close); + final chips = await container.read(mediaTagChipsProvider('p1').future); + expect(chips.single.storedName, 'Grouper'); + + await tags.removeTag(mediaId: 'p1', speciesId: 'c1'); + + final after = await _eventually( + () => container.read(mediaTagChipsProvider('p1').future), + (v) => v.isEmpty, + ); + expect(after, isEmpty); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/media/presentation/providers/species_media_providers_test.dart` +Expected: compilation errors, the three providers are not defined. + +- [ ] **Step 3: Add the providers** + +Replace the whole of `lib/features/media/presentation/providers/species_media_providers.dart` with: + +```dart +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; +import 'package:submersion/features/media/data/services/species_tagging_service.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/species_tag_candidate_group.dart'; +import 'package:submersion/features/media/domain/entities/species_tag_chip.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; + +final mediaSpeciesRepositoryProvider = Provider((ref) { + return MediaSpeciesRepository(); +}); + +final speciesTaggingServiceProvider = Provider((ref) { + return SpeciesTaggingService( + tags: ref.watch(mediaSpeciesRepositoryProvider), + media: ref.watch(mediaRepositoryProvider), + species: ref.watch(speciesRepositoryProvider), + ); +}); + +/// Photo-tag rows per species, the twin of `speciesSightingCountsProvider`: +/// the catalog manager refuses to delete a species that either one counts. +final speciesTagCountsProvider = FutureProvider>((ref) async { + final repository = ref.watch(mediaSpeciesRepositoryProvider); + ref.invalidateSelfWhen(repository.watchTagChanges()); + return repository.tagCountsBySpecies(); +}); + +/// Every photo tagged with a species, newest first, for the current diver. +/// +/// Not scoped by the Statistics filter: the species detail page is a +/// logbook surface. Ticks on tags and on media (a photo edit, unlink or +/// delete changes the gallery without touching `media_species`). +final mediaForSpeciesProvider = FutureProvider.family, String>(( + ref, + speciesId, +) async { + final repository = ref.watch(mediaSpeciesRepositoryProvider); + final diverId = ref.watch(currentDiverIdProvider); + ref.invalidateSelfWhen(repository.watchTagChanges()); + ref.invalidateSelfWhen(ref.watch(mediaRepositoryProvider).watchMediaChanges()); + return repository.getMediaForSpecies(speciesId, diverId: diverId); +}); + +/// Untagged photos on the dives where the species was sighted, grouped by +/// dive. Also ticks on sightings: logging the species on another dive +/// brings that dive's photos into the picker. +final speciesTagCandidatesProvider = + FutureProvider.family, String>(( + ref, + speciesId, + ) async { + final repository = ref.watch(mediaSpeciesRepositoryProvider); + final speciesRepository = ref.watch(speciesRepositoryProvider); + final diverId = ref.watch(currentDiverIdProvider); + ref.invalidateSelfWhen(repository.watchTagChanges()); + ref.invalidateSelfWhen( + ref.watch(mediaRepositoryProvider).watchMediaChanges(), + ); + ref.invalidateSelfWhen(speciesRepository.watchSightingChanges()); + return repository.getTagCandidatesForSpecies(speciesId, diverId: diverId); + }); + +/// The species tags on one photo, as the viewer's chips show them. Ticks on +/// species too, so a renamed custom species relabels its chip. +final mediaTagChipsProvider = + FutureProvider.family, String>((ref, mediaId) async { + final repository = ref.watch(mediaSpeciesRepositoryProvider); + ref.invalidateSelfWhen(repository.watchTagChanges()); + ref.invalidateSelfWhen( + ref.watch(speciesRepositoryProvider).watchSpeciesChanges(), + ); + return repository.getTagChipsForMedia(mediaId); + }); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/providers/species_media_providers_test.dart test/features/marine_life/presentation/pages/species_manage_page_test.dart` +Expected: `All tests passed!`, exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media/presentation/providers/species_media_providers.dart test/features/media/presentation/providers/species_media_providers_test.dart +git add lib/features/media/presentation/providers/species_media_providers.dart test/features/media/presentation/providers/species_media_providers_test.dart +git commit -m "feat(media): add species media providers" +``` + +--- + +### Task 7: Localization keys for phase A + +**Files:** +- Modify: `lib/l10n/arb/app_{ar,de,en,es,fr,he,hu,it,nl,pt,zh}.arb` +- Regenerate: `lib/l10n/arb/app_localizations*.dart` +- Test: `test/l10n/species_photos_strings_test.dart` + +**Interfaces:** +- Produces `AppLocalizations` members: `media_species_actionTooltip`, `_sheetTitle`, `_sightedOnDive`, `_otherSpecies`, `_noDiveHint`, `_chipsLabel`; `marineLife_speciesPhotos_title(Object count)`, `_empty`, `_tagPhotos`, `_addPhotos`, `_thumbnailLabel`, `_importAdded(int)`, `_importSkipped(int)`, `_importFailed(int)`; `marineLife_tagPicker_title`, `_empty`, `_emptyHint`, `_selectAll`, `_confirm(int)`, `_tagged(int)`, `_diveLabel(Object number)`. + +Background: the ARB files are feature blocks, not sorted; new keys are inserted after an anchor key present in every locale (`media_info_title` for the `media_species_*` keys, `marineLife_speciesManage_searchHint` for the rest), with `@` placeholder metadata directly after each key in `app_en.arb` only. `test/l10n/arb_parity_test.dart` enforces that every locale has every English key with matching placeholders. + +- [ ] **Step 1: Write the failing strings test** + +`test/l10n/species_photos_strings_test.dart`: + +```dart +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + test('species photo strings exist in English', () async { + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + expect(l10n.marineLife_speciesPhotos_title(3), 'Photos (3)'); + expect(l10n.marineLife_speciesPhotos_tagPhotos, 'Tag photos'); + expect(l10n.marineLife_tagPicker_confirm(1), 'Tag 1 photo'); + expect(l10n.marineLife_tagPicker_confirm(4), 'Tag 4 photos'); + expect(l10n.marineLife_speciesPhotos_importAdded(2), '2 photos added'); + expect(l10n.media_species_sheetTitle, 'Species in this photo'); + expect(l10n.marineLife_tagPicker_diveLabel(101), 'Dive 101'); + }); + + test('species photo strings are translated in German', () async { + final l10n = await AppLocalizations.delegate.load(const Locale('de')); + + expect(l10n.marineLife_speciesPhotos_tagPhotos, 'Fotos markieren'); + expect(l10n.marineLife_tagPicker_confirm(4), '4 Fotos markieren'); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/l10n/species_photos_strings_test.dart` +Expected: compilation error, `marineLife_speciesPhotos_title` is not defined. + +- [ ] **Step 3: Insert the keys with the script** + +Save as `/private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/1a1e1ff7-4477-4b97-b07b-8de224f521ea/scratchpad/add_species_photo_keys.py` (throwaway, not committed) and run it from the worktree root with `python3`: + +```python +import json, re, sys +from pathlib import Path + +ROOT = Path('lib/l10n/arb') +ANCHORS = { + 'media_': 'media_info_title', + 'marineLife_': 'marineLife_speciesManage_searchHint', +} + +KEYS = [ + ('media_species_actionTooltip', None), + ('media_species_sheetTitle', None), + ('media_species_sightedOnDive', None), + ('media_species_otherSpecies', None), + ('media_species_noDiveHint', None), + ('media_species_chipsLabel', None), + ('marineLife_speciesPhotos_title', {'count': 'Object'}), + ('marineLife_speciesPhotos_empty', None), + ('marineLife_speciesPhotos_tagPhotos', None), + ('marineLife_speciesPhotos_addPhotos', None), + ('marineLife_speciesPhotos_thumbnailLabel', None), + ('marineLife_speciesPhotos_importAdded', {'count': 'int'}), + ('marineLife_speciesPhotos_importSkipped', {'count': 'int'}), + ('marineLife_speciesPhotos_importFailed', {'count': 'int'}), + ('marineLife_tagPicker_title', None), + ('marineLife_tagPicker_empty', None), + ('marineLife_tagPicker_emptyHint', None), + ('marineLife_tagPicker_selectAll', None), + ('marineLife_tagPicker_confirm', {'count': 'int'}), + ('marineLife_tagPicker_tagged', {'count': 'int'}), + ('marineLife_tagPicker_diveLabel', {'number': 'Object'}), +] + +VALUES = { + 'en': [ + 'Species', 'Species in this photo', 'Sighted on this dive', + 'Other species...', + 'This photo is not linked to a dive. Search for a species to tag it.', + 'Species tags', + 'Photos ({count})', 'Photos tagged with this species appear here.', + 'Tag photos', 'Add photos', 'Species photo', + '{count, plural, =1{1 photo added} other{{count} photos added}}', + '{count, plural, =1{1 skipped} other{{count} skipped}}', + '{count, plural, =1{1 failed} other{{count} failed}}', + 'Tag photos', + 'No untagged photos on dives where you logged this species.', + 'Use Add photos to import pictures from your camera roll.', + 'Select all', + '{count, plural, =1{Tag 1 photo} other{Tag {count} photos}}', + '{count, plural, =1{Tagged 1 photo} other{Tagged {count} photos}}', + 'Dive {number}', + ], + 'de': [ + 'Arten', 'Arten auf diesem Foto', 'Bei diesem Tauchgang gesichtet', + 'Andere Arten...', + 'Dieses Foto ist keinem Tauchgang zugeordnet. Suchen Sie eine Art, um es zu markieren.', + 'Artenmarkierungen', + 'Fotos ({count})', + 'Fotos, die mit dieser Art markiert sind, erscheinen hier.', + 'Fotos markieren', 'Fotos hinzufügen', 'Artenfoto', + '{count, plural, =1{1 Foto hinzugefügt} other{{count} Fotos hinzugefügt}}', + '{count, plural, =1{1 übersprungen} other{{count} übersprungen}}', + '{count, plural, =1{1 fehlgeschlagen} other{{count} fehlgeschlagen}}', + 'Fotos markieren', + 'Keine unmarkierten Fotos bei Tauchgängen, bei denen Sie diese Art protokolliert haben.', + 'Verwenden Sie Fotos hinzufügen, um Bilder aus Ihren Aufnahmen zu importieren.', + 'Alle auswählen', + '{count, plural, =1{1 Foto markieren} other{{count} Fotos markieren}}', + '{count, plural, =1{1 Foto markiert} other{{count} Fotos markiert}}', + 'Tauchgang {number}', + ], + 'es': [ + 'Especies', 'Especies en esta foto', 'Avistadas en esta inmersión', + 'Otras especies...', + 'Esta foto no está vinculada a una inmersión. Busca una especie para etiquetarla.', + 'Etiquetas de especies', + 'Fotos ({count})', + 'Las fotos etiquetadas con esta especie aparecen aquí.', + 'Etiquetar fotos', 'Añadir fotos', 'Foto de la especie', + '{count, plural, =1{1 foto añadida} other{{count} fotos añadidas}}', + '{count, plural, =1{1 omitida} other{{count} omitidas}}', + '{count, plural, =1{1 con error} other{{count} con error}}', + 'Etiquetar fotos', + 'No hay fotos sin etiquetar en las inmersiones donde registraste esta especie.', + 'Usa Añadir fotos para importar imágenes de tu carrete.', + 'Seleccionar todo', + '{count, plural, =1{Etiquetar 1 foto} other{Etiquetar {count} fotos}}', + '{count, plural, =1{1 foto etiquetada} other{{count} fotos etiquetadas}}', + 'Inmersión {number}', + ], + 'fr': [ + 'Espèces', 'Espèces sur cette photo', 'Observées lors de cette plongée', + 'Autres espèces...', + "Cette photo n'est liée à aucune plongée. Recherchez une espèce pour l'étiqueter.", + "Étiquettes d'espèces", + 'Photos ({count})', + 'Les photos étiquetées avec cette espèce apparaissent ici.', + 'Étiqueter des photos', 'Ajouter des photos', "Photo de l'espèce", + '{count, plural, =1{1 photo ajoutée} other{{count} photos ajoutées}}', + '{count, plural, =1{1 ignorée} other{{count} ignorées}}', + '{count, plural, =1{1 en échec} other{{count} en échec}}', + 'Étiqueter des photos', + 'Aucune photo non étiquetée sur les plongées où vous avez consigné cette espèce.', + 'Utilisez Ajouter des photos pour importer des images depuis votre pellicule.', + 'Tout sélectionner', + '{count, plural, =1{Étiqueter 1 photo} other{Étiqueter {count} photos}}', + '{count, plural, =1{1 photo étiquetée} other{{count} photos étiquetées}}', + 'Plongée {number}', + ], + 'it': [ + 'Specie', 'Specie in questa foto', 'Avvistate in questa immersione', + 'Altre specie...', + "Questa foto non è collegata a un'immersione. Cerca una specie per taggarla.", + 'Tag delle specie', + 'Foto ({count})', 'Le foto taggate con questa specie compaiono qui.', + 'Tagga foto', 'Aggiungi foto', 'Foto della specie', + '{count, plural, =1{1 foto aggiunta} other{{count} foto aggiunte}}', + '{count, plural, =1{1 saltata} other{{count} saltate}}', + '{count, plural, =1{1 non riuscita} other{{count} non riuscite}}', + 'Tagga foto', + 'Nessuna foto senza tag nelle immersioni in cui hai registrato questa specie.', + 'Usa Aggiungi foto per importare immagini dal rullino.', + 'Seleziona tutto', + '{count, plural, =1{Tagga 1 foto} other{Tagga {count} foto}}', + '{count, plural, =1{1 foto taggata} other{{count} foto taggate}}', + 'Immersione {number}', + ], + 'pt': [ + 'Espécies', 'Espécies nesta foto', 'Avistadas neste mergulho', + 'Outras espécies...', + 'Esta foto não está vinculada a um mergulho. Pesquise uma espécie para marcá-la.', + 'Marcações de espécies', + 'Fotos ({count})', 'As fotos marcadas com esta espécie aparecem aqui.', + 'Marcar fotos', 'Adicionar fotos', 'Foto da espécie', + '{count, plural, =1{1 foto adicionada} other{{count} fotos adicionadas}}', + '{count, plural, =1{1 ignorada} other{{count} ignoradas}}', + '{count, plural, =1{1 com falha} other{{count} com falha}}', + 'Marcar fotos', + 'Nenhuma foto sem marcação nos mergulhos em que você registrou esta espécie.', + 'Use Adicionar fotos para importar imagens do seu rolo da câmera.', + 'Selecionar tudo', + '{count, plural, =1{Marcar 1 foto} other{Marcar {count} fotos}}', + '{count, plural, =1{1 foto marcada} other{{count} fotos marcadas}}', + 'Mergulho {number}', + ], + 'nl': [ + 'Soorten', 'Soorten op deze foto', 'Gezien tijdens deze duik', + 'Andere soorten...', + 'Deze foto is niet aan een duik gekoppeld. Zoek een soort om de foto te taggen.', + 'Soorttags', + "Foto's ({count})", "Foto's die met deze soort zijn getagd verschijnen hier.", + "Foto's taggen", "Foto's toevoegen", 'Soortfoto', + "{count, plural, =1{1 foto toegevoegd} other{{count} foto's toegevoegd}}", + '{count, plural, =1{1 overgeslagen} other{{count} overgeslagen}}', + '{count, plural, =1{1 mislukt} other{{count} mislukt}}', + "Foto's taggen", + 'Geen ongetagde foto\'s bij duiken waar je deze soort hebt gelogd.', + "Gebruik Foto's toevoegen om beelden uit je camerarol te importeren.", + 'Alles selecteren', + "{count, plural, =1{1 foto taggen} other{{count} foto's taggen}}", + "{count, plural, =1{1 foto getagd} other{{count} foto's getagd}}", + 'Duik {number}', + ], + 'hu': [ + 'Fajok', 'Fajok ezen a fotón', 'Ezen a merülésen észlelve', + 'Más fajok...', + 'Ez a fotó nincs merüléshez kapcsolva. Keress egy fajt a címkézéshez.', + 'Fajcímkék', + 'Fotók ({count})', 'Az ezzel a fajjal címkézett fotók itt jelennek meg.', + 'Fotók címkézése', 'Fotók hozzáadása', 'Fajfotó', + '{count, plural, =1{1 fotó hozzáadva} other{{count} fotó hozzáadva}}', + '{count, plural, =1{1 kihagyva} other{{count} kihagyva}}', + '{count, plural, =1{1 sikertelen} other{{count} sikertelen}}', + 'Fotók címkézése', + 'Nincs címkézetlen fotó azokon a merüléseken, ahol ezt a fajt naplóztad.', + 'Használd a Fotók hozzáadása gombot képek importálásához a kameratekercsből.', + 'Összes kijelölése', + '{count, plural, =1{1 fotó címkézése} other{{count} fotó címkézése}}', + '{count, plural, =1{1 fotó címkézve} other{{count} fotó címkézve}}', + '{number}. merülés', + ], + 'zh': [ + '物种', '这张照片中的物种', '本次潜水目击', '其他物种...', + '这张照片未关联到潜水记录。搜索物种以添加标签。', '物种标签', + '照片 ({count})', '标记为该物种的照片会显示在这里。', + '标记照片', '添加照片', '物种照片', + '{count, plural, =1{已添加 1 张照片} other{已添加 {count} 张照片}}', + '{count, plural, =1{跳过 1 张} other{跳过 {count} 张}}', + '{count, plural, =1{1 张失败} other{{count} 张失败}}', + '标记照片', '在记录过该物种的潜水中没有未标记的照片。', + '使用“添加照片”从相册导入图片。', '全选', + '{count, plural, =1{标记 1 张照片} other{标记 {count} 张照片}}', + '{count, plural, =1{已标记 1 张照片} other{已标记 {count} 张照片}}', + '第 {number} 次潜水', + ], + 'ar': [ + 'الأنواع', 'الأنواع في هذه الصورة', 'شوهدت في هذه الغوصة', + 'أنواع أخرى...', + 'هذه الصورة غير مرتبطة بغوصة. ابحث عن نوع لوسمها.', 'وسوم الأنواع', + 'الصور ({count})', 'الصور الموسومة بهذا النوع تظهر هنا.', + 'وسم الصور', 'إضافة صور', 'صورة النوع', + '{count, plural, =1{تمت إضافة صورة واحدة} other{تمت إضافة {count} صور}}', + '{count, plural, =1{تم تخطي صورة واحدة} other{تم تخطي {count}}}', + '{count, plural, =1{فشلت صورة واحدة} other{فشل {count}}}', + 'وسم الصور', + 'لا توجد صور غير موسومة في الغوصات التي سجلت فيها هذا النوع.', + 'استخدم إضافة صور لاستيراد الصور من ألبوم الكاميرا.', 'تحديد الكل', + '{count, plural, =1{وسم صورة واحدة} other{وسم {count} صور}}', + '{count, plural, =1{تم وسم صورة واحدة} other{تم وسم {count} صور}}', + 'الغوصة {number}', + ], + 'he': [ + 'מינים', 'מינים בתמונה זו', 'נצפו בצלילה זו', 'מינים אחרים...', + 'התמונה אינה מקושרת לצלילה. חפשו מין כדי לתייג אותה.', 'תגיות מינים', + 'תמונות ({count})', 'תמונות שתויגו במין זה יופיעו כאן.', + 'תיוג תמונות', 'הוספת תמונות', 'תמונת מין', + '{count, plural, =1{תמונה אחת נוספה} other{{count} תמונות נוספו}}', + '{count, plural, =1{אחת דולגה} other{{count} דולגו}}', + '{count, plural, =1{אחת נכשלה} other{{count} נכשלו}}', + 'תיוג תמונות', 'אין תמונות ללא תגית בצלילות שבהן תיעדתם מין זה.', + 'השתמשו בהוספת תמונות כדי לייבא תמונות מגלריית המצלמה.', 'בחירת הכול', + '{count, plural, =1{תיוג תמונה אחת} other{תיוג {count} תמונות}}', + '{count, plural, =1{תמונה אחת תויגה} other{{count} תמונות תויגו}}', + 'צלילה {number}', + ], +} + +def key_line(key, value): + return ' ' + json.dumps(key) + ': ' + json.dumps(value, ensure_ascii=False) + ',\n' + +def meta_lines(key, placeholders): + out = [' ' + json.dumps('@' + key) + ': {\n', ' "placeholders": {\n'] + items = list(placeholders.items()) + for i, (name, typ) in enumerate(items): + comma = ',' if i < len(items) - 1 else '' + out += [' ' + json.dumps(name) + ': {\n', + ' "type": ' + json.dumps(typ) + '\n', + ' }' + comma + '\n'] + out += [' }\n', ' },\n'] + return out + +def find_anchor(lines, anchor): + pattern = re.compile(r'^ "' + re.escape(anchor) + r'": ') + hits = [i for i, l in enumerate(lines) if pattern.match(l)] + if len(hits) != 1: + sys.exit(f'anchor {anchor} found {len(hits)} times') + return hits[0] + +for locale, values in VALUES.items(): + if len(values) != len(KEYS): + sys.exit(f'{locale}: {len(values)} values for {len(KEYS)} keys') + path = ROOT / f'app_{locale}.arb' + text = path.read_text(encoding='utf-8') + for key, _ in KEYS: + if f'"{key}"' in text: + sys.exit(f'{locale}: {key} already present') + lines = text.splitlines(keepends=True) + blocks = {anchor: [] for anchor in ANCHORS.values()} + for (key, placeholders), value in zip(KEYS, values): + block = [key_line(key, value)] + if locale == 'en' and placeholders: + block += meta_lines(key, placeholders) + prefix = next(p for p in ANCHORS if key.startswith(p)) + blocks[ANCHORS[prefix]].extend(block) + for anchor, block in sorted( + blocks.items(), key=lambda pair: find_anchor(lines, pair[0]), reverse=True + ): + at = find_anchor(lines, anchor) + 1 + lines[at:at] = block + new_text = ''.join(lines) + json.loads(new_text) + path.write_text(new_text, encoding='utf-8') + print(f'{locale}: added {len(KEYS)} keys') +``` + +Run: + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +python3 /private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/1a1e1ff7-4477-4b97-b07b-8de224f521ea/scratchpad/add_species_photo_keys.py +flutter gen-l10n +``` + +Expected: eleven `added 21 keys` lines. + +- [ ] **Step 4: Run the strings test and the parity test** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/l10n/species_photos_strings_test.dart test/l10n/arb_parity_test.dart` +Expected: `All tests passed!`, exit code 0. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +git add lib/l10n/arb test/l10n/species_photos_strings_test.dart +git commit -m "feat(l10n): add species photo, tag picker and viewer species strings" +``` + +--- + +### Task 8: `SpeciesPhotoViewerPage` + +**Files:** +- Create: `lib/features/media/presentation/pages/species_photo_viewer_page.dart` +- Test: `test/features/media/presentation/pages/species_photo_viewer_page_test.dart` + +**Interfaces:** +- Consumes: `mediaForSpeciesProvider` (Task 6); `MediaViewerPage({required List mediaList, required String initialMediaId, bool showGoToDive = false})` from `lib/features/media/presentation/pages/media_viewer_page.dart`. +- Produces: `SpeciesPhotoViewerPage({required String speciesId, required String initialMediaId})`. + +- [ ] **Step 1: Write the failing widget test** + +`test/features/media/presentation/pages/species_photo_viewer_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/presentation/pages/media_viewer_page.dart'; +import 'package:submersion/features/media/presentation/pages/species_photo_viewer_page.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; + +import '../support/media_widget_harness.dart'; + +void main() { + testWidgets('resolves the species gallery and hands off to the viewer', ( + tester, + ) async { + final items = [ + testMediaItem(id: 'p1', diveId: 'd1', takenAt: DateTime(2024, 1, 10)), + testMediaItem(id: 'p2', diveId: 'd1', takenAt: DateTime(2024, 1, 11)), + ]; + await tester.pumpWidget( + await mediaTestApp( + home: const SpeciesPhotoViewerPage( + speciesId: 'sp_x', + initialMediaId: 'p2', + ), + overrides: [ + mediaForSpeciesProvider('sp_x').overrideWith((ref) async => items), + ], + ), + ); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pumpAndSettle(); + + final viewer = tester.widget(find.byType(MediaViewerPage)); + expect(viewer.mediaList.map((m) => m.id).toList(), ['p1', 'p2']); + expect(viewer.initialMediaId, 'p2'); + expect(viewer.showGoToDive, isTrue); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/pages/species_photo_viewer_page_test.dart` +Expected: compilation error, `species_photo_viewer_page.dart` not found. + +- [ ] **Step 3: Write the page** + +`lib/features/media/presentation/pages/species_photo_viewer_page.dart`: + +```dart +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/media/presentation/pages/media_viewer_page.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; + +/// Species-scoped wrapper around [MediaViewerPage]: resolves every photo +/// tagged with the species reactively, then hands off. The viewer internals +/// (zoom, video, overlays, share, dive context) live in MediaViewerPage; +/// this only loads the list, like the trip and dive wrappers. +class SpeciesPhotoViewerPage extends ConsumerWidget { + const SpeciesPhotoViewerPage({ + super.key, + required this.speciesId, + required this.initialMediaId, + }); + + final String speciesId; + + /// The photo to open on. + final String initialMediaId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mediaAsync = ref.watch(mediaForSpeciesProvider(speciesId)); + return mediaAsync.when( + data: (mediaList) => MediaViewerPage( + mediaList: mediaList, + initialMediaId: initialMediaId, + // The gallery spans dives, so the viewer offers the jump to each. + showGoToDive: true, + ), + loading: () => const Scaffold( + backgroundColor: Colors.black, + body: Center(child: CircularProgressIndicator(color: Colors.white)), + ), + error: (error, stack) => Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Text('$error', style: const TextStyle(color: Colors.white)), + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/pages/species_photo_viewer_page_test.dart` +Expected: `All tests passed!`, exit code 0. If `MediaViewerPage` needs providers the harness does not supply (the test will name the provider in its error), add the same overrides `test/features/media/presentation/pages/site_media_viewer_page_test.dart` uses for the viewer. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media/presentation/pages/species_photo_viewer_page.dart test/features/media/presentation/pages/species_photo_viewer_page_test.dart +git add lib/features/media/presentation/pages/species_photo_viewer_page.dart test/features/media/presentation/pages/species_photo_viewer_page_test.dart +git commit -m "feat(media): add the species photo viewer wrapper" +``` + +--- + +### Task 9: `SpeciesPhotosSection` on the species detail page + +**Files:** +- Create: `lib/features/media/presentation/widgets/species_photos_section.dart` +- Modify: `lib/features/marine_life/presentation/pages/species_detail_page.dart` (the `Column` children in `build`, between `_buildStatisticsSection(context, ref),` and `SpeciesSightingsSection(speciesId: speciesId),`; imports) +- Test: `test/features/media/presentation/widgets/species_photos_section_test.dart` + +**Interfaces:** +- Consumes: `mediaForSpeciesProvider` (Task 6); `MediaThumbnailTile({item, settings, isSelectionMode, isSelected, semanticsLabel})` and `MediaEmptyState({icon, message})` from `lib/features/media/presentation/widgets/media_grid.dart`; `settingsProvider`; `SpeciesPhotoViewerPage` (Task 8); Task 7 strings. +- Produces: `SpeciesPhotosSection({required String speciesId, VoidCallback? onTagPhotos, VoidCallback? onAddPhotos})`. Each action button renders only when its callback is non-null; Tasks 10 and 11 supply them. + +- [ ] **Step 1: Write the failing widget test** + +`test/features/media/presentation/widgets/species_photos_section_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/presentation/pages/species_photo_viewer_page.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/media_grid.dart'; +import 'package:submersion/features/media/presentation/widgets/species_photos_section.dart'; + +import '../support/media_widget_harness.dart'; + +Future _pump( + WidgetTester tester, + List items, { + VoidCallback? onTagPhotos, + VoidCallback? onAddPhotos, +}) async { + await tester.pumpWidget( + await mediaTestApp( + home: Scaffold( + body: SingleChildScrollView( + child: SpeciesPhotosSection( + speciesId: 'sp_x', + onTagPhotos: onTagPhotos, + onAddPhotos: onAddPhotos, + ), + ), + ), + overrides: [ + mediaForSpeciesProvider('sp_x').overrideWith((ref) async => items), + ], + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + final items = [ + testMediaItem(id: 'p1', diveId: 'd1', takenAt: DateTime(2024, 1, 11)), + testMediaItem(id: 'p2', diveId: 'd1', takenAt: DateTime(2024, 1, 10)), + ]; + + testWidgets('renders a titled grid of tagged photos', (tester) async { + await _pump(tester, items); + + expect(find.text('Photos (2)'), findsOneWidget); + expect(find.byType(MediaThumbnailTile), findsNWidgets(2)); + expect(find.byType(MediaEmptyState), findsNothing); + }); + + testWidgets('shows the empty state with the actions still available', ( + tester, + ) async { + await _pump(tester, const [], onTagPhotos: () {}, onAddPhotos: () {}); + + expect(find.text('Photos (0)'), findsOneWidget); + expect(find.byType(MediaEmptyState), findsOneWidget); + expect(find.text('Tag photos'), findsOneWidget); + expect(find.text('Add photos'), findsOneWidget); + }); + + testWidgets('hides an action whose callback is absent', (tester) async { + await _pump(tester, items, onAddPhotos: () {}); + + expect(find.text('Tag photos'), findsNothing); + expect(find.text('Add photos'), findsOneWidget); + }); + + testWidgets('the actions call back', (tester) async { + var tagged = false; + var added = false; + await _pump( + tester, + items, + onTagPhotos: () => tagged = true, + onAddPhotos: () => added = true, + ); + + await tester.tap(find.text('Tag photos')); + await tester.tap(find.text('Add photos')); + + expect(tagged, isTrue); + expect(added, isTrue); + }); + + testWidgets('tapping a thumbnail opens the species viewer on that photo', ( + tester, + ) async { + await _pump(tester, items); + + await tester.tap(find.byType(MediaThumbnailTile).last); + await tester.pumpAndSettle(); + + final viewer = tester.widget( + find.byType(SpeciesPhotoViewerPage), + ); + expect(viewer.speciesId, 'sp_x'); + expect(viewer.initialMediaId, 'p2'); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/widgets/species_photos_section_test.dart` +Expected: compilation error, `species_photos_section.dart` not found. + +- [ ] **Step 3: Write the section and mount it** + +`lib/features/media/presentation/widgets/species_photos_section.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/presentation/pages/species_photo_viewer_page.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/media_grid.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The photos tagged with one species, on the species detail page. +/// +/// A titled three-column grid with two header actions. Either action is +/// hidden when its callback is null, so the section can be mounted before +/// the flows behind the buttons exist. +class SpeciesPhotosSection extends ConsumerWidget { + final String speciesId; + final VoidCallback? onTagPhotos; + final VoidCallback? onAddPhotos; + + const SpeciesPhotosSection({ + super.key, + required this.speciesId, + this.onTagPhotos, + this.onAddPhotos, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final mediaAsync = ref.watch(mediaForSpeciesProvider(speciesId)); + final settings = ref.watch(settingsProvider); + final items = mediaAsync.value ?? const []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: Text( + l10n.marineLife_speciesPhotos_title(items.length), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + if (onTagPhotos != null) + TextButton.icon( + key: const ValueKey('species_tag_photos'), + onPressed: onTagPhotos, + icon: const Icon(Icons.sell_outlined), + label: Text(l10n.marineLife_speciesPhotos_tagPhotos), + ), + if (onAddPhotos != null) + TextButton.icon( + key: const ValueKey('species_add_photos'), + onPressed: onAddPhotos, + icon: const Icon(Icons.add_photo_alternate_outlined), + label: Text(l10n.marineLife_speciesPhotos_addPhotos), + ), + ], + ), + const SizedBox(height: 8), + mediaAsync.when( + loading: () => const SizedBox( + height: 80, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Text( + '$error', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + data: (items) { + if (items.isEmpty) { + return MediaEmptyState( + icon: Icons.photo_library_outlined, + message: l10n.marineLife_speciesPhotos_empty, + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openViewer(context, item), + child: MediaThumbnailTile( + item: item, + settings: settings, + isSelectionMode: false, + isSelected: false, + semanticsLabel: + l10n.marineLife_speciesPhotos_thumbnailLabel, + ), + ); + }, + ); + }, + ), + ], + ); + } + + void _openViewer(BuildContext context, MediaItem item) { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => SpeciesPhotoViewerPage( + speciesId: speciesId, + initialMediaId: item.id, + ), + ), + ); + } +} +``` + +In `species_detail_page.dart`, add the import +`import 'package:submersion/features/media/presentation/widgets/species_photos_section.dart';` +and, between `_buildStatisticsSection(context, ref),` and `SpeciesSightingsSection(speciesId: speciesId),`, insert: + +```dart + SpeciesPhotosSection(speciesId: speciesId), +``` + +(The two callbacks are added by Tasks 10 and 11.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/widgets/species_photos_section_test.dart` +Expected: `All tests passed!` (5 tests), exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media/presentation/widgets/species_photos_section.dart lib/features/marine_life/presentation/pages/species_detail_page.dart test/features/media/presentation/widgets/species_photos_section_test.dart +git add lib/features/media/presentation/widgets/species_photos_section.dart lib/features/marine_life/presentation/pages/species_detail_page.dart test/features/media/presentation/widgets/species_photos_section_test.dart +git commit -m "feat(marine-life): show a species' tagged photos on its detail page" +``` + +--- + +### Task 10: `SpeciesTagPickerPage` and the "Tag photos" action + +**Files:** +- Create: `lib/features/media/presentation/pages/species_tag_picker_page.dart` +- Modify: `lib/features/marine_life/presentation/pages/species_detail_page.dart` (pass `onTagPhotos` to `SpeciesPhotosSection`, convert the page's `build` to open the picker and show the result) +- Test: `test/features/media/presentation/pages/species_tag_picker_page_test.dart` + +**Interfaces:** +- Consumes: `speciesTagCandidatesProvider` and `speciesTaggingServiceProvider` (Task 6); `SpeciesTagCandidateGroup` (Task 2); `TagPhotosResult` (Task 5); `SelectionController` (`lib/shared/selection/selection_controller.dart`: `toggle(id)`, `selectAll(ids)`, `deselectAll()`, `value.checkedIds`); `MediaThumbnailTile`; `UnitFormatter`; Task 7 strings. +- Produces: `SpeciesTagPickerPage({required String speciesId})`, pushed with `Navigator.push`; pops with the `TagPhotosResult` after tagging, or null when dismissed. + +Background: the whole page is a picker, so every tile renders in selection mode and toggles on tap; there is no drag-select and no "exit selection" state, which keeps the per-group index bookkeeping of `DragSelectGridView` out of it. Selection is keyed by media id in a `SelectionController` so "Select all" and the confirm count span all groups. + +- [ ] **Step 1: Write the failing widget test** + +`test/features/media/presentation/pages/species_tag_picker_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; +import 'package:submersion/features/media/data/services/species_tagging_service.dart'; +import 'package:submersion/features/media/domain/entities/species_tag_candidate_group.dart'; +import 'package:submersion/features/media/presentation/pages/species_tag_picker_page.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/media_grid.dart'; + +import '../support/media_widget_harness.dart'; + +/// Records what the page asks to tag instead of touching a database. +class _RecordingTaggingService extends SpeciesTaggingService { + _RecordingTaggingService() + : super( + tags: MediaSpeciesRepository(), + media: MediaRepository(), + species: SpeciesRepository(), + ); + + final List taggedIds = []; + String? taggedSpecies; + + @override + Future tagPhotos({ + required List mediaIds, + required String speciesId, + }) async { + taggedIds.addAll(mediaIds); + taggedSpecies = speciesId; + return TagPhotosResult(tagged: mediaIds.length); + } +} + +List _groups() => [ + SpeciesTagCandidateGroup( + diveId: 'd2', + diveNumber: 102, + diveDateTime: DateTime(2024, 3, 5), + siteName: null, + sightingId: 'sg2', + items: [testMediaItem(id: 'p4', diveId: 'd2')], + ), + SpeciesTagCandidateGroup( + diveId: 'd1', + diveNumber: 101, + diveDateTime: DateTime(2024, 1, 10), + siteName: 'Blue Hole', + sightingId: 'sg1', + items: [ + testMediaItem(id: 'p2', diveId: 'd1'), + testMediaItem(id: 'p3', diveId: 'd1'), + ], + ), +]; + +Future Function() _pumpPicker( + WidgetTester tester, + List groups, + _RecordingTaggingService service, +) { + TagPhotosResult? popped; + var pumped = false; + Future pump() async { + await tester.pumpWidget( + await mediaTestApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + popped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const SpeciesTagPickerPage(speciesId: 'sp_x'), + ), + ); + }, + child: const Text('OPEN'), + ), + ), + ), + ), + overrides: [ + speciesTagCandidatesProvider('sp_x').overrideWith( + (ref) async => groups, + ), + speciesTaggingServiceProvider.overrideWithValue(service), + ], + ), + ); + await tester.tap(find.text('OPEN')); + await tester.pumpAndSettle(); + pumped = true; + } + + return () async { + if (!pumped) await pump(); + return popped; + }; +} + +void main() { + testWidgets('groups candidates by dive with a header per dive', ( + tester, + ) async { + final service = _RecordingTaggingService(); + final read = _pumpPicker(tester, _groups(), service); + await read(); + + expect(find.byType(MediaThumbnailTile), findsNWidgets(3)); + expect(find.textContaining('Dive 102'), findsOneWidget); + expect(find.textContaining('Blue Hole'), findsOneWidget); + expect(find.textContaining('Unknown site'), findsOneWidget); + }); + + testWidgets('the confirm button counts the selection and tags it', ( + tester, + ) async { + final service = _RecordingTaggingService(); + final read = _pumpPicker(tester, _groups(), service); + await read(); + + expect(find.text('Tag 0 photos'), findsNothing); + final confirm = find.byKey(const ValueKey('tag_picker_confirm')); + expect(tester.widget(confirm).onPressed, isNull); + + await tester.tap(find.byType(MediaThumbnailTile).at(1)); + await tester.tap(find.byType(MediaThumbnailTile).at(2)); + await tester.pump(); + expect(find.text('Tag 2 photos'), findsOneWidget); + + await tester.tap(confirm); + await tester.pumpAndSettle(); + + expect(service.taggedSpecies, 'sp_x'); + expect(service.taggedIds.toSet(), {'p2', 'p3'}); + final popped = await read(); + expect(popped?.tagged, 2); + }); + + testWidgets('Select all checks every candidate across groups', ( + tester, + ) async { + final service = _RecordingTaggingService(); + final read = _pumpPicker(tester, _groups(), service); + await read(); + + await tester.tap(find.byKey(const ValueKey('tag_picker_select_all'))); + await tester.pump(); + + expect(find.text('Tag 3 photos'), findsOneWidget); + }); + + testWidgets('shows the empty state when nothing is left to tag', ( + tester, + ) async { + final service = _RecordingTaggingService(); + final read = _pumpPicker(tester, const [], service); + await read(); + + expect( + find.text('No untagged photos on dives where you logged this species.'), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('tag_picker_confirm')), findsNothing); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/media/presentation/pages/species_tag_picker_page_test.dart` +Expected: compilation error, `species_tag_picker_page.dart` not found. + +- [ ] **Step 3: Write the page and wire the action** + +`lib/features/media/presentation/pages/species_tag_picker_page.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/media/data/services/species_tagging_service.dart'; +import 'package:submersion/features/media/domain/entities/species_tag_candidate_group.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/media_grid.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/selection/selection_controller.dart'; +import 'package:submersion/shared/selection/selection_state.dart'; + +/// Picks photos to tag with one species, from the dives where the diver +/// logged it. Pops with the [TagPhotosResult] after tagging, or null. +class SpeciesTagPickerPage extends ConsumerStatefulWidget { + final String speciesId; + + const SpeciesTagPickerPage({super.key, required this.speciesId}); + + @override + ConsumerState createState() => + _SpeciesTagPickerPageState(); +} + +class _SpeciesTagPickerPageState extends ConsumerState { + final SelectionController _selection = SelectionController(); + bool _busy = false; + + @override + void initState() { + super.initState(); + // The whole page is a picker: selection mode from the first frame. + _selection.enterExplicit(); + } + + @override + void dispose() { + _selection.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final groupsAsync = ref.watch( + speciesTagCandidatesProvider(widget.speciesId), + ); + final settings = ref.watch(settingsProvider); + final units = UnitFormatter(settings); + final allIds = [ + for (final group in groupsAsync.value ?? const []) + for (final item in group.items) item.id, + ]; + + return ValueListenableBuilder( + valueListenable: _selection, + builder: (context, selection, _) { + final checked = selection.checkedIds; + return Scaffold( + appBar: AppBar( + title: Text(l10n.marineLife_tagPicker_title), + actions: [ + if (allIds.isNotEmpty) + TextButton( + key: const ValueKey('tag_picker_select_all'), + onPressed: () => _selection.selectAll(allIds), + child: Text(l10n.marineLife_tagPicker_selectAll), + ), + ], + ), + body: groupsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('$error')), + data: (groups) { + if (groups.isEmpty) { + return _EmptyState(l10n: l10n); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final group in groups) ...[ + _GroupHeader(group: group, units: units, l10n: l10n), + const SizedBox(height: 8), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: group.items.length, + itemBuilder: (context, index) { + final item = group.items[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _selection.toggle(item.id), + child: MediaThumbnailTile( + item: item, + settings: settings, + isSelectionMode: true, + isSelected: checked.contains(item.id), + semanticsLabel: + l10n.marineLife_speciesPhotos_thumbnailLabel, + ), + ); + }, + ), + const SizedBox(height: 24), + ], + ], + ); + }, + ), + bottomNavigationBar: allIds.isEmpty + ? null + : SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: FilledButton( + key: const ValueKey('tag_picker_confirm'), + onPressed: checked.isEmpty || _busy + ? null + : () => _confirm(checked.toList()), + child: Text( + l10n.marineLife_tagPicker_confirm(checked.length), + ), + ), + ), + ), + ); + }, + ); + } + + Future _confirm(List mediaIds) async { + setState(() => _busy = true); + try { + final result = await ref + .read(speciesTaggingServiceProvider) + .tagPhotos(mediaIds: mediaIds, speciesId: widget.speciesId); + if (mounted) Navigator.of(context).pop(result); + } finally { + if (mounted) setState(() => _busy = false); + } + } +} + +class _GroupHeader extends StatelessWidget { + final SpeciesTagCandidateGroup group; + final UnitFormatter units; + final AppLocalizations l10n; + + const _GroupHeader({ + required this.group, + required this.units, + required this.l10n, + }); + + @override + Widget build(BuildContext context) { + final number = group.diveNumber; + final parts = [ + if (number != null) l10n.marineLife_tagPicker_diveLabel(number), + units.formatDate(group.diveDateTime), + group.siteName ?? l10n.marineLife_speciesDetail_unknownSite, + ]; + return Text( + parts.join(' · '), + style: Theme.of(context).textTheme.titleSmall, + ); + } +} + +class _EmptyState extends StatelessWidget { + final AppLocalizations l10n; + + const _EmptyState({required this.l10n}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Icon( + Icons.photo_library_outlined, + size: 48, + color: theme.colorScheme.onSurface.withAlpha(77), + ), + ), + const SizedBox(height: 12), + Text( + l10n.marineLife_tagPicker_empty, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + l10n.marineLife_tagPicker_emptyHint, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(128), + ), + ), + ], + ), + ), + ); + } +} +``` + +If `SelectionState.checkedIds` or `SelectionController.enterExplicit` / `selectAll` / `toggle` differ in name, read `lib/shared/selection/selection_controller.dart` and `selection_state.dart` and use the declared names; the manage page (`species_manage_page.dart`) is a working consumer to compare against. + +In `species_detail_page.dart`: `SpeciesDetailPage` is a `ConsumerWidget`, so add a private method and pass it: + +```dart + SpeciesPhotosSection( + speciesId: speciesId, + onTagPhotos: () => _openTagPicker(context), + ), +``` + +and, at the end of the class: + +```dart + Future _openTagPicker(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + final result = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => SpeciesTagPickerPage(speciesId: speciesId), + ), + ); + if (result == null) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.marineLife_tagPicker_tagged(result.tagged))), + ); + } +``` + +with imports for `species_tag_picker_page.dart` and `species_tagging_service.dart` (for `TagPhotosResult`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/pages/species_tag_picker_page_test.dart test/features/media/presentation/widgets/species_photos_section_test.dart` +Expected: `All tests passed!`, exit code 0. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media/presentation/pages/species_tag_picker_page.dart lib/features/marine_life/presentation/pages/species_detail_page.dart test/features/media/presentation/pages/species_tag_picker_page_test.dart +git add lib/features/media/presentation/pages/species_tag_picker_page.dart lib/features/marine_life/presentation/pages/species_detail_page.dart test/features/media/presentation/pages/species_tag_picker_page_test.dart +git commit -m "feat(media): add the species tag picker and the Tag photos action" +``` + +--- + +### Task 11: Import camera-roll photos into a species + +**Files:** +- Modify: `lib/features/media/domain/entities/import_candidate.dart` (`ImportReviewResult` gains `importedIds`) +- Modify: `lib/features/media/presentation/pages/media_import_view.dart` (`importResolved` fills `importedIds`) +- Create: `lib/features/media/presentation/helpers/species_photo_import_helper.dart` +- Modify: `lib/features/marine_life/presentation/pages/species_detail_page.dart` (pass `onAddPhotos`) +- Test: `test/features/media/presentation/media_import_resolved_test.dart` (extend), `test/features/media/presentation/helpers/species_photo_import_helper_test.dart` (new) + +**Interfaces:** +- Consumes: `MediaImportView.importResolved({service, diveRepository, assets, targets})` and `MediaImportView.libraryWindowStart`; `showPhotoPicker({context, diveStartTime, diveEndTime, buffer})` from `lib/features/media/presentation/pages/photo_picker_page.dart`; `AssetInfo` from `lib/features/media/data/services/photo_picker_service.dart`; `ImportCandidate`, `AssetImportPreview` (`lib/features/media/domain/value_objects/import_preview.dart`), `TripMediaScanner.toWallClockUtc` (`lib/features/media/data/services/trip_media_scanner.dart`); `MediaImportReviewPage({candidates, onConfirm})`; `mediaImportServiceProvider` (`lib/features/media/presentation/providers/photo_picker_providers.dart`); `diveRepositoryProvider`; `speciesTaggingServiceProvider` (Task 6). +- Produces: `ImportReviewResult.importedIds: List`; `class SpeciesImportOutcome { added, skipped, failed }`; `SpeciesPhotoImportHelper.tagImported({review, service, speciesId}) -> Future` (pure composition, tested) and `SpeciesPhotoImportHelper.importPhotosForSpecies(context, ref, {required speciesId, pick})` (the flow). + +- [ ] **Step 1: Extend the existing importResolved test** + +In `test/features/media/presentation/media_import_resolved_test.dart`, inside the first test (`'groups assets by dive and by site, one service call each'`) add after `expect(out.failures, isEmpty);`: + +```dart + // Species import tags what was created, so the ids must come back. + expect(out.importedIds, ['row-a', 'row-b', 'row-c']); +``` + +- [ ] **Step 2: Write the failing helper test** + +`test/features/media/presentation/helpers/species_photo_import_helper_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; +import 'package:submersion/features/media/data/services/species_tagging_service.dart'; +import 'package:submersion/features/media/domain/entities/import_candidate.dart'; +import 'package:submersion/features/media/presentation/helpers/species_photo_import_helper.dart'; + +import '../../../../helpers/test_database.dart'; +import '../../data/repositories/species_photo_fixtures.dart'; + +void main() { + late SpeciesTaggingService service; + late MediaSpeciesRepository tags; + + setUp(() async { + await setUpTestDatabase(); + tags = MediaSpeciesRepository(); + service = SpeciesTaggingService( + tags: tags, + media: MediaRepository(), + species: SpeciesRepository(), + ); + await insertTestDive(id: 'd1', at: DateTime(2024, 1, 10)); + await insertTestSpecies(id: 'sp_whale_shark', name: 'Whale Shark'); + await insertTestMedia(id: 'p1', diveId: 'd1'); + await insertTestMedia(id: 'p2', diveId: 'd1'); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('tags every imported row and folds the counts into one outcome', + () async { + const review = ImportReviewResult( + linked: 2, + skipped: 1, + failures: {'asset-x': 'no dive'}, + importedIds: ['p1', 'p2', 'gone'], + ); + + final outcome = await SpeciesPhotoImportHelper.tagImported( + review: review, + service: service, + speciesId: 'sp_whale_shark', + ); + + expect(outcome.added, 2); + expect(outcome.skipped, 1); + // One import failure plus one tag failure (the row that does not exist). + expect(outcome.failed, 2); + expect(await tags.getTagsForMedia('p1'), hasLength(1)); + expect(await tags.getTagsForMedia('p2'), hasLength(1)); + }); + + test('ImportReviewResult defaults to no imported ids', () { + const review = ImportReviewResult(linked: 0, skipped: 0); + expect(review.importedIds, isEmpty); + }); +} +``` + +- [ ] **Step 3: Run both tests to verify they fail** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/media_import_resolved_test.dart test/features/media/presentation/helpers/species_photo_import_helper_test.dart` +Expected: compilation errors (`importedIds` is not defined; helper file not found). + +- [ ] **Step 4: Implement** + +In `lib/features/media/domain/entities/import_candidate.dart`, replace `ImportReviewResult` with: + +```dart +/// What confirming did, for the result snackbar and for callers that need +/// the rows themselves (a species import tags what was created). +class ImportReviewResult { + const ImportReviewResult({ + required this.linked, + required this.skipped, + this.failures = const {}, + this.importedIds = const [], + }); + + final int linked; + final int skipped; + final Map failures; + + /// Ids of the media rows the import created, in import order. + final List importedIds; +} +``` + +In `media_import_view.dart` `importResolved`: declare `final importedIds = [];` next to `var linked = 0;`, and after each `linked += result.imported.length;` (the dive loop and the site loop) add `importedIds.addAll(result.imported.map((m) => m.id));`. Return `ImportReviewResult(linked: linked, skipped: assets.length - targets.length, failures: failures, importedIds: importedIds)`. + +Create `lib/features/media/presentation/helpers/species_photo_import_helper.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/media/data/services/photo_picker_service.dart'; +import 'package:submersion/features/media/data/services/species_tagging_service.dart'; +import 'package:submersion/features/media/data/services/trip_media_scanner.dart'; +import 'package:submersion/features/media/domain/entities/import_candidate.dart'; +import 'package:submersion/features/media/domain/value_objects/import_preview.dart'; +import 'package:submersion/features/media/presentation/pages/media_import_review_page.dart'; +import 'package:submersion/features/media/presentation/pages/media_import_view.dart'; +import 'package:submersion/features/media/presentation/pages/photo_picker_page.dart'; +import 'package:submersion/features/media/presentation/providers/photo_picker_providers.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// What an import into a species did, for its snackbar. +class SpeciesImportOutcome { + final int added; + final int skipped; + final int failed; + + const SpeciesImportOutcome({ + required this.added, + required this.skipped, + required this.failed, + }); +} + +/// "Add photos" on a species: the library's reviewed import (every photo +/// resolves to a dive or a site before any row exists), followed by a tag +/// on each row it created. The species is applied after the attach, never +/// instead of it, so the attached-or-absent rule is untouched. +class SpeciesPhotoImportHelper { + const SpeciesPhotoImportHelper._(); + + /// Tags the rows a review created and folds both failure maps together. + static Future tagImported({ + required ImportReviewResult review, + required SpeciesTaggingService service, + required String speciesId, + }) async { + final tagged = await service.tagPhotos( + mediaIds: review.importedIds, + speciesId: speciesId, + ); + return SpeciesImportOutcome( + added: tagged.tagged, + skipped: review.skipped, + failed: review.failures.length + tagged.failures.length, + ); + } + + /// Runs the picker, the review page and the tagging, then reports. + /// + /// [pick] is a test seam that replaces the platform photo picker. + static Future importPhotosForSpecies( + BuildContext context, + WidgetRef ref, { + required String speciesId, + Future?> Function(BuildContext context)? pick, + }) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + final assets = + await (pick?.call(context) ?? + showPhotoPicker( + context: context, + diveStartTime: MediaImportView.libraryWindowStart, + diveEndTime: DateTime.now().add(const Duration(days: 1)), + buffer: Duration.zero, + )) ?? + const []; + if (assets.isEmpty || !context.mounted) return; + + final candidates = [ + for (final a in assets) + ImportCandidate( + key: a.id, + title: a.filename ?? a.id, + takenAt: TripMediaScanner.toWallClockUtc(a.createDateTime), + preview: AssetImportPreview(a.id), + ), + ]; + + SpeciesImportOutcome? outcome; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MediaImportReviewPage( + candidates: candidates, + onConfirm: (targets) async { + final review = await MediaImportView.importResolved( + service: ref.read(mediaImportServiceProvider), + diveRepository: ref.read(diveRepositoryProvider), + assets: assets, + targets: targets, + ); + outcome = await tagImported( + review: review, + service: ref.read(speciesTaggingServiceProvider), + speciesId: speciesId, + ); + return review; + }, + ), + ), + ); + + final done = outcome; + if (done == null) return; + final parts = [ + l10n.marineLife_speciesPhotos_importAdded(done.added), + if (done.skipped > 0) + l10n.marineLife_speciesPhotos_importSkipped(done.skipped), + if (done.failed > 0) + l10n.marineLife_speciesPhotos_importFailed(done.failed), + ]; + messenger.showSnackBar(SnackBar(content: Text(parts.join(' · ')))); + } +} +``` + +In `species_detail_page.dart`, pass the second callback: + +```dart + SpeciesPhotosSection( + speciesId: speciesId, + onTagPhotos: () => _openTagPicker(context), + onAddPhotos: () => SpeciesPhotoImportHelper.importPhotosForSpecies( + context, + ref, + speciesId: speciesId, + ), + ), +``` + +with the import `import 'package:submersion/features/media/presentation/helpers/species_photo_import_helper.dart';`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/media_import_resolved_test.dart test/features/media/presentation/helpers/species_photo_import_helper_test.dart test/features/media/presentation/` +Expected: `All tests passed!`, exit code 0 (the whole media presentation directory, since `ImportReviewResult` changed shape). + +- [ ] **Step 6: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media lib/features/marine_life/presentation/pages/species_detail_page.dart test/features/media +git add lib/features/media/domain/entities/import_candidate.dart lib/features/media/presentation/pages/media_import_view.dart lib/features/media/presentation/helpers/species_photo_import_helper.dart lib/features/marine_life/presentation/pages/species_detail_page.dart test/features/media/presentation/media_import_resolved_test.dart test/features/media/presentation/helpers/species_photo_import_helper_test.dart +git commit -m "feat(media): import camera-roll photos into a species through the reviewed importer" +``` + +--- + +### Task 12: Photo viewer: Species action, sheet, and tag chips + +**Files:** +- Create: `lib/features/media/presentation/widgets/media_species_sheet.dart` +- Create: `lib/features/media/presentation/widgets/media_species_chips_row.dart` +- Modify: `lib/features/media/presentation/pages/media_viewer_page.dart` (`_TopOverlay` gets an `onTagSpecies` callback and a button beside Info; `_BottomMetadataOverlay` mounts the chips row) +- Test: `test/features/media/presentation/widgets/media_species_sheet_test.dart`, `test/features/media/presentation/widgets/media_species_chips_row_test.dart` + +**Interfaces:** +- Consumes: `mediaTagChipsProvider(mediaId)` and `speciesTaggingServiceProvider` (Task 6); `diveSightingsProvider(diveId)` from `lib/features/marine_life/presentation/providers/species_providers.dart` (returns `List` with `speciesId`, `speciesName`, `speciesCategory`); `SpeciesPickerSheet({required ScrollController scrollController, required void Function(Species species, int count, String notes) onSpeciesSelected})` from `lib/features/dive_log/presentation/widgets/pickers/species_picker_sheet.dart`; `localizedSpeciesName(l10n, id, storedName)` and `iconForSpeciesCategory` / `colorForSpeciesCategory`; Task 7 strings. +- Produces: `Future showMediaSpeciesSheet(BuildContext context, MediaItem item)`; `MediaSpeciesSheet({required MediaItem item, ScrollController? scrollController})`; `MediaSpeciesChipsRow({required String mediaId})`. + +- [ ] **Step 1: Write the failing widget tests** + +`test/features/media/presentation/widgets/media_species_chips_row_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/media/domain/entities/species_tag_chip.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/media_species_chips_row.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_app.dart'; + +void main() { + testWidgets('renders one chip per tag with the localized name', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + final router = GoRouter( + initialLocation: '/viewer', + routes: [ + GoRoute( + path: '/viewer', + builder: (_, _) => const Scaffold( + body: MediaSpeciesChipsRow(mediaId: 'p1'), + ), + ), + GoRoute( + path: '/species/:id', + builder: (_, state) => + Scaffold(body: Text('DETAIL ${state.pathParameters['id']}')), + ), + ], + ); + await tester.pumpWidget( + testAppRouter( + router: router, + locale: const Locale('de'), + overrides: [ + ...overrides, + mediaTagChipsProvider('p1').overrideWith( + (ref) async => const [ + SpeciesTagChip( + speciesId: 'sp_whale_shark', + storedName: 'Whale Shark', + category: SpeciesCategory.shark, + isBuiltIn: true, + ), + SpeciesTagChip( + speciesId: 'c1', + storedName: 'My Nudibranch', + category: SpeciesCategory.invertebrate, + isBuiltIn: false, + ), + ], + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Walhai'), findsOneWidget); + expect(find.text('My Nudibranch'), findsOneWidget); + + await tester.tap(find.text('Walhai')); + await tester.pumpAndSettle(); + expect(find.text('DETAIL sp_whale_shark'), findsOneWidget); + }); + + testWidgets('renders nothing without tags', (tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + ...overrides, + mediaTagChipsProvider('p1').overrideWith((ref) async => const []), + ], + child: const MediaSpeciesChipsRow(mediaId: 'p1'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ActionChip), findsNothing); + }); +} +``` + +`test/features/media/presentation/widgets/media_species_sheet_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/data/repositories/species_repository.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/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; +import 'package:submersion/features/media/data/services/species_tagging_service.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/species_tag_chip.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/media_species_sheet.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_app.dart'; + +class _RecordingTaggingService extends SpeciesTaggingService { + _RecordingTaggingService() + : super( + tags: MediaSpeciesRepository(), + media: MediaRepository(), + species: SpeciesRepository(), + ); + + final List<(String mediaId, String speciesId)> tagged = []; + final List<(String mediaId, String speciesId)> untagged = []; + + @override + Future tagPhoto({ + required String mediaId, + required String speciesId, + }) async { + tagged.add((mediaId, speciesId)); + return MediaSpeciesTag( + id: 't', + mediaId: mediaId, + speciesId: speciesId, + createdAt: DateTime(2024), + ); + } + + @override + Future untagPhoto({ + required String mediaId, + required String speciesId, + }) async { + untagged.add((mediaId, speciesId)); + } +} + +MediaItem _photo({String? diveId}) => MediaItem( + id: 'p1', + diveId: diveId, + siteId: diveId == null ? 's1' : null, + mediaType: MediaType.photo, + createdAt: DateTime(2024), + updatedAt: DateTime(2024), +); + +const _sightings = [ + Sighting( + id: 'sg1', + diveId: 'd1', + speciesId: 'sp_whale_shark', + speciesName: 'Whale Shark', + speciesCategory: SpeciesCategory.shark, + ), + Sighting( + id: 'sg2', + diveId: 'd1', + speciesId: 'c1', + speciesName: 'My Nudibranch', + speciesCategory: SpeciesCategory.invertebrate, + ), +]; + +Future _pump( + WidgetTester tester, { + required MediaItem item, + required _RecordingTaggingService service, + List chips = const [], +}) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + ...overrides, + speciesTaggingServiceProvider.overrideWithValue(service), + mediaTagChipsProvider('p1').overrideWith((ref) async => chips), + diveSightingsProvider('d1').overrideWith((ref) async => _sightings), + ], + child: MediaSpeciesSheet(item: item), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('lists the dive\'s sightings as chips, checked when tagged', ( + tester, + ) async { + final service = _RecordingTaggingService(); + await _pump( + tester, + item: _photo(diveId: 'd1'), + service: service, + chips: const [ + SpeciesTagChip( + speciesId: 'sp_whale_shark', + storedName: 'Whale Shark', + category: SpeciesCategory.shark, + isBuiltIn: true, + ), + ], + ); + + expect(find.text('Species in this photo'), findsOneWidget); + expect(find.text('Sighted on this dive'), findsOneWidget); + final whale = tester.widget( + find.widgetWithText(FilterChip, 'Whale Shark'), + ); + final nudi = tester.widget( + find.widgetWithText(FilterChip, 'My Nudibranch'), + ); + expect(whale.selected, isTrue); + expect(nudi.selected, isFalse); + }); + + testWidgets('toggling a chip tags or untags the photo', (tester) async { + final service = _RecordingTaggingService(); + await _pump( + tester, + item: _photo(diveId: 'd1'), + service: service, + chips: const [ + SpeciesTagChip( + speciesId: 'sp_whale_shark', + storedName: 'Whale Shark', + category: SpeciesCategory.shark, + isBuiltIn: true, + ), + ], + ); + + await tester.tap(find.widgetWithText(FilterChip, 'My Nudibranch')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilterChip, 'Whale Shark')); + await tester.pump(); + + expect(service.tagged, [('p1', 'c1')]); + expect(service.untagged, [('p1', 'sp_whale_shark')]); + }); + + testWidgets('a site-only photo offers only the search', (tester) async { + final service = _RecordingTaggingService(); + await _pump(tester, item: _photo(), service: service); + + expect(find.text('Sighted on this dive'), findsNothing); + expect(find.byType(FilterChip), findsNothing); + expect( + find.text( + 'This photo is not linked to a dive. Search for a species to tag it.', + ), + findsOneWidget, + ); + expect(find.text('Other species...'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/widgets/media_species_chips_row_test.dart test/features/media/presentation/widgets/media_species_sheet_test.dart` +Expected: compilation errors, both widget files missing. + +- [ ] **Step 3: Write the widgets and wire the viewer** + +`lib/features/media/presentation/widgets/media_species_chips_row.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/marine_life/presentation/species_display.dart'; +import 'package:submersion/features/marine_life/presentation/utils/species_category_icon.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The species tagged on a photo, as a wrapping row of chips over the +/// viewer's dark overlay. Tapping a chip opens the species. Renders nothing +/// when the photo has no tags. +class MediaSpeciesChipsRow extends ConsumerWidget { + final String mediaId; + + const MediaSpeciesChipsRow({super.key, required this.mediaId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final chips = ref.watch(mediaTagChipsProvider(mediaId)).value ?? const []; + if (chips.isEmpty) return const SizedBox.shrink(); + + return Semantics( + label: l10n.media_species_chipsLabel, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final chip in chips) + ActionChip( + avatar: ExcludeSemantics( + child: Icon(iconForSpeciesCategory(chip.category), size: 16), + ), + label: Text( + localizedSpeciesName(l10n, chip.speciesId, chip.storedName), + ), + onPressed: () => context.push('/species/${chip.speciesId}'), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ); + } +} +``` + +`lib/features/media/presentation/widgets/media_species_sheet.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/dive_log/presentation/widgets/pickers/species_picker_sheet.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart'; +import 'package:submersion/features/marine_life/presentation/species_display.dart'; +import 'package:submersion/features/marine_life/presentation/utils/species_category_icon.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Opens the species sheet for [item], the same modal shape as the info +/// sheet: every transient panel in the app is a bottom sheet at every width. +Future showMediaSpeciesSheet(BuildContext context, MediaItem item) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (_, controller) => + MediaSpeciesSheet(item: item, scrollController: controller), + ), + ); + +/// Tag a photo with species. The dive's sightings are one tap away as +/// chips; anything else goes through the species picker, which also logs +/// the sighting on the dive (a photo is evidence). +class MediaSpeciesSheet extends ConsumerWidget { + final MediaItem item; + final ScrollController? scrollController; + + const MediaSpeciesSheet({super.key, required this.item, this.scrollController}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final diveId = item.diveId; + final tagged = { + for (final chip + in ref.watch(mediaTagChipsProvider(item.id)).value ?? const []) + chip.speciesId, + }; + final sightings = diveId == null + ? null + : ref.watch(diveSightingsProvider(diveId)).value ?? const []; + + return ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: [ + Text( + l10n.media_species_sheetTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + if (sightings == null) + Text(l10n.media_species_noDiveHint) + else ...[ + Text( + l10n.media_species_sightedOnDive, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final sighting in sightings) + FilterChip( + avatar: ExcludeSemantics( + child: Icon( + iconForSpeciesCategory( + sighting.speciesCategory ?? SpeciesCategory.other, + ), + size: 16, + ), + ), + label: Text( + localizedSpeciesName( + l10n, + sighting.speciesId, + sighting.speciesName, + ), + ), + selected: tagged.contains(sighting.speciesId), + onSelected: (selected) => _toggle( + ref, + speciesId: sighting.speciesId, + selected: selected, + ), + ), + ], + ), + ], + const SizedBox(height: 16), + OutlinedButton.icon( + key: const ValueKey('media_species_other'), + icon: const Icon(Icons.search), + label: Text(l10n.media_species_otherSpecies), + onPressed: () => _pickOther(context, ref), + ), + ], + ); + } + + Future _toggle( + WidgetRef ref, { + required String speciesId, + required bool selected, + }) async { + final service = ref.read(speciesTaggingServiceProvider); + if (selected) { + await service.tagPhoto(mediaId: item.id, speciesId: speciesId); + } else { + await service.untagPhoto(mediaId: item.id, speciesId: speciesId); + } + } + + Future _pickOther(BuildContext context, WidgetRef ref) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (_, controller) => SpeciesPickerSheet( + scrollController: controller, + onSpeciesSelected: (species, count, notes) { + Navigator.of(sheetContext).pop(); + ref + .read(speciesTaggingServiceProvider) + .tagPhoto(mediaId: item.id, speciesId: species.id); + }, + ), + ), + ); + } +} +``` + +Add `import 'package:submersion/core/constants/enums.dart';` to the sheet for `SpeciesCategory`. + +In `media_viewer_page.dart`: + +1. `_TopOverlay`: add a field `final VoidCallback onTagSpecies;` and the constructor parameter `required this.onTagSpecies,` (after `onWriteMetadata`); in its action row, directly before the Info `IconButton`, add: +```dart + IconButton( + key: const ValueKey('viewer_species'), + icon: const Icon(Icons.sell_outlined, color: Colors.white), + tooltip: context.l10n.media_species_actionTooltip, + onPressed: onTagSpecies, + ), +``` +2. Where `_TopOverlay(` is instantiated, add `onTagSpecies: () => showMediaSpeciesSheet(context, currentItem),` after the `onWriteMetadata:` argument, and import `media_species_sheet.dart`. +3. In `_BottomMetadataOverlay.build`, inside the `Column` children directly before the `// Metadata row` comment, add `MediaSpeciesChipsRow(mediaId: item.id),` and import `media_species_chips_row.dart`. + +- [ ] **Step 4: Run the tests** + +Run: `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos && flutter test test/features/media/presentation/widgets/media_species_chips_row_test.dart test/features/media/presentation/widgets/media_species_sheet_test.dart test/features/media/presentation/pages/` +Expected: `All tests passed!`, exit code 0. Existing viewer tests construct `_TopOverlay` only through `MediaViewerPage`, so the new required parameter needs no test edits; if a viewer test now reaches `mediaTagChipsProvider` against a database, override it with an empty list in that test's `ProviderScope`. + +- [ ] **Step 5: Format and commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format lib/features/media/presentation test/features/media/presentation +git add lib/features/media/presentation/widgets/media_species_sheet.dart lib/features/media/presentation/widgets/media_species_chips_row.dart lib/features/media/presentation/pages/media_viewer_page.dart test/features/media/presentation/widgets/media_species_chips_row_test.dart test/features/media/presentation/widgets/media_species_sheet_test.dart +git commit -m "feat(media): tag species from the photo viewer and show tag chips" +``` + +--- + +### Task 13: Docs, formatting, analysis, full test run + +**Files:** +- Modify: `docs/features/marine-life.md` + +- [ ] **Step 1: Update the feature doc** + +In `docs/features/marine-life.md`, replace the `## Photo Integration` section (from its heading through the `
...Coming Soon...
` block) with: + +```markdown +## Photo Integration + +### Species Photos + +Tag the photos on your dives with the species in them, and see every photo of +a species in one place. + +- On a species page, the **Photos** section shows every photo tagged with it. + **Tag photos** offers the untagged photos from the dives where you logged + the species; **Add photos** imports pictures from your camera roll, matches + each one to a dive as the media importer does, and tags it. +- In the photo viewer, the **Species** action lists the dive's sightings as + chips you can toggle, plus a search for any other species. Tagging a species + that is not yet logged on that dive adds the sighting for you. +- Tagged species appear as chips under the photo; tap one to open the species. + +A species with tagged photos cannot be deleted from the catalog until the +tags are removed, the same rule as for sightings. +``` + +- [ ] **Step 2: Format the whole project and confirm only intended files changed** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +dart format . +git status --short +``` + +Expected: only `docs/features/marine-life.md` modified (plus the untracked spec files that were copied into this worktree before the plan started; leave those alone). + +- [ ] **Step 3: Analyze the whole project** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +flutter analyze --fatal-infos +``` + +Expected: first line `Analyzing species-photos...`, result `No issues found!`. + +- [ ] **Step 4: Run the full test suite once** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +flutter test +``` + +Detach it to a log with the exit code appended, read the summary line yourself, and check `ps -axo command | grep flutter_tester | grep -o "packages=[^ ]*"` first: another session's suite running at the same time fakes failures in both. Expected: `All tests passed!` with an exit code of 0 and a summary line present. A run that ends with `Bad state: Cannot close sink while adding stream` and no summary line was killed; rerun it. + +- [ ] **Step 5: Commit the docs** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photos +git add docs/features/marine-life.md +git commit -m "docs: describe species photo tagging" +``` + +Do not push. Report the branch state and stop. + +--- + +## Self-review notes + +- Spec coverage (phase A): 3.1 to 3.3 data and repository (Tasks 1, 2); 3.4 sync (Task 3); 4 tagging service (Task 5); 5 import (Task 11); 6.1 Photos section (Task 9), 6.2 viewer wrapper (Task 8), 6.3 picker (Task 10), 6.4 viewer action, sheet and chips (Task 12); 8 integrity rules (Task 4); 9 l10n (Task 7); 10 tests are spread across tasks; docs (Task 13). Section 7 (phase B) is intentionally absent. +- Deviations from the spec, both deliberate: the picker uses a plain grid with tap-to-toggle instead of `DragSelectGridView`, so one `SelectionController` can span groups without per-grid index bookkeeping; the sheet's "Other species" reuses `SpeciesPickerSheet`, whose confirm dialog asks for a count and notes that the tag path ignores (the sighting the service creates has count 1), which is acceptable for phase A and noted for phase B polish. +- Type consistency: `TagPhotosResult` is defined in Task 5 and consumed in Tasks 10 and 11; `SpeciesTagCandidateGroup.sightingId` is a `String` (Task 2) while `MediaSpeciesTag.sightingId` is `String?`; `ImportReviewResult.importedIds` is added in Task 11 before `tagImported` reads it; `mediaTagChipsProvider` is keyed by media id and `mediaForSpeciesProvider` / `speciesTagCandidatesProvider` by species id, matching every override in the widget tests. diff --git a/docs/superpowers/plans/2026-08-27-species-photos-phase-b.md b/docs/superpowers/plans/2026-08-27-species-photos-phase-b.md new file mode 100644 index 0000000000..d238d67edd --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-species-photos-phase-b.md @@ -0,0 +1,338 @@ +# Species Photos, Phase B, Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface the species tags from phase A in three more places: cover photos on the Species page tiles, a photo-count chip on each dive-detail sighting row that opens the dive's photos of that species, and a species facet in the media library filter (sheet, active chips, smart albums). + +**Architecture:** Three read-only providers over `MediaSpeciesRepository` queries that already exist (`getCoverMediaBySpecies`, `getPhotoCountsBySpeciesForDive`, `getMediaForSpecies`); a `MediaItem? cover` parameter on `SeenSpeciesTile`; the sighting row extracted from `dive_detail_page.dart` into a `DiveSightingRow` widget so it can carry the chip and be tested alone; a `speciesId` field on `MediaLibraryFilter` compiled to an `EXISTS` subquery. No schema change. + +**Tech Stack:** Flutter, Riverpod, Drift (`existsQuery`), `flutter gen-l10n`, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-08-26-species-photos-design.md`, section 7 (phase B). Phase A is on this branch's base (`worktree-species-photos`, PR #1339). + +## Global Constraints + +- Work only in `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/species-photo-surfaces` on branch `worktree-species-photo-surfaces`, stacked on `worktree-species-photos`. `flutter analyze` prints `Analyzing species-photo-surfaces...` when it runs in the right tree. +- No schema change. No em-dashes. No emojis in code or docs. Immutable entities; tests first; single-file runs with exit codes, never through a pipe. +- Every new string is an ARB key in all 11 locales; generated l10n files are committed with the ARB change. +- `dart format .` before every commit. + +--- + +### Task 1: Providers for covers, per-dive counts, and a dive-scoped species gallery + +**Files:** +- Modify: `lib/features/media/presentation/providers/species_media_providers.dart` (append) +- Test: `test/features/media/presentation/providers/species_media_surfaces_providers_test.dart` + +**Interfaces:** +- Produces: + ```dart + final speciesCoverMediaProvider = FutureProvider>; // speciesId -> newest tagged photo + final diveSpeciesPhotoCountsProvider = FutureProvider.family, String>; // diveId -> {speciesId: count} + typedef DiveSpeciesKey = ({String diveId, String speciesId}); + final mediaForDiveSpeciesProvider = FutureProvider.family, DiveSpeciesKey>; // the dive's photos tagged with the species + ``` + +- [ ] **Step 1: Write the failing provider test** + +`test/features/media/presentation/providers/species_media_surfaces_providers_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/media/data/repositories/media_species_repository.dart'; +import 'package:submersion/features/media/presentation/providers/species_media_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../../helpers/test_database.dart'; +import '../../data/repositories/species_photo_fixtures.dart'; + +Future _eventually( + Future Function() read, + bool Function(T value) until, +) async { + late T value; + for (var i = 0; i < 50; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + value = await read(); + if (until(value)) break; + } + return value; +} + +void main() { + late SharedPreferences prefs; + late MediaSpeciesRepository tags; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + tags = MediaSpeciesRepository(); + await insertTestDive(id: 'd1', at: DateTime(2024, 1, 10)); + await insertTestDive(id: 'd2', at: DateTime(2024, 2, 10)); + await insertTestSpecies(id: 'c1', name: 'Grouper'); + await insertTestSpecies(id: 'c2', name: 'Wrasse'); + await insertTestMedia(id: 'p1', diveId: 'd1', takenAt: DateTime(2024, 1, 10, 9)); + await insertTestMedia(id: 'p2', diveId: 'd1', takenAt: DateTime(2024, 1, 10, 10)); + await insertTestMedia(id: 'p3', diveId: 'd2', takenAt: DateTime(2024, 2, 10, 9)); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + ProviderContainer makeContainer() { + final container = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); + addTearDown(container.dispose); + return container; + } + + test('speciesCoverMediaProvider maps species to their newest photo', () async { + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + await tags.addTag(mediaId: 'p3', speciesId: 'c1'); + await tags.addTag(mediaId: 'p2', speciesId: 'c2'); + final container = makeContainer(); + final sub = container.listen(speciesCoverMediaProvider, (_, _) {}); + addTearDown(sub.close); + + final covers = await container.read(speciesCoverMediaProvider.future); + + expect(covers['c1']!.id, 'p3'); + expect(covers['c2']!.id, 'p2'); + }); + + test('speciesCoverMediaProvider refreshes when a tag is added', () async { + final container = makeContainer(); + final sub = container.listen(speciesCoverMediaProvider, (_, _) {}); + addTearDown(sub.close); + expect(await container.read(speciesCoverMediaProvider.future), isEmpty); + + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + + final covers = await _eventually( + () => container.read(speciesCoverMediaProvider.future), + (v) => v.isNotEmpty, + ); + expect(covers['c1']!.id, 'p1'); + }); + + test('diveSpeciesPhotoCountsProvider counts per species on one dive', () async { + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + await tags.addTag(mediaId: 'p2', speciesId: 'c1'); + await tags.addTag(mediaId: 'p3', speciesId: 'c1'); + final container = makeContainer(); + final sub = container.listen(diveSpeciesPhotoCountsProvider('d1'), (_, _) {}); + addTearDown(sub.close); + + expect(await container.read(diveSpeciesPhotoCountsProvider('d1').future), { + 'c1': 2, + }); + }); + + test('mediaForDiveSpeciesProvider keeps only the dive\'s photos of the species', + () async { + await tags.addTag(mediaId: 'p1', speciesId: 'c1'); + await tags.addTag(mediaId: 'p2', speciesId: 'c2'); + await tags.addTag(mediaId: 'p3', speciesId: 'c1'); + final container = makeContainer(); + const key = (diveId: 'd1', speciesId: 'c1'); + final sub = container.listen(mediaForDiveSpeciesProvider(key), (_, _) {}); + addTearDown(sub.close); + + final items = await container.read(mediaForDiveSpeciesProvider(key).future); + + expect(items.map((m) => m.id).toList(), ['p1']); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/media/presentation/providers/species_media_surfaces_providers_test.dart` +Expected: compilation errors, the three providers are not defined. + +- [ ] **Step 3: Append the providers** + +Append to `species_media_providers.dart`: + +```dart + +/// Newest tagged photo per species, for the Species page's tile avatars. +/// One query for the whole list; derived, never chosen (built-in species +/// rows never sync, so a chosen cover could not follow the diver). +final speciesCoverMediaProvider = FutureProvider>(( + ref, +) async { + final repository = ref.watch(mediaSpeciesRepositoryProvider); + final diverId = ref.watch(currentDiverIdProvider); + ref.invalidateSelfWhen(repository.watchTagChanges()); + ref.invalidateSelfWhen(ref.watch(mediaRepositoryProvider).watchMediaChanges()); + return repository.getCoverMediaBySpecies(diverId: diverId); +}); + +/// How many photos on one dive carry each species tag, for the sighting +/// rows' photo chips. +final diveSpeciesPhotoCountsProvider = + FutureProvider.family, String>((ref, diveId) async { + final repository = ref.watch(mediaSpeciesRepositoryProvider); + ref.invalidateSelfWhen(repository.watchTagChanges()); + ref.invalidateSelfWhen( + ref.watch(mediaRepositoryProvider).watchMediaChanges(), + ); + return repository.getPhotoCountsBySpeciesForDive(diveId); + }); + +/// Identifies one dive's photos of one species. +typedef DiveSpeciesKey = ({String diveId, String speciesId}); + +/// The photos on [DiveSpeciesKey.diveId] tagged with [DiveSpeciesKey.speciesId], +/// newest first: the species gallery narrowed to one dive. +final mediaForDiveSpeciesProvider = + FutureProvider.family, DiveSpeciesKey>((ref, key) async { + final items = await ref.watch( + mediaForSpeciesProvider(key.speciesId).future, + ); + return [ + for (final item in items) + if (item.diveId == key.diveId) item, + ]; + }); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/media/presentation/providers/species_media_surfaces_providers_test.dart` +Expected: `All tests passed!` (4 tests), exit 0. + +- [ ] **Step 5: Format and commit** + +```bash +dart format lib/features/media/presentation/providers/species_media_providers.dart test/features/media/presentation/providers/species_media_surfaces_providers_test.dart +git add lib/features/media/presentation/providers/species_media_providers.dart test/features/media/presentation/providers/species_media_surfaces_providers_test.dart +git commit -m "feat(media): add cover, per-dive count and dive-scoped species gallery providers" +``` + +--- + +### Task 2: Localization keys + +**Files:** +- Modify: all 11 `lib/l10n/arb/app_*.arb`; regenerate `lib/l10n/arb/app_localizations*.dart` +- Test: `test/l10n/species_photo_surfaces_strings_test.dart` + +Keys (anchors: `diveLog_detail_section_marineLife` for the dive key, `media_library_filter_site` for the library key; `@` metadata in `app_en.arb` only): + +| key | en | de | es | fr | it | pt | nl | hu | zh | ar | he | +|---|---|---|---|---|---|---|---|---|---|---|---| +| `diveLog_detail_sightingPhotos` (count int, plural) | `{count, plural, =1{1 photo} other{{count} photos}}` | `{count, plural, =1{1 Foto} other{{count} Fotos}}` | `{count, plural, =1{1 foto} other{{count} fotos}}` | `{count, plural, =1{1 photo} other{{count} photos}}` | `{count, plural, =1{1 foto} other{{count} foto}}` | `{count, plural, =1{1 foto} other{{count} fotos}}` | `{count, plural, =1{1 foto} other{{count} foto's}}` | `{count, plural, =1{1 fotó} other{{count} fotó}}` | `{count, plural, =1{1 张照片} other{{count} 张照片}}` | `{count, plural, =1{صورة واحدة} other{{count} صور}}` | `{count, plural, =1{תמונה אחת} other{{count} תמונות}}` | +| `media_library_filter_species` | Species | Art | Especie | Espèce | Specie | Espécie | Soort | Faj | 物种 | النوع | מין | + +- [ ] **Step 1: Write the failing strings test** asserting `diveLog_detail_sightingPhotos(1) == '1 photo'`, `(3) == '3 photos'`, `media_library_filter_species == 'Species'` in `en`, and `media_library_filter_species == 'Art'` in `de`. +- [ ] **Step 2: Run it to see it fail** (undefined getters). +- [ ] **Step 3: Insert the keys** with the same anchored-insertion script shape used in phase A (`add_species_photo_keys.py`), then `flutter gen-l10n`. +- [ ] **Step 4: Run the strings test and `test/l10n/arb_parity_test.dart`**; expect green. +- [ ] **Step 5: Commit** `lib/l10n/arb` and the test: `feat(l10n): add sighting photo count and library species facet strings`. + +--- + +### Task 3: Cover photos on the Species page + +**Files:** +- Modify: `lib/features/marine_life/presentation/widgets/seen_species_tile.dart` (`MediaItem? cover` parameter; the `leading` becomes a clipped `MediaItemView` thumbnail when a cover exists) +- Modify: `lib/features/marine_life/presentation/pages/species_page.dart` (watch `speciesCoverMediaProvider`, pass `cover: covers[entry.species.id]`) +- Test: `test/features/marine_life/presentation/widgets/seen_species_tile_test.dart` (add a case), `test/features/marine_life/presentation/pages/species_page_test.dart` (override `speciesCoverMediaProvider`, assert a `MediaItemView` is rendered for the covered species and the category avatar for the other) + +Tile change: + +```dart + leading: cover == null + ? CircleAvatar(... as today ...) + : ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: MediaItemView( + item: cover, + thumbnail: true, + targetSize: const Size(80, 80), + fit: BoxFit.cover, + ), + ), + ), +``` + +Page change: `final covers = ref.watch(speciesCoverMediaProvider).value ?? const {};` in `build`, and `cover: covers[entry.species.id]` in the `itemBuilder`. Widget tests use the media harness's `testMediaItem` plus `mediaTestApp` (its fake resolver serves a 1x1 PNG) for the tile, and `speciesCoverMediaProvider.overrideWith((ref) async => {'sp_whale_shark': item})` for the page; the page test also needs `mediaSourceResolverRegistryProvider` overridden the way `mediaTestApp` does, or the tile falls back to its placeholder (assert `find.byType(MediaItemView)` rather than pixels). + +- [ ] Steps: failing tests, red, implement, green, format, commit `feat(marine-life): show a species' newest photo on its Species page tile`. + +--- + +### Task 4: Photo-count chips on dive-detail sighting rows + +**Files:** +- Create: `lib/features/dive_log/presentation/widgets/dive_sighting_row.dart` (the body of `_buildSightingTile` moved into `DiveSightingRow({required Sighting sighting, required int photoCount, required VoidCallback onOpen, VoidCallback? onOpenPhotos})`, with a trailing photo chip when `photoCount > 0`) +- Create: `lib/features/media/presentation/pages/dive_species_photo_viewer_page.dart` (`DiveSpeciesPhotoViewerPage({diveId, speciesId, initialMediaId?})`: watches `mediaForDiveSpeciesProvider`, hands off to `MediaViewerPage` with the first photo as initial when none given) +- Modify: `lib/features/dive_log/presentation/pages/dive_detail_page.dart` (`_buildSightingsSection` watches `diveSpeciesPhotoCountsProvider(diveId)` and builds `DiveSightingRow`s; delete `_buildSightingTile`) +- Test: `test/features/dive_log/presentation/widgets/dive_sighting_row_test.dart`, `test/features/media/presentation/pages/dive_species_photo_viewer_page_test.dart` + +Row chip (trailing, beside the existing count badge): + +```dart + if (photoCount > 0 && onOpenPhotos != null) + ActionChip( + key: const ValueKey('sighting_photos'), + avatar: const Icon(Icons.photo_outlined, size: 16), + label: Text(l10n.diveLog_detail_sightingPhotos(photoCount)), + onPressed: onOpenPhotos, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), +``` + +The page passes `onOpenPhotos: () => Navigator.of(context).push(MaterialPageRoute(fullscreenDialog: true, builder: (_) => DiveSpeciesPhotoViewerPage(diveId: diveId, speciesId: sighting.speciesId)))` only when the count is positive. Row tests: name and notes render, `x3` badge for count 3, photo chip present and calls back only when `photoCount > 0`, tap on the row calls `onOpen`. Viewer test mirrors `species_photo_viewer_page_test.dart`. + +- [ ] Steps: failing tests, red, implement (including the extraction), green plus `test/features/dive_log/presentation/pages/` still green, format, commit `feat(dive-log): show photo counts on sighting rows and open the dive's photos of a species`. + +--- + +### Task 5: Species facet in the media library + +**Files:** +- Modify: `lib/features/media/domain/entities/media_library_filter.dart` (`speciesId` in the constructor, field, `isEmpty`, `copyWith`, `toJson`, `fromJson`, `==`, `hashCode`) +- Modify: `lib/features/media/data/repositories/media_library_repository.dart` (`_baseWhere`: after the `tripId` block add + ```dart + final speciesId = filter.speciesId; + if (speciesId != null) { + final ms = _db.mediaSpecies; + where = + where & + existsQuery( + _db.selectOnly(ms) + ..addColumns([ms.id]) + ..where(ms.mediaId.equalsExp(m.id) & ms.speciesId.equals(speciesId)), + ); + } + ``` + ) +- Modify: `lib/features/media/presentation/widgets/media_library_filter_sheet.dart` (`String? _speciesId` seeded, cleared, applied and restored like `_siteId`; a `ListTile` titled `media_library_filter_species` after the site tile, options from `allSpeciesProvider` labelled with `localizedCommonName(l10n)` sorted by that label) +- Modify: `lib/features/media/presentation/widgets/media_library_active_filter_chips.dart` (a chip after the site chip, labelled with the species' localized name, fallback `media_library_filter_species`, clearing `speciesId`) +- Tests: `test/features/media/domain/media_library_filter_json_test.dart` (add `speciesId` to the round-trip and equality cases), `test/features/media/data/media_library_repository_test.dart` (a new test inserting a `media_species` row through `MediaSpeciesRepository.addTag` and asserting `getPage(filter: MediaLibraryFilter(speciesId: ...))` returns only tagged rows), `test/features/media/presentation/media_library_filter_sheet_test.dart` (override `allSpeciesProvider`; `picking a species drafts it and Apply commits the id`; extend Clear All), `test/features/media/presentation/media_library_active_filter_chips_test.dart` (species chip shows the localized name and clears only its facet), `test/features/media/data/media_smart_album_repository_test.dart` (round-trip `speciesId`) + +- [ ] Steps: failing tests, red, implement all five files, green for the five test files, format, commit `feat(media): filter the library by species`. + +--- + +### Task 6: Docs, format, analyze, full suite + +- [ ] Add to `docs/features/marine-life.md`, under "### Species Photos", a final bullet: `- The Species page shows each species' newest tagged photo; a dive's sighting rows show how many of its photos carry the species, and the media library can be filtered by species (also inside smart albums).` +- [ ] `dart format .`, `flutter analyze --fatal-infos` (expect `Analyzing species-photo-surfaces...` then no issues), full `flutter test` detached with exit line and summary checked, commit `docs: describe the phase B species photo surfaces`. Do not push. + +## Self-review notes + +- Spec section 7 coverage: cover tiles (Task 3), sighting-row counts opening a dive-scoped viewer (Task 4), library facet with sheet, chips and smart albums (Task 5); providers (Task 1) and strings (Task 2) serve them. +- Deviation: the sighting row is extracted into `DiveSightingRow` rather than widening a private builder, because the dive detail page has no test that renders sightings and a widget of its own can be tested directly. diff --git a/docs/superpowers/plans/2026-08-27-statistics-trend-charts.md b/docs/superpowers/plans/2026-08-27-statistics-trend-charts.md new file mode 100644 index 0000000000..7bbf9912d8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-statistics-trend-charts.md @@ -0,0 +1,4791 @@ +# Statistics Trend Charts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the monthly-averaged progression charts with per-dive values on a real date axis, scoped by the existing statistics filter, with optional per-chart smoothing and fitted trend overlays. + +**Architecture:** Five repository methods drop their `GROUP BY` and their hardcoded five-year cutoff and return one point per dive. Bucketing and curve fitting move into a pure Dart domain module with no Flutter or database imports. A new `DiveTrendChart` widget plots those points against a date-valued x axis, layering an optional mean/min-max band, a rolling mean, and a linear fit. A `TrendControlStrip` beneath each chart drives per-chart state held in a Riverpod family. + +**Tech Stack:** Flutter, Riverpod 3, Drift (raw `customSelect`), fl_chart 1.2.0, `flutter_localizations` with 11 ARB locales. + +**Spec:** `docs/superpowers/specs/2026-08-27-statistics-trend-charts-design.md` + +## Global Constraints + +- **No em-dashes** anywhere: not in code, comments, commit messages, or ARB strings. En-dashes as prose punctuation and spaced hyphens are equally forbidden. +- **No emojis** in code, comments, or documentation. +- **TDD**: write the failing test first, watch it fail, then implement. +- **Immutability**: never mutate objects or lists in place. +- **Run `dart format .`** after completing any task, before committing. +- **File size**: 200-400 lines typical, 800 maximum. +- **Import grouping**: dart, flutter, packages, local (relative), in that order. +- **Units**: anything displaying a value must respect the active diver's unit settings via `UnitFormatter(ref.watch(settingsProvider))`. +- **`dive_date_time` is epoch milliseconds holding a UTC-flagged wall clock.** Always construct with `DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)` and always build bucket boundaries with `DateTime.utc(...)`, never the bare `DateTime(...)` constructor. CI runs in UTC and is blind to this class of bug. +- **New ARB keys go into all 11 locales**: `app_ar`, `app_de`, `app_en`, `app_es`, `app_fr`, `app_he`, `app_hu`, `app_it`, `app_nl`, `app_pt`, `app_zh`. `arb_parity_test` fails on a key present in English and missing elsewhere. +- **Test command**: `flutter test --exclude-tags performance ` (matches `hooks/pre-push:298`). +- **Commit only what the task touches.** Never `git add -A` or `git add -u` in this repo: sibling worktrees share the checkout and a stale submodule pointer gets re-staged. Stage explicit paths. + +## File Structure + +**Create:** + +| Path | Responsibility | +| --- | --- | +| `lib/features/statistics/domain/trend_aggregation.dart` | `TrendDataPoint`, `TrendAggregation`, `TrendBucket`, `LinearFit`, and the three pure functions `aggregate`, `rollingMean`, `linearFit`. No Flutter, no Drift. | +| `lib/features/statistics/presentation/widgets/date_axis.dart` | `DateAxis` and `DateAxisGranularity`: tick selection for a date-valued x axis. Pure, no Flutter widgets. | +| `lib/features/statistics/presentation/widgets/dive_trend_chart.dart` | `DiveTrendChart`: fl_chart `LineChart` rendering points, band, rolling mean and linear fit on a date axis. | +| `lib/features/statistics/presentation/widgets/trend_control_strip.dart` | `TrendControlStrip`: aggregation dropdown plus two tappable legend toggles. | +| `lib/features/statistics/presentation/providers/trend_chart_settings_provider.dart` | `TrendChartSettings` plus a `StateProvider.family` keyed by chart id. | + +**Modify:** + +| Path | Change | +| --- | --- | +| `lib/features/statistics/data/repositories/statistics_repository.dart` | Remove the `TrendDataPoint` declaration and re-export it; rewrite five trend methods as per-dive; add `getWaterTempPerDive`. | +| `lib/features/statistics/presentation/providers/statistics_providers.dart` | Point providers at the renamed methods; add `waterTempTrendProvider`. | +| `lib/features/statistics/presentation/pages/statistics_progression_page.dart` | Depth and bottom time move to `DiveTrendChart`. | +| `lib/features/statistics/presentation/pages/statistics_gas_page.dart` | SAC trend moves to `DiveTrendChart`. | +| `lib/features/statistics/presentation/pages/statistics_equipment_page.dart` | Weight trend moves to `DiveTrendChart`. | +| `lib/features/statistics/presentation/pages/statistics_conditions_page.dart` | Add a water temperature trend card; retitle the seasonal chart. | +| `lib/features/dive_log/presentation/widgets/dive_filter_sheet.dart` | Add "Last 5 Years" and "Last 10 Years" date presets. | +| `lib/l10n/arb/app_*.arb` (11 files) | New keys; rewrite four stale subtitles. | + +**Unchanged on purpose:** `stat_charts.dart` (`TrendLineChart` still serves Cumulative Dive Count; `MultiTrendLineChart` still serves the seasonal temperature chart), `chart_axis.dart`, `getTemperatureByMonth`. + +--- + +## Task 1: Domain module, bucketing + +**Files:** +- Create: `lib/features/statistics/domain/trend_aggregation.dart` +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart:31-42` (remove class, add export) +- Test: `test/features/statistics/domain/trend_aggregation_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `class TrendDataPoint { final DateTime date; final double value; final String label; TrendDataPoint({required this.date, required this.value, this.label = ''}); }` + - `enum TrendAggregation { none, weekly, monthly }` + - `class TrendBucket { final DateTime date; final double mean; final double min; final double max; final int count; }` + - `List aggregate(List points, TrendAggregation mode)` + +- [ ] **Step 1: Write the failing test** + +Create `test/features/statistics/domain/trend_aggregation_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; + +TrendDataPoint p(int y, int m, int d, double v) => + TrendDataPoint(date: DateTime.utc(y, m, d), value: v); + +void main() { + group('aggregate none', () { + test('returns one bucket per point, mean equal to min and max', () { + final buckets = aggregate([p(2024, 3, 1, 10), p(2024, 3, 2, 20)], + TrendAggregation.none); + + expect(buckets, hasLength(2)); + expect(buckets[0].date, DateTime.utc(2024, 3, 1)); + expect(buckets[0].mean, 10); + expect(buckets[0].min, 10); + expect(buckets[0].max, 10); + expect(buckets[0].count, 1); + }); + + test('returns an empty list for no points', () { + expect(aggregate(const [], TrendAggregation.none), isEmpty); + }); + }); + + group('aggregate monthly', () { + test('collapses a month into mean, min, max and count', () { + final buckets = aggregate([ + p(2024, 3, 1, 10), + p(2024, 3, 20, 30), + p(2024, 4, 2, 50), + ], TrendAggregation.monthly); + + expect(buckets, hasLength(2)); + expect(buckets[0].date, DateTime.utc(2024, 3, 1)); + expect(buckets[0].mean, 20); + expect(buckets[0].min, 10); + expect(buckets[0].max, 30); + expect(buckets[0].count, 2); + expect(buckets[1].date, DateTime.utc(2024, 4, 1)); + expect(buckets[1].count, 1); + }); + + test('orders buckets by date regardless of input order', () { + final buckets = aggregate([ + p(2024, 5, 1, 1), + p(2023, 1, 1, 2), + ], TrendAggregation.monthly); + + expect(buckets.map((b) => b.date), [ + DateTime.utc(2023, 1, 1), + DateTime.utc(2024, 5, 1), + ]); + }); + }); + + group('aggregate weekly', () { + test('buckets to the Monday of the point week', () { + // 2024-03-07 is a Thursday; its Monday is 2024-03-04. + final buckets = + aggregate([p(2024, 3, 7, 10)], TrendAggregation.weekly); + + expect(buckets.single.date, DateTime.utc(2024, 3, 4)); + }); + + test('a Monday point stays on its own Monday', () { + final buckets = + aggregate([p(2024, 3, 4, 10)], TrendAggregation.weekly); + + expect(buckets.single.date, DateTime.utc(2024, 3, 4)); + }); + + test('a Sunday point falls into the week that started six days earlier', + () { + // 2024-03-10 is a Sunday. + final buckets = + aggregate([p(2024, 3, 10, 10)], TrendAggregation.weekly); + + expect(buckets.single.date, DateTime.utc(2024, 3, 4)); + }); + + test('splits points across two adjacent weeks', () { + final buckets = aggregate([ + p(2024, 3, 10, 10), // Sunday, week of Mar 4 + p(2024, 3, 11, 20), // Monday, week of Mar 11 + ], TrendAggregation.weekly); + + expect(buckets, hasLength(2)); + expect(buckets[0].date, DateTime.utc(2024, 3, 4)); + expect(buckets[1].date, DateTime.utc(2024, 3, 11)); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/domain/trend_aggregation_test.dart` +Expected: FAIL, `Target of URI doesn't exist: 'package:submersion/features/statistics/domain/trend_aggregation.dart'` + +- [ ] **Step 3: Write the implementation** + +Create `lib/features/statistics/domain/trend_aggregation.dart`: + +```dart +/// Pure trend maths for the statistics charts: bucketing a per-dive series and +/// fitting curves through it. No I/O and no Flutter, so the statistical +/// behaviour is unit-testable in isolation (issue #299). +/// +/// Every date here is a UTC-flagged wall clock, matching how `dive_date_time` +/// is stored and read. Bucket boundaries are built with `DateTime.utc` so the +/// machine's local offset can never shift a dive into a neighbouring bucket. +library; + +/// Data point for line chart trends. +/// +/// [label] is only used by the older index-axis `TrendLineChart`; per-dive +/// series leave it empty and format their axis from [date]. +class TrendDataPoint { + final DateTime date; + final double value; + final String label; + + TrendDataPoint({required this.date, required this.value, this.label = ''}); +} + +/// How a per-dive series is folded before drawing. [none] is the default: one +/// drawn point per dive. +enum TrendAggregation { none, weekly, monthly } + +/// One drawn point: a single dive under [TrendAggregation.none], or the dives +/// sharing a week or month otherwise. +class TrendBucket { + const TrendBucket({ + required this.date, + required this.mean, + required this.min, + required this.max, + required this.count, + }); + + /// Start of the bucket, or the dive's own timestamp when not aggregating. + final DateTime date; + final double mean; + final double min; + final double max; + final int count; +} + +/// Folds [points] according to [mode], always returning buckets ordered by +/// date. Input order does not matter. +List aggregate( + List points, + TrendAggregation mode, +) { + if (points.isEmpty) return const []; + + if (mode == TrendAggregation.none) { + final ordered = [...points]..sort((a, b) => a.date.compareTo(b.date)); + return ordered + .map( + (p) => TrendBucket( + date: p.date, + mean: p.value, + min: p.value, + max: p.value, + count: 1, + ), + ) + .toList(growable: false); + } + + final grouped = >{}; + for (final point in points) { + final key = _bucketStart(point.date, mode); + grouped.putIfAbsent(key, () => []).add(point.value); + } + + final keys = grouped.keys.toList()..sort(); + return keys.map((key) { + final values = grouped[key]!; + var sum = 0.0; + var min = values.first; + var max = values.first; + for (final v in values) { + sum += v; + if (v < min) min = v; + if (v > max) max = v; + } + return TrendBucket( + date: key, + mean: sum / values.length, + min: min, + max: max, + count: values.length, + ); + }).toList(growable: false); +} + +DateTime _bucketStart(DateTime date, TrendAggregation mode) { + switch (mode) { + case TrendAggregation.monthly: + return DateTime.utc(date.year, date.month); + case TrendAggregation.weekly: + final midnight = DateTime.utc(date.year, date.month, date.day); + // DateTime.weekday is 1 for Monday through 7 for Sunday. + return midnight.subtract(Duration(days: midnight.weekday - 1)); + case TrendAggregation.none: + return date; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/domain/trend_aggregation_test.dart` +Expected: PASS, 8 tests + +- [ ] **Step 5: Move `TrendDataPoint` out of the repository** + +In `lib/features/statistics/data/repositories/statistics_repository.dart`, delete lines 31-42 (the `/// Data point for line chart trends` comment and the whole `TrendDataPoint` class) and add this immediately after the import block (after line 14): + +```dart +export 'package:submersion/features/statistics/domain/trend_aggregation.dart' + show TrendDataPoint; +``` + +Then add to the import block, in local-import alphabetical order (after line 13): + +```dart +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +``` + +The re-export keeps every existing `import '.../statistics_repository.dart'` that uses `TrendDataPoint` compiling unchanged, including `stat_charts.dart:4` and `statistics_conditions_page.dart:7`. + +- [ ] **Step 6: Verify the whole project still analyzes and the statistics tests still pass** + +Run: `flutter analyze 2>&1 | tail -20` +Expected: "No issues found!" + +Run: `flutter test --exclude-tags performance test/features/statistics/` +Expected: PASS, no failures + +Note: `flutter analyze` must be run without piping to `grep`; a pipe masks the exit code. Infos are fatal in CI. + +- [ ] **Step 7: Format and commit** + +```bash +dart format . +git add lib/features/statistics/domain/trend_aggregation.dart \ + lib/features/statistics/data/repositories/statistics_repository.dart \ + test/features/statistics/domain/trend_aggregation_test.dart +git commit -m "feat(stats): add pure trend bucketing domain module + +Moves TrendDataPoint into the statistics domain layer and adds +TrendAggregation, TrendBucket and aggregate(). Bucket boundaries are built +with DateTime.utc so the local offset cannot shift a dive between buckets. + +Refs #299" +``` + +--- + +## Task 2: Domain module, rolling mean and linear fit + +**Files:** +- Modify: `lib/features/statistics/domain/trend_aggregation.dart` +- Test: `test/features/statistics/domain/trend_aggregation_test.dart` + +**Interfaces:** +- Consumes: `TrendDataPoint` from Task 1. +- Produces: + - `const int kMinTrendFitPoints = 5;` + - `List rollingMean(List points, {int window = 21})` + - `class LinearFit { final DateTime origin; final double slopePerDay; final double intercept; double get perYear; double valueAt(DateTime date); }` + - `LinearFit? linearFit(List points)` + +- [ ] **Step 1: Write the failing test** + +Append to `test/features/statistics/domain/trend_aggregation_test.dart`, inside `main()`: + +```dart + group('rollingMean', () { + test('returns an empty list below the minimum point count', () { + final points = List.generate(4, (i) => p(2024, 1, i + 1, 10)); + expect(rollingMean(points), isEmpty); + }); + + test('leaves a flat series flat', () { + final points = List.generate(10, (i) => p(2024, 1, i + 1, 7)); + final smoothed = rollingMean(points, window: 3); + + expect(smoothed, hasLength(10)); + for (final s in smoothed) { + expect(s.value, closeTo(7, 1e-9)); + } + }); + + test('averages the centred window', () { + // Values 0..9, window 3: interior point i averages i-1, i, i+1. + final points = + List.generate(10, (i) => p(2024, 1, i + 1, i.toDouble())); + final smoothed = rollingMean(points, window: 3); + + expect(smoothed[5].value, closeTo(5, 1e-9)); + expect(smoothed[1].value, closeTo(1, 1e-9)); + }); + + test('truncates the window at both ends rather than padding', () { + final points = + List.generate(10, (i) => p(2024, 1, i + 1, i.toDouble())); + final smoothed = rollingMean(points, window: 5); + + // First point sees indices 0,1,2 only: mean 1. + expect(smoothed.first.value, closeTo(1, 1e-9)); + // Last point sees indices 7,8,9 only: mean 8. + expect(smoothed.last.value, closeTo(8, 1e-9)); + }); + + test('keeps each smoothed point on its own date', () { + final points = + List.generate(10, (i) => p(2024, 1, i + 1, i.toDouble())); + final smoothed = rollingMean(points, window: 3); + + expect(smoothed[3].date, points[3].date); + }); + + test('counts dives rather than calendar time', () { + // Two clusters far apart. A time-based window would average across the + // gap; a count-based window of 3 must not. + final points = [ + p(2024, 1, 1, 10), + p(2024, 1, 2, 10), + p(2024, 1, 3, 10), + p(2026, 1, 1, 50), + p(2026, 1, 2, 50), + p(2026, 1, 3, 50), + ]; + final smoothed = rollingMean(points, window: 3); + + expect(smoothed.first.value, closeTo(10, 1e-9)); + expect(smoothed.last.value, closeTo(50, 1e-9)); + }); + }); + + group('linearFit', () { + test('returns null below the minimum point count', () { + final points = List.generate(4, (i) => p(2024, 1, i + 1, 10)); + expect(linearFit(points), isNull); + }); + + test('recovers a known slope of one unit per day', () { + final points = + List.generate(10, (i) => p(2024, 1, i + 1, i.toDouble())); + final fit = linearFit(points)!; + + expect(fit.slopePerDay, closeTo(1.0, 1e-9)); + expect(fit.perYear, closeTo(365.25, 1e-6)); + }); + + test('reports a zero slope for a flat series', () { + final points = List.generate(10, (i) => p(2024, 1, i + 1, 7)); + final fit = linearFit(points)!; + + expect(fit.slopePerDay, closeTo(0, 1e-9)); + }); + + test('valueAt reproduces the fitted line at the origin and beyond', () { + final points = + List.generate(10, (i) => p(2024, 1, i + 1, i.toDouble())); + final fit = linearFit(points)!; + + expect(fit.valueAt(DateTime.utc(2024, 1, 1)), closeTo(0, 1e-9)); + expect(fit.valueAt(DateTime.utc(2024, 1, 11)), closeTo(10, 1e-9)); + }); + + test('returns null when every point shares one date', () { + final points = List.generate(10, (i) => p(2024, 1, 1, i.toDouble())); + expect(linearFit(points), isNull); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/domain/trend_aggregation_test.dart` +Expected: FAIL, "The function 'rollingMean' isn't defined" + +- [ ] **Step 3: Write the implementation** + +Append to `lib/features/statistics/domain/trend_aggregation.dart`: + +```dart +/// Fewer points than this and neither fit is drawn: a confident line through +/// four dives says more than the data supports. +const int kMinTrendFitPoints = 5; + +/// Centred mean over [window] neighbouring dives, ordered by date. +/// +/// The window counts dives rather than calendar days on purpose. A time-based +/// window would compute some points from a liveaboard's forty dives and others +/// from none, so the line would be least stable exactly where the diving was +/// densest. At the ends the window truncates rather than padding. +/// +/// Returns an empty list below [kMinTrendFitPoints]. +List rollingMean( + List points, { + int window = 21, +}) { + if (points.length < kMinTrendFitPoints) return const []; + + final ordered = [...points]..sort((a, b) => a.date.compareTo(b.date)); + final half = window ~/ 2; + + return List.generate(ordered.length, (i) { + final lo = (i - half) < 0 ? 0 : i - half; + final hi = (i + half + 1) > ordered.length ? ordered.length : i + half + 1; + var sum = 0.0; + for (var j = lo; j < hi; j++) { + sum += ordered[j].value; + } + return TrendDataPoint( + date: ordered[i].date, + value: sum / (hi - lo), + ); + }, growable: false); +} + +/// A least-squares line through a per-dive series, expressed against a fixed +/// [origin] so it can be evaluated at any date. +class LinearFit { + const LinearFit({ + required this.origin, + required this.slopePerDay, + required this.intercept, + }); + + /// Date the fit is anchored to. [intercept] is the fitted value here. + final DateTime origin; + final double slopePerDay; + final double intercept; + + /// The rate a diver can actually state, for example "+4.4 m per year". + double get perYear => slopePerDay * 365.25; + + double valueAt(DateTime date) => + intercept + + slopePerDay * (date.difference(origin).inSeconds / Duration.secondsPerDay); +} + +/// Ordinary least squares over [points], with x measured in days since the +/// earliest point. +/// +/// Returns null below [kMinTrendFitPoints], and null when every point shares a +/// single date (the slope would be undefined). +LinearFit? linearFit(List points) { + if (points.length < kMinTrendFitPoints) return null; + + final ordered = [...points]..sort((a, b) => a.date.compareTo(b.date)); + final origin = ordered.first.date; + + final xs = ordered + .map( + (p) => p.date.difference(origin).inSeconds / Duration.secondsPerDay, + ) + .toList(growable: false); + final ys = ordered.map((p) => p.value).toList(growable: false); + + final n = xs.length; + final meanX = xs.reduce((a, b) => a + b) / n; + final meanY = ys.reduce((a, b) => a + b) / n; + + var numerator = 0.0; + var denominator = 0.0; + for (var i = 0; i < n; i++) { + final dx = xs[i] - meanX; + numerator += dx * (ys[i] - meanY); + denominator += dx * dx; + } + if (denominator == 0) return null; + + final slope = numerator / denominator; + return LinearFit( + origin: origin, + slopePerDay: slope, + intercept: meanY - slope * meanX, + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/domain/trend_aggregation_test.dart` +Expected: PASS, 19 tests + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/statistics/domain/trend_aggregation.dart \ + test/features/statistics/domain/trend_aggregation_test.dart +git commit -m "feat(stats): add rolling mean and linear fit to the trend domain + +The rolling window counts dives rather than calendar days so trip clustering +does not destabilise the line. Both fits return nothing below five points. + +Refs #299" +``` + +--- + +## Task 3: Repository, depth and bottom time per dive + +**Files:** +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart:839-931` +- Modify: `test/features/statistics/data/repositories/statistics_repository_error_test.dart` +- Test: `test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart` (create) + +**Interfaces:** +- Consumes: `TrendDataPoint` from Task 1. +- Produces: + - `Future> getDepthPerDive({String? diverId, DiveFilterState filter = const DiveFilterState()})` + - `Future> getBottomTimePerDive({String? diverId, DiveFilterState filter = const DiveFilterState()})` + + Both return one point per dive, ordered by `dive_date_time`, with `value` in metres and minutes respectively, and `label` left empty. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/statistics/data/repositories/statistics_repository.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late StatisticsRepository repository; + late AppDatabase db; + + setUp(() async { + db = await setUpTestDatabase(); + repository = StatisticsRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future insertDive({ + required String id, + required DateTime at, + double? maxDepth, + int? bottomTimeSeconds, + double? waterTemp, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveDateTime: Value(at.millisecondsSinceEpoch), + maxDepth: Value(maxDepth), + bottomTime: Value(bottomTimeSeconds), + waterTemp: Value(waterTemp), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + } + + group('getDepthPerDive', () { + test('returns one point per dive, ordered by date', () async { + await insertDive( + id: 'b', at: DateTime.utc(2024, 5, 20), maxDepth: 30.0); + await insertDive( + id: 'a', at: DateTime.utc(2024, 5, 10), maxDepth: 18.0); + + final points = await repository.getDepthPerDive(); + + expect(points, hasLength(2)); + expect(points[0].value, 18.0); + expect(points[1].value, 30.0); + expect(points[0].date.isBefore(points[1].date), isTrue); + }); + + test('does not collapse two dives in the same month', () async { + await insertDive( + id: 'a', at: DateTime.utc(2024, 5, 10), maxDepth: 18.0); + await insertDive( + id: 'b', at: DateTime.utc(2024, 5, 11), maxDepth: 30.0); + + expect(await repository.getDepthPerDive(), hasLength(2)); + }); + + test('includes a dive far older than five years', () async { + // The regression that matters: the old code hardcoded a five-year + // cutoff, so "lifetime" was unreachable no matter what filter was set. + final longAgo = DateTime.now().toUtc().subtract( + const Duration(days: 365 * 8), + ); + await insertDive(id: 'ancient', at: longAgo, maxDepth: 12.0); + + final points = await repository.getDepthPerDive(); + + expect(points, hasLength(1)); + expect(points.single.value, 12.0); + }); + + test('skips dives with no recorded max depth', () async { + await insertDive(id: 'a', at: DateTime.utc(2024, 5, 10)); + + expect(await repository.getDepthPerDive(), isEmpty); + }); + + test('honours a date filter', () async { + await insertDive( + id: 'a', at: DateTime.utc(2020, 5, 10), maxDepth: 18.0); + await insertDive( + id: 'b', at: DateTime.utc(2024, 5, 10), maxDepth: 30.0); + + final points = await repository.getDepthPerDive( + filter: DiveFilterState(startDate: DateTime.utc(2023, 1, 1)), + ); + + expect(points, hasLength(1)); + expect(points.single.value, 30.0); + }); + }); + + group('getBottomTimePerDive', () { + test('returns minutes, one point per dive', () async { + await insertDive( + id: 'a', at: DateTime.utc(2024, 5, 10), bottomTimeSeconds: 45 * 60); + await insertDive( + id: 'b', at: DateTime.utc(2024, 5, 11), bottomTimeSeconds: 60 * 60); + + final points = await repository.getBottomTimePerDive(); + + expect(points, hasLength(2)); + expect(points[0].value, closeTo(45, 1e-9)); + expect(points[1].value, closeTo(60, 1e-9)); + }); + + test('includes a dive far older than five years', () async { + final longAgo = DateTime.now().toUtc().subtract( + const Duration(days: 365 * 8), + ); + await insertDive( + id: 'ancient', at: longAgo, bottomTimeSeconds: 30 * 60); + + expect(await repository.getBottomTimePerDive(), hasLength(1)); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart` +Expected: FAIL, "The method 'getDepthPerDive' isn't defined for the type 'StatisticsRepository'" + +- [ ] **Step 3: Replace `getDepthProgressionTrend`** + +In `statistics_repository.dart`, replace lines 839-884 in full with: + +```dart + /// Maximum depth of every dive in scope, ordered by date. + /// + /// One point per dive. Scope comes entirely from [filter]; there is + /// deliberately no built-in window, because a hardcoded five-year cutoff + /// used to make "lifetime" unreachable (issue #299). + Future> getDepthPerDive({ + String? diverId, + DiveFilterState filter = const DiveFilterState(), + }) async { + try { + final diverFilter = diverId != null ? 'AND diver_id = ?' : ''; + final df = _diveFilter(filter, alias: 'dives'); + final params = diverId != null + ? [diverId, ...df.params] + : [...df.params]; + + final results = await _db.customSelect(''' + SELECT dive_date_time, max_depth + FROM dives + WHERE max_depth IS NOT NULL $diverFilter ${df.clause} + ORDER BY dive_date_time + ''', variables: params.map((p) => Variable(p)).toList()).get(); + + return results.map((row) { + return TrendDataPoint( + date: DateTime.fromMillisecondsSinceEpoch( + row.read('dive_date_time'), + isUtc: true, + ), + value: row.read('max_depth'), + ); + }).toList(); + } catch (e, stackTrace) { + _log.error( + 'Failed to get per-dive max depth', + error: e, + stackTrace: stackTrace, + ); + return []; + } + } +``` + +- [ ] **Step 4: Replace `getBottomTimeTrend`** + +Replace the following block (originally lines 886-931) in full with: + +```dart + /// Bottom time in minutes for every dive in scope, ordered by date. + Future> getBottomTimePerDive({ + String? diverId, + DiveFilterState filter = const DiveFilterState(), + }) async { + try { + final diverFilter = diverId != null ? 'AND diver_id = ?' : ''; + final df = _diveFilter(filter, alias: 'dives'); + final params = diverId != null + ? [diverId, ...df.params] + : [...df.params]; + + final results = await _db.customSelect(''' + SELECT dive_date_time, bottom_time / 60.0 AS minutes + FROM dives + WHERE bottom_time IS NOT NULL $diverFilter ${df.clause} + ORDER BY dive_date_time + ''', variables: params.map((p) => Variable(p)).toList()).get(); + + return results.map((row) { + return TrendDataPoint( + date: DateTime.fromMillisecondsSinceEpoch( + row.read('dive_date_time'), + isUtc: true, + ), + value: row.read('minutes'), + ); + }).toList(); + } catch (e, stackTrace) { + _log.error( + 'Failed to get per-dive bottom time', + error: e, + stackTrace: stackTrace, + ); + return []; + } + } +``` + +- [ ] **Step 5: Update the error test's method names** + +In `test/features/statistics/data/repositories/statistics_repository_error_test.dart`, rename the two calls: +- `repository.getDepthProgressionTrend()` becomes `repository.getDepthPerDive()` +- `repository.getBottomTimeTrend()` becomes `repository.getBottomTimePerDive()` + +- [ ] **Step 6: Run the tests** + +Run: `flutter test --exclude-tags performance test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart test/features/statistics/data/repositories/statistics_repository_error_test.dart` +Expected: PASS + +The two provider call sites still reference the old names, so `flutter analyze` will report errors until Task 6. That is expected; do not fix them here. + +- [ ] **Step 7: Format and commit** + +```bash +dart format . +git add lib/features/statistics/data/repositories/statistics_repository.dart \ + test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart \ + test/features/statistics/data/repositories/statistics_repository_error_test.dart +git commit -m "feat(stats): return per-dive depth and bottom time + +Drops the monthly GROUP BY and the hardcoded five-year cutoff from both +methods, so the statistics filter is now the only thing that scopes them and +a lifetime range is reachable. + +Refs #299" +``` + +--- + +## Task 4: Repository, weight and water temperature per dive + +**Files:** +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart` (`getWeightTrend`, originally L2111-2157) +- Modify: `test/features/statistics/data/repositories/statistics_repository_error_test.dart` +- Test: `test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart` + +**Interfaces:** +- Consumes: `TrendDataPoint` from Task 1. +- Produces: + - `Future> getWeightPerDive({String? diverId, DiveFilterState filter = const DiveFilterState()})` returning **total** kilograms per dive. + - `Future> getWaterTempPerDive({String? diverId, DiveFilterState filter = const DiveFilterState()})` returning degrees Celsius per dive. + +- [ ] **Step 1: Write the failing test** + +Append to `test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart`, inside `main()`: + +```dart + Future insertWeight({ + required String diveId, + required String id, + required double amountKg, + }) async { + await db + .into(db.diveWeights) + .insert( + DiveWeightsCompanion( + id: Value(id), + diveId: Value(diveId), + amountKg: Value(amountKg), + ), + ); + } + + group('getWeightPerDive', () { + test('sums every weight row on a dive into one point', () async { + // The old monthly query averaged across weight ROWS, so a diver with a + // 4 kg belt plus 2 kg of trim weights was recorded as 3 kg rather than + // the 6 kg actually carried. + await insertDive(id: 'a', at: DateTime.utc(2024, 5, 10)); + await insertWeight(diveId: 'a', id: 'w1', amountKg: 4.0); + await insertWeight(diveId: 'a', id: 'w2', amountKg: 2.0); + + final points = await repository.getWeightPerDive(); + + expect(points, hasLength(1)); + expect(points.single.value, closeTo(6.0, 1e-9)); + }); + + test('returns one point per dive, ordered by date', () async { + await insertDive(id: 'b', at: DateTime.utc(2024, 5, 20)); + await insertWeight(diveId: 'b', id: 'w2', amountKg: 5.0); + await insertDive(id: 'a', at: DateTime.utc(2024, 5, 10)); + await insertWeight(diveId: 'a', id: 'w1', amountKg: 7.0); + + final points = await repository.getWeightPerDive(); + + expect(points.map((p) => p.value), [7.0, 5.0]); + }); + + test('skips dives with no weight rows', () async { + await insertDive(id: 'a', at: DateTime.utc(2024, 5, 10)); + + expect(await repository.getWeightPerDive(), isEmpty); + }); + + test('includes a dive far older than five years', () async { + final longAgo = DateTime.now().toUtc().subtract( + const Duration(days: 365 * 8), + ); + await insertDive(id: 'ancient', at: longAgo); + await insertWeight(diveId: 'ancient', id: 'w1', amountKg: 6.0); + + expect(await repository.getWeightPerDive(), hasLength(1)); + }); + }); + + group('getWaterTempPerDive', () { + test('returns one point per dive with a recorded temperature', () async { + await insertDive( + id: 'a', at: DateTime.utc(2024, 5, 10), waterTemp: 12.5); + await insertDive( + id: 'b', at: DateTime.utc(2024, 8, 10), waterTemp: 28.0); + + final points = await repository.getWaterTempPerDive(); + + expect(points.map((p) => p.value), [12.5, 28.0]); + }); + + test('skips dives with no recorded temperature', () async { + await insertDive(id: 'a', at: DateTime.utc(2024, 5, 10)); + + expect(await repository.getWaterTempPerDive(), isEmpty); + }); + + test('does not collapse different years into one calendar month', () async { + // The seasonal chart deliberately does collapse years; this one must not. + await insertDive( + id: 'a', at: DateTime.utc(2023, 7, 10), waterTemp: 10.0); + await insertDive( + id: 'b', at: DateTime.utc(2024, 7, 10), waterTemp: 29.0); + + final points = await repository.getWaterTempPerDive(); + + expect(points, hasLength(2)); + expect(points.map((p) => p.value), [10.0, 29.0]); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart` +Expected: FAIL, "The method 'getWeightPerDive' isn't defined" + +- [ ] **Step 3: Replace `getWeightTrend`** + +Replace the `getWeightTrend` method (originally L2111-2157) in full with: + +```dart + /// Total lead carried on every dive in scope, in kilograms, ordered by date. + /// + /// Sums the dive's weight rows. The monthly version this replaced averaged + /// across rows, so a 4 kg belt plus 2 kg of trim weights was reported as + /// 3 kg rather than the 6 kg actually carried. + Future> getWeightPerDive({ + String? diverId, + DiveFilterState filter = const DiveFilterState(), + }) async { + try { + final diverFilter = diverId != null ? 'AND d.diver_id = ?' : ''; + final df = _diveFilter(filter, alias: 'd'); + final params = diverId != null + ? [diverId, ...df.params] + : [...df.params]; + + final results = await _db.customSelect(''' + SELECT d.dive_date_time AS dive_date_time, + SUM(dw.amount_kg) AS total_kg + FROM dives d + JOIN dive_weights dw ON dw.dive_id = d.id + WHERE 1 = 1 $diverFilter ${df.clause} + GROUP BY d.id + HAVING total_kg IS NOT NULL + ORDER BY d.dive_date_time + ''', variables: params.map((p) => Variable(p)).toList()).get(); + + return results.map((row) { + return TrendDataPoint( + date: DateTime.fromMillisecondsSinceEpoch( + row.read('dive_date_time'), + isUtc: true, + ), + value: row.read('total_kg'), + ); + }).toList(); + } catch (e, stackTrace) { + _log.error( + 'Failed to get per-dive weight', + error: e, + stackTrace: stackTrace, + ); + return []; + } + } + + /// Water temperature of every dive in scope, in Celsius, ordered by date. + /// + /// Distinct from [getTemperatureByMonth], which collapses all years into + /// twelve calendar buckets to show a season. This one is a time series. + Future> getWaterTempPerDive({ + String? diverId, + DiveFilterState filter = const DiveFilterState(), + }) async { + try { + final diverFilter = diverId != null ? 'AND diver_id = ?' : ''; + final df = _diveFilter(filter, alias: 'dives'); + final params = diverId != null + ? [diverId, ...df.params] + : [...df.params]; + + final results = await _db.customSelect(''' + SELECT dive_date_time, water_temp + FROM dives + WHERE water_temp IS NOT NULL $diverFilter ${df.clause} + ORDER BY dive_date_time + ''', variables: params.map((p) => Variable(p)).toList()).get(); + + return results.map((row) { + return TrendDataPoint( + date: DateTime.fromMillisecondsSinceEpoch( + row.read('dive_date_time'), + isUtc: true, + ), + value: row.read('water_temp'), + ); + }).toList(); + } catch (e, stackTrace) { + _log.error( + 'Failed to get per-dive water temperature', + error: e, + stackTrace: stackTrace, + ); + return []; + } + } +``` + +- [ ] **Step 4: Update the error test** + +In `statistics_repository_error_test.dart`, rename `repository.getWeightTrend()` to `repository.getWeightPerDive()`, and add an assertion in the same style for `getWaterTempPerDive` returning `[]` on a database failure. + +- [ ] **Step 5: Run the tests** + +Run: `flutter test --exclude-tags performance test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart test/features/statistics/data/repositories/statistics_repository_error_test.dart` +Expected: PASS + +- [ ] **Step 6: Format and commit** + +```bash +dart format . +git add lib/features/statistics/data/repositories/statistics_repository.dart \ + test/features/statistics/data/repositories/statistics_repository_per_dive_test.dart \ + test/features/statistics/data/repositories/statistics_repository_error_test.dart +git commit -m "feat(stats): return per-dive weight and water temperature + +Weight now SUMs a dive's weight rows instead of averaging across them, so a +belt plus trim weights reports the total lead actually carried. Adds +getWaterTempPerDive for the new temperature trend chart. + +Refs #299" +``` + +--- + +## Task 5: Repository, SAC per dive + +**Files:** +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart` (`getSacVolumeTrend` originally L203-334, `getSacPressureTrend` originally L336-401) +- Modify: `test/features/statistics/data/repositories/statistics_repository_sac_test.dart` +- Modify: `test/features/statistics/data/repositories/statistics_repository_error_test.dart` + +**Interfaces:** +- Consumes: `TrendDataPoint` from Task 1. +- Produces: + - `Future> getSacVolumePerDive({String? diverId, DiveFilterState filter = const DiveFilterState()})` in litres per minute. + - `Future> getSacPressurePerDive({String? diverId, DiveFilterState filter = const DiveFilterState()})` in bar per minute. + +- [ ] **Step 1: Write the failing test** + +Append to `test/features/statistics/data/repositories/statistics_repository_sac_test.dart`, inside `main()`: + +```dart + group('getSacVolumePerDive', () { + test('returns one point per dive rather than one per month', () async { + await insertDiveWithTank( + id: 'dive-1', + bottomTimeSeconds: 40 * 60, + avgDepth: 20.0, + startPressure: 200, + endPressure: 50, + volume: 11.1, + diveDateTimeMs: DateTime.utc(2024, 5, 10).millisecondsSinceEpoch, + ); + await insertDiveWithTank( + id: 'dive-2', + bottomTimeSeconds: 40 * 60, + avgDepth: 20.0, + startPressure: 200, + endPressure: 100, + volume: 11.1, + diveDateTimeMs: DateTime.utc(2024, 5, 11).millisecondsSinceEpoch, + ); + + final points = await repository.getSacVolumePerDive(); + + expect(points, hasLength(2)); + // Ordered by date, and the second dive used less gas. + expect(points[0].date.isBefore(points[1].date), isTrue); + expect(points[1].value, lessThan(points[0].value)); + }); + + test('sums every tank on a dive into that dive single SAC', () async { + final diveId = await insertDiveWithTank( + id: 'dive-twin', + bottomTimeSeconds: 40 * 60, + avgDepth: 20.0, + startPressure: 200, + endPressure: 100, + volume: 11.1, + ); + await insertTank( + diveId: diveId, + startPressure: 200, + endPressure: 100, + volume: 11.1, + ); + + final points = await repository.getSacVolumePerDive(); + + expect(points, hasLength(1)); + }); + + test('includes a dive far older than five years', () async { + final longAgo = DateTime.now().toUtc().subtract( + const Duration(days: 365 * 8), + ); + await insertDiveWithTank( + id: 'ancient', + bottomTimeSeconds: 40 * 60, + avgDepth: 20.0, + startPressure: 200, + endPressure: 50, + volume: 11.1, + diveDateTimeMs: longAgo.millisecondsSinceEpoch, + ); + + expect(await repository.getSacVolumePerDive(), hasLength(1)); + }); + }); + + group('getSacPressurePerDive', () { + test('returns one point per dive rather than one per month', () async { + await insertDiveWithTank( + id: 'dive-1', + bottomTimeSeconds: 40 * 60, + avgDepth: 20.0, + startPressure: 200, + endPressure: 50, + diveDateTimeMs: DateTime.utc(2024, 5, 10).millisecondsSinceEpoch, + ); + await insertDiveWithTank( + id: 'dive-2', + bottomTimeSeconds: 40 * 60, + avgDepth: 20.0, + startPressure: 200, + endPressure: 100, + diveDateTimeMs: DateTime.utc(2024, 5, 11).millisecondsSinceEpoch, + ); + + final points = await repository.getSacPressurePerDive(); + + expect(points, hasLength(2)); + // (200-50) / 40 / 3.0 = 1.25 bar/min + expect(points[0].value, closeTo(1.25, 1e-6)); + // (200-100) / 40 / 3.0 = 0.8333 bar/min + expect(points[1].value, closeTo(0.8333, 1e-3)); + }); + + test('includes a dive far older than five years', () async { + final longAgo = DateTime.now().toUtc().subtract( + const Duration(days: 365 * 8), + ); + await insertDiveWithTank( + id: 'ancient', + bottomTimeSeconds: 40 * 60, + avgDepth: 20.0, + startPressure: 200, + endPressure: 50, + diveDateTimeMs: longAgo.millisecondsSinceEpoch, + ); + + expect(await repository.getSacPressurePerDive(), hasLength(1)); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/data/repositories/statistics_repository_sac_test.dart` +Expected: FAIL, "The method 'getSacVolumePerDive' isn't defined" + +- [ ] **Step 3: Replace `getSacVolumeTrend`** + +Replace the whole method (originally L203-334). The gas-summing loop is kept verbatim; only the cutoff and the final monthly bucketing change. + +```dart + /// SAC rate of every dive in scope, in L/min at surface pressure, ordered by + /// date. Requires tank volume data. + /// + /// Gas used is summed across a dive's tanks first, so a twinset or a stage + /// dive yields one SAC value rather than one per cylinder. + Future> getSacVolumePerDive({ + String? diverId, + DiveFilterState filter = const DiveFilterState(), + }) async { + try { + final diverFilter = diverId != null ? 'AND d.diver_id = ?' : ''; + final df = _diveFilter(filter, alias: 'd'); + final params = diverId != null + ? [diverId, ...df.params] + : [...df.params]; + + final results = await _db.customSelect(''' + SELECT + d.id AS dive_id, + d.dive_date_time, + d.avg_depth, + COALESCE(d.runtime, d.bottom_time) AS duration_sec, + t.start_pressure, + t.end_pressure, + t.volume, + t.o2_percent, + t.he_percent + FROM dives d + JOIN dive_tanks t ON t.dive_id = d.id + WHERE d.dive_mode <> 'gauge' $diverFilter ${df.clause} + AND COALESCE(d.runtime, d.bottom_time) > 0 + AND d.avg_depth > 0 + AND t.start_pressure > t.end_pressure + AND t.volume > 0 + ORDER BY d.dive_date_time + ''', variables: params.map((p) => Variable(p)).toList()).get(); + + // Sum gas across each dive's tanks before dividing, so a twinset is one + // SAC value and not two. + final Map< + String, + ({double gas, DateTime dateTime, int durationSec, double avgDepth}) + > + diveSacs = {}; + + for (final row in results) { + final diveId = row.read('dive_id'); + final startP = row.read('start_pressure'); + final endP = row.read('end_pressure'); + final vol = row.read('volume'); + final o2 = row.read('o2_percent'); + final he = row.read('he_percent'); + final dateTimeMs = row.read('dive_date_time'); + + final gasUsed = + gasVolume( + tankSizeLiters: vol, + pressureBar: startP, + o2Percent: o2, + hePercent: he, + model: gasModel, + ) - + gasVolume( + tankSizeLiters: vol, + pressureBar: endP, + o2Percent: o2, + hePercent: he, + model: gasModel, + ); + if (gasUsed <= 0) continue; + + final existing = diveSacs[diveId]; + if (existing == null) { + diveSacs[diveId] = ( + gas: gasUsed, + dateTime: DateTime.fromMillisecondsSinceEpoch( + dateTimeMs, + isUtc: true, + ), + durationSec: row.read('duration_sec'), + avgDepth: row.read('avg_depth'), + ); + } else { + diveSacs[diveId] = ( + gas: existing.gas + gasUsed, + dateTime: existing.dateTime, + durationSec: existing.durationSec, + avgDepth: existing.avgDepth, + ); + } + } + + final points = []; + for (final entry in diveSacs.entries) { + final d = entry.value; + final sac = + d.gas / (d.durationSec / 60.0) / ((d.avgDepth / 10.0) + 1.0); + if (sac <= 0) continue; + points.add(TrendDataPoint(date: d.dateTime, value: sac)); + } + points.sort((a, b) => a.date.compareTo(b.date)); + return points; + } catch (e, stackTrace) { + _log.error( + 'Failed to get per-dive SAC volume', + error: e, + stackTrace: stackTrace, + ); + return []; + } + } +``` + +- [ ] **Step 4: Replace `getSacPressureTrend`** + +Replace the whole method (originally L336-401). The single-back-gas-tank picker subquery is kept verbatim; the `AVG(...)`, `GROUP BY`, `HAVING` and cutoff go. + +```dart + /// SAC rate of every dive in scope, in pressure per minute, ordered by date. + /// + /// Does not require tank volume: uses the pressure drop of the dive's single + /// back-gas tank normalised to surface pressure. + Future> getSacPressurePerDive({ + String? diverId, + DiveFilterState filter = const DiveFilterState(), + }) async { + try { + final diverFilter = diverId != null ? 'AND d.diver_id = ?' : ''; + final df = _diveFilter(filter, alias: 'd'); + final params = diverId != null + ? [diverId, ...df.params] + : [...df.params]; + + final results = await _db.customSelect(''' + SELECT + d.dive_date_time AS dive_date_time, + (t.start_pressure - t.end_pressure) / (COALESCE(d.runtime, d.bottom_time) / 60.0) / ((d.avg_depth / 10.0) + 1) AS sac + FROM dives d + JOIN dive_tanks t ON t.id = ( + SELECT t2.id FROM dive_tanks t2 + WHERE t2.dive_id = d.id + AND t2.start_pressure > t2.end_pressure + AND ( + t2.tank_role = 'backGas' + OR NOT EXISTS ( + SELECT 1 FROM dive_tanks t3 + WHERE t3.dive_id = d.id AND t3.tank_role = 'backGas' + ) + ) + ORDER BY t2.tank_order, t2.rowid + LIMIT 1 + ) + WHERE d.dive_mode <> 'gauge' $diverFilter ${df.clause} + AND COALESCE(d.runtime, d.bottom_time) > 0 + AND d.avg_depth > 0 + ORDER BY d.dive_date_time + ''', variables: params.map((p) => Variable(p)).toList()).get(); + + return results + .map( + (row) => TrendDataPoint( + date: DateTime.fromMillisecondsSinceEpoch( + row.read('dive_date_time'), + isUtc: true, + ), + value: row.read('sac'), + ), + ) + .toList(); + } catch (e, stackTrace) { + _log.error( + 'Failed to get per-dive SAC pressure', + error: e, + stackTrace: stackTrace, + ); + return []; + } + } +``` + +- [ ] **Step 5: Update the existing SAC tests and the error test** + +In `statistics_repository_sac_test.dart`, rename every `getSacVolumeTrend()` call to `getSacVolumePerDive()` and every `getSacPressureTrend()` call to `getSacPressurePerDive()`. Rename the `group('getSacVolumeTrend')` and `group('getSacPressureTrend')` headings to match. Also rename `group('getBottomTimeTrend')` calls to `getBottomTimePerDive()`. + +Existing assertions that expect `hasLength(1)` for a single dive still hold, because one dive produced one monthly bucket. Any assertion that relies on **two dives in the same month collapsing to one point** must be changed to expect two points; that behaviour change is the point of this task. + +In `statistics_repository_error_test.dart`, rename `getSacVolumeTrend` to `getSacVolumePerDive` and `getSacPressureTrend` to `getSacPressurePerDive`. + +- [ ] **Step 6: Run the tests** + +Run: `flutter test --exclude-tags performance test/features/statistics/data/repositories/` +Expected: PASS + +- [ ] **Step 7: Format and commit** + +```bash +dart format . +git add lib/features/statistics/data/repositories/statistics_repository.dart \ + test/features/statistics/data/repositories/statistics_repository_sac_test.dart \ + test/features/statistics/data/repositories/statistics_repository_error_test.dart +git commit -m "feat(stats): return per-dive SAC for both the volume and pressure lanes + +The volume lane already computed a per-dive value before bucketing it; it now +stops there. The pressure lane drops its AVG and GROUP BY while keeping the +single-back-gas-tank picker unchanged. + +Refs #299" +``` + +--- + +## Task 6: Providers + +**Files:** +- Modify: `lib/features/statistics/presentation/providers/statistics_providers.dart:82-100, 179-200, 485-491` +- Create: `lib/features/statistics/presentation/providers/trend_chart_settings_provider.dart` +- Modify: `test/features/statistics/presentation/providers/statistics_providers_all_test.dart` +- Test: `test/features/statistics/presentation/providers/trend_chart_settings_provider_test.dart` (create) + +**Interfaces:** +- Consumes: the six per-dive repository methods from Tasks 3-5; `TrendAggregation` from Task 1. +- Produces: + - `final waterTempTrendProvider = FutureProvider>(...)` + - `class TrendChartSettings { final TrendAggregation aggregation; final bool showRollingMean; final bool showLinearFit; const TrendChartSettings({this.aggregation = TrendAggregation.none, this.showRollingMean = true, this.showLinearFit = false}); TrendChartSettings copyWith({TrendAggregation? aggregation, bool? showRollingMean, bool? showLinearFit}); }` + - `final trendChartSettingsProvider = StateProvider.family(...)` + - `abstract final class TrendChartIds { static const depth = 'depth'; static const bottomTime = 'bottom-time'; static const sac = 'sac'; static const weight = 'weight'; static const waterTemp = 'water-temp'; }` +- Provider names `depthProgressionTrendProvider`, `bottomTimeTrendProvider`, `sacTrendProvider`, `weightTrendProvider` are **kept** so no consumer outside this feature breaks. + +- [ ] **Step 1: Write the failing test for the settings provider** + +Create `test/features/statistics/presentation/providers/trend_chart_settings_provider_test.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/providers/trend_chart_settings_provider.dart'; + +void main() { + test('defaults to raw per-dive with the rolling mean on', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final settings = container.read( + trendChartSettingsProvider(TrendChartIds.depth), + ); + + expect(settings.aggregation, TrendAggregation.none); + expect(settings.showRollingMean, isTrue); + expect(settings.showLinearFit, isFalse); + }); + + test('each chart id holds its own independent settings', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container + .read(trendChartSettingsProvider(TrendChartIds.depth).notifier) + .state = + const TrendChartSettings(aggregation: TrendAggregation.monthly); + + expect( + container.read(trendChartSettingsProvider(TrendChartIds.depth)) + .aggregation, + TrendAggregation.monthly, + ); + expect( + container.read(trendChartSettingsProvider(TrendChartIds.weight)) + .aggregation, + TrendAggregation.none, + ); + }); + + test('copyWith replaces only the named field', () { + const base = TrendChartSettings(); + final updated = base.copyWith(showLinearFit: true); + + expect(updated.showLinearFit, isTrue); + expect(updated.showRollingMean, isTrue); + expect(updated.aggregation, TrendAggregation.none); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/providers/trend_chart_settings_provider_test.dart` +Expected: FAIL, `Target of URI doesn't exist` + +- [ ] **Step 3: Create the settings provider** + +Create `lib/features/statistics/presentation/providers/trend_chart_settings_provider.dart`: + +```dart +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; + +/// Stable ids for the charts that carry a [TrendControlStrip]. Used as the +/// family key so each chart keeps its own aggregation and overlay choices. +abstract final class TrendChartIds { + static const depth = 'depth'; + static const bottomTime = 'bottom-time'; + static const sac = 'sac'; + static const weight = 'weight'; + static const waterTemp = 'water-temp'; +} + +/// How one trend chart is drawn. Raw per-dive by default: the whole point of +/// issue #299 is that an average is opt-in, not the starting position. +class TrendChartSettings { + const TrendChartSettings({ + this.aggregation = TrendAggregation.none, + this.showRollingMean = true, + this.showLinearFit = false, + }); + + final TrendAggregation aggregation; + final bool showRollingMean; + final bool showLinearFit; + + TrendChartSettings copyWith({ + TrendAggregation? aggregation, + bool? showRollingMean, + bool? showLinearFit, + }) { + return TrendChartSettings( + aggregation: aggregation ?? this.aggregation, + showRollingMean: showRollingMean ?? this.showRollingMean, + showLinearFit: showLinearFit ?? this.showLinearFit, + ); + } +} + +/// Per-chart drawing settings, keyed by [TrendChartIds]. +/// +/// Deliberately in-memory for the session only, matching +/// `statisticsFilterProvider`, which is likewise an unpersisted StateProvider. +final trendChartSettingsProvider = + StateProvider.family( + (ref, chartId) => const TrendChartSettings(), + ); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/providers/trend_chart_settings_provider_test.dart` +Expected: PASS, 3 tests + +- [ ] **Step 5: Repoint the existing providers** + +In `statistics_providers.dart`: + +Replace the body of `sacTrendProvider` (L89-99) so the two branches call the renamed methods: + +```dart + if (lane == GasConsumptionLane.rmv) { + return repository.getSacVolumePerDive( + diverId: currentDiverId, + filter: filter, + ); + } else { + return repository.getSacPressurePerDive( + diverId: currentDiverId, + filter: filter, + ); + } +``` + +In `depthProgressionTrendProvider` (L186-189) replace the call with: + +```dart + return repository.getDepthPerDive(diverId: currentDiverId, filter: filter); +``` + +In `bottomTimeTrendProvider` (L199) replace the call with: + +```dart + return repository.getBottomTimePerDive(diverId: currentDiverId, filter: filter); +``` + +In `weightTrendProvider` (L490) replace the call with: + +```dart + return repository.getWeightPerDive(diverId: currentDiverId, filter: filter); +``` + +- [ ] **Step 6: Add `waterTempTrendProvider`** + +Insert immediately after `temperatureByMonthProvider` (which ends at L295): + +```dart +/// Water temperature as a per-dive time series. +/// +/// Sibling of [temperatureByMonthProvider], which stays a twelve-bucket +/// seasonal climatology. A travelling diver needs the series; a diver with one +/// home region gets real value from the season (issue #299). +final waterTempTrendProvider = FutureProvider>(( + ref, +) async { + _keepAliveWithExpiry(ref); + final repository = ref.watch(statisticsRepositoryProvider); + final currentDiverId = ref.watch(currentDiverIdProvider); + final filter = ref.watch(statisticsFilterProvider); + return repository.getWaterTempPerDive( + diverId: currentDiverId, + filter: filter, + ); +}); +``` + +- [ ] **Step 7: Add the new provider to the all-providers smoke test** + +In `test/features/statistics/presentation/providers/statistics_providers_all_test.dart`, add after the `temperatureByMonthProvider` line (L72): + +```dart + expect(await container.read(waterTempTrendProvider.future), isEmpty); +``` + +- [ ] **Step 8: Confirm no consumer outside the Statistics tab is affected** + +Run: + +```bash +grep -rn "depthProgressionTrendProvider\|bottomTimeTrendProvider\|sacTrendProvider\|weightTrendProvider\|waterTempTrendProvider" lib/ | grep -v "features/statistics/" +``` + +Expected: no output. If any consumer outside `features/statistics/` appears, stop and report it: making a shared provider filter-aware silently rescoped the marine-life species page once before (issue #453), and the same trap applies to renaming what one returns. + +- [ ] **Step 9: Run the tests and analyze** + +Run: `flutter test --exclude-tags performance test/features/statistics/` +Expected: PASS + +Run: `flutter analyze 2>&1 | tail -20` +Expected: "No issues found!" (the repository renames from Tasks 3-5 now have matching call sites) + +- [ ] **Step 10: Format and commit** + +```bash +dart format . +git add lib/features/statistics/presentation/providers/statistics_providers.dart \ + lib/features/statistics/presentation/providers/trend_chart_settings_provider.dart \ + test/features/statistics/presentation/providers/trend_chart_settings_provider_test.dart \ + test/features/statistics/presentation/providers/statistics_providers_all_test.dart +git commit -m "feat(stats): point trend providers at the per-dive queries + +Adds waterTempTrendProvider and a per-chart settings family keyed by chart id, +defaulting to raw per-dive with the rolling mean on. Provider names are +unchanged so no consumer outside the Statistics tab is disturbed. + +Refs #299" +``` + +--- + +## Task 7: Date axis helper + +**Files:** +- Create: `lib/features/statistics/presentation/widgets/date_axis.dart` +- Test: `test/features/statistics/presentation/widgets/date_axis_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `enum DateAxisGranularity { day, month, quarter, year }` + - `class DateAxis { final double min; final double max; final List ticks; final DateAxisGranularity granularity; factory DateAxis.forRange(DateTime first, DateTime last); }` + + `min` and `max` are `millisecondsSinceEpoch` as doubles, ready to feed fl_chart's `minX` and `maxX`. Tick labels are **not** produced here: formatting is the widget's job so this file stays pure and locale-free. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/statistics/presentation/widgets/date_axis_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/presentation/widgets/date_axis.dart'; + +void main() { + test('bounds are the range endpoints as epoch milliseconds', () { + final first = DateTime.utc(2024, 1, 1); + final last = DateTime.utc(2024, 12, 31); + + final axis = DateAxis.forRange(first, last); + + expect(axis.min, first.millisecondsSinceEpoch.toDouble()); + expect(axis.max, last.millisecondsSinceEpoch.toDouble()); + }); + + test('a multi-year range ticks by year', () { + final axis = DateAxis.forRange( + DateTime.utc(2020, 3, 5), + DateTime.utc(2026, 8, 20), + ); + + expect(axis.granularity, DateAxisGranularity.year); + expect(axis.ticks.every((t) => t.month == 1 && t.day == 1), isTrue); + expect(axis.ticks.first.year, greaterThanOrEqualTo(2020)); + expect(axis.ticks.last.year, lessThanOrEqualTo(2026)); + }); + + test('a range of about two years ticks by quarter', () { + final axis = DateAxis.forRange( + DateTime.utc(2024, 1, 1), + DateTime.utc(2025, 12, 31), + ); + + expect(axis.granularity, DateAxisGranularity.quarter); + expect(axis.ticks.every((t) => t.month % 3 == 1 && t.day == 1), isTrue); + }); + + test('a range of a few months ticks by month', () { + final axis = DateAxis.forRange( + DateTime.utc(2024, 1, 1), + DateTime.utc(2024, 5, 31), + ); + + expect(axis.granularity, DateAxisGranularity.month); + expect(axis.ticks.every((t) => t.day == 1), isTrue); + }); + + test('a range of a few weeks ticks by day', () { + final axis = DateAxis.forRange( + DateTime.utc(2024, 1, 1), + DateTime.utc(2024, 1, 20), + ); + + expect(axis.granularity, DateAxisGranularity.day); + expect(axis.ticks, isNotEmpty); + }); + + test('every tick lies inside the bounds', () { + final axis = DateAxis.forRange( + DateTime.utc(2020, 3, 5), + DateTime.utc(2026, 8, 20), + ); + + for (final tick in axis.ticks) { + final ms = tick.millisecondsSinceEpoch.toDouble(); + expect(ms, greaterThanOrEqualTo(axis.min)); + expect(ms, lessThanOrEqualTo(axis.max)); + } + }); + + test('a single-instant range still yields a drawable axis', () { + final only = DateTime.utc(2024, 6, 1); + + final axis = DateAxis.forRange(only, only); + + expect(axis.max, greaterThan(axis.min)); + expect(axis.ticks, isNotEmpty); + }); + + test('ticks are strictly increasing', () { + final axis = DateAxis.forRange( + DateTime.utc(2021, 6, 1), + DateTime.utc(2026, 6, 1), + ); + + for (var i = 1; i < axis.ticks.length; i++) { + expect(axis.ticks[i].isAfter(axis.ticks[i - 1]), isTrue); + } + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/date_axis_test.dart` +Expected: FAIL, `Target of URI doesn't exist` + +- [ ] **Step 3: Write the implementation** + +Create `lib/features/statistics/presentation/widgets/date_axis.dart`: + +```dart +/// Tick selection for a date-valued chart x axis. +/// +/// The older `TrendLineChart` plots `FlSpot(index, value)`, so a three-month +/// gap and a three-week gap render identically. Per-dive series cannot do that +/// and stay honest, so they plot real timestamps and need ticks chosen from the +/// span rather than from the point count (issue #299). +/// +/// Deliberately free of Flutter and of locale: labels are formatted by the +/// widget, which has a BuildContext. This file only decides where ticks go. +enum DateAxisGranularity { day, month, quarter, year } + +class DateAxis { + const DateAxis({ + required this.min, + required this.max, + required this.ticks, + required this.granularity, + }); + + /// Lower bound, as `millisecondsSinceEpoch`, for fl_chart's `minX`. + final double min; + + /// Upper bound, as `millisecondsSinceEpoch`, for fl_chart's `maxX`. + final double max; + + /// Tick positions, strictly increasing and always inside the bounds. + final List ticks; + + final DateAxisGranularity granularity; + + /// Chooses a granularity from the span, then walks ticks across it. + /// + /// A degenerate range (one dive, or several on one day) is widened to a day + /// so fl_chart has a non-zero axis to draw. + factory DateAxis.forRange(DateTime first, DateTime last) { + var start = first; + var end = last; + if (!end.isAfter(start)) { + end = start.add(const Duration(days: 1)); + } + + final days = end.difference(start).inDays; + final granularity = days > 1095 + ? DateAxisGranularity.year + : days > 365 + ? DateAxisGranularity.quarter + : days > 60 + ? DateAxisGranularity.month + : DateAxisGranularity.day; + + return DateAxis( + min: start.millisecondsSinceEpoch.toDouble(), + max: end.millisecondsSinceEpoch.toDouble(), + ticks: _ticksFor(start, end, granularity), + granularity: granularity, + ); + } + + static List _ticksFor( + DateTime start, + DateTime end, + DateAxisGranularity granularity, + ) { + final ticks = []; + + switch (granularity) { + case DateAxisGranularity.year: + for (var y = start.year; y <= end.year; y++) { + final tick = DateTime.utc(y); + if (!tick.isBefore(start) && !tick.isAfter(end)) ticks.add(tick); + } + case DateAxisGranularity.quarter: + var cursor = DateTime.utc(start.year, ((start.month - 1) ~/ 3) * 3 + 1); + while (!cursor.isAfter(end)) { + if (!cursor.isBefore(start)) ticks.add(cursor); + cursor = DateTime.utc(cursor.year, cursor.month + 3); + } + case DateAxisGranularity.month: + var cursor = DateTime.utc(start.year, start.month); + while (!cursor.isAfter(end)) { + if (!cursor.isBefore(start)) ticks.add(cursor); + cursor = DateTime.utc(cursor.year, cursor.month + 1); + } + case DateAxisGranularity.day: + // Aim for about five labels rather than one per day. + final span = end.difference(start).inDays; + final step = span <= 5 ? 1 : (span / 5).ceil(); + var cursor = DateTime.utc(start.year, start.month, start.day); + if (cursor.isBefore(start)) cursor = cursor.add(const Duration(days: 1)); + while (!cursor.isAfter(end)) { + ticks.add(cursor); + cursor = cursor.add(Duration(days: step)); + } + } + + // A range narrower than one tick step would otherwise draw a bare axis. + if (ticks.isEmpty) ticks.add(start); + return List.unmodifiable(ticks); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/date_axis_test.dart` +Expected: PASS, 8 tests + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/statistics/presentation/widgets/date_axis.dart \ + test/features/statistics/presentation/widgets/date_axis_test.dart +git commit -m "feat(stats): add a date-valued chart axis helper + +Chooses year, quarter, month or day ticks from the span rather than from the +point count, so gaps between dives render at their real width. + +Refs #299" +``` + +--- + +## Task 8: Localization keys for the control strip + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb` and the other 10 locale ARB files +- Test: existing `test/l10n/arb_parity_test.dart` (do not edit; it must pass) + +**Interfaces:** +- Consumes: nothing. +- Produces these `context.l10n` getters, available to Tasks 9 onward: + - `statistics_trend_aggregation_perDive`, `_weekly`, `_monthly` + - `statistics_trend_aggregation_tooltip` + - `statistics_trend_legend_rollingAverage` + - `statistics_trend_legend_rate` + - `statistics_trend_rate_perYear(String value)` + - `statistics_trend_band_semanticLabel` + +- [ ] **Step 1: Add the English keys** + +Insert into `lib/l10n/arb/app_en.arb`, keeping the file's alphabetical key order (these sort just after `statistics_timePatterns_*` and before `statistics_progression_*` depending on your collation; place them so the file stays sorted): + +```json + "statistics_trend_aggregation_monthly": "Monthly", + "statistics_trend_aggregation_perDive": "Per dive", + "statistics_trend_aggregation_tooltip": "Group dives", + "statistics_trend_aggregation_weekly": "Weekly", + "statistics_trend_band_semanticLabel": "Shaded band spans the lowest and highest value in each group", + "statistics_trend_legend_rate": "Rate", + "statistics_trend_legend_rollingAverage": "Rolling avg", + "statistics_trend_rate_perYear": "{value}/yr", + "@statistics_trend_rate_perYear": { + "description": "Linear trend rate shown beside the rate legend entry. The value already carries its unit symbol, for example '+4.4 m'.", + "placeholders": { + "value": { + "type": "String" + } + } + }, +``` + +- [ ] **Step 2: Verify the parity test fails** + +Run: `flutter test --exclude-tags performance test/l10n/arb_parity_test.dart` +Expected: FAIL, reporting 8 keys missing from each of the other 10 locales + +- [ ] **Step 3: Translate into all 10 remaining locales** + +Add the same 8 keys, translated, to `app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, `app_zh.arb`. Keep each file's existing key ordering. + +Only `app_en.arb` carries the `@statistics_trend_rate_perYear` metadata block; the other locales carry the plain key. Preserve the `{value}` placeholder verbatim in every translation. + +German, for example: + +```json + "statistics_trend_aggregation_monthly": "Monatlich", + "statistics_trend_aggregation_perDive": "Pro Tauchgang", + "statistics_trend_aggregation_tooltip": "Tauchgänge gruppieren", + "statistics_trend_aggregation_weekly": "Wöchentlich", + "statistics_trend_band_semanticLabel": "Der schattierte Bereich umfasst den niedrigsten und höchsten Wert jeder Gruppe", + "statistics_trend_legend_rate": "Rate", + "statistics_trend_legend_rollingAverage": "Gleitender Durchschnitt", + "statistics_trend_rate_perYear": "{value}/Jahr", +``` + +- [ ] **Step 4: Regenerate and verify** + +Run: `flutter gen-l10n` +Then: `flutter test --exclude-tags performance test/l10n/` +Expected: PASS + +CI regenerates l10n but never verifies it, so a stale generated file will not be caught downstream. Regenerate here. + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/l10n/arb/ +git commit -m "i18n: add trend chart control strip strings + +Aggregation mode names, the two overlay legend labels, the per-year rate +format and a band semantic label, across all 11 locales. + +Refs #299" +``` + +--- + +## Task 9: TrendControlStrip + +**Files:** +- Create: `lib/features/statistics/presentation/widgets/trend_control_strip.dart` +- Test: `test/features/statistics/presentation/widgets/trend_control_strip_test.dart` + +**Interfaces:** +- Consumes: `TrendAggregation` (Task 1), the l10n keys from Task 8. +- Produces: + +```dart +class TrendControlStrip extends StatelessWidget { + const TrendControlStrip({ + super.key, + required this.chartId, + required this.aggregation, + required this.onAggregationChanged, + required this.showRollingMean, + required this.onToggleRollingMean, + required this.showLinearFit, + required this.onToggleLinearFit, + required this.rollingColor, + required this.rateColor, + this.rateLabel, + }); + + final String chartId; + final TrendAggregation aggregation; + final ValueChanged onAggregationChanged; + final bool showRollingMean; + final VoidCallback onToggleRollingMean; + final bool showLinearFit; + final VoidCallback onToggleLinearFit; + final Color rollingColor; + final Color rateColor; + final String? rateLabel; +} +``` + +Test keys, following the `gps_track_date_filter_action.dart` convention: `'trend-aggregation-$chartId'`, `'trend-legend-rolling-$chartId'`, `'trend-legend-rate-$chartId'`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/statistics/presentation/widgets/trend_control_strip_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_control_strip.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + Widget host({ + TrendAggregation aggregation = TrendAggregation.none, + bool showRollingMean = true, + bool showLinearFit = false, + String? rateLabel, + ValueChanged? onAggregationChanged, + VoidCallback? onToggleRollingMean, + VoidCallback? onToggleLinearFit, + }) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: TrendControlStrip( + chartId: 'depth', + aggregation: aggregation, + onAggregationChanged: onAggregationChanged ?? (_) {}, + showRollingMean: showRollingMean, + onToggleRollingMean: onToggleRollingMean ?? () {}, + showLinearFit: showLinearFit, + onToggleLinearFit: onToggleLinearFit ?? () {}, + rollingColor: Colors.blue, + rateColor: Colors.orange, + rateLabel: rateLabel, + ), + ), + ); + } + + testWidgets('shows the active aggregation mode', (tester) async { + await tester.pumpWidget(host(aggregation: TrendAggregation.monthly)); + + expect(find.text('Monthly'), findsOneWidget); + }); + + testWidgets('opening the dropdown and choosing weekly reports the change', + (tester) async { + TrendAggregation? chosen; + await tester.pumpWidget(host(onAggregationChanged: (m) => chosen = m)); + + await tester.tap(find.byKey(const ValueKey('trend-aggregation-depth'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Weekly').last); + await tester.pumpAndSettle(); + + expect(chosen, TrendAggregation.weekly); + }); + + testWidgets('tapping the rolling legend entry toggles it', (tester) async { + var toggled = false; + await tester.pumpWidget(host(onToggleRollingMean: () => toggled = true)); + + await tester.tap(find.byKey(const ValueKey('trend-legend-rolling-depth'))); + await tester.pump(); + + expect(toggled, isTrue); + }); + + testWidgets('tapping the rate legend entry toggles it', (tester) async { + var toggled = false; + await tester.pumpWidget(host(onToggleLinearFit: () => toggled = true)); + + await tester.tap(find.byKey(const ValueKey('trend-legend-rate-depth'))); + await tester.pump(); + + expect(toggled, isTrue); + }); + + testWidgets('shows the rate value when the fit is on and a label is given', + (tester) async { + await tester + .pumpWidget(host(showLinearFit: true, rateLabel: '+4.4 m')); + + expect(find.text('+4.4 m/yr'), findsOneWidget); + }); + + testWidgets('shows the plain rate label when the fit is off', + (tester) async { + await tester.pumpWidget(host(rateLabel: '+4.4 m')); + + expect(find.text('Rate'), findsOneWidget); + expect(find.text('+4.4 m/yr'), findsNothing); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/trend_control_strip_test.dart` +Expected: FAIL, `Target of URI doesn't exist` + +- [ ] **Step 3: Write the implementation** + +Create `lib/features/statistics/presentation/widgets/trend_control_strip.dart`: + +```dart +import 'package:flutter/material.dart'; + +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The control row beneath a [DiveTrendChart]: how dives are grouped, and +/// which fitted overlays are drawn. +/// +/// Everything lives on one row so a card costs exactly one extra line however +/// many controls it carries. The overlay toggles double as the colour key, so +/// a diver can see what the second line is without tapping anything. +/// +/// A `PopupMenuButton` rather than Material 3's `DropdownMenu`: the latter is a +/// text-field-shaped widget far too heavy for a chart footer. Same compact +/// pattern as `gps_track_date_filter_action.dart`. +class TrendControlStrip extends StatelessWidget { + const TrendControlStrip({ + super.key, + required this.chartId, + required this.aggregation, + required this.onAggregationChanged, + required this.showRollingMean, + required this.onToggleRollingMean, + required this.showLinearFit, + required this.onToggleLinearFit, + required this.rollingColor, + required this.rateColor, + this.rateLabel, + }); + + /// Stable id from `TrendChartIds`, used to build unique widget keys. + final String chartId; + + final TrendAggregation aggregation; + final ValueChanged onAggregationChanged; + final bool showRollingMean; + final VoidCallback onToggleRollingMean; + final bool showLinearFit; + final VoidCallback onToggleLinearFit; + final Color rollingColor; + final Color rateColor; + + /// The fitted rate with its unit symbol, for example "+4.4 m". Null when + /// there are too few dives to fit. Only shown while [showLinearFit] is true. + final String? rateLabel; + + String _modeLabel(BuildContext context, TrendAggregation mode) { + switch (mode) { + case TrendAggregation.none: + return context.l10n.statistics_trend_aggregation_perDive; + case TrendAggregation.weekly: + return context.l10n.statistics_trend_aggregation_weekly; + case TrendAggregation.monthly: + return context.l10n.statistics_trend_aggregation_monthly; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final rate = rateLabel; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 12, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + PopupMenuButton( + key: ValueKey('trend-aggregation-$chartId'), + tooltip: context.l10n.statistics_trend_aggregation_tooltip, + initialValue: aggregation, + onSelected: onAggregationChanged, + itemBuilder: (context) => TrendAggregation.values + .map( + (mode) => PopupMenuItem( + value: mode, + child: Text(_modeLabel(context, mode)), + ), + ) + .toList(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _modeLabel(context, aggregation), + style: theme.textTheme.bodySmall, + ), + const Icon(Icons.arrow_drop_down, size: 18), + ], + ), + ), + _LegendToggle( + toggleKey: ValueKey('trend-legend-rolling-$chartId'), + color: rollingColor, + label: context.l10n.statistics_trend_legend_rollingAverage, + enabled: showRollingMean, + onTap: onToggleRollingMean, + ), + _LegendToggle( + toggleKey: ValueKey('trend-legend-rate-$chartId'), + color: rateColor, + label: showLinearFit && rate != null + ? context.l10n.statistics_trend_rate_perYear(rate) + : context.l10n.statistics_trend_legend_rate, + enabled: showLinearFit, + onTap: onToggleLinearFit, + ), + ], + ), + ); + } +} + +/// One tappable legend entry: a colour swatch plus a label, dimmed when its +/// overlay is off. +class _LegendToggle extends StatelessWidget { + const _LegendToggle({ + required this.toggleKey, + required this.color, + required this.label, + required this.enabled, + required this.onTap, + }); + + final Key toggleKey; + final Color color; + final String label; + final bool enabled; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + key: toggleKey, + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 3, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 4), + Text(label, style: theme.textTheme.bodySmall), + ], + ), + ), + ), + ); + } +} +``` + +Note the swatch plus label markup mirrors `MultiTrendLineChart`'s legend at `stat_charts.dart:735-752`, so the two read the same. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/trend_control_strip_test.dart` +Expected: PASS, 6 tests + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/statistics/presentation/widgets/trend_control_strip.dart \ + test/features/statistics/presentation/widgets/trend_control_strip_test.dart +git commit -m "feat(stats): add the trend chart control strip + +One row beneath the chart: a compact aggregation dropdown plus two tappable +legend entries that toggle their overlay and act as the colour key. + +Refs #299" +``` + +--- + +## Task 10: DiveTrendChart, raw points on a date axis + +**Files:** +- Create: `lib/features/statistics/presentation/widgets/dive_trend_chart.dart` +- Test: `test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` + +**Interfaces:** +- Consumes: `TrendDataPoint`, `TrendAggregation`, `aggregate` (Task 1); `DateAxis` (Task 7); `ChartAxis` from `chart_axis.dart`. +- Produces: + +```dart +class DiveTrendChart extends StatelessWidget { + const DiveTrendChart({ + super.key, + required this.points, + this.aggregation = TrendAggregation.none, + this.showRollingMean = false, + this.showLinearFit = false, + this.pointColor, + this.rollingColor, + this.rateColor, + this.yAxisLabel, + this.height = 200, + this.valueFormatter, + this.yAxisFormatter, + }); + + final List points; + final TrendAggregation aggregation; + final bool showRollingMean; + final bool showLinearFit; + final Color? pointColor; + final Color? rollingColor; + final Color? rateColor; + final String? yAxisLabel; + final double height; + final String Function(double)? valueFormatter; + final String Function(double)? yAxisFormatter; +} +``` + +This task implements raw mode only: `showRollingMean` and `showLinearFit` are accepted but ignored until Task 12, and aggregation renders a plain mean line without the band until Task 11. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/statistics/presentation/widgets/dive_trend_chart_test.dart`: + +```dart +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/widgets/dive_trend_chart.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +List series(int n) => List.generate( + n, + (i) => TrendDataPoint( + date: DateTime.utc(2024, 1, 1).add(Duration(days: i * 7)), + value: 10.0 + i, + ), + ); + +Widget host(Widget child) => MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); + +LineChartData readData(WidgetTester tester) => + tester.widget(find.byType(LineChart)).data; + +void main() { + testWidgets('plots x as epoch milliseconds, not the array index', + (tester) async { + final points = series(6); + await tester.pumpWidget(host(DiveTrendChart(points: points))); + + final data = readData(tester); + final spots = data.lineBarsData.first.spots; + + expect(spots, hasLength(6)); + expect(spots.first.x, points.first.date.millisecondsSinceEpoch.toDouble()); + expect(spots.last.x, points.last.date.millisecondsSinceEpoch.toDouble()); + }); + + testWidgets('the x bounds span the first and last dive', (tester) async { + final points = series(6); + await tester.pumpWidget(host(DiveTrendChart(points: points))); + + final data = readData(tester); + + expect(data.minX, points.first.date.millisecondsSinceEpoch.toDouble()); + expect(data.maxX, points.last.date.millisecondsSinceEpoch.toDouble()); + }); + + testWidgets('draws dots with no connecting stroke in raw mode', + (tester) async { + await tester.pumpWidget(host(DiveTrendChart(points: series(6)))); + + final bar = readData(tester).lineBarsData.first; + + expect(bar.barWidth, 0); + expect(bar.dotData.show, isTrue); + }); + + testWidgets('a gap between dives is preserved in the x spacing', + (tester) async { + final points = [ + TrendDataPoint(date: DateTime.utc(2024, 1, 1), value: 10), + TrendDataPoint(date: DateTime.utc(2024, 1, 2), value: 12), + TrendDataPoint(date: DateTime.utc(2026, 1, 1), value: 40), + ]; + await tester.pumpWidget(host(DiveTrendChart(points: points))); + + final spots = readData(tester).lineBarsData.first.spots; + final firstGap = spots[1].x - spots[0].x; + final secondGap = spots[2].x - spots[1].x; + + expect(secondGap, greaterThan(firstGap * 100)); + }); + + testWidgets('monthly aggregation collapses to one point per month', + (tester) async { + final points = [ + TrendDataPoint(date: DateTime.utc(2024, 1, 5), value: 10), + TrendDataPoint(date: DateTime.utc(2024, 1, 20), value: 20), + TrendDataPoint(date: DateTime.utc(2024, 2, 5), value: 30), + ]; + await tester.pumpWidget( + host( + DiveTrendChart( + points: points, + aggregation: TrendAggregation.monthly, + ), + ), + ); + + final spots = readData(tester).lineBarsData.first.spots; + + expect(spots, hasLength(2)); + expect(spots.first.y, 15); // mean of 10 and 20 + }); + + testWidgets('renders the empty state rather than a chart for no dives', + (tester) async { + await tester.pumpWidget(host(const DiveTrendChart(points: []))); + + expect(find.byType(LineChart), findsNothing); + expect(find.text('No trend data available'), findsOneWidget); + }); + + testWidgets('a single dive still renders a chart', (tester) async { + await tester.pumpWidget(host(DiveTrendChart(points: series(1)))); + + final data = readData(tester); + + expect(data.maxX, greaterThan(data.minX)); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` +Expected: FAIL, `Target of URI doesn't exist` + +- [ ] **Step 3: Write the implementation** + +Create `lib/features/statistics/presentation/widgets/dive_trend_chart.dart`: + +```dart +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/widgets/chart_axis.dart'; +import 'package:submersion/features/statistics/presentation/widgets/date_axis.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// A per-dive trend chart on a real date axis. +/// +/// Distinct from `TrendLineChart`, which plots `FlSpot(index, value)` and so +/// draws a three-month gap and a three-week gap identically. That is fine for +/// a dense monthly series; it is not fine for individual dives, which cluster +/// hard around trips (issue #299). +/// +/// Layers, all sharing one set of axes: +/// - the data, as dots when raw or a mean line when aggregated +/// - a rolling mean, optional +/// - a linear fit, optional +/// +/// fl_chart's ScatterChart is deliberately not used: it cannot carry the +/// overlay line series alongside the points. +class DiveTrendChart extends StatelessWidget { + const DiveTrendChart({ + super.key, + required this.points, + this.aggregation = TrendAggregation.none, + this.showRollingMean = false, + this.showLinearFit = false, + this.pointColor, + this.rollingColor, + this.rateColor, + this.yAxisLabel, + this.height = 200, + this.valueFormatter, + this.yAxisFormatter, + }); + + /// Raw per-dive points, in any order. Never pre-aggregated by the caller. + final List points; + + final TrendAggregation aggregation; + final bool showRollingMean; + final bool showLinearFit; + final Color? pointColor; + final Color? rollingColor; + final Color? rateColor; + final String? yAxisLabel; + final double height; + final String Function(double)? valueFormatter; + final String Function(double)? yAxisFormatter; + + static double _x(DateTime date) => + date.millisecondsSinceEpoch.toDouble(); + + @override + Widget build(BuildContext context) { + if (points.isEmpty) { + return _EmptyChart(height: height); + } + + final theme = Theme.of(context); + final color = pointColor ?? theme.colorScheme.primary; + + final buckets = aggregate(points, aggregation); + final dateAxis = DateAxis.forRange( + buckets.first.date, + buckets.last.date, + ); + final yAxis = ChartAxis.forTrend( + buckets.expand((b) => [b.min, b.max]), + ); + + final isRaw = aggregation == TrendAggregation.none; + + return Semantics( + label: yAxisLabel != null + ? context.l10n.statistics_chart_trendSemanticLabelWithAxis( + points.length, + yAxisLabel!, + ) + : context.l10n.statistics_chart_trendSemanticLabel(points.length), + child: SizedBox( + height: height, + child: LineChart( + LineChartData( + minX: dateAxis.min, + maxX: dateAxis.max, + minY: yAxis.min, + maxY: yAxis.max, + lineTouchData: _touchData(context, buckets), + titlesData: _titles(context, dateAxis, yAxis), + borderData: FlBorderData(show: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: yAxis.interval, + getDrawingHorizontalLine: (value) => FlLine( + color: theme.colorScheme.outlineVariant, + strokeWidth: 1, + ), + ), + lineBarsData: [ + LineChartBarData( + spots: buckets + .map((b) => FlSpot(_x(b.date), b.mean)) + .toList(growable: false), + isCurved: false, + color: color, + // Raw mode draws dots only: a stroke between two dives eight + // months apart would assert something happened in between. + barWidth: isRaw ? 0 : 2, + isStrokeCapRound: true, + dotData: FlDotData( + show: isRaw, + getDotPainter: (spot, percent, barData, index) => + FlDotCirclePainter( + radius: 2.2, + color: color.withValues(alpha: 0.7), + strokeWidth: 0, + ), + ), + ), + ], + ), + ), + ), + ); + } + + LineTouchData _touchData(BuildContext context, List buckets) { + return LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + final date = DateTime.fromMillisecondsSinceEpoch( + spot.x.toInt(), + isUtc: true, + ); + final value = + valueFormatter?.call(spot.y) ?? spot.y.toStringAsFixed(1); + return LineTooltipItem( + '${DateFormat.yMMMd().format(date)}\n$value', + TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ); + }).toList(); + }, + ), + ); + } + + FlTitlesData _titles( + BuildContext context, + DateAxis dateAxis, + ChartAxis yAxis, + ) { + final tickMs = dateAxis.ticks + .map((t) => t.millisecondsSinceEpoch) + .toSet(); + + return FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + if (!tickMs.contains(value.toInt())) return const Text(''); + final date = DateTime.fromMillisecondsSinceEpoch( + value.toInt(), + isUtc: true, + ); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _formatTick(date, dateAxis.granularity), + style: Theme.of(context).textTheme.bodySmall, + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + axisNameWidget: yAxisLabel != null + ? Text( + yAxisLabel!, + style: Theme.of(context).textTheme.bodySmall, + ) + : null, + axisNameSize: yAxisLabel != null ? 20 : 0, + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, + interval: yAxis.interval, + getTitlesWidget: (value, meta) { + final formatter = yAxisFormatter ?? valueFormatter; + return Text( + formatter?.call(value) ?? value.toStringAsFixed(0), + style: Theme.of(context).textTheme.bodySmall, + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ); + } + + String _formatTick(DateTime date, DateAxisGranularity granularity) { + switch (granularity) { + case DateAxisGranularity.year: + return DateFormat.y().format(date); + case DateAxisGranularity.quarter: + case DateAxisGranularity.month: + return DateFormat.MMM().format(date); + case DateAxisGranularity.day: + return DateFormat.Md().format(date); + } + } +} + +/// Same empty state as `TrendLineChart`, so a chart with no dives reads the +/// same wherever it appears. +class _EmptyChart extends StatelessWidget { + const _EmptyChart({required this.height}); + + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.show_chart, + size: 48, + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + ), + const SizedBox(height: 8), + Text( + context.l10n.statistics_chart_noTrendData, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` +Expected: PASS, 7 tests + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/statistics/presentation/widgets/dive_trend_chart.dart \ + test/features/statistics/presentation/widgets/dive_trend_chart_test.dart +git commit -m "feat(stats): add DiveTrendChart with a date-valued x axis + +Plots one dot per dive at its real timestamp, so trip clusters read as +clusters and quiet years read as gaps. Raw mode draws no connecting stroke, +because a segment between two dives months apart asserts something that did +not happen. + +Refs #299" +``` + +--- + +## Task 11: DiveTrendChart, min/max band when aggregated + +**Files:** +- Modify: `lib/features/statistics/presentation/widgets/dive_trend_chart.dart` +- Test: `test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` + +**Interfaces:** +- Consumes: `TrendBucket` (Task 1), the chart from Task 10. +- Produces: no new public API. When `aggregation != TrendAggregation.none`, `LineChartData.lineBarsData` gains two invisible series (bucket min at index 1, bucket max at index 2) and `LineChartData.betweenBarsData` gains one `BetweenBarsData(fromIndex: 1, toIndex: 2)`. + +Smoothing must not re-hide the spread that this whole issue is about, so an aggregated bucket shows its range as well as its mean. + +- [ ] **Step 1: Write the failing test** + +Append to `test/features/statistics/presentation/widgets/dive_trend_chart_test.dart`, inside `main()`: + +```dart + group('min/max band', () { + final spread = [ + TrendDataPoint(date: DateTime.utc(2024, 1, 5), value: 10), + TrendDataPoint(date: DateTime.utc(2024, 1, 20), value: 30), + TrendDataPoint(date: DateTime.utc(2024, 2, 5), value: 40), + TrendDataPoint(date: DateTime.utc(2024, 2, 25), value: 60), + ]; + + testWidgets('draws no band in raw mode', (tester) async { + await tester.pumpWidget(host(DiveTrendChart(points: spread))); + + expect(readData(tester).betweenBarsData, isEmpty); + }); + + testWidgets('draws one band between the min and max series when monthly', + (tester) async { + await tester.pumpWidget( + host( + DiveTrendChart( + points: spread, + aggregation: TrendAggregation.monthly, + ), + ), + ); + + final data = readData(tester); + + expect(data.betweenBarsData, hasLength(1)); + expect(data.betweenBarsData.first.fromIndex, 1); + expect(data.betweenBarsData.first.toIndex, 2); + }); + + testWidgets('the band series carry the bucket min and max', (tester) async { + await tester.pumpWidget( + host( + DiveTrendChart( + points: spread, + aggregation: TrendAggregation.monthly, + ), + ), + ); + + final bars = readData(tester).lineBarsData; + + expect(bars[1].spots.map((s) => s.y), [10, 40]); + expect(bars[2].spots.map((s) => s.y), [30, 60]); + }); + + testWidgets('the band series are not stroked or dotted', (tester) async { + await tester.pumpWidget( + host( + DiveTrendChart( + points: spread, + aggregation: TrendAggregation.monthly, + ), + ), + ); + + final bars = readData(tester).lineBarsData; + + expect(bars[1].barWidth, 0); + expect(bars[1].dotData.show, isFalse); + expect(bars[2].barWidth, 0); + expect(bars[2].dotData.show, isFalse); + }); + + testWidgets('a bucket holding one dive yields a zero-height band', + (tester) async { + await tester.pumpWidget( + host( + DiveTrendChart( + points: [ + TrendDataPoint(date: DateTime.utc(2024, 1, 5), value: 10), + TrendDataPoint(date: DateTime.utc(2024, 2, 5), value: 40), + ], + aggregation: TrendAggregation.monthly, + ), + ), + ); + + final bars = readData(tester).lineBarsData; + + expect(bars[1].spots.map((s) => s.y), bars[2].spots.map((s) => s.y)); + }); + + testWidgets('the y bounds cover the band, not just the means', + (tester) async { + await tester.pumpWidget( + host( + DiveTrendChart( + points: spread, + aggregation: TrendAggregation.monthly, + ), + ), + ); + + final data = readData(tester); + + expect(data.minY, lessThanOrEqualTo(10)); + expect(data.maxY, greaterThanOrEqualTo(60)); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` +Expected: FAIL, "Expected: an object with length of <1> Actual: [] Which: is empty" + +- [ ] **Step 3: Extract the bar list into a helper** + +In `dive_trend_chart.dart`, replace the inline `lineBarsData: [ ... ]` argument with `lineBarsData: _bars(context, buckets, color, isRaw),` and add `betweenBarsData: _bands(context, isRaw),` immediately after it. + +Then add these two methods to the class: + +```dart + /// Index 0 is always the data series. When aggregating, indices 1 and 2 are + /// the invisible bucket min and max that [_bands] fills between. + List _bars( + BuildContext context, + List buckets, + Color color, + bool isRaw, + ) { + final bars = [ + LineChartBarData( + spots: buckets + .map((b) => FlSpot(_x(b.date), b.mean)) + .toList(growable: false), + isCurved: false, + color: color, + barWidth: isRaw ? 0 : 2, + isStrokeCapRound: true, + dotData: FlDotData( + show: isRaw, + getDotPainter: (spot, percent, barData, index) => + FlDotCirclePainter( + radius: 2.2, + color: color.withValues(alpha: 0.7), + strokeWidth: 0, + ), + ), + ), + ]; + + if (!isRaw) { + for (final selector in [ + (b) => b.min, + (b) => b.max, + ]) { + bars.add( + LineChartBarData( + spots: buckets + .map((b) => FlSpot(_x(b.date), selector(b))) + .toList(growable: false), + isCurved: false, + barWidth: 0, + color: Colors.transparent, + dotData: const FlDotData(show: false), + ), + ); + } + } + + return bars; + } + + /// Fills between the min and max series so an aggregated chart still shows + /// the spread. Smoothing must not put back the hiding this issue is about. + List _bands(BuildContext context, bool isRaw) { + if (isRaw) return const []; + final color = pointColor ?? Theme.of(context).colorScheme.primary; + return [ + BetweenBarsData( + fromIndex: 1, + toIndex: 2, + color: color.withValues(alpha: 0.15), + ), + ]; + } +``` + +The y-axis already reads `buckets.expand((b) => [b.min, b.max])` from Task 10, so the band is inside the bounds with no further change. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` +Expected: PASS, 13 tests + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/statistics/presentation/widgets/dive_trend_chart.dart \ + test/features/statistics/presentation/widgets/dive_trend_chart_test.dart +git commit -m "feat(stats): shade the min/max range behind an aggregated trend + +An aggregated bucket draws its mean over a band spanning the lowest and +highest dive in it, so opting into smoothing does not re-hide the spread. + +Refs #299" +``` + +--- + +## Task 12: DiveTrendChart, rolling mean and linear fit overlays + +**Files:** +- Modify: `lib/features/statistics/presentation/widgets/dive_trend_chart.dart` +- Test: `test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` + +**Interfaces:** +- Consumes: `rollingMean`, `linearFit`, `LinearFit`, `kMinTrendFitPoints` (Task 2). +- Produces: `showRollingMean` and `showLinearFit` become live. Both fits are computed from the **raw** `points`, never from the buckets, in every aggregation mode. + +- [ ] **Step 1: Write the failing test** + +Append to `test/features/statistics/presentation/widgets/dive_trend_chart_test.dart`, inside `main()`: + +```dart + group('overlays', () { + testWidgets('draws neither overlay by default', (tester) async { + await tester.pumpWidget(host(DiveTrendChart(points: series(20)))); + + expect(readData(tester).lineBarsData, hasLength(1)); + }); + + testWidgets('draws a rolling mean series when asked', (tester) async { + await tester.pumpWidget( + host(DiveTrendChart(points: series(20), showRollingMean: true)), + ); + + final bars = readData(tester).lineBarsData; + + expect(bars, hasLength(2)); + expect(bars.last.spots, hasLength(20)); + expect(bars.last.barWidth, greaterThan(0)); + }); + + testWidgets('draws the linear fit as exactly two endpoints', + (tester) async { + await tester.pumpWidget( + host(DiveTrendChart(points: series(20), showLinearFit: true)), + ); + + final bars = readData(tester).lineBarsData; + + expect(bars, hasLength(2)); + expect(bars.last.spots, hasLength(2)); + }); + + testWidgets('draws both overlays together', (tester) async { + await tester.pumpWidget( + host( + DiveTrendChart( + points: series(20), + showRollingMean: true, + showLinearFit: true, + ), + ), + ); + + expect(readData(tester).lineBarsData, hasLength(3)); + }); + + testWidgets('draws no overlay below the minimum point count', + (tester) async { + await tester.pumpWidget( + host( + DiveTrendChart( + points: series(4), + showRollingMean: true, + showLinearFit: true, + ), + ), + ); + + expect(readData(tester).lineBarsData, hasLength(1)); + }); + + testWidgets('the rolling mean is unchanged by the aggregation mode', + (tester) async { + // Both fits read the raw dives. If they read the buckets instead, + // changing the dropdown would move the trend line and wrongly imply the + // underlying trend had changed. + await tester.pumpWidget( + host(DiveTrendChart(points: series(20), showRollingMean: true)), + ); + final raw = readData(tester).lineBarsData.last.spots.map((s) => s.y) + .toList(); + + await tester.pumpWidget( + host( + DiveTrendChart( + points: series(20), + showRollingMean: true, + aggregation: TrendAggregation.monthly, + ), + ), + ); + final aggregated = readData(tester) + .lineBarsData + .where((b) => b.barWidth > 0) + .last + .spots + .map((s) => s.y) + .toList(); + + expect(aggregated, raw); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` +Expected: FAIL, "Expected: an object with length of <2> Actual: [...] Which: has length of <1>" + +- [ ] **Step 3: Implement the overlays** + +In `_bars`, append the two overlay series just before `return bars;`: + +```dart + // Both fits read the RAW dives, never the buckets. Fitting over monthly + // means would smooth twice, and the line would visibly move when the + // dropdown changed, implying the underlying trend had changed when only + // the drawing did. + if (showRollingMean) { + final smoothed = rollingMean(points); + if (smoothed.isNotEmpty) { + bars.add( + LineChartBarData( + spots: smoothed + .map((p) => FlSpot(_x(p.date), p.value)) + .toList(growable: false), + isCurved: false, + color: rollingColor ?? Theme.of(context).colorScheme.primary, + barWidth: 2.2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + ), + ); + } + } + + if (showLinearFit) { + final fit = linearFit(points); + if (fit != null) { + final first = buckets.first.date; + final last = buckets.last.date; + bars.add( + LineChartBarData( + spots: [ + FlSpot(_x(first), fit.valueAt(first)), + FlSpot(_x(last), fit.valueAt(last)), + ], + isCurved: false, + color: rateColor ?? Theme.of(context).colorScheme.tertiary, + barWidth: 1.8, + dashArray: const [6, 4], + dotData: const FlDotData(show: false), + ), + ); + } + } +``` + +Because `_bands` addresses the band series by fixed indices 1 and 2, the overlays must be appended **after** the band series, which the ordering above already does. + +The y axis must now also cover the fit, which can run outside the bucket range. In `build`, replace the `yAxis` assignment with: + +```dart + final smoothed = showRollingMean ? rollingMean(points) : const []; + final fit = showLinearFit ? linearFit(points) : null; + final yValues = [ + ...buckets.expand((b) => [b.min, b.max]), + ...smoothed.map((p) => p.value), + if (fit != null) ...[ + fit.valueAt(buckets.first.date), + fit.valueAt(buckets.last.date), + ], + ]; + final yAxis = ChartAxis.forTrend(yValues); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/dive_trend_chart_test.dart` +Expected: PASS, 19 tests + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/statistics/presentation/widgets/dive_trend_chart.dart \ + test/features/statistics/presentation/widgets/dive_trend_chart_test.dart +git commit -m "feat(stats): add rolling mean and linear fit overlays + +Both fits read the raw per-dive points in every aggregation mode, so changing +how the data is grouped never moves the trend line. Neither is drawn below +five dives. + +Refs #299" +``` + +--- + +## Task 13: TrendChartSection, the shared card + +**Files:** +- Create: `lib/features/statistics/presentation/widgets/trend_chart_section.dart` +- Test: `test/features/statistics/presentation/widgets/trend_chart_section_test.dart` + +**Interfaces:** +- Consumes: `DiveTrendChart` (Tasks 10-12), `TrendControlStrip` (Task 9), `trendChartSettingsProvider` and `TrendChartIds` (Task 6), `linearFit` (Task 2), `StatSectionCard`. +- Produces: + +```dart +class TrendChartSection extends ConsumerWidget { + const TrendChartSection({ + super.key, + required this.chartId, + required this.title, + required this.subtitle, + required this.pointsAsync, + required this.errorMessage, + required this.lineColor, + this.yAxisLabel, + this.valueFormatter, + this.yAxisFormatter, + this.rateFormatter, + }); + + final String chartId; + final String title; + final String subtitle; + final AsyncValue> pointsAsync; + final String errorMessage; + final Color lineColor; + final String? yAxisLabel; + final String Function(double)? valueFormatter; + final String Function(double)? yAxisFormatter; + /// Formats the fitted per-year rate with its unit symbol, for example + /// `(v) => units.formatDepth(v)`. Null hides the rate value. + final String Function(double)? rateFormatter; +} +``` + +Four pages need the identical card, chart, strip and settings wiring. Putting it in one widget means the page tasks that follow are a handful of lines each, and there is one place to fix a layout bug rather than four. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/statistics/presentation/widgets/trend_chart_section_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/providers/trend_chart_settings_provider.dart'; +import 'package:submersion/features/statistics/presentation/widgets/dive_trend_chart.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_chart_section.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_control_strip.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +List series(int n) => List.generate( + n, + (i) => TrendDataPoint( + date: DateTime.utc(2024, 1, 1).add(Duration(days: i * 7)), + value: 10.0 + i, + ), + ); + +Widget host(AsyncValue> value) { + return ProviderScope( + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: TrendChartSection( + chartId: TrendChartIds.depth, + title: 'Maximum Depth Progression', + subtitle: 'Every dive', + pointsAsync: value, + errorMessage: 'Failed to load depth progression', + lineColor: Colors.indigo, + valueFormatter: (v) => '${v.toStringAsFixed(1)}m', + rateFormatter: (v) => '${v.toStringAsFixed(1)}m', + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('renders the title, the chart and the control strip', + (tester) async { + await tester.pumpWidget(host(AsyncValue.data(series(20)))); + await tester.pumpAndSettle(); + + expect(find.text('Maximum Depth Progression'), findsOneWidget); + expect(find.byType(DiveTrendChart), findsOneWidget); + expect(find.byType(TrendControlStrip), findsOneWidget); + }); + + testWidgets('starts in per-dive mode with the rolling mean on', + (tester) async { + await tester.pumpWidget(host(AsyncValue.data(series(20)))); + await tester.pumpAndSettle(); + + expect(find.text('Per dive'), findsOneWidget); + final chart = tester.widget(find.byType(DiveTrendChart)); + expect(chart.aggregation, TrendAggregation.none); + expect(chart.showRollingMean, isTrue); + expect(chart.showLinearFit, isFalse); + }); + + testWidgets('choosing monthly re-renders the chart aggregated', + (tester) async { + await tester.pumpWidget(host(AsyncValue.data(series(20)))); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(ValueKey('trend-aggregation-${TrendChartIds.depth}')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Monthly').last); + await tester.pumpAndSettle(); + + final chart = tester.widget(find.byType(DiveTrendChart)); + expect(chart.aggregation, TrendAggregation.monthly); + }); + + testWidgets('tapping the rate legend turns the linear fit on', + (tester) async { + await tester.pumpWidget(host(AsyncValue.data(series(20)))); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(ValueKey('trend-legend-rate-${TrendChartIds.depth}')), + ); + await tester.pumpAndSettle(); + + final chart = tester.widget(find.byType(DiveTrendChart)); + expect(chart.showLinearFit, isTrue); + }); + + testWidgets('shows the fitted rate once the fit is on', (tester) async { + await tester.pumpWidget(host(AsyncValue.data(series(20)))); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(ValueKey('trend-legend-rate-${TrendChartIds.depth}')), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('/yr'), findsOneWidget); + }); + + testWidgets('shows the error message on failure', (tester) async { + await tester.pumpWidget( + host(AsyncValue.error('boom', StackTrace.empty)), + ); + await tester.pumpAndSettle(); + + expect(find.text('Failed to load depth progression'), findsOneWidget); + }); + + testWidgets('hides the control strip while loading', (tester) async { + await tester.pumpWidget(host(const AsyncValue.loading())); + await tester.pump(); + + expect(find.byType(TrendControlStrip), findsNothing); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/trend_chart_section_test.dart` +Expected: FAIL, `Target of URI doesn't exist` + +- [ ] **Step 3: Write the implementation** + +Create `lib/features/statistics/presentation/widgets/trend_chart_section.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/providers/trend_chart_settings_provider.dart'; +import 'package:submersion/features/statistics/presentation/widgets/dive_trend_chart.dart'; +import 'package:submersion/features/statistics/presentation/widgets/stat_charts.dart'; +import 'package:submersion/features/statistics/presentation/widgets/stat_section_card.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_control_strip.dart'; + +/// One per-dive trend chart, its card and its controls. +/// +/// Four pages need exactly this combination, so it lives in one widget: the +/// pages stay short and a layout fix lands once rather than four times. +class TrendChartSection extends ConsumerWidget { + const TrendChartSection({ + super.key, + required this.chartId, + required this.title, + required this.subtitle, + required this.pointsAsync, + required this.errorMessage, + required this.lineColor, + this.yAxisLabel, + this.valueFormatter, + this.yAxisFormatter, + this.rateFormatter, + }); + + /// Key into [trendChartSettingsProvider]. Use a `TrendChartIds` constant. + final String chartId; + + final String title; + final String subtitle; + final AsyncValue> pointsAsync; + final String errorMessage; + final Color lineColor; + final String? yAxisLabel; + final String Function(double)? valueFormatter; + final String Function(double)? yAxisFormatter; + + /// Formats the fitted per-year rate with its unit symbol. Null hides the + /// numeric rate and leaves the legend entry as a plain toggle. + final String Function(double)? rateFormatter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(trendChartSettingsProvider(chartId)); + final theme = Theme.of(context); + final rateColor = theme.colorScheme.tertiary; + + return StatSectionCard( + title: title, + subtitle: subtitle, + child: pointsAsync.when( + data: (points) { + final fit = settings.showLinearFit ? linearFit(points) : null; + final rate = (fit != null && rateFormatter != null) + ? rateFormatter!(fit.perYear) + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DiveTrendChart( + points: points, + aggregation: settings.aggregation, + showRollingMean: settings.showRollingMean, + showLinearFit: settings.showLinearFit, + pointColor: lineColor, + rollingColor: lineColor, + rateColor: rateColor, + yAxisLabel: yAxisLabel, + valueFormatter: valueFormatter, + yAxisFormatter: yAxisFormatter, + ), + TrendControlStrip( + chartId: chartId, + aggregation: settings.aggregation, + onAggregationChanged: (mode) => _update( + ref, + settings.copyWith(aggregation: mode), + ), + showRollingMean: settings.showRollingMean, + onToggleRollingMean: () => _update( + ref, + settings.copyWith( + showRollingMean: !settings.showRollingMean, + ), + ), + showLinearFit: settings.showLinearFit, + onToggleLinearFit: () => _update( + ref, + settings.copyWith(showLinearFit: !settings.showLinearFit), + ), + rollingColor: lineColor, + rateColor: rateColor, + rateLabel: rate, + ), + ], + ); + }, + loading: () => const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ), + error: (_, _) => StatEmptyState( + icon: Icons.error_outline, + message: errorMessage, + ), + ), + ); + } + + void _update(WidgetRef ref, TrendChartSettings next) { + ref.read(trendChartSettingsProvider(chartId).notifier).state = next; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/trend_chart_section_test.dart` +Expected: PASS, 7 tests + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/statistics/presentation/widgets/trend_chart_section.dart \ + test/features/statistics/presentation/widgets/trend_chart_section_test.dart +git commit -m "feat(stats): add the shared per-dive trend chart section + +Combines the card, chart, control strip and per-chart settings so the four +consuming pages stay short and a layout fix lands in one place. + +Refs #299" +``` + +--- + +## Task 14: Wire the Progression page + +**Files:** +- Modify: `lib/features/statistics/presentation/pages/statistics_progression_page.dart:51-102` +- Modify: `lib/l10n/arb/app_*.arb` (2 subtitles, 11 files) +- Test: `test/features/statistics/presentation/pages/statistics_progression_page_test.dart` (create) + +**Interfaces:** +- Consumes: `TrendChartSection`, `TrendChartIds`, `depthProgressionTrendProvider`, `bottomTimeTrendProvider`. +- Produces: nothing new. + +- [ ] **Step 1: Rewrite the two stale subtitles** + +The current English strings assert a window that no longer exists. In `lib/l10n/arb/app_en.arb`: + +```json + "statistics_progression_depthProgression_subtitle": "Every dive in range", + "statistics_progression_bottomTime_subtitle": "Every dive in range", +``` + +Update the same two keys in the other 10 locales with the equivalent translation. No new keys, so the parity test cannot catch a miss here: check each of the 11 files by hand. + +Run: `flutter gen-l10n` + +- [ ] **Step 2: Write the failing test** + +Create `test/features/statistics/presentation/pages/statistics_progression_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/pages/statistics_progression_page.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; +import 'package:submersion/features/statistics/presentation/widgets/dive_trend_chart.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_control_strip.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +List series(int n) => List.generate( + n, + (i) => TrendDataPoint( + date: DateTime.utc(2024, 1, 1).add(Duration(days: i * 7)), + value: 10.0 + i, + ), + ); + +void main() { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpPage(WidgetTester tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + depthProgressionTrendProvider.overrideWith( + (ref) async => series(20), + ), + bottomTimeTrendProvider.overrideWith((ref) async => series(20)), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StatisticsProgressionPage(embedded: true), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('renders a per-dive chart for depth and for bottom time', + (tester) async { + await pumpPage(tester); + + expect(find.byType(DiveTrendChart), findsNWidgets(2)); + expect(find.byType(TrendControlStrip), findsNWidgets(2)); + }); + + testWidgets('both charts start in per-dive mode', (tester) async { + await pumpPage(tester); + + final charts = tester + .widgetList(find.byType(DiveTrendChart)) + .toList(); + + for (final chart in charts) { + expect(chart.aggregation, TrendAggregation.none); + } + }); + + testWidgets('the two charts hold independent aggregation settings', + (tester) async { + await pumpPage(tester); + + await tester.tap(find.byKey(const ValueKey('trend-aggregation-depth'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Monthly').last); + await tester.pumpAndSettle(); + + final charts = tester + .widgetList(find.byType(DiveTrendChart)) + .toList(); + + expect(charts[0].aggregation, TrendAggregation.monthly); + expect(charts[1].aggregation, TrendAggregation.none); + }); + + testWidgets('the cumulative and per-year charts are untouched', + (tester) async { + await pumpPage(tester); + + expect(find.text('Cumulative Dive Count'), findsOneWidget); + expect(find.text('Dives Per Year'), findsOneWidget); + }); +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/pages/statistics_progression_page_test.dart` +Expected: FAIL, "Expected: exactly 2 matching candidates Actual: _TypeWidgetFinder:" + +- [ ] **Step 4: Replace the two section builders** + +In `statistics_progression_page.dart`, add the imports: + +```dart +import 'package:submersion/features/statistics/presentation/providers/trend_chart_settings_provider.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_chart_section.dart'; +``` + +Replace `_buildDepthProgressionSection` (L51-77) in full with: + +```dart + Widget _buildDepthProgressionSection( + BuildContext context, + WidgetRef ref, + UnitFormatter units, + ) { + return TrendChartSection( + chartId: TrendChartIds.depth, + title: context.l10n.statistics_progression_depthProgression_title, + subtitle: context.l10n.statistics_progression_depthProgression_subtitle, + pointsAsync: ref.watch(depthProgressionTrendProvider), + errorMessage: + context.l10n.statistics_progression_depthProgression_error, + lineColor: Colors.indigo, + valueFormatter: (value) => units.formatDepth(value), + rateFormatter: (value) => units.formatDepth(value), + ); + } +``` + +Replace `_buildBottomTimeSection` (L79-102) in full with: + +```dart + Widget _buildBottomTimeSection(BuildContext context, WidgetRef ref) { + String minutes(double value) => context.l10n + .surfaceInterval_format_minutes(value.toStringAsFixed(0)); + + return TrendChartSection( + chartId: TrendChartIds.bottomTime, + title: context.l10n.statistics_progression_bottomTime_title, + subtitle: context.l10n.statistics_progression_bottomTime_subtitle, + pointsAsync: ref.watch(bottomTimeTrendProvider), + errorMessage: context.l10n.statistics_progression_bottomTime_error, + lineColor: Colors.teal, + valueFormatter: minutes, + rateFormatter: minutes, + ); + } +``` + +If `stat_charts.dart` is no longer referenced by this file, remove its import; `flutter analyze` will flag it as unused otherwise, and unused imports are fatal in CI. + +- [ ] **Step 5: Run the test** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/pages/statistics_progression_page_test.dart` +Expected: PASS, 4 tests + +- [ ] **Step 6: Format, analyze and commit** + +```bash +dart format . +flutter analyze 2>&1 | tail -20 +git add lib/features/statistics/presentation/pages/statistics_progression_page.dart \ + lib/l10n/arb/ \ + test/features/statistics/presentation/pages/statistics_progression_page_test.dart +git commit -m "feat(stats): plot max depth and bottom time per dive + +Both charts move to TrendChartSection and gain their own aggregation and +overlay controls. Their subtitles no longer claim a five-year window that no +longer exists. + +Refs #299" +``` + +--- + +## Task 15: Wire the Gas page + +**Files:** +- Modify: `lib/features/statistics/presentation/pages/statistics_gas_page.dart:84-124` +- Modify: `lib/l10n/arb/app_*.arb` (1 subtitle, 11 files) +- Modify: `test/features/statistics/presentation/pages/statistics_gas_page_test.dart` + +**Interfaces:** +- Consumes: `TrendChartSection`, `TrendChartIds.sac`, `sacTrendProvider`, `statisticsGasLaneProvider`. +- Produces: nothing new. + +- [ ] **Step 1: Rewrite the stale subtitle** + +In `lib/l10n/arb/app_en.arb`, replace: + +```json + "statistics_gas_sacTrend_subtitle": "Every dive in range", +``` + +Update the same key in the other 10 locales. The current value, "Monthly average over 5 years", is false on both counts once this task lands. + +Run: `flutter gen-l10n` + +- [ ] **Step 2: Write the failing test** + +Append to `test/features/statistics/presentation/pages/statistics_gas_page_test.dart`, inside `main()`: + +```dart + testWidgets('the consumption trend renders as a per-dive chart', + (tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + sacTrendProvider.overrideWith( + (ref) async => List.generate( + 20, + (i) => TrendDataPoint( + date: DateTime.utc(2024, 1, 1).add(Duration(days: i * 7)), + value: 15.0 + i, + ), + ), + ), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StatisticsGasPage(embedded: true), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DiveTrendChart), findsOneWidget); + expect( + find.byKey(const ValueKey('trend-aggregation-sac')), + findsOneWidget, + ); + }); +``` + +Add the imports this test needs at the top of the file: + +```dart +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/widgets/dive_trend_chart.dart'; +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/pages/statistics_gas_page_test.dart` +Expected: FAIL, "Expected: exactly one matching candidate Actual: zero widgets" + +- [ ] **Step 4: Replace `_buildSacTrendSection`** + +Add the imports: + +```dart +import 'package:submersion/features/statistics/presentation/providers/trend_chart_settings_provider.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_chart_section.dart'; +``` + +Replace `_buildSacTrendSection` (L84-124) in full with: + +```dart + Widget _buildSacTrendSection( + BuildContext context, + WidgetRef ref, + UnitFormatter units, + ) { + final lane = ref.watch(statisticsGasLaneProvider); + final isRmv = lane == GasConsumptionLane.rmv; + final unitSymbol = isRmv ? units.rmvSymbol : units.sacSymbol; + String format(double v) => isRmv ? units.formatRmv(v) : units.formatSac(v); + double convert(double v) => + isRmv ? units.convertRmv(v) : units.convertSac(v); + // The axis draws the bare number, so it has to round the way the + // tooltip's labelled value does or the two disagree (an imperial RMV + // tick read 0.5 where its tooltip said 0.53). + final decimals = isRmv ? units.rmvDecimals : units.sacDecimals; + + return TrendChartSection( + chartId: TrendChartIds.sac, + title: context.l10n.statistics_gas_sacTrend_title, + subtitle: context.l10n.statistics_gas_sacTrend_subtitle, + pointsAsync: ref.watch(sacTrendProvider), + errorMessage: context.l10n.statistics_gas_sacTrend_error, + lineColor: Colors.blue, + yAxisLabel: unitSymbol, + valueFormatter: format, + yAxisFormatter: (value) => convert(value).toStringAsFixed(decimals), + rateFormatter: format, + ); + } +``` + +Both lanes share `TrendChartIds.sac`, so switching between SAC and RMV keeps the diver's chosen aggregation rather than resetting it. + +- [ ] **Step 5: Run the test** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/pages/statistics_gas_page_test.dart` +Expected: PASS + +- [ ] **Step 6: Format, analyze and commit** + +```bash +dart format . +flutter analyze 2>&1 | tail -20 +git add lib/features/statistics/presentation/pages/statistics_gas_page.dart \ + lib/l10n/arb/ \ + test/features/statistics/presentation/pages/statistics_gas_page_test.dart +git commit -m "feat(stats): plot the gas consumption trend per dive + +Both the SAC and RMV lanes share one chart id, so switching lane keeps the +diver's chosen grouping rather than resetting it. + +Refs #299" +``` + +--- + +## Task 16: Wire the Equipment page + +**Files:** +- Modify: `lib/features/statistics/presentation/pages/statistics_equipment_page.dart:72-98` +- Modify: `lib/l10n/arb/app_*.arb` (1 subtitle, 11 files) +- Test: `test/features/statistics/presentation/pages/statistics_equipment_page_test.dart` (create) + +**Interfaces:** +- Consumes: `TrendChartSection`, `TrendChartIds.weight`, `weightTrendProvider`. +- Produces: nothing new. + +- [ ] **Step 1: Rewrite the subtitle** + +In `lib/l10n/arb/app_en.arb`: + +```json + "statistics_equipment_weightTrend_subtitle": "Total lead carried per dive", +``` + +Update the same key in the other 10 locales. This is not only a window correction: the value itself changed from a monthly average across weight rows to the total lead on each dive, so the old "Average weight over time" describes neither the axis nor the quantity. + +Run: `flutter gen-l10n` + +- [ ] **Step 2: Write the failing test** + +Create `test/features/statistics/presentation/pages/statistics_equipment_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/pages/statistics_equipment_page.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; +import 'package:submersion/features/statistics/presentation/widgets/dive_trend_chart.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +void main() { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpPage(WidgetTester tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + weightTrendProvider.overrideWith( + (ref) async => List.generate( + 20, + (i) => TrendDataPoint( + date: DateTime.utc(2024, 1, 1).add(Duration(days: i * 7)), + value: 6.0 + (i % 3), + ), + ), + ), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StatisticsEquipmentPage(embedded: true), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('renders the weight trend as a per-dive chart', (tester) async { + await pumpPage(tester); + + expect(find.byType(DiveTrendChart), findsOneWidget); + expect( + find.byKey(const ValueKey('trend-aggregation-weight')), + findsOneWidget, + ); + }); + + testWidgets('starts in per-dive mode', (tester) async { + await pumpPage(tester); + + final chart = tester.widget(find.byType(DiveTrendChart)); + expect(chart.aggregation, TrendAggregation.none); + }); +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/pages/statistics_equipment_page_test.dart` +Expected: FAIL, "Expected: exactly one matching candidate Actual: zero widgets" + +- [ ] **Step 4: Replace `_buildWeightTrendSection`** + +Add the imports: + +```dart +import 'package:submersion/features/statistics/presentation/providers/trend_chart_settings_provider.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_chart_section.dart'; +``` + +Replace `_buildWeightTrendSection` (L72-98) in full with: + +```dart + Widget _buildWeightTrendSection( + BuildContext context, + WidgetRef ref, + UnitFormatter units, + ) { + return TrendChartSection( + chartId: TrendChartIds.weight, + title: context.l10n.statistics_equipment_weightTrend_title, + subtitle: context.l10n.statistics_equipment_weightTrend_subtitle, + pointsAsync: ref.watch(weightTrendProvider), + errorMessage: context.l10n.statistics_equipment_weightTrend_error, + lineColor: Colors.purple, + valueFormatter: (value) => units.formatWeight(value), + rateFormatter: (value) => units.formatWeight(value), + ); + } +``` + +- [ ] **Step 5: Run the test** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/pages/statistics_equipment_page_test.dart` +Expected: PASS, 2 tests + +- [ ] **Step 6: Format, analyze and commit** + +```bash +dart format . +flutter analyze 2>&1 | tail -20 +git add lib/features/statistics/presentation/pages/statistics_equipment_page.dart \ + lib/l10n/arb/ \ + test/features/statistics/presentation/pages/statistics_equipment_page_test.dart +git commit -m "feat(stats): plot total lead carried per dive + +The subtitle now names the quantity the chart actually shows, which changed +from a monthly average across weight rows to each dive's total lead. + +Refs #299" +``` + +--- + +## Task 17: Wire the Conditions page + +**Files:** +- Modify: `lib/features/statistics/presentation/pages/statistics_conditions_page.dart` (build list plus `_buildTemperatureSection` L193-262) +- Modify: `lib/l10n/arb/app_*.arb` (2 changed values, 4 new keys, 11 files) +- Test: `test/features/statistics/presentation/pages/statistics_conditions_temperature_test.dart` (create) + +**Interfaces:** +- Consumes: `TrendChartSection`, `TrendChartIds.waterTemp`, `waterTempTrendProvider` (Task 6), the existing `temperatureByMonthProvider`. +- Produces: nothing new. + +The seasonal chart is kept, not replaced. It genuinely shows real variation for a diver with one home region; it just needs to stop pretending to be a progression. + +- [ ] **Step 1: Add and rewrite the localization** + +In `lib/l10n/arb/app_en.arb`, change these two values: + +```json + "statistics_conditions_temperature_title": "Seasonal Water Temperature", + "statistics_conditions_temperature_subtitle": "Min, average and max by calendar month, across every year", +``` + +And add these four new keys: + +```json + "statistics_conditions_tempTrend_empty": "No temperature data available", + "statistics_conditions_tempTrend_error": "Failed to load temperature trend", + "statistics_conditions_tempTrend_subtitle": "Every dive in range", + "statistics_conditions_tempTrend_title": "Water Temperature Trend", +``` + +Apply the two changed values and the four new keys to the other 10 locales. + +- [ ] **Step 2: Verify parity, then regenerate** + +Run: `flutter test --exclude-tags performance test/l10n/arb_parity_test.dart` +Expected: PASS once all 11 files carry the four new keys + +Run: `flutter gen-l10n` + +- [ ] **Step 3: Write the failing test** + +Create `test/features/statistics/presentation/pages/statistics_conditions_temperature_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/statistics/domain/trend_aggregation.dart'; +import 'package:submersion/features/statistics/presentation/pages/statistics_conditions_page.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; +import 'package:submersion/features/statistics/presentation/widgets/dive_trend_chart.dart'; +import 'package:submersion/features/statistics/presentation/widgets/stat_charts.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +void main() { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpPage(WidgetTester tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + waterTempTrendProvider.overrideWith( + (ref) async => List.generate( + 20, + (i) => TrendDataPoint( + date: DateTime.utc(2024, 1, 1).add(Duration(days: i * 7)), + value: 10.0 + i, + ), + ), + ), + temperatureByMonthProvider.overrideWith( + (ref) async => [ + (month: 1, minTemp: 8.0, avgTemp: 10.0, maxTemp: 12.0), + (month: 7, minTemp: 24.0, avgTemp: 27.0, maxTemp: 29.0), + ], + ), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StatisticsConditionsPage(embedded: true), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('shows a per-dive temperature trend', (tester) async { + await pumpPage(tester); + + expect(find.text('Water Temperature Trend'), findsOneWidget); + expect(find.byType(DiveTrendChart), findsOneWidget); + }); + + testWidgets('keeps the seasonal chart, retitled', (tester) async { + await pumpPage(tester); + + expect(find.text('Seasonal Water Temperature'), findsOneWidget); + expect(find.byType(MultiTrendLineChart), findsOneWidget); + }); + + testWidgets('the trend chart starts in per-dive mode', (tester) async { + await pumpPage(tester); + + final chart = tester.widget(find.byType(DiveTrendChart)); + expect(chart.aggregation, TrendAggregation.none); + }); + + testWidgets('the seasonal subtitle says it collapses years', + (tester) async { + await pumpPage(tester); + + expect(find.textContaining('across every year'), findsOneWidget); + }); +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/pages/statistics_conditions_temperature_test.dart` +Expected: FAIL, "Expected: exactly one matching candidate Actual: zero widgets" for 'Water Temperature Trend' + +- [ ] **Step 5: Add the trend section and keep the seasonal one** + +Add the imports: + +```dart +import 'package:submersion/features/statistics/presentation/providers/trend_chart_settings_provider.dart'; +import 'package:submersion/features/statistics/presentation/widgets/trend_chart_section.dart'; +``` + +Add this new method beside `_buildTemperatureSection`: + +```dart + /// Water temperature as a time series, one point per dive. + /// + /// The sibling seasonal chart collapses every year into twelve calendar + /// buckets, which is meaningful for a diver with one home region and + /// meaningless for one who travels between cold and warm water (issue #299). + /// Both are kept because both readings are legitimate. + Widget _buildTemperatureTrendSection( + BuildContext context, + WidgetRef ref, + UnitFormatter units, + ) { + return TrendChartSection( + chartId: TrendChartIds.waterTemp, + title: context.l10n.statistics_conditions_tempTrend_title, + subtitle: context.l10n.statistics_conditions_tempTrend_subtitle, + pointsAsync: ref.watch(waterTempTrendProvider), + errorMessage: context.l10n.statistics_conditions_tempTrend_error, + lineColor: Colors.teal, + valueFormatter: (value) => units.formatTemperature(value), + rateFormatter: (value) => units.formatTemperature(value), + ); + } +``` + +Leave `_buildTemperatureSection` itself unchanged: it already reads its title and subtitle from the two keys whose values Step 1 rewrote. + +In the page's `build` list, insert the new section immediately before the existing temperature section, with the usual `const SizedBox(height: 16)` between them, so the time series is read first and the seasonal view sits below it as context. + +- [ ] **Step 6: Run the test** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/pages/` +Expected: PASS + +- [ ] **Step 7: Format, analyze and commit** + +```bash +dart format . +flutter analyze 2>&1 | tail -20 +git add lib/features/statistics/presentation/pages/statistics_conditions_page.dart \ + lib/l10n/arb/ \ + test/features/statistics/presentation/pages/statistics_conditions_temperature_test.dart +git commit -m "feat(stats): add a per-dive water temperature trend + +The twelve-bucket calendar chart is kept and retitled Seasonal Water +Temperature, with a subtitle stating that it collapses every year. A +travelling diver now gets the honest series; a diver with one home region +keeps the seasonal read. + +Refs #299" +``` + +--- + +## Task 18: Last 5 Years and Last 10 Years date presets + +**Files:** +- Modify: `lib/features/dive_log/presentation/widgets/dive_filter_sheet.dart:274-285` (insert after the "Last Year" chip) +- Modify: `lib/l10n/arb/app_*.arb` (2 new keys, 11 files) +- Test: `test/features/dive_log/presentation/widgets/dive_filter_sheet_presets_test.dart` (create) + +**Interfaces:** +- Consumes: the existing `_datePresetChip` helper at L1116-1127. +- Produces: two new l10n keys, `diveLog_filter_presetLast5Years` and `diveLog_filter_presetLast10Years`. + +The sheet is shared with the dive list filter, so these presets appear there too. That is a deliberate, accepted side effect outside the Statistics tab. + +- [ ] **Step 1: Add the localization** + +In `lib/l10n/arb/app_en.arb`: + +```json + "diveLog_filter_presetLast10Years": "Last 10 Years", + "diveLog_filter_presetLast5Years": "Last 5 Years", +``` + +Add translations of both keys to the other 10 locales. + +Run: `flutter test --exclude-tags performance test/l10n/arb_parity_test.dart` +Expected: PASS + +Run: `flutter gen-l10n` + +- [ ] **Step 2: Write the failing test** + +Create `test/features/dive_log/presentation/widgets/dive_filter_sheet_presets_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_filter_sheet.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_filter_provider.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +void main() { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpSheet(WidgetTester tester) async { + final overrides = await getBaseOverrides(); + final container = ProviderContainer(overrides: overrides.cast()); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Consumer( + builder: (context, ref, _) => Scaffold( + body: SingleChildScrollView( + child: DiveFilterSheet( + ref: ref, + filterProvider: statisticsFilterProvider, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return container; + } + + testWidgets('offers the two new long-range presets', (tester) async { + await pumpSheet(tester); + + expect(find.text('Last 5 Years'), findsOneWidget); + expect(find.text('Last 10 Years'), findsOneWidget); + }); + + testWidgets('Last 5 Years sets a start date about five years back', + (tester) async { + await pumpSheet(tester); + + await tester.tap(find.text('Last 5 Years')); + await tester.pumpAndSettle(); + + // The chip sets local state; the sheet's apply button commits it. Read + // the rendered start-date button label rather than the provider. + final now = DateTime.now(); + expect(find.textContaining('${now.year - 5}'), findsWidgets); + }); + + testWidgets('All Time still clears both dates', (tester) async { + await pumpSheet(tester); + + await tester.tap(find.text('Last 5 Years')); + await tester.pumpAndSettle(); + await tester.tap(find.text('All Time')); + await tester.pumpAndSettle(); + + expect(find.text('Start Date'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + }); +} +``` + +If the sheet's start/end button placeholder text differs from `Start Date` / `End Date`, read the current values of `diveLog_filter_startDate` and `diveLog_filter_endDate` in `app_en.arb` and use those exact strings. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/dive_log/presentation/widgets/dive_filter_sheet_presets_test.dart` +Expected: FAIL, "Expected: exactly one matching candidate Actual: zero widgets" for 'Last 5 Years' + +- [ ] **Step 4: Add the two chips** + +In `dive_filter_sheet.dart`, inside the `Wrap` at L227-286, insert immediately after the "Last Year" chip (which ends at L284) and before the closing `],`: + +```dart + _datePresetChip( + context, + context.l10n.diveLog_filter_presetLast5Years, + () { + final now = DateTime.now(); + setState(() { + _startDate = DateTime( + now.year - 5, + now.month, + now.day, + ); + _endDate = DateTime( + now.year, + now.month, + now.day, + ); + }); + }, + ), + _datePresetChip( + context, + context.l10n.diveLog_filter_presetLast10Years, + () { + final now = DateTime.now(); + setState(() { + _startDate = DateTime( + now.year - 10, + now.month, + now.day, + ); + _endDate = DateTime( + now.year, + now.month, + now.day, + ); + }); + }, + ), +``` + +"Lifetime" needs no chip: the existing "All Time" preset already nulls both dates, and with the five-year cutoffs gone from Task 3 onward that now genuinely means every dive. + +- [ ] **Step 5: Run the test** + +Run: `flutter test --exclude-tags performance test/features/dive_log/presentation/widgets/dive_filter_sheet_presets_test.dart` +Expected: PASS, 3 tests + +- [ ] **Step 6: Format, analyze and commit** + +```bash +dart format . +flutter analyze 2>&1 | tail -20 +git add lib/features/dive_log/presentation/widgets/dive_filter_sheet.dart \ + lib/l10n/arb/ \ + test/features/dive_log/presentation/widgets/dive_filter_sheet_presets_test.dart +git commit -m "feat(filter): add Last 5 Years and Last 10 Years date presets + +Completes the preset list issue #299 asked for. Lifetime needs no chip: All +Time already nulls both dates, and that now reaches every dive. + +Refs #299" +``` + +--- + +## Task 19: Filter affordance on the statistics detail pages + +**Files:** +- Create: `lib/features/statistics/presentation/widgets/statistics_filter_action.dart` +- Modify: `lib/features/statistics/presentation/pages/statistics_progression_page.dart`, `statistics_gas_page.dart`, `statistics_equipment_page.dart`, `statistics_conditions_page.dart` (each `Scaffold`/`AppBar`) +- Test: `test/features/statistics/presentation/widgets/statistics_filter_action_test.dart` (create) + +**Interfaces:** +- Consumes: `statisticsFilterProvider`, `DiveFilterSheet`, `StatisticsFilterBar`. +- Produces: `class StatisticsFilterAction extends ConsumerWidget { const StatisticsFilterAction({super.key}); }` + +Today none of these pages has a filter control. On mobile you must set the span on `/statistics` and then drill in, which makes the span the whole feature depends on unreachable from the chart it governs. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/statistics/presentation/widgets/statistics_filter_action_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_filter_sheet.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_filter_provider.dart'; +import 'package:submersion/features/statistics/presentation/widgets/statistics_filter_action.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +void main() { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpAction( + WidgetTester tester, { + DiveFilterState filter = const DiveFilterState(), + }) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + statisticsFilterProvider.overrideWith((ref) => filter), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + appBar: AppBar(actions: const [StatisticsFilterAction()]), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('renders an unbadged filter icon when no filter is active', + (tester) async { + await pumpAction(tester); + + expect(find.byKey(const ValueKey('statistics-filter-action')), + findsOneWidget); + final badge = tester.widget(find.byType(Badge)); + expect(badge.isLabelVisible, isFalse); + }); + + testWidgets('badges the icon when a filter is active', (tester) async { + await pumpAction( + tester, + filter: DiveFilterState(startDate: DateTime.utc(2024, 1, 1)), + ); + + final badge = tester.widget(find.byType(Badge)); + expect(badge.isLabelVisible, isTrue); + }); + + testWidgets('tapping it opens the filter sheet', (tester) async { + await pumpAction(tester); + + await tester.tap(find.byKey(const ValueKey('statistics-filter-action'))); + await tester.pumpAndSettle(); + + expect(find.byType(DiveFilterSheet), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/statistics_filter_action_test.dart` +Expected: FAIL, `Target of URI doesn't exist` + +- [ ] **Step 3: Write the implementation** + +Create `lib/features/statistics/presentation/widgets/statistics_filter_action.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/dive_log/presentation/widgets/dive_filter_sheet.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_filter_provider.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// AppBar action opening the statistics filter, badged while a filter is set. +/// +/// Extracted from the three places that had hand-rolled the same block. The +/// statistics detail pages had no filter affordance at all, so on a phone the +/// span governing a chart could only be changed from the tab root and then +/// drilled back into (issue #299). +class StatisticsFilterAction extends ConsumerWidget { + const StatisticsFilterAction({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + key: const ValueKey('statistics-filter-action'), + icon: Badge( + isLabelVisible: ref.watch(statisticsFilterProvider).hasActiveFilters, + child: const Icon(Icons.filter_list), + ), + tooltip: context.l10n.statistics_tooltip_filter, + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => DiveFilterSheet( + ref: ref, + filterProvider: statisticsFilterProvider, + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test --exclude-tags performance test/features/statistics/presentation/widgets/statistics_filter_action_test.dart` +Expected: PASS, 3 tests + +- [ ] **Step 5: Mount it on the four detail pages** + +In each of `statistics_progression_page.dart`, `statistics_gas_page.dart`, `statistics_equipment_page.dart` and `statistics_conditions_page.dart`, add the imports: + +```dart +import 'package:submersion/features/statistics/presentation/widgets/statistics_filter_action.dart'; +import 'package:submersion/features/statistics/presentation/widgets/statistics_filter_bar.dart'; +``` + +Then replace each page's non-embedded return. For the conditions page, for example, replace: + +```dart + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.statistics_conditions_appBar_title), + ), + body: content, + ); +``` + +with: + +```dart + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.statistics_conditions_appBar_title), + actions: const [StatisticsFilterAction()], + ), + body: Column( + children: [ + const StatisticsFilterBar(), + Expanded(child: content), + ], + ), + ); +``` + +Apply the same shape to the other three pages, substituting their own AppBar title key. The `embedded` branch is left untouched: in the master-detail layout the list pane already owns the filter icon and the bar, so a second copy would be redundant. + +`Expanded(child: content)` is required because `content` is a `SingleChildScrollView`; without it the Column gives the scroll view unbounded height and the page throws a layout error. + +- [ ] **Step 6: Replace the three existing duplicated blocks** + +Now that the widget exists, replace the hand-rolled filter `IconButton` in each of `statistics_page.dart:102-118`, `statistics_list_content.dart:167-181` and `statistics_list_content.dart:221-235` with `const StatisticsFilterAction()`. Remove any imports those files no longer need. + +This duplication was recorded as a follow-up when the filter shipped; adding a fourth copy without collapsing them would make it worse. + +- [ ] **Step 7: Run the affected tests** + +Run: `flutter test --exclude-tags performance test/features/statistics/` +Expected: PASS + +Run: `flutter analyze 2>&1 | tail -20` +Expected: "No issues found!" + +- [ ] **Step 8: Format and commit** + +```bash +dart format . +git add lib/features/statistics/presentation/widgets/statistics_filter_action.dart \ + lib/features/statistics/presentation/pages/ \ + lib/features/statistics/presentation/widgets/statistics_list_content.dart \ + test/features/statistics/presentation/widgets/statistics_filter_action_test.dart +git commit -m "feat(stats): put the filter on the statistics detail pages + +Extracts the filter action that three call sites had duplicated and mounts it +on the four chart pages, which previously had no filter affordance at all: on +a phone the span governing a chart could only be set from the tab root. + +Refs #299" +``` + +--- + +## Task 20: Whole-suite verification + +**Files:** none changed unless a failure requires it. + +**Interfaces:** +- Consumes: everything. +- Produces: a branch ready for a pull request. + +- [ ] **Step 1: Confirm no hardcoded window survives** + +Run: + +```bash +grep -rn "365 \* 5\|365\*5" lib/features/statistics/ +``` + +Expected: no output. Any hit is a cutoff that survived, and "lifetime" is still unreachable through it. + +- [ ] **Step 2: Confirm no per-dive query aggregates by month** + +Run: + +```bash +grep -n "GROUP BY year, month" lib/features/statistics/data/repositories/statistics_repository.dart +``` + +Expected: only `getCumulativeDiveCount`, which is deliberately still monthly and out of scope. + +- [ ] **Step 3: Confirm no provider leaked outside the feature** + +Run: + +```bash +grep -rn "waterTempTrendProvider\|trendChartSettingsProvider\|DiveTrendChart\|TrendChartSection" lib/ | grep -v "features/statistics/" +``` + +Expected: no output. + +- [ ] **Step 4: Format the whole project** + +Run: `dart format .` +Expected: files reformatted or already formatted; commit any change. + +- [ ] **Step 5: Analyze the whole project** + +Run: `flutter analyze 2>&1 | tail -20` +Expected: "No issues found!" + +Do not pipe this into `grep`: a pipe returns grep's exit status and masks a failure. Infos are fatal in CI, so treat any output as a failure. + +- [ ] **Step 6: Run the full test suite once** + +Run: `flutter test --exclude-tags performance` +Expected: all tests pass + +Run it once, and do not run a second suite concurrently in another worktree: overlapping runs on this machine produce spurious single-file failures through shared temp directories. If exactly one unfamiliar test fails, rerun that file alone before investigating. + +- [ ] **Step 7: Verify the generated localizations are current** + +Run: `flutter gen-l10n && git status --short lib/l10n/` +Expected: no modified generated files. CI regenerates l10n but never verifies it, so a stale generated file would not be caught downstream. + +- [ ] **Step 8: Commit anything the previous steps changed** + +```bash +git add lib/ test/ +git commit -m "chore(stats): formatting and generated localizations + +Refs #299" +``` + +- [ ] **Step 9: Manual smoke check** + +Launch the app on macOS and confirm, on a real logbook: + +1. Statistics > Progression shows individual dive dots, not a monthly line. +2. The aggregation dropdown switches to Weekly and Monthly, and the shaded band appears. +3. Tapping the rate legend draws the dashed fit and shows a per-year figure in the diver's depth unit. +4. The filter icon is present on the Progression, Gas, Equipment and Conditions pages. +5. Setting "All Time" reveals dives older than five years. +6. Conditions shows both Water Temperature Trend and Seasonal Water Temperature. +7. Switching the diver's units from metric to imperial changes the chart axis, the tooltip and the rate figure together. + +Note that `open_application` launches the installed app, not this worktree's build; build and run from this worktree explicitly. + +--- + +## Self-review notes + +Checked against the spec, 2026-08-27: + +- **Spec coverage.** Every spec section maps to a task: domain module (1, 2), repository rewrite (3, 4, 5), providers (6), date axis and chart (7, 10, 11, 12), control strip (9), pages (14, 15, 16, 17), span control (18), filter affordance (19), localization (8 plus per-page steps), testing (throughout), verification (20). The spec's "no schema change" needs no task. +- **One deliberate addition.** The spec did not name `TrendChartSection`; it emerged from the four pages needing identical wiring. Task 13 carries it. Without it, Tasks 14 through 17 would each repeat forty lines of the same code. +- **One deliberate correction.** The spec said `getWeightPerDive` returns weight per dive without saying which statistic. Task 4 uses `SUM` over a dive's weight rows and says why the old `AVG` was wrong. The subtitle rewrite in Task 16 follows from it. +- **Type consistency.** `TrendDataPoint` is defined once in Task 1 and re-exported; `TrendAggregation`, `TrendBucket` and `LinearFit` are used with the same names and members throughout. `TrendChartIds` constants (`depth`, `bottomTime`, `sac`, `weight`, `waterTemp`) match the widget keys asserted in Tasks 14 through 17. +- **Ordering constraint.** Task 12 appends the overlay series after the band series because Task 11's `_bands` addresses indices 1 and 2 by position. Both tasks state this. diff --git a/docs/superpowers/plans/2026-08-28-exclude-dive-from-statistics.md b/docs/superpowers/plans/2026-08-28-exclude-dive-from-statistics.md new file mode 100644 index 0000000000..e1b9d84643 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-exclude-dive-from-statistics.md @@ -0,0 +1,2419 @@ +# Exclude a Dive from Statistics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a diver mark a dive as excluded from all statistics, or from gas statistics only, while keeping it fully visible and editable in the logbook. + +**Architecture:** Two boolean columns on `dives`, enforced by a single shared SQL predicate helper (`DiveStatsScope`) that every descriptive aggregate query interpolates. The helper is applied *orthogonally* to the diver's `DiveFilterState` view filter, never inside it, so the exclusion cannot evaporate when no view filter is active. A source-census test prevents future aggregates from silently skipping the scope. + +**Tech Stack:** Flutter, Drift (SQLite), Riverpod, raw `customSelect` SQL throughout. + +**Spec:** `docs/superpowers/specs/2026-08-28-exclude-dive-from-statistics-design.md` + +## Global Constraints + +- **Schema version 178.** Main is at 175; 176 is claimed by open PR #1328, 177 by open PR #1361. Before pushing, re-run BOTH claim scans (open-PR diffs and every worktree's working-tree scalar). A rung claimed below main's scalar merges with no conflict marker and its migration step then silently never runs. +- **Worktree:** all work happens in `.claude/worktrees/issue-526-exclude-from-stats` on branch `worktree-issue-526-exclude-from-stats`. Use absolute paths or `git -C`; the Bash working directory resets to the main checkout between calls. +- **No em-dashes** (U+2014) in any output: code, comments, docs, commit messages. No en-dashes as prose punctuation, no double hyphens, no spaced hyphens as punctuation. +- **No emojis** in code, comments, or documentation. +- **Localization:** every new user-facing string needs keys in all eleven locales: `ar de en es fr he hu it nl pt zh`. English-only additions fail `arb_parity_test` against every other locale. +- **Immutability:** never mutate objects or arrays. All domain entities have `copyWith`. +- **TDD:** write the failing test first, watch it fail, then implement. +- **Commit frequently**, one commit per task minimum. No `Co-Authored-By` trailer. No Claude Code attribution or session URL in commit messages or PR bodies. +- **Never run `git add -A` or `git add -u`** in this shared checkout. Stage explicit paths only; `-A` sweeps a sibling session's uncommitted work into your commit. +- **Never pipe `flutter test` into `grep`.** The pipeline returns grep's exit status, not the test runner's. +- **Do not overlap local test runs.** Two concurrent runs fake a lone failure. + +--- + +## File Structure + +**Created:** + +| File | Responsibility | +| --- | --- | +| `lib/core/database/dive_stats_scope.dart` | The single canonical SQL predicate for "which dives count as statistics". Pure string builder, no I/O. | +| `test/core/database/dive_stats_scope_test.dart` | Unit tests for the predicate strings. | +| `test/core/database/dive_stats_scope_census_test.dart` | Source census: every `FROM dives` query either carries the scope or is explicitly marked exempt. | +| `test/features/statistics/dive_stats_scope_behavior_test.dart` | Behavioral guard suite over a seeded fixture, covering every descriptive aggregate. | +| `test/features/dive_log/dive_stats_exclusion_migration_test.dart` | v177 to v178 migration, backstop idempotency, minimal-schema self-guard. | + +**Modified (principal):** + +| File | Change | +| --- | --- | +| `lib/core/database/database.dart` | Two columns on `Dives`, `currentSchemaVersion` to 178, `_assertDiveStatsExclusionColumns()`, `onUpgrade` rung, `beforeOpen` backstop. | +| `lib/features/statistics/data/repositories/statistics_repository.dart` | `_diveFilter` emits the scope unconditionally; four hand patches; seven gas queries switch to `gas: true`. | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart` | Five aggregate methods get the scope; entity mappers and companions carry the two flags. | +| `lib/features/dive_log/domain/entities/dive.dart` | Two fields, constructor defaults, `copyWith`, equality list. | +| `lib/features/dive_log/domain/models/dive_filter_state.dart` | `excludedFromStatsOnly` axis. | +| `lib/features/dive_log/presentation/pages/dive_edit_page.dart` | Two checkboxes in The Dive section; two gated bulk-edit rows. | +| `lib/features/dive_log/presentation/pages/bulk_edit_field_set.dart` | Two `BulkField` values, two `BulkScalarInputs` fields. | +| Nine per-entity repositories | Scope added to descriptive count queries (Task 6). | +| `lib/l10n/arb/app_*.arb` (11 files) | New keys. | + +--- + +### Task 1: Schema columns and migration + +**Files:** +- Modify: `lib/core/database/database.dart` (Dives table ~line 731, `currentSchemaVersion` line 3291, helper near `_assertBuddyFavoriteColumn` ~line 5412, `onUpgrade` rung ~line 9073, `beforeOpen` backstop ~line 9310) +- Test: `test/features/dive_log/dive_stats_exclusion_migration_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces: columns `dives.excluded_from_stats` and `dives.excluded_from_gas_stats`, both `INTEGER NOT NULL DEFAULT 0`. Drift getters `excludedFromStats` and `excludedFromGasStats` of type `BoolColumn`. Method `AppDatabase._assertDiveStatsExclusionColumns()`. + +- [ ] **Step 1: Write the failing migration test** + +Create `test/features/dive_log/dive_stats_exclusion_migration_test.dart`: + +```dart +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + Future> diveColumnNames(AppDatabase database) async { + final cols = await database + .customSelect("PRAGMA table_info('dives')") + .get(); + return cols.map((c) => c.read('name')).toSet(); + } + + test('schema version is 178', () { + expect(AppDatabase.currentSchemaVersion, 178); + }); + + test('a fresh database has both exclusion columns defaulting to 0', () async { + final names = await diveColumnNames(db); + expect(names, contains('excluded_from_stats')); + expect(names, contains('excluded_from_gas_stats')); + + await db.customStatement( + "INSERT INTO dives (id, dive_date_time) VALUES ('d1', 0)", + ); + final row = await db + .customSelect( + 'SELECT excluded_from_stats, excluded_from_gas_stats ' + "FROM dives WHERE id = 'd1'", + ) + .getSingle(); + expect(row.read('excluded_from_stats'), 0); + expect(row.read('excluded_from_gas_stats'), 0); + }); + + test('the column assert is idempotent across repeated calls', () async { + await db.assertDiveStatsExclusionColumnsForTesting(); + await db.assertDiveStatsExclusionColumnsForTesting(); + final names = await diveColumnNames(db); + expect(names, contains('excluded_from_stats')); + expect(names, contains('excluded_from_gas_stats')); + }); + + test('the column assert self-guards when dives does not exist', () async { + await db.customStatement('DROP TABLE dives'); + await expectLater( + db.assertDiveStatsExclusionColumnsForTesting(), + completes, + ); + }); + + test('an upgrade from v177 adds both columns to an existing dives table', + () async { + await db.customStatement('DROP TABLE dives'); + await db.customStatement( + 'CREATE TABLE dives (id TEXT NOT NULL PRIMARY KEY, ' + 'dive_date_time INTEGER NOT NULL, ' + 'is_planned INTEGER NOT NULL DEFAULT 0)', + ); + await db.customStatement( + "INSERT INTO dives (id, dive_date_time) VALUES ('legacy', 0)", + ); + + await db.assertDiveStatsExclusionColumnsForTesting(); + + final row = await db + .customSelect( + 'SELECT excluded_from_stats, excluded_from_gas_stats ' + "FROM dives WHERE id = 'legacy'", + ) + .getSingle(); + expect(row.read('excluded_from_stats'), 0, + reason: 'pre-existing rows must default to included'); + expect(row.read('excluded_from_gas_stats'), 0); + }); +} +``` + +Note: if `AppDatabase.forTesting` does not exist under that exact name in this repo, use whatever constructor the neighbouring migration tests in `test/` already use. Find it with: + +```bash +grep -rn "AppDatabase(" test/ --include='*.dart' | head -5 +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/issue-526-exclude-from-stats +flutter test test/features/dive_log/dive_stats_exclusion_migration_test.dart +``` + +Expected: FAIL. `AppDatabase.currentSchemaVersion` is 175, the columns do not exist, and `assertDiveStatsExclusionColumnsForTesting` is undefined. + +- [ ] **Step 3: Add the two columns to the Dives table** + +In `lib/core/database/database.dart`, immediately after the `isFavorite` getter (~line 731): + +```dart + // Statistics exclusion (schema v178, issues #526 and #1272). + // excludedFromStats is the master flag: the dive stays in the logbook but + // contributes to no descriptive aggregate, its count included. + // excludedFromGasStats drops the dive from SAC/RMV and gas-mix aggregates + // only, for an otherwise ordinary dive whose gas number is unrepresentative + // (for example purging the tank for an end-of-dive weight check). + // The master flag implies the gas flag; see DiveStatsScope. + BoolColumn get excludedFromStats => + boolean().withDefault(const Constant(false))(); + BoolColumn get excludedFromGasStats => + boolean().withDefault(const Constant(false))(); +``` + +- [ ] **Step 4: Bump the schema version** + +At `lib/core/database/database.dart:3291`, change: + +```dart + static const int currentSchemaVersion = 175; +``` + +to: + +```dart + static const int currentSchemaVersion = 178; +``` + +- [ ] **Step 5: Add the idempotent column-assert helper** + +In `lib/core/database/database.dart`, next to `_assertBuddyFavoriteColumn` (~line 5412): + +```dart + /// Idempotent DDL for the v178 dives.excluded_from_stats and + /// dives.excluded_from_gas_stats columns (issues #526 and #1272), letting a + /// diver keep a dive in the logbook while removing it from statistics. + /// Self-guards on the table existing, and defaults every pre-existing row to + /// included. Same dual-call contract (onUpgrade + beforeOpen backstop) as + /// the other column-assert helpers. + Future _assertDiveStatsExclusionColumns() async { + final cols = await customSelect("PRAGMA table_info('dives')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('excluded_from_stats')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN excluded_from_stats ' + 'INTEGER NOT NULL DEFAULT 0', + ); + } + if (!names.contains('excluded_from_gas_stats')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN excluded_from_gas_stats ' + 'INTEGER NOT NULL DEFAULT 0', + ); + } + } + + /// Test-only entry point for [_assertDiveStatsExclusionColumns]. + @visibleForTesting + Future assertDiveStatsExclusionColumnsForTesting() => + _assertDiveStatsExclusionColumns(); +``` + +If `@visibleForTesting` is not already imported in this file, add `import 'package:meta/meta.dart';` to the import block. Check first: + +```bash +grep -n "visibleForTesting" lib/core/database/database.dart | head -3 +``` + +- [ ] **Step 6: Add the onUpgrade rung** + +In `onUpgrade`, immediately after the `if (from < 175) await reportProgress();` line (~line 9073): + +```dart + // v178: dives.excluded_from_stats and dives.excluded_from_gas_stats + // (issues #526 and #1272). Column-only rung, no backfill: every + // pre-existing row correctly defaults to included. + if (from < 178) { + await _assertDiveStatsExclusionColumns(); + } + if (from < 178) await reportProgress(); +``` + +- [ ] **Step 7: Add the beforeOpen backstop** + +In `beforeOpen`, immediately after the v175 backstop call (~line 9310): + +```dart + // v178 backstop: re-assert the dives statistics-exclusion columns + // (same parallel-branch version-collision self-heal). Safe to re-run + // on every open: the helper is column-only with no backfill, so it + // cannot resurrect or overwrite diver data. + await _assertDiveStatsExclusionColumns(); +``` + +- [ ] **Step 8: Regenerate Drift code** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/issue-526-exclude-from-stats +dart run build_runner build --delete-conflicting-outputs +``` + +Expected: `database.g.dart` regenerates with `excludedFromStats` and `excludedFromGasStats` on both the table class and the data class. If only one of the two has them, the build was interrupted; re-run before continuing. + +- [ ] **Step 9: Run the migration test to verify it passes** + +```bash +flutter test test/features/dive_log/dive_stats_exclusion_migration_test.dart +``` + +Expected: PASS, all five tests. + +- [ ] **Step 10: Commit** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/issue-526-exclude-from-stats +git add lib/core/database/database.dart lib/core/database/database.g.dart \ + test/features/dive_log/dive_stats_exclusion_migration_test.dart +git commit -m "feat(db): add dives statistics-exclusion columns at schema v178 + +Two boolean columns, excluded_from_stats (master) and +excluded_from_gas_stats (SAC/RMV only), both defaulting to included. +Column-only rung with a beforeOpen backstop; no backfill needed." +``` + +--- + +### Task 2: The DiveStatsScope predicate helper + +**Files:** +- Create: `lib/core/database/dive_stats_scope.dart` +- Test: `test/core/database/dive_stats_scope_test.dart` + +**Interfaces:** +- Consumes: the column names from Task 1. +- Produces: `DiveStatsScope.predicate({String alias = 'd', bool gas = false}) -> String` and `DiveStatsScope.and({String alias = 'd', bool gas = false}) -> String`. `and()` returns `predicate()` prefixed with a leading space and `AND `, for appending into an existing WHERE clause. Both are `static`; the class is not instantiable. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/database/dive_stats_scope_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/dive_stats_scope.dart'; + +void main() { + group('DiveStatsScope.predicate', () { + test('defaults to alias d and the two descriptive rules', () { + expect( + DiveStatsScope.predicate(), + 'd.excluded_from_stats = 0 AND d.is_planned = 0', + ); + }); + + test('honours a custom alias on every term', () { + expect( + DiveStatsScope.predicate(alias: 'd2'), + 'd2.excluded_from_stats = 0 AND d2.is_planned = 0', + ); + }); + + test('the gas variant adds the gas flag and the gauge-mode rule', () { + expect( + DiveStatsScope.predicate(gas: true), + 'd.excluded_from_stats = 0 AND d.is_planned = 0 ' + "AND d.excluded_from_gas_stats = 0 AND d.dive_mode <> 'gauge'", + ); + }); + + test('the gas variant honours a custom alias on every term', () { + final sql = DiveStatsScope.predicate(alias: 'dives', gas: true); + expect(sql.contains('d.'), isFalse, + reason: 'no term may fall back to the default alias'); + expect(sql, contains('dives.excluded_from_gas_stats = 0')); + expect(sql, contains("dives.dive_mode <> 'gauge'")); + }); + }); + + group('DiveStatsScope.and', () { + test('prefixes the predicate for appending into an existing WHERE', () { + expect( + DiveStatsScope.and(), + ' AND d.excluded_from_stats = 0 AND d.is_planned = 0', + ); + }); + + test('carries alias and gas through to the predicate', () { + expect( + DiveStatsScope.and(alias: 'x', gas: true), + ' AND ${DiveStatsScope.predicate(alias: 'x', gas: true)}', + ); + }); + + test('never emits a bind placeholder', () { + expect(DiveStatsScope.and(gas: true).contains('?'), isFalse, + reason: 'callers must not have to thread params for the scope'); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/core/database/dive_stats_scope_test.dart +``` + +Expected: FAIL with "Target of URI doesn't exist: package:submersion/core/database/dive_stats_scope.dart". + +- [ ] **Step 3: Write the implementation** + +Create `lib/core/database/dive_stats_scope.dart`: + +```dart +/// The canonical SQL predicate deciding which dives contribute to +/// *descriptive* statistics. +/// +/// Two rules always apply: +/// +/// - `excluded_from_stats = 0`: the diver ticked "exclude from statistics" +/// (issue #526), for example a 90 minute session at 12 ft that is not an +/// official dive by most agency standards. +/// - `is_planned = 0`: a dive the planner created that has not happened. +/// Before schema v178 these counted toward every total, which was a bug. +/// +/// The [gas] variant adds two more, for SAC/RMV and gas-mix aggregates: +/// +/// - `excluded_from_gas_stats = 0`: the diver ticked "exclude from gas +/// statistics" (issue #1272), for example purging the tank down to 500 psi +/// for an end-of-dive weight check. +/// - `dive_mode <> 'gauge'`: gauge-mode dives carry no usable gas data. This +/// rule predates v178 and was hand-copied into seven queries in +/// StatisticsRepository; it now lives here. +/// +/// **This is deliberately NOT folded into `buildFilteredDiveIdSubquery`.** +/// That function implements the diver's transient *view* filter and correctly +/// returns an empty no-op when no axis is active. This scope is a persistent +/// property of the dive and must apply unconditionally. Merging the two would +/// either make the exclusion evaporate for every diver who never opens the +/// filter sheet, or silently scope the deliberately-unfiltered surfaces +/// (dashboard quick stats, dive-log summary, species detail page). +/// +/// **Operational counts deliberately ignore this scope.** Equipment service +/// intervals, course-requirement progress, and the logbook list header all +/// count excluded dives on purpose; each carries a doc comment saying so. A +/// practice dive still cycled the regulator, and a dive the diver linked to a +/// course requirement was linked on purpose. +/// +/// See `docs/superpowers/specs/2026-08-28-exclude-dive-from-statistics-design.md`. +class DiveStatsScope { + const DiveStatsScope._(); + + /// The bare predicate, with no leading conjunction. Use when the query has + /// no WHERE clause yet: `WHERE ${DiveStatsScope.predicate()}`. + /// + /// [alias] must match the alias the query already gives the `dives` table + /// (or `dives` itself when the query does not alias it). Queries that join + /// `dives` to itself need one call per alias. + static String predicate({String alias = 'd', bool gas = false}) { + final parts = [ + '$alias.excluded_from_stats = 0', + '$alias.is_planned = 0', + ]; + if (gas) { + parts.add('$alias.excluded_from_gas_stats = 0'); + parts.add("$alias.dive_mode <> 'gauge'"); + } + return parts.join(' AND '); + } + + /// The predicate prefixed with ` AND `, for appending into an existing + /// WHERE clause. Emits no bind placeholders, so callers never thread params + /// for the scope. + static String and({String alias = 'd', bool gas = false}) => + ' AND ${predicate(alias: alias, gas: gas)}'; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +flutter test test/core/database/dive_stats_scope_test.dart +``` + +Expected: PASS, all seven tests. + +- [ ] **Step 5: Commit** + +```bash +git add lib/core/database/dive_stats_scope.dart \ + test/core/database/dive_stats_scope_test.dart +git commit -m "feat(db): add DiveStatsScope, the canonical statistics predicate + +One place deciding which dives count as statistics. Absorbs the +gauge-mode rule that was hand-copied into seven gas queries, and adds +is_planned, which no aggregate filtered on before." +``` + +--- + +### Task 3: Entity and mapper plumbing + +**Files:** +- Modify: `lib/features/dive_log/domain/entities/dive.dart` (fields ~line 120, constructor ~line 235, `copyWith` params ~line 587, `copyWith` body ~line 682, props list ~line 780) +- Modify: `lib/features/dive_log/domain/entities/dive_summary.dart` +- Modify: `lib/features/dive_log/domain/services/dive_merge_builder.dart` +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (mappers ~3562 and ~3944, companions ~1409 and ~1664) +- Test: `test/features/dive_log/dive_stats_exclusion_entity_test.dart` + +**Interfaces:** +- Consumes: `dives.excludedFromStats` / `excludedFromGasStats` from Task 1. +- Produces: `Dive.excludedFromStats` and `Dive.excludedFromGasStats`, both `final bool`, defaulting to `false` in the constructor, present in `copyWith` as `bool?` and in the equality props list. Same two fields on `DiveSummary`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/dive_stats_exclusion_entity_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +void main() { + Dive makeDive() => Dive(id: 'd1', diveDateTime: DateTime(2026, 1, 1)); + + test('both exclusion flags default to false', () { + final dive = makeDive(); + expect(dive.excludedFromStats, isFalse); + expect(dive.excludedFromGasStats, isFalse); + }); + + test('copyWith sets each flag independently', () { + final dive = makeDive(); + expect(dive.copyWith(excludedFromStats: true).excludedFromStats, isTrue); + expect( + dive.copyWith(excludedFromStats: true).excludedFromGasStats, + isFalse, + reason: 'the master flag must not write through to the gas flag; ' + 'the implication lives in SQL, not in the entity', + ); + expect( + dive.copyWith(excludedFromGasStats: true).excludedFromGasStats, + isTrue, + ); + }); + + test('copyWith preserves the flags when they are not passed', () { + final excluded = makeDive().copyWith( + excludedFromStats: true, + excludedFromGasStats: true, + ); + final renamed = excluded.copyWith(notes: 'changed'); + expect(renamed.excludedFromStats, isTrue); + expect(renamed.excludedFromGasStats, isTrue); + }); + + test('the flags participate in equality', () { + final a = makeDive(); + final b = makeDive().copyWith(excludedFromStats: true); + expect(a, isNot(equals(b))); + }); +} +``` + +If `Dive`'s required constructor arguments differ from `id` and `diveDateTime`, adjust `makeDive()` to the minimal valid set. Check with: + +```bash +sed -n '225,245p' lib/features/dive_log/domain/entities/dive.dart +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/features/dive_log/dive_stats_exclusion_entity_test.dart +``` + +Expected: FAIL, "The getter 'excludedFromStats' isn't defined for the class 'Dive'". + +- [ ] **Step 3: Add the fields to the Dive entity** + +In `lib/features/dive_log/domain/entities/dive.dart`, immediately after `final bool isFavorite;` (~line 120): + +```dart + /// Excluded from every descriptive statistic, its count included (#526). + /// The dive stays fully visible and editable in the logbook. + final bool excludedFromStats; + + /// Excluded from SAC/RMV and gas-mix aggregates only (#1272), for a dive + /// whose gas number is unrepresentative. Implied by [excludedFromStats]; + /// the implication is applied in SQL by DiveStatsScope, not here. + final bool excludedFromGasStats; +``` + +After `this.isFavorite = false,` in the constructor (~line 235): + +```dart + this.excludedFromStats = false, + this.excludedFromGasStats = false, +``` + +After `bool? isFavorite,` in the `copyWith` signature (~line 587): + +```dart + bool? excludedFromStats, + bool? excludedFromGasStats, +``` + +After `isFavorite: isFavorite ?? this.isFavorite,` in the `copyWith` body (~line 682): + +```dart + excludedFromStats: excludedFromStats ?? this.excludedFromStats, + excludedFromGasStats: excludedFromGasStats ?? this.excludedFromGasStats, +``` + +After `isFavorite,` in the props list (~line 780): + +```dart + excludedFromStats, + excludedFromGasStats, +``` + +- [ ] **Step 4: Run the entity test to verify it passes** + +```bash +flutter test test/features/dive_log/dive_stats_exclusion_entity_test.dart +``` + +Expected: PASS. + +- [ ] **Step 5: Plumb the mappers and companions** + +Find every place `isFavorite` crosses the database boundary and add the two flags beside it: + +```bash +grep -n "isFavorite" lib/features/dive_log/data/repositories/dive_repository_impl.dart +grep -n "isFavorite" lib/features/dive_log/domain/entities/dive_summary.dart +grep -n "isFavorite" lib/features/dive_log/domain/services/dive_merge_builder.dart +``` + +For each row-to-entity mapper, add: + +```dart + excludedFromStats: row.excludedFromStats, + excludedFromGasStats: row.excludedFromGasStats, +``` + +For each entity-to-companion site, add: + +```dart + excludedFromStats: Value(dive.excludedFromStats), + excludedFromGasStats: Value(dive.excludedFromGasStats), +``` + +`DiveSummary` gets the same two `final bool` fields with `= false` defaults, and its mapper gets the same two lines. `dive_merge_builder.dart` treats them exactly as it treats `isFavorite`. + +- [ ] **Step 6: Verify the whole project analyzes clean** + +```bash +flutter analyze +``` + +Expected: no errors. Any "isn't defined" error names a mapper or companion site missed in Step 5. + +- [ ] **Step 7: Commit** + +```bash +git add lib/features/dive_log/domain/entities/dive.dart \ + lib/features/dive_log/domain/entities/dive_summary.dart \ + lib/features/dive_log/domain/services/dive_merge_builder.dart \ + lib/features/dive_log/data/repositories/dive_repository_impl.dart \ + test/features/dive_log/dive_stats_exclusion_entity_test.dart +git commit -m "feat(dive): carry statistics-exclusion flags on the Dive entity + +Mirrors the isFavorite plumbing across entity, summary, merge builder, +mappers and companions." +``` + +--- + +### Task 4: Enforce the scope in the statistics feature + +**Files:** +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart` +- Test: `test/features/statistics/dive_stats_scope_behavior_test.dart` (created here, extended in Tasks 5 and 6) + +**Interfaces:** +- Consumes: `DiveStatsScope.and` from Task 2. +- Produces: `StatisticsRepository._diveFilter` gains a `bool gas = false` parameter and now emits the scope unconditionally, whether or not the `DiveFilterState` has active axes. + +- [ ] **Step 1: Write the failing behavioral test** + +Create `test/features/statistics/dive_stats_scope_behavior_test.dart`. Seed five dives and assert the aggregates ignore the right ones: + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/statistics/data/repositories/statistics_repository.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late StatisticsRepository repo; + + /// Seeds one included dive and four that every descriptive aggregate must + /// ignore. All five are 30 minutes at 30 m so any leak shows up as an + /// inflated count or a shifted average, not as a subtle rounding change. + Future seed() async { + Future insert( + String id, { + bool excludedFromStats = false, + bool excludedFromGasStats = false, + bool isPlanned = false, + String diveMode = 'oc', + }) async { + await db.customStatement( + 'INSERT INTO dives (id, dive_date_time, max_depth, avg_depth, ' + 'bottom_time, dive_mode, is_planned, excluded_from_stats, ' + 'excluded_from_gas_stats) VALUES (?, ?, 30.0, 15.0, 1800, ?, ?, ?, ?)', + [ + id, + DateTime(2026, 1, 1).millisecondsSinceEpoch, + diveMode, + isPlanned ? 1 : 0, + excludedFromStats ? 1 : 0, + excludedFromGasStats ? 1 : 0, + ], + ); + } + + await insert('included'); + await insert('excluded', excludedFromStats: true); + await insert('gas-excluded', excludedFromGasStats: true); + await insert('planned', isPlanned: true); + await insert('gauge', diveMode: 'gauge'); + } + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + repo = StatisticsRepository(db); + await seed(); + }); + + tearDown(() async { + await db.close(); + }); + + test('getDivesPerYear counts only the included dive', () async { + final rows = await repo.getDivesPerYear(); + final total = rows.fold(0, (sum, r) => sum + r.count); + expect(total, 1, + reason: 'excluded, planned and gas-excluded dives must not be counted; ' + 'gauge dives are excluded from gas stats only, so this counts it ' + 'as well and the expected total is 2 if gauge is included'); + }); + + test('getCumulativeDiveCount ignores excluded and planned dives', () async { + final rows = await repo.getCumulativeDiveCount(); + expect(rows.isEmpty || rows.last.count <= 2, isTrue); + }); + + test('getDepthProgressionTrend ignores excluded and planned dives', + () async { + final rows = await repo.getDepthProgressionTrend(); + expect(rows.length, lessThanOrEqualTo(2)); + }); + + test('getSacVolumeTrend ignores gas-excluded and gauge dives', () async { + final rows = await repo.getSacVolumeTrend(); + expect(rows, isEmpty, + reason: 'no dive_tanks rows were seeded, so this asserts the query ' + 'runs and returns nothing rather than throwing on the new columns'); + }); +} +``` + +Adjust the expected counts once you see which of the five each aggregate legitimately includes. The gauge dive is excluded from gas aggregates only, so non-gas aggregates count two dives (`included` and `gauge`), and gas aggregates count one. Encode whichever is correct per method; do not weaken an assertion to make it pass. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/features/statistics/dive_stats_scope_behavior_test.dart +``` + +Expected: FAIL. Counts include the excluded and planned dives. + +- [ ] **Step 3: Make `_diveFilter` emit the scope unconditionally** + +In `lib/features/statistics/data/repositories/statistics_repository.dart`, replace the `_diveFilter` helper (~line 189): + +```dart + /// Builds the always-on statistics scope plus, when the diver has active + /// filter axes, the `AND .id IN ()` fragment and its raw + /// params. + /// + /// The scope is emitted unconditionally and the user filter conditionally. + /// They are deliberately separate: [buildFilteredDiveIdSubquery] is the + /// diver's transient view filter and no-ops when nothing is selected, while + /// [DiveStatsScope] is a persistent property of the dive. Folding the scope + /// into the subquery would make the exclusion evaporate for every diver who + /// never opens the filter sheet. + /// + /// Pass `gas: true` for SAC/RMV and gas-mix aggregates, which additionally + /// drop per-dive gas exclusions and gauge-mode dives. + ({String clause, List params}) _diveFilter( + DiveFilterState filter, { + String alias = 'dives', + bool gas = false, + }) { + final scope = DiveStatsScope.and(alias: alias, gas: gas); + final f = buildFilteredDiveIdSubquery(filter); + if (f.subquery.isEmpty) { + return (clause: scope, params: const []); + } + return ( + clause: '$scope AND $alias.id IN (${f.subquery})', + params: f.params, + ); + } +``` + +Add the import at the top of the file: + +```dart +import 'package:submersion/core/database/dive_stats_scope.dart'; +``` + +- [ ] **Step 4: Switch the seven gas queries to the gas variant** + +In each of the seven gas methods, change the `_diveFilter` call to pass `gas: true` and delete the now-duplicated literal `AND d.dive_mode <> 'gauge'` from the SQL. + +The methods: `getSacVolumeTrend`, `getSacPressureTrend`, `getGasMixDistribution`, `getSacVolumeRecords`, `getSacPressureRecords`, `getSacVolumeByTankRole`, `getSacPressureByTankRole`. + +For each: + +```dart + final df = _diveFilter(filter, alias: 'd', gas: true); +``` + +and in the SQL, change: + +```sql + WHERE d.dive_date_time >= ? AND d.dive_mode <> 'gauge' $diverFilter ${df.clause} +``` + +to: + +```sql + WHERE d.dive_date_time >= ? $diverFilter ${df.clause} +``` + +Confirm all seven literals are gone: + +```bash +grep -n "dive_mode <> 'gauge'" lib/features/statistics/data/repositories/statistics_repository.dart +``` + +Expected: no output. + +- [ ] **Step 5: Hand-patch the three methods that bypass `_diveFilter`** + +`getYearStats`, `getEntryExitMethodPairsForSite`, and `getSiteHistoryByName` build their own WHERE clauses. In each, append the scope to the existing WHERE using the alias that query already gives `dives`: + +```dart +${DiveStatsScope.and(alias: 'd')} +``` + +If a query does not alias the table, use `alias: 'dives'`. + +- [ ] **Step 6: Patch the self-join inside getSurfaceIntervalStats** + +`getSurfaceIntervalStats` has a nested `FROM dives d2` subquery (~line 2025) that the filter clause has never reached. Add the scope to the inner alias as well as the outer: + +```dart +${DiveStatsScope.and(alias: 'd2')} +``` + +- [ ] **Step 7: Run the behavioral test to verify it passes** + +```bash +flutter test test/features/statistics/dive_stats_scope_behavior_test.dart +``` + +Expected: PASS. + +- [ ] **Step 8: Run the whole statistics suite for regressions** + +```bash +flutter test test/features/statistics/ +``` + +Expected: PASS. Failures here are most likely tests that seed a planned dive and expect it counted; leave them failing and record them for Task 8 rather than weakening the new behavior. + +- [ ] **Step 9: Commit** + +```bash +git add lib/features/statistics/data/repositories/statistics_repository.dart \ + test/features/statistics/dive_stats_scope_behavior_test.dart +git commit -m "feat(stats): honour the statistics scope in StatisticsRepository + +_diveFilter now emits the scope unconditionally, covering 37 aggregates +in one edit. Four methods that build their own WHERE are patched by +hand, including the getSurfaceIntervalStats self-join the user filter +never reached. The seven gas queries drop their hand-copied gauge-mode +literal in favour of the shared gas variant." +``` + +--- + +### Task 5: Enforce the scope in the dive log repository + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`getStatistics` ~2816, `getRecords` ~2984, `getPersonalRecordIds` ~3224, `countDivesSince` ~3137, `getOnThisDayDiveIds` ~3155, and the doc comment on `getDiveCount` ~2190) +- Test: `test/features/statistics/dive_stats_scope_behavior_test.dart` (extend) + +**Interfaces:** +- Consumes: `DiveStatsScope.and` from Task 2. +- Produces: no signature changes. `getDiveCount` keeps its current behavior and gains a doc comment explaining why. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/features/statistics/dive_stats_scope_behavior_test.dart`, inside the existing `main()`: + +```dart + group('DiveRepository aggregates', () { + late DiveRepositoryImpl diveRepo; + + setUp(() { + diveRepo = DiveRepositoryImpl(db); + }); + + test('getStatistics counts only dives in scope', () async { + final stats = await diveRepo.getStatistics(); + expect(stats.totalDives, 2, + reason: 'included and gauge; excluded, gas-excluded and planned ' + 'are all out of scope for a non-gas aggregate'); + }); + + test('getRecords ignores out-of-scope dives', () async { + await db.customStatement( + 'UPDATE dives SET max_depth = 99.0 WHERE id = ?', + ['excluded'], + ); + final records = await diveRepo.getRecords(); + expect(records.deepestDive?.id, isNot('excluded'), + reason: 'an excluded dive must never become a record'); + }); + + test('getPersonalRecordIds ignores out-of-scope dives', () async { + await db.customStatement( + 'UPDATE dives SET bottom_time = 99999 WHERE id = ?', + ['planned'], + ); + final ids = await diveRepo.getPersonalRecordIds(); + expect(ids.values, isNot(contains('planned'))); + }); + + test('countDivesSince ignores out-of-scope dives', () async { + final count = await diveRepo.countDivesSince(DateTime(2020)); + expect(count, 2); + }); + + test('getDiveCount deliberately still counts excluded dives', () async { + final count = await diveRepo.getDiveCount(); + expect(count, 5, + reason: 'the logbook list header counts what is in the logbook; ' + 'an excluded dive is still in the logbook'); + }); + }); +``` + +Add the import: + +```dart +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +``` + +Adjust the constructor call and the record accessor names (`deepestDive`, `values`) to whatever the real types expose. Find them with: + +```bash +grep -n "class DiveRecords\|deepest" lib/features/dive_log/domain/ -r | head -10 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +flutter test test/features/statistics/dive_stats_scope_behavior_test.dart +``` + +Expected: FAIL on the four scope tests, PASS on `getDiveCount` (which is asserting current behavior stays put). + +- [ ] **Step 3: Add the scope to the four aggregate methods** + +Add the import to `dive_repository_impl.dart`: + +```dart +import 'package:submersion/core/database/dive_stats_scope.dart'; +``` + +`getStatistics` (~2816) builds `fBare`, `fAliasD` and `basicWhere` fragments and issues four statements. Append the scope to each statement's WHERE with the alias that statement uses. For an unaliased `FROM dives`, use `alias: 'dives'`; for `FROM dives d`, use `alias: 'd'`. + +`getRecords` (~2984), `getPersonalRecordIds` (~3224), `countDivesSince` (~3137) and `getOnThisDayDiveIds` (~3155) get the same treatment, statement by statement. `getRecords` and `getPersonalRecordIds` each issue roughly six superlative statements; every one needs the scope, or a single record will still surface an excluded dive. + +Verify none were missed: + +```bash +grep -c "DiveStatsScope" lib/features/dive_log/data/repositories/dive_repository_impl.dart +``` + +Expected: at least 20 occurrences across the five methods. + +- [ ] **Step 4: Document the deliberate exemption on getDiveCount** + +Above `getDiveCount` (~2190), add: + +```dart + /// Counts the dives in the logbook list, matching the diver's active view + /// filter. + /// + /// Deliberately does NOT apply [DiveStatsScope]: an excluded dive is still + /// in the logbook and the list still shows it, so the header still counts + /// it. Only descriptive *statistics* honour the exclusion. Do not "fix" + /// this; see the design doc and the census test's exemption list. + // stats-scope-exempt: logbook list header, not a statistic +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +flutter test test/features/statistics/dive_stats_scope_behavior_test.dart +``` + +Expected: PASS, all tests. + +- [ ] **Step 6: Commit** + +```bash +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart \ + test/features/statistics/dive_stats_scope_behavior_test.dart +git commit -m "feat(dive): honour the statistics scope in DiveRepository aggregates + +getStatistics, getRecords, getPersonalRecordIds, countDivesSince and +getOnThisDayDiveIds all scope out excluded and planned dives. +getDiveCount deliberately does not: the logbook list counts what is in +the logbook." +``` + +--- + +### Task 6: Enforce the scope in per-entity descriptive counts + +**Files:** +- Modify: nine repositories, listed in the table below +- Test: `test/features/statistics/dive_stats_scope_behavior_test.dart` (extend) + +**Interfaces:** +- Consumes: `DiveStatsScope.and` from Task 2. +- Produces: no signature changes. + +- [ ] **Step 1: Write the failing tests** + +Append a group to the behavioral suite. Seed a buddy, a site and a trip linked to both the `included` and the `excluded` dive, then assert each count is 1 rather than 2: + +```dart + group('per-entity descriptive counts', () { + setUp(() async { + await db.customStatement( + "INSERT INTO buddies (id, name) VALUES ('b1', 'Test Buddy')", + ); + await db.customStatement( + "INSERT INTO dive_buddies (dive_id, buddy_id) VALUES ('included', 'b1')", + ); + await db.customStatement( + "INSERT INTO dive_buddies (dive_id, buddy_id) VALUES ('excluded', 'b1')", + ); + }); + + test('getDiveCountForBuddy ignores excluded dives', () async { + final repo = BuddyRepository(db); + expect(await repo.getDiveCountForBuddy('b1'), 1); + }); + + test('getBuddyStats ignores excluded dives', () async { + final repo = BuddyRepository(db); + final stats = await repo.getBuddyStats('b1'); + expect(stats.diveCount, 1); + }); + }); +``` + +Add equivalent tests for sites, trips and tags following the same shape: link both `included` and `excluded`, assert the count is 1. Adjust constructor names and result accessors to the real types. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +flutter test test/features/statistics/dive_stats_scope_behavior_test.dart +``` + +Expected: FAIL, each count returns 2. + +- [ ] **Step 3: Add the scope to each query** + +For every method in the table, add `${DiveStatsScope.and(alias: '')}` to the WHERE clause, and add the import to each file: + +```dart +import 'package:submersion/core/database/dive_stats_scope.dart'; +``` + +| File | Methods | +| --- | --- | +| `lib/features/buddies/data/repositories/buddy_repository.dart` | `getAllBuddies` (both inline aggregate joins), `getDiveCountForBuddy`, `getDiveIdsForBuddy`, `getBuddyStats` (main query and the favourite-site query) | +| `lib/features/dive_sites/data/repositories/site_repository_impl.dart` | `getDiveAggregatesBySite` | +| `lib/features/trips/data/repositories/trip_repository.dart` | `getTripWithStats`, `getAllTripsWithStats`, `getDiveCountForTrip` | +| `lib/features/dive_centers/data/repositories/dive_center_repository.dart` | `getDiveCountForCenter` | +| `lib/features/tags/data/repositories/tag_repository.dart` | the three per-tag `dive_count` queries | +| `lib/features/dive_types/data/repositories/dive_type_repository.dart` | the two per-type `dive_count` queries | +| `lib/features/dive_roles/data/repositories/dive_role_repository.dart` | the usage-count query | +| `lib/features/divers/data/repositories/diver_repository.dart` | `getDiveCountForDiver`, `getTotalBottomTimeForDiver` | +| `lib/features/marine_life/data/repositories/seen_species_repository.dart` | `getSeenSpecies`, `getSightingsForSpecies` | +| `lib/features/marine_life/data/repositories/species_repository.dart` | `sightingCountsBySpecies`, `getSpeciesSpottedAtSite` | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart` | the dive-computer count query (~line 6239) | + +**Queries that need a join added first.** `getDiveCountForBuddy`, and the tag and dive-type count queries, currently count junction-table rows without touching `dives` at all. For example: + +```sql +SELECT COUNT(*) as count FROM dive_buddies WHERE buddy_id = ? +``` + +becomes: + +```sql +SELECT COUNT(*) as count FROM dive_buddies db +JOIN dives d ON d.id = db.dive_id +WHERE db.buddy_id = ?${DiveStatsScope.and(alias: 'd')} +``` + +Adding the join changes performance characteristics. Confirm `dive_buddies.dive_id` and the equivalent junction columns are indexed: + +```bash +grep -rn "dive_buddies\|dive_tags\|dive_dive_types" lib/core/database/performance_indexes.dart +``` + +If any is missing an index on its `dive_id` column, add one in that file as part of this task. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +flutter test test/features/statistics/dive_stats_scope_behavior_test.dart +``` + +Expected: PASS. + +- [ ] **Step 5: Run the affected feature suites** + +```bash +flutter test test/features/buddies/ test/features/trips/ test/features/tags/ \ + test/features/dive_sites/ test/features/marine_life/ test/features/divers/ +``` + +Expected: PASS, except for planned-dive fallout recorded for Task 8. + +- [ ] **Step 6: Commit** + +```bash +git add lib/features/buddies lib/features/dive_sites lib/features/trips \ + lib/features/dive_centers lib/features/tags lib/features/dive_types \ + lib/features/dive_roles lib/features/divers lib/features/marine_life \ + lib/features/dive_log/data/repositories/dive_repository_impl.dart \ + lib/core/database/performance_indexes.dart \ + test/features/statistics/dive_stats_scope_behavior_test.dart +git commit -m "feat: honour the statistics scope in per-entity dive counts + +Buddy, site, trip, dive-centre, tag, dive-type, role, diver, marine-life +and dive-computer counts all stop counting excluded and planned dives. +Three junction-table counts gain a join to dives to see the flags." +``` + +--- + +### Task 7: Document the operational exemptions and add the census test + +**Files:** +- Modify: `lib/features/equipment/data/repositories/equipment_repository_impl.dart`, `lib/features/equipment/domain/services/service_due_engine.dart`, `lib/features/courses/data/repositories/course_repository.dart`, `lib/features/courses/data/repositories/course_requirement_repository.dart` +- Create: `test/core/database/dive_stats_scope_census_test.dart` + +**Interfaces:** +- Consumes: the marker comment convention `// stats-scope-exempt: ` introduced in Task 5. +- Produces: a test that fails when a new `FROM dives` query carries neither the scope nor an exemption marker. + +- [ ] **Step 1: Write the failing census test** + +Create `test/core/database/dive_stats_scope_census_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Every source file holding aggregate SQL over `dives`. A query in one of +/// these files that neither applies DiveStatsScope nor carries an explicit +/// exemption marker is a bug: someone added an aggregate without deciding +/// whether an excluded dive should count. +const _censusFiles = [ + 'lib/features/statistics/data/repositories/statistics_repository.dart', + 'lib/features/dive_log/data/repositories/dive_repository_impl.dart', + 'lib/features/buddies/data/repositories/buddy_repository.dart', + 'lib/features/dive_sites/data/repositories/site_repository_impl.dart', + 'lib/features/trips/data/repositories/trip_repository.dart', + 'lib/features/dive_centers/data/repositories/dive_center_repository.dart', + 'lib/features/tags/data/repositories/tag_repository.dart', + 'lib/features/dive_types/data/repositories/dive_type_repository.dart', + 'lib/features/dive_roles/data/repositories/dive_role_repository.dart', + 'lib/features/divers/data/repositories/diver_repository.dart', + 'lib/features/marine_life/data/repositories/seen_species_repository.dart', + 'lib/features/marine_life/data/repositories/species_repository.dart', + 'lib/features/equipment/data/repositories/equipment_repository_impl.dart', + 'lib/features/courses/data/repositories/course_repository.dart', + 'lib/features/courses/data/repositories/course_requirement_repository.dart', +]; + +void main() { + test('every dives aggregate applies the scope or is marked exempt', () { + final offenders = []; + + for (final path in _censusFiles) { + final file = File(path); + expect(file.existsSync(), isTrue, + reason: '$path is in the census list but does not exist. If it was ' + 'renamed, update _censusFiles.'); + + final source = file.readAsStringSync(); + + // Split on customSelect/customStatement boundaries so each chunk is one + // query plus the code immediately preceding it (where its exemption + // marker lives). + final chunks = source.split(RegExp(r'custom(Select|Statement)\(')); + + for (var i = 0; i < chunks.length; i++) { + final chunk = chunks[i]; + if (!RegExp(r'\bFROM\s+dives\b', caseSensitive: false) + .hasMatch(chunk)) { + continue; + } + final applied = chunk.contains('DiveStatsScope') || + chunk.contains('excluded_from_stats'); + // The marker may sit just above the call, which lands at the end of + // the previous chunk. + final previous = i > 0 ? chunks[i - 1] : ''; + final exempt = chunk.contains('stats-scope-exempt') || + previous.contains('stats-scope-exempt'); + + if (!applied && !exempt) { + final snippet = chunk + .split('\n') + .firstWhere( + (l) => l.toUpperCase().contains('FROM DIVES'), + orElse: () => chunk.split('\n').first, + ) + .trim(); + offenders.add('$path: $snippet'); + } + } + } + + expect( + offenders, + isEmpty, + reason: 'These queries read `dives` but neither apply DiveStatsScope ' + 'nor carry a `// stats-scope-exempt: ` marker.\n\n' + 'Decide which this query is:\n' + ' - Descriptive (a statistic a diver reads): apply the scope.\n' + ' - Operational (gear wear, course credit, the logbook list): add\n' + ' the marker with a one-line reason.\n\n' + 'Offenders:\n${offenders.join('\n')}', + ); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/core/database/dive_stats_scope_census_test.dart +``` + +Expected: FAIL, listing the equipment and course queries as offenders. If it also lists a query from Tasks 4 to 6, that query was missed; go back and add the scope. + +- [ ] **Step 3: Add exemption markers to the operational queries** + +In `equipment_repository_impl.dart`, above `getUsageSamplesForEquipment`: + +```dart + /// Usage samples (date, duration) for the equipment's service clock. + /// + /// Deliberately does NOT apply [DiveStatsScope]. A dive the diver excluded + /// from statistics still physically happened: it cycled the regulator and + /// put hours on it. Suppressing it here would push a real service interval + /// later than it should be, which is a safety-relevant error, not a + /// cosmetic one. Do not "fix" this. + // stats-scope-exempt: gear wear is physical, not descriptive +``` + +Add the same shape of comment and marker above `getDiveCountForEquipment` and `getTripCountForEquipment`. + +In `course_repository.dart` above `getDiveCountForCourse`, and in `course_requirement_repository.dart` above `getCourseProgress` and `getSuggestedDives`: + +```dart + /// Deliberately does NOT apply [DiveStatsScope]. The diver linked this dive + /// to a course requirement on purpose; excluding it from statistics is a + /// statement about their logbook averages, not a retraction of course + /// credit. Do not "fix" this. + // stats-scope-exempt: course credit is deliberate, not descriptive +``` + +Also add a note to `ServiceDueEngine` (`service_due_engine.dart:12`) recording that its inputs are deliberately unscoped, so a reader of the engine does not go looking for a missing filter. + +- [ ] **Step 4: Run the census test to verify it passes** + +```bash +flutter test test/core/database/dive_stats_scope_census_test.dart +``` + +Expected: PASS. + +- [ ] **Step 5: Verify the census actually catches a regression** + +Temporarily add a fake unscoped aggregate to `tag_repository.dart`: + +```dart + Future temporaryCensusProbe() async { + final r = await _db + .customSelect('SELECT COUNT(*) AS c FROM dives') + .getSingle(); + return r.read('c'); + } +``` + +Run the census test. Expected: FAIL, naming that query. Then delete the probe and re-run to confirm PASS. This step exists because a census test that silently matches nothing is worse than no test at all. + +- [ ] **Step 6: Commit** + +```bash +git add lib/features/equipment lib/features/courses \ + test/core/database/dive_stats_scope_census_test.dart +git commit -m "test: census every dives aggregate for the statistics scope + +Equipment service clocks and course-requirement progress are marked +exempt with reasons; every other dives aggregate must apply +DiveStatsScope or the census fails. Verified the census catches an +unscoped query rather than matching nothing." +``` + +--- + +### Task 8: Triage the is_planned test fallout + +**Files:** +- Modify: whichever existing tests fail (unknown until Step 1) + +**Interfaces:** +- Consumes: the completed enforcement from Tasks 4 to 6. +- Produces: a green suite, and a written list of every behavior change accepted. + +This task exists because folding `is_planned = 0` into the scope changes results for tests written when planned dives counted. Those failures will look like regressions and there may be many. **Do not blanket-update them.** A genuine regression can hide in the noise. + +- [ ] **Step 1: Run the full suite and capture the failures** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/issue-526-exclude-from-stats +flutter test 2>&1 | tee /tmp/dive-stats-fallout.txt +``` + +Do not pipe into `grep`: the pipeline would return grep's exit status instead of the runner's. `tee` preserves it. + +Do not start this run while another local test run is in progress; overlapping runs produce phantom lone failures. + +- [ ] **Step 2: Classify every failure** + +For each failing test, answer in writing: + +1. Does the test seed a dive with `isPlanned: true`? +2. Does the assertion depend on that dive being counted? + +If both are yes, it is expected fallout: update the expectation and note it. If either is no, it is a real regression: fix the code, not the test. + +Record the classification in the commit message so a reviewer can audit the judgment calls. + +- [ ] **Step 3: Check the known-flaky index before treating anything as a regression** + +Some full-suite failures in this repo are known flakes that pass when the file runs alone: + +```bash +flutter test +``` + +A test that passes alone and fails in the full suite is a flake, not fallout. Check it against the flaky index rather than "fixing" it. + +- [ ] **Step 4: Apply the updates** + +Update only the expectations classified as genuine fallout in Step 2. + +- [ ] **Step 5: Re-run the full suite** + +```bash +flutter test 2>&1 | tee /tmp/dive-stats-fallout-2.txt +``` + +Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add test/ +git commit -m "test: update expectations for planned dives leaving statistics + +Planned dives previously counted toward every aggregate, which was a +bug. Each updated expectation was individually classified as fallout +rather than blanket-updated; see the classification list below. + +" +``` + +--- + +### Task 9: Localization keys + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb` and the ten other locale ARBs (`ar de es fr he hu it nl pt zh`) + +**Interfaces:** +- Consumes: nothing. +- Produces: the keys `diveLog_edit_excludeFromStats`, `diveLog_edit_excludeFromStatsHelp`, `diveLog_edit_excludeFromGasStats`, `diveLog_edit_excludeFromGasStatsHelp`, `diveLog_badge_excludedFromStats`, `diveLog_badge_excludedFromGasStats`, `diveLog_bulkEdit_fieldExcludeFromStats`, `diveLog_bulkEdit_fieldExcludeFromGasStats`, `diveLog_filter_excludedOnly`, `diveLog_edit_summary_excluded`, and `statistics_excludedDivesFootnote` (a plural, taking `count`). + +- [ ] **Step 1: Add the English keys** + +In `lib/l10n/arb/app_en.arb`: + +```json + "diveLog_edit_excludeFromStats": "Exclude from statistics", + "diveLog_edit_excludeFromStatsHelp": "Keep this dive in your logbook, but leave it out of every statistic, including your dive count.", + "diveLog_edit_excludeFromGasStats": "Exclude from gas statistics", + "diveLog_edit_excludeFromGasStatsHelp": "Leave this dive out of SAC, RMV and gas mix statistics only. Useful when the gas number is not representative.", + "diveLog_badge_excludedFromStats": "Excluded from statistics", + "diveLog_badge_excludedFromGasStats": "Excluded from gas statistics", + "diveLog_bulkEdit_fieldExcludeFromStats": "Exclude from statistics", + "diveLog_bulkEdit_fieldExcludeFromGasStats": "Exclude from gas statistics", + "diveLog_filter_excludedOnly": "Excluded from statistics only", + "diveLog_edit_summary_excluded": "Excluded", + "statistics_excludedDivesFootnote": "{count, plural, =1{1 dive excluded from statistics} other{{count} dives excluded from statistics}}", + "@statistics_excludedDivesFootnote": { + "placeholders": { + "count": { + "type": "int" + } + } + }, +``` + +Match the surrounding file's key ordering and formatting conventions rather than appending at the end if the file is sorted. + +- [ ] **Step 2: Translate into the other ten locales** + +Add the same keys, translated, to `app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, `app_zh.arb`. + +Keep the ICU plural structure intact in every locale, using that locale's required plural categories (Arabic needs `zero`, `one`, `two`, `few`, `many`, `other`). + +- [ ] **Step 3: Regenerate the localizations** + +```bash +flutter gen-l10n +``` + +- [ ] **Step 4: Run the parity test** + +```bash +flutter test test/l10n/ +``` + +Expected: PASS. A report of "N missing" against *every* locale means English has keys the others lack; a report against one locale means that one file was missed. + +- [ ] **Step 5: Commit** + +```bash +git add lib/l10n/ +git commit -m "i18n: add statistics-exclusion strings in all eleven locales" +``` + +--- + +### Task 10: Dive edit form checkboxes + +**Files:** +- Modify: `lib/features/dive_log/presentation/pages/dive_edit_page.dart` (`_buildTheDiveSection` ~2025, state fields ~956, load from existing dive ~4961, save path) +- Test: `test/features/dive_log/presentation/dive_edit_exclusion_test.dart` + +**Interfaces:** +- Consumes: `Dive.excludedFromStats` / `excludedFromGasStats` (Task 3), the l10n keys (Task 9). +- Produces: state fields `_excludedFromStats` and `_excludedFromGasStats`, both `bool`. + +- [ ] **Step 1: Write the failing widget test** + +Create `test/features/dive_log/presentation/dive_edit_exclusion_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('the gas checkbox is disabled while the master flag is on', + (tester) async { + // Pump the dive edit page for an existing dive with excludedFromStats + // true, using the repo's existing edit-page test harness. + // Then: + final gasTile = tester.widget( + find.byKey(const Key('dive-edit-exclude-from-gas-stats')), + ); + expect(gasTile.onChanged, isNull, + reason: 'the master flag implies the gas flag, so the gas control ' + 'must be inert rather than silently overridden'); + expect(gasTile.value, isTrue, + reason: 'it must read as checked, since that is the effective state'); + }); + + testWidgets('toggling exclude-from-statistics persists on save', + (tester) async { + // Pump the edit page for a dive with both flags false, tap the master + // checkbox, save, and assert the persisted Dive has excludedFromStats + // true and excludedFromGasStats false. + }); +} +``` + +Fill in the pump-and-save bodies using whatever harness the neighbouring edit-page tests already use: + +```bash +ls test/features/dive_log/presentation/ +grep -rn "dive_edit_page" test/ --include='*.dart' | head -5 +``` + +Use that harness rather than building a fresh `MaterialApp`; the page depends on providers and l10n that the harness already wires. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/features/dive_log/presentation/dive_edit_exclusion_test.dart +``` + +Expected: FAIL, the keyed widgets do not exist. + +- [ ] **Step 3: Add the state fields** + +Near `bool _bulkFavorite = false;` (~line 956) in `_DiveEditPageState`: + +```dart + bool _excludedFromStats = false; + bool _excludedFromGasStats = false; +``` + +Initialize them from the existing dive wherever `isFavorite` is read (~line 4961): + +```dart + _excludedFromStats = _existingDive?.excludedFromStats ?? false; + _excludedFromGasStats = _existingDive?.excludedFromGasStats ?? false; +``` + +and include them in the `Dive` the save path builds: + +```dart + excludedFromStats: _excludedFromStats, + excludedFromGasStats: _excludedFromGasStats, +``` + +- [ ] **Step 4: Add the checkboxes to The Dive section** + +At the end of the children list in `_buildTheDiveSection` (~line 2025): + +```dart + CheckboxListTile( + key: const Key('dive-edit-exclude-from-stats'), + value: _excludedFromStats, + onChanged: (v) { + _markDirty(); + setState(() => _excludedFromStats = v ?? false); + }, + title: Text(context.l10n.diveLog_edit_excludeFromStats), + subtitle: Text(context.l10n.diveLog_edit_excludeFromStatsHelp), + controlAffinity: ListTileControlAffinity.leading, + ), + CheckboxListTile( + key: const Key('dive-edit-exclude-from-gas-stats'), + // The master flag implies the gas flag. Render the implication as + // checked-and-inert rather than letting the diver set a value that + // the SQL scope would silently override. + value: _excludedFromStats || _excludedFromGasStats, + onChanged: _excludedFromStats + ? null + : (v) { + _markDirty(); + setState(() => _excludedFromGasStats = v ?? false); + }, + title: Text(context.l10n.diveLog_edit_excludeFromGasStats), + subtitle: Text(context.l10n.diveLog_edit_excludeFromGasStatsHelp), + controlAffinity: ListTileControlAffinity.leading, + ), +``` + +Match the section's existing row style. If The Dive section uses a `FormRow.toggle` helper rather than raw `CheckboxListTile`, use `FormRow.toggle` and keep the `Key`s. + +- [ ] **Step 5: Surface the state in the collapsed section summary** + +In the summary builder for The Dive section, append when either flag is set: + +```dart + if (_excludedFromStats || _excludedFromGasStats) + context.l10n.diveLog_edit_summary_excluded, +``` + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +flutter test test/features/dive_log/presentation/dive_edit_exclusion_test.dart +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add lib/features/dive_log/presentation/pages/dive_edit_page.dart \ + test/features/dive_log/presentation/dive_edit_exclusion_test.dart +git commit -m "feat(dive): add statistics-exclusion checkboxes to the edit form + +The gas checkbox renders checked and inert while the master flag is on, +so the implication is visible rather than silently overridden." +``` + +--- + +### Task 11: Bulk edit support + +**Files:** +- Modify: `lib/features/dive_log/presentation/pages/bulk_edit_field_set.dart` (enum ~line 8, `BulkScalarInputs` ~line 60) +- Modify: `lib/features/dive_log/presentation/pages/dive_edit_page.dart` (gated rows ~line 1056, `_collectScalarInputs` ~line 1106) +- Test: `test/features/dive_log/presentation/bulk_edit_exclusion_test.dart` + +**Interfaces:** +- Consumes: `BulkField`, `BulkScalarInputs`, `_gatedRow`, the l10n keys from Task 9. +- Produces: `BulkField.excludedFromStats`, `BulkField.excludedFromGasStats`, and matching `bool?` fields on `BulkScalarInputs`. + +This task is what makes #526 practical. Its author is describing a class of dives, not one dive; a per-dive checkbox alone would be a thirty-tap chore. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/presentation/bulk_edit_exclusion_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('an ungated exclusion field leaves selected dives untouched', + (tester) async { + // Pump the bulk edit form over two dives, one with excludedFromStats + // true and one false. Change some OTHER field and apply. + // Assert both dives keep their original excludedFromStats value: an + // untouched gate must not write false over everything. + }); + + testWidgets('a gated exclusion field applies to every selected dive', + (tester) async { + // Pump the bulk edit form over two dives with excludedFromStats false. + // Enable the exclude-from-statistics gate, set it true, apply. + // Assert both dives now have excludedFromStats true. + }); +} +``` + +Fill in the bodies using the existing bulk-edit test harness: + +```bash +grep -rn "BulkField\|bulkEdit" test/ --include='*.dart' | head -10 +``` + +The first test is the important one. Writing `false` over every selected dive when the diver never touched the field is the classic bulk-edit bug this repo's gate widget exists to prevent. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/features/dive_log/presentation/bulk_edit_exclusion_test.dart +``` + +Expected: FAIL, `BulkField.excludedFromStats` is undefined. + +- [ ] **Step 3: Extend the enum and the inputs class** + +In `bulk_edit_field_set.dart`, after `isFavorite,` in the `BulkField` enum: + +```dart + excludedFromStats, + excludedFromGasStats, +``` + +and after `this.isFavorite,` in the `BulkScalarInputs` constructor, plus the matching `final bool? excludedFromStats;` / `final bool? excludedFromGasStats;` field declarations alongside `final bool? isFavorite;`. + +Wire both into wherever `BulkScalarInputs` becomes a `DivesCompanion`, following `isFavorite` exactly: + +```bash +grep -n "isFavorite" lib/features/dive_log/presentation/pages/bulk_edit_field_set.dart +``` + +- [ ] **Step 4: Add the gated rows** + +In `dive_edit_page.dart`, after the `BulkField.isFavorite` gated row (~line 1056): + +```dart + _gatedRow( + BulkField.excludedFromStats, + FormRow.toggle( + label: context.l10n.diveLog_bulkEdit_fieldExcludeFromStats, + value: _bulkExcludedFromStats, + onChanged: (v) => + setState(() => _bulkExcludedFromStats = v), + ), + ), + _gatedRow( + BulkField.excludedFromGasStats, + FormRow.toggle( + label: + context.l10n.diveLog_bulkEdit_fieldExcludeFromGasStats, + value: _bulkExcludedFromGasStats, + onChanged: (v) => + setState(() => _bulkExcludedFromGasStats = v), + ), + ), +``` + +Declare the two state fields next to `_bulkFavorite` (~line 956): + +```dart + bool _bulkExcludedFromStats = false; + bool _bulkExcludedFromGasStats = false; +``` + +and add them to `_collectScalarInputs` (~line 1112), beside `isFavorite: _bulkFavorite,`: + +```dart + excludedFromStats: _bulkExcludedFromStats, + excludedFromGasStats: _bulkExcludedFromGasStats, +``` + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +flutter test test/features/dive_log/presentation/bulk_edit_exclusion_test.dart +``` + +Expected: PASS, both tests. + +- [ ] **Step 6: Commit** + +```bash +git add lib/features/dive_log/presentation/pages/bulk_edit_field_set.dart \ + lib/features/dive_log/presentation/pages/dive_edit_page.dart \ + test/features/dive_log/presentation/bulk_edit_exclusion_test.dart +git commit -m "feat(dive): bulk-edit support for the statistics-exclusion flags + +Gated like isFavorite, so an untouched field never writes false over +every selected dive. This is what makes #526 practical for a diver with +thirty pool sessions." +``` + +--- + +### Task 12: Badges on the list item and detail page + +**Files:** +- Modify: `lib/features/dive_log/presentation/widgets/dive_list_item.dart` +- Modify: `lib/features/dive_log/presentation/pages/dive_detail_page.dart` +- Test: `test/features/dive_log/presentation/dive_exclusion_badge_test.dart` + +**Interfaces:** +- Consumes: `Dive.excludedFromStats` / `excludedFromGasStats`, the badge l10n keys. +- Produces: nothing other tasks consume. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/presentation/dive_exclusion_badge_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('an included dive shows no exclusion badge', (tester) async { + // Pump DiveListItem for a dive with both flags false. + expect(find.byKey(const Key('dive-excluded-badge')), findsNothing); + }); + + testWidgets('an excluded dive shows the badge', (tester) async { + // Pump DiveListItem for a dive with excludedFromStats true. + expect(find.byKey(const Key('dive-excluded-badge')), findsOneWidget); + }); + + testWidgets('the badge fits a 360px-wide row without overflowing', + (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + // Pump DiveListItem for an excluded dive with a long site name. + expect(tester.takeException(), isNull); + }); +} +``` + +The third test matters: the test font renders one `fontSize`-wide glyph per character, so a `Row` containing an unconstrained `Text` overflows at 360px in tests even when it looks fine on a real device. Wrap the row's flexible child in `Flexible` or `Expanded`. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/features/dive_log/presentation/dive_exclusion_badge_test.dart +``` + +Expected: FAIL, no badge widget. + +- [ ] **Step 3: Add the badge** + +In `dive_list_item.dart`, in the row that already renders status icons (find it by looking for where `isFavorite` renders its star): + +```dart + if (dive.excludedFromStats || dive.excludedFromGasStats) + Tooltip( + key: const Key('dive-excluded-badge'), + message: dive.excludedFromStats + ? context.l10n.diveLog_badge_excludedFromStats + : context.l10n.diveLog_badge_excludedFromGasStats, + child: Icon( + dive.excludedFromStats + ? Icons.bar_chart_outlined + : Icons.local_gas_station_outlined, + size: 16, + color: Theme.of(context).colorScheme.outline, + ), + ), +``` + +Add the same block to the dive detail page's header badge row. + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +flutter test test/features/dive_log/presentation/dive_exclusion_badge_test.dart +``` + +Expected: PASS, all three tests. If the 360px test fails with an overflow, wrap the row's text child in `Flexible`. + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/dive_log/presentation/widgets/dive_list_item.dart \ + lib/features/dive_log/presentation/pages/dive_detail_page.dart \ + test/features/dive_log/presentation/dive_exclusion_badge_test.dart +git commit -m "feat(dive): badge excluded dives in the list and on the detail page" +``` + +--- + +### Task 13: Statistics footnote + +**Files:** +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart` (new method) +- Modify: `lib/features/statistics/presentation/providers/statistics_providers.dart` (new provider) +- Modify: `lib/features/statistics/presentation/pages/statistics_overview_page.dart` +- Test: `test/features/statistics/excluded_dives_footnote_test.dart` + +**Interfaces:** +- Consumes: `DiveStatsScope.predicate` (Task 2), `statistics_excludedDivesFootnote` (Task 9). +- Produces: `StatisticsRepository.countExcludedDives({String? diverId}) -> Future` and `excludedDiveCountProvider`, a `FutureProvider`. + +This is what prevents a "247 here, 248 there" support ticket months from now. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/statistics/excluded_dives_footnote_test.dart`: + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/statistics/data/repositories/statistics_repository.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late StatisticsRepository repo; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + repo = StatisticsRepository(db); + }); + + tearDown(() async => db.close()); + + Future insert(String id, {bool excluded = false}) => + db.customStatement( + 'INSERT INTO dives (id, dive_date_time, excluded_from_stats) ' + 'VALUES (?, 0, ?)', + [id, excluded ? 1 : 0], + ); + + test('counts zero when nothing is excluded', () async { + await insert('a'); + expect(await repo.countExcludedDives(), 0); + }); + + test('counts only dives the diver excluded', () async { + await insert('a'); + await insert('b', excluded: true); + await insert('c', excluded: true); + expect(await repo.countExcludedDives(), 2); + }); + + test('does not count planned dives', () async { + await insert('a'); + await db.customStatement( + "INSERT INTO dives (id, dive_date_time, is_planned) " + "VALUES ('planned', 0, 1)", + ); + expect(await repo.countExcludedDives(), 0, + reason: 'the footnote explains the diver\'s own choice; a planned ' + 'dive is not something they excluded'); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/features/statistics/excluded_dives_footnote_test.dart +``` + +Expected: FAIL, `countExcludedDives` undefined. + +- [ ] **Step 3: Add the repository method** + +In `statistics_repository.dart`: + +```dart + /// Counts the dives the diver has explicitly excluded from statistics. + /// + /// Deliberately counts only `excluded_from_stats`, not the whole + /// [DiveStatsScope]: the footnote exists to explain the diver's own choice + /// back to them. Planned dives are not something they chose to exclude, so + /// folding them in would make the number confusing rather than clarifying. + // stats-scope-exempt: counts the excluded, by definition + Future countExcludedDives({String? diverId}) async { + final diverFilter = diverId != null ? 'AND diver_id = ?' : ''; + final row = await _db.customSelect( + 'SELECT COUNT(*) AS c FROM dives ' + 'WHERE excluded_from_stats = 1 $diverFilter', + variables: diverId != null ? [Variable(diverId)] : const [], + ).getSingle(); + return row.read('c'); + } +``` + +- [ ] **Step 4: Add the provider and render the footnote** + +In `statistics_providers.dart`: + +```dart +/// Count of dives the diver excluded from statistics, for the Overview +/// footnote. Kept separate from the filtered providers: it explains a +/// persistent property of the logbook, not the current view filter. +final excludedDiveCountProvider = FutureProvider((ref) async { + ref.watch(statisticsTickProvider); + final repo = ref.watch(statisticsRepositoryProvider); + final diver = ref.watch(currentDiverProvider); + return repo.countExcludedDives(diverId: diver?.id); +}); +``` + +Match the surrounding providers' exact dependency names; `statisticsTickProvider` and `currentDiverProvider` may be spelled differently in this file. Check: + +```bash +grep -n "^final .*Provider" lib/features/statistics/presentation/providers/statistics_providers.dart | head -20 +``` + +In `statistics_overview_page.dart`, at the bottom of the page body: + +```dart + ref.watch(excludedDiveCountProvider).maybeWhen( + data: (count) => count == 0 + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + context.l10n.statistics_excludedDivesFootnote(count), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + orElse: () => const SizedBox.shrink(), + ), +``` + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +flutter test test/features/statistics/excluded_dives_footnote_test.dart +``` + +Expected: PASS, all three tests. + +- [ ] **Step 6: Commit** + +```bash +git add lib/features/statistics/ \ + test/features/statistics/excluded_dives_footnote_test.dart +git commit -m "feat(stats): footnote the excluded dive count on the Overview + +Renders only when nonzero. Explains why the statistics dive count and +the logbook dive count differ." +``` + +--- + +### Task 14: The excluded-dives filter axis + +**Files:** +- Modify: `lib/features/dive_log/domain/models/dive_filter_state.dart` (field ~line 18, constructor ~line 80, `hasActiveFilters` ~line 103, `copyWith` ~line 139, `apply` ~line 298) +- Modify: `lib/features/statistics/data/dive_filter_sql.dart` +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`_buildFilterWhereClauses` ~line 2293) +- Modify: the filter sheet widget +- Test: `test/features/dive_log/dive_filter_excluded_axis_test.dart` + +**Interfaces:** +- Consumes: the `excluded_from_stats` column, `diveLog_filter_excludedOnly`. +- Produces: `DiveFilterState.excludedFromStatsOnly`, a `bool?`. Null means no filtering on this axis; true means only excluded dives. Mirrors `favoritesOnly`. + +Three implementations of the same semantics must agree. A divergence between them is invisible until a diver notices two screens disagreeing. + +- [ ] **Step 1: Write the failing parity test** + +Create `test/features/dive_log/dive_filter_excluded_axis_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/statistics/data/dive_filter_sql.dart'; + +void main() { + final included = Dive( + id: 'included', + diveDateTime: DateTime(2026, 1, 1), + ); + final excluded = Dive( + id: 'excluded', + diveDateTime: DateTime(2026, 1, 2), + excludedFromStats: true, + ); + + test('a null axis is inactive and filters nothing', () { + const filter = DiveFilterState(); + expect(filter.hasActiveFilters, isFalse); + expect(filter.apply([included, excluded]), hasLength(2)); + expect(buildFilteredDiveIdSubquery(filter).subquery, isEmpty); + }); + + test('apply() keeps only excluded dives when the axis is true', () { + const filter = DiveFilterState(excludedFromStatsOnly: true); + expect(filter.hasActiveFilters, isTrue); + final result = filter.apply([included, excluded]); + expect(result.map((d) => d.id), ['excluded']); + }); + + test('the SQL subquery encodes the same rule', () { + const filter = DiveFilterState(excludedFromStatsOnly: true); + final sql = buildFilteredDiveIdSubquery(filter); + expect(sql.subquery, contains('excluded_from_stats = 1')); + }); + + test('copyWith can set and clear the axis', () { + const filter = DiveFilterState(excludedFromStatsOnly: true); + expect(filter.copyWith(clearExcludedFromStatsOnly: true) + .excludedFromStatsOnly, isNull); + expect(const DiveFilterState() + .copyWith(excludedFromStatsOnly: true) + .excludedFromStatsOnly, isTrue); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/features/dive_log/dive_filter_excluded_axis_test.dart +``` + +Expected: FAIL, no such named parameter. + +- [ ] **Step 3: Add the axis to DiveFilterState** + +Beside `final bool? favoritesOnly;` (~line 18): + +```dart + /// When true, keep only dives the diver excluded from statistics, so they + /// can find and review them. Null means this axis is inactive. + /// + /// This is a view filter for *finding* excluded dives. It plays no part in + /// enforcing the exclusion; that is DiveStatsScope's job and applies + /// unconditionally. + final bool? excludedFromStatsOnly; +``` + +Add `this.excludedFromStatsOnly,` to the constructor (~line 80). + +In `hasActiveFilters` (~line 103), beside the `favoritesOnly == true ||` term: + +```dart + excludedFromStatsOnly == true || +``` + +In `copyWith`, add `bool? excludedFromStatsOnly,` and `bool clearExcludedFromStatsOnly = false,` to the signature, and to the body: + +```dart + excludedFromStatsOnly: clearExcludedFromStatsOnly + ? null + : (excludedFromStatsOnly ?? this.excludedFromStatsOnly), +``` + +In `apply` (~line 298), beside the `favoritesOnly` check: + +```dart + if (excludedFromStatsOnly == true && !dive.excludedFromStats) { + return false; + } +``` + +Match the surrounding code's exact predicate style; the existing block may use `continue` inside a loop rather than `return false` inside a `where`. + +- [ ] **Step 4: Add the axis to the two SQL builders** + +In `dive_filter_sql.dart`, beside the `favoritesOnly` condition: + +```dart + if (filter.excludedFromStatsOnly == true) { + conditions.add('excluded_from_stats = 1'); + } +``` + +In `dive_repository_impl.dart`'s `_buildFilterWhereClauses` (~line 2293), add the equivalent clause with that method's alias convention. + +- [ ] **Step 5: Add the control to the filter sheet** + +In `lib/features/statistics/presentation/widgets/dive_filter_sheet.dart` (confirm the path with `find lib -name 'dive_filter_sheet.dart'`), add a toggle beside the favorites toggle, labelled `context.l10n.diveLog_filter_excludedOnly`, reading and writing `excludedFromStatsOnly` with `clearExcludedFromStatsOnly: true` when switched off. + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +flutter test test/features/dive_log/dive_filter_excluded_axis_test.dart +``` + +Expected: PASS, all four tests. + +- [ ] **Step 7: Commit** + +```bash +git add lib/features/dive_log/domain/models/dive_filter_state.dart \ + lib/features/statistics/data/dive_filter_sql.dart \ + lib/features/dive_log/data/repositories/dive_repository_impl.dart \ + lib/features/statistics/presentation/widgets/dive_filter_sheet.dart \ + test/features/dive_log/dive_filter_excluded_axis_test.dart +git commit -m "feat(dive): add an excluded-dives filter axis + +Lets a diver find the dives they excluded. Implemented in all three +filter paths (SQL subquery, repository WHERE builder, in-memory apply) +with a parity test asserting they agree." +``` + +--- + +### Task 15: UDDF and sync round trips + +**Files:** +- Modify: `lib/core/services/export/uddf/uddf_export_builders.dart` +- Modify: `lib/core/services/export/uddf/uddf_full_import_service.dart` +- Test: `test/core/services/export/uddf_exclusion_roundtrip_test.dart` +- Test: `test/core/services/sync/sync_exclusion_roundtrip_test.dart` + +**Interfaces:** +- Consumes: `Dive.excludedFromStats` / `excludedFromGasStats`. +- Produces: nothing other tasks consume. + +Sync needs no hand plumbing: `_exportDives` serializes via the generated `row.toJson()`, so the columns ride along. The sync test is a regression guard. UDDF is hand-plumbed and genuinely needs the work. + +- [ ] **Step 1: Write the failing round-trip tests** + +Create `test/core/services/export/uddf_exclusion_roundtrip_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('UDDF export then import preserves both exclusion flags', () async { + // Build a Dive with excludedFromStats true and excludedFromGasStats + // false, export it to UDDF, import the result, and assert both flags + // survive with their original values. + // + // Then repeat with the flags swapped, so a bug that writes one flag + // into the other's slot cannot pass. + }); + + test('importing UDDF without the exclusion extension defaults to included', + () async { + // Import a UDDF document produced by another application, with no + // Submersion exclusion element. Assert both flags read back false + // rather than null or true. + }); +} +``` + +Create `test/core/services/sync/sync_exclusion_roundtrip_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('sync serialization carries both exclusion flags', () async { + // Insert a dive with both flags true, run the dives export the sync + // serializer uses, and assert the emitted JSON contains + // excluded_from_stats and excluded_from_gas_stats set truthy. + // + // This is a regression guard: the serializer is generated, so this test + // exists to catch a future hand-written column allowlist that would + // silently drop the flags. + }); +} +``` + +Fill in both bodies using the existing export and sync test harnesses: + +```bash +ls test/core/services/export/uddf/ +ls test/core/services/sync/ | head -20 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +flutter test test/core/services/export/uddf_exclusion_roundtrip_test.dart \ + test/core/services/sync/sync_exclusion_roundtrip_test.dart +``` + +Expected: the UDDF tests FAIL (flags are dropped). The sync test may already PASS, which is the correct outcome for a regression guard. + +- [ ] **Step 3: Plumb UDDF export and import** + +Find how `isFavorite` crosses the UDDF boundary and mirror it: + +```bash +grep -n "isFavorite" lib/core/services/export/uddf/uddf_export_builders.dart \ + lib/core/services/export/uddf/uddf_full_import_service.dart +``` + +Write both flags into the same application-specific extension element `isFavorite` uses, and read them back with a `false` default so a document from another application imports as included rather than null. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +flutter test test/core/services/export/uddf_exclusion_roundtrip_test.dart \ + test/core/services/sync/sync_exclusion_roundtrip_test.dart +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/core/services/export/uddf/ test/core/services/ +git commit -m "feat(export): carry statistics-exclusion flags through UDDF + +Round-trip tested in both directions, with a default-to-included path +for documents from other applications. Sync needs no plumbing (generated +serializer); its test is a regression guard." +``` + +--- + +### Task 16: Release notes and final verification + +**Files:** +- Create or modify: the release notes file for the next version +- Modify: nothing in `lib/` + +**Interfaces:** +- Consumes: everything. +- Produces: a branch ready to push. + +- [ ] **Step 1: Write the release note** + +Find the release notes location and the current version: + +```bash +ls docs/releases/ | tail -5 +grep -n "^version:" pubspec.yaml +``` + +Add entries under the next version: + +```markdown +### Added + +- Dives can now be excluded from statistics. Open a dive, and under The Dive + you will find "Exclude from statistics" (keeps the dive in your logbook but + leaves it out of every statistic, dive count included) and "Exclude from gas + statistics" (leaves it out of SAC, RMV and gas mix figures only, for a dive + whose gas number is not representative). Both are available in bulk edit, and + a filter axis lets you find the dives you have excluded. (#526, #1272) + +### Fixed + +- Planned dives no longer count toward your statistics. A dive created in the + planner but never logged was previously included in every total, average and + record. If you have used the planner, your dive count and averages will + change to reflect only dives you actually made. +``` + +The Fixed entry is not optional. It changes existing divers' numbers on upgrade with no action on their part, and an unexplained shift in a dive count reads as data loss. + +- [ ] **Step 2: Format the whole project** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/issue-526-exclude-from-stats +dart format . +``` + +- [ ] **Step 3: Analyze the whole project** + +```bash +flutter analyze +``` + +Expected: zero issues. CI treats infos as fatal, so an info here fails the build. + +- [ ] **Step 4: Run the full test suite once** + +```bash +flutter test 2>&1 | tee /tmp/dive-stats-final.txt +``` + +Do not pipe into `grep`. Do not run this while another local test run is active. One full run is sufficient before the PR. + +Expected: green. Check any failure against the known-flaky index by rerunning that file alone before treating it as real. + +- [ ] **Step 5: Re-run both schema ladder claim scans** + +This is the last chance to catch a rung collision, and it must happen now rather than earlier, because another branch may have claimed 178 in the meantime. + +```bash +# 1. Claims visible in open PRs +for n in $(gh pr list --state open --limit 100 --json number --jq '.[].number'); do + gh pr diff "$n" | grep -oE '^\+\s*static const int currentSchemaVersion = [0-9]+' \ + | sed "s/^/PR $n: /" +done + +# 2. The claims method 1 cannot see: every worktree's working-tree scalar +for w in $(git worktree list --porcelain | awk '/^worktree /{print $2}'); do + v=$(grep -oE 'currentSchemaVersion = [0-9]+' \ + "$w/lib/core/database/database.dart" 2>/dev/null | head -1) + echo "$w: $v" +done +``` + +If anything else claims 178, renumber this branch to the next free rung under the prior-claim tiebreak (a pushed open PR wins over an unpushed worktree), updating the column comment, the `onUpgrade` rung, the `beforeOpen` comment, the migration test, and the release note together. + +- [ ] **Step 6: Commit** + +```bash +git add docs/releases/ +git commit -m "docs: release notes for statistics exclusion + +Documents the new flags and, separately, the behavior change from +planned dives leaving statistics." +``` + +- [ ] **Step 7: Verify the branch is clean and ready** + +```bash +git status --short +git log --oneline origin/main..HEAD +``` + +Expected: a clean working tree and roughly sixteen commits. Do not push or open a PR without asking; the diver on the other end of this decides when. + +--- + +## Self-Review + +**Spec coverage.** Every spec section maps to at least one task: + +| Spec section | Tasks | +| --- | --- | +| Section 1, schema and entity | 1, 3 | +| Section 1, sync and UDDF | 15 | +| Section 2, DiveStatsScope helper | 2 | +| Section 2, Tier 1 statistics | 4 | +| Section 2, Tier 2 dive log | 5 | +| Section 2, Tier 3 per-entity | 6 | +| Section 2, operational exemptions | 7 | +| Section 2, is_planned fix | 2 (predicate), 8 (fallout) | +| Section 3, edit form | 10 | +| Section 3, bulk edit | 11 | +| Section 3, badges | 12 | +| Section 3, statistics footnote | 13 | +| Section 3, filter axis | 14 | +| Section 3, localization | 9 | +| Section 4, behavioral guard suite | 4, 5, 6 | +| Section 4, source census | 7 | +| Section 4, migration tests | 1 | +| Section 4, filter parity | 14 | +| Section 4, round trips | 15 | +| Section 4, widget tests | 10, 11, 12 | +| Risks, is_planned fallout | 8 | +| Risks, schema rung collision | 16 | +| Risks, release note | 16 | +| Verification | 16 | + +No gaps. + +**Type consistency.** `DiveStatsScope.predicate` and `DiveStatsScope.and` keep the same signature (`{String alias = 'd', bool gas = false}`) everywhere they appear, in Tasks 2, 4, 5, 6 and 7. `Dive.excludedFromStats` and `Dive.excludedFromGasStats` are `bool` (non-nullable, defaulting to false) in Tasks 3, 10, 11, 12 and 14. `DiveFilterState.excludedFromStatsOnly` is `bool?` in Task 14, matching `favoritesOnly`, and its `copyWith` clear flag is `clearExcludedFromStatsOnly` in both the signature and the test. `BulkScalarInputs.excludedFromStats` is `bool?` in Task 11, matching `isFavorite`. `countExcludedDives({String? diverId})` returns `Future` in Task 13 and its test. The marker string `stats-scope-exempt` is identical in Tasks 5, 7, 13 and the census test's matcher. + +**Placeholder scan.** No TBD or TODO. Three tasks contain deliberately unfilled test bodies (10, 11, 15) where the body depends on a test harness whose API this plan cannot quote without inventing it; each carries the exact `grep`/`ls` command that locates the harness, plus a precise statement of what the test must assert. Task 12's badge test and every other test are written in full. + +**Line numbers.** Every line reference is marked approximate and paired with a symbol name, because line numbers drift as earlier tasks land. Anchor on the symbol, not the number. diff --git a/docs/superpowers/plans/2026-08-28-gas-calculators-split-view.md b/docs/superpowers/plans/2026-08-28-gas-calculators-split-view.md new file mode 100644 index 0000000000..07dc7a2435 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-gas-calculators-split-view.md @@ -0,0 +1,216 @@ +# Gas Calculators Split View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the six-tab `TabBar` inside Gas Calculators with a nested master-detail layout: the six calculators become a list on the left, the selected one fills the pane on the right, and the whole thing is a full-window page pushed from Planning rather than a tool loaded into Planning's detail pane. + +**Architecture:** `GasCalculatorsPage` becomes a second consumer of the existing `MasterDetailScaffold`, structured exactly like `PlanningPage`: split view at >=1100px, bare list below that. The six calculator widgets and every provider in `gas_calculators_providers.dart` are untouched, because calculator state already lives in top-level `StateProvider`s rather than in the tab widgets. `PlanningTool` and `PlanningTile` are reused rather than copied, so the two lists are identical by construction; `PlanningTool` gains a `routePrefix` and a `presentation` (`PlanningToolPresentation`), which also generalizes the hardcoded dive-planner exception that exists today and carries the entry verb each presentation needs. + +**Tech Stack:** Flutter, Riverpod (`StateProvider` via `package:submersion/core/providers/provider.dart`), go_router (nested `GoRoute` children), `flutter gen-l10n` (11 arb files), `flutter_test` widget tests with `testApp`/`ProviderScope` overrides. + +**Spec:** `docs/superpowers/specs/2026-08-28-gas-calculators-split-view-design.md` + +## Global Constraints + +- Work only in the worktree. Every shell command starts with `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/gas-calculators-split-view` and every file path below is relative to that root. Read/Edit/Write tools need the absolute worktree path; the main checkout must not be touched. +- Never write an em-dash (U+2014) anywhere: code, comments, docs, arb values, commit messages. Use commas, colons, or two sentences. +- No emojis in code, comments, or documentation. No `Co-Authored-By` trailer and no session URL in commit messages. +- TDD: write the failing test, run it and see it fail, implement, run it and see it pass, commit. +- Files stay at or below 800 lines; 200 to 400 is the target. Every new file below is well under that. +- New user-facing strings get a key in all 11 arb files (`ar, de, en, es, fr, he, hu, it, nl, pt, zh`), then `flutter gen-l10n`. Check with `grep -c '""' lib/l10n/arb/app_en.arb` that a key does not already exist before adding it. +- Riverpod imports come from `package:submersion/core/providers/provider.dart`, not `flutter_riverpod` directly. +- Run `dart format .` from the worktree root before every commit. Run `flutter test ` directly, never piped into `grep`/`tail` (the pipe returns grep's exit code, not the suite's). +- Anything displaying a depth or a volume goes through `UnitFormatter`. This plan adds no numeric display of its own; the calculators keep their existing formatting. +- Widget tests that need the split-view branch must set a surface size at or above 1100px wide before pumping, and reset it in a tear-down. + +--- + +## File Structure + +New files (one responsibility each): + +| File | Responsibility | +| --- | --- | +| `lib/features/gas_calculators/presentation/gas_calculator_tools.dart` | `gasCalculatorToolsOf(context)`: the six entries in display order | +| `lib/features/gas_calculators/presentation/widgets/gas_calculators_list_content.dart` | The master list: header, tiles, disclaimer | +| `lib/features/gas_calculators/presentation/widgets/gas_calculators_summary_widget.dart` | The empty right pane | +| `lib/features/gas_calculators/presentation/pages/gas_calculator_detail_page.dart` | Id to calculator widget, with pane or full-page chrome | +| `test/features/gas_calculators/gas_calculators_page_test.dart` | Layout and routing tests for the new page | + +Modified: `gas_calculators_page.dart`, `planning_tools.dart`, `planning_list_content.dart`, `planning_tool_pane.dart`, `planning_page.dart`, `app_router.dart`, the 11 arb files, `planning_tool_embedding_test.dart`, `planning_page_test.dart`. + +Deleted: nothing. + +--- + +## Task 1: Localization keys + +- [x] Confirm none of the seven keys exist yet: `grep -c '"gasCalculators_desc_mod"' lib/l10n/arb/app_en.arb` and the same for `_bestMix`, `_consumption`, `_rockBottom`, `_mnd`, `_blender`, and `gasCalculators_summary_prompt`. +- [x] Add all seven to `lib/l10n/arb/app_en.arb` with a `@key` description entry each, then translate into the other ten arb files. English values: + - `gasCalculators_desc_mod`: "Deepest safe depth for a mix" + - `gasCalculators_desc_bestMix`: "Richest mix for a target depth" + - `gasCalculators_desc_consumption`: "Gas a planned dive will use" + - `gasCalculators_desc_rockBottom`: "Reserve to bring two divers up" + - `gasCalculators_desc_mnd`: "Narcosis depth limit for a mix" + - `gasCalculators_desc_blender`: "Fill procedure for a target mix" + - `gasCalculators_summary_prompt`: "Select a calculator to get started" +- [x] Run `flutter gen-l10n`. +- [x] Run `flutter test test/l10n/` (the arb parity suite) and see it pass. +- [x] Commit. + +Note: the six titles reuse the existing `gasCalculators_tab_*` values. Do not add new title keys. + +## Task 2: `PlanningTool` gains `routePrefix` and `presentation` + +- [x] Write a test in `test/features/planning/planning_page_test.dart` asserting that a `PlanningTool` with `routePrefix: '/planning/gas-calculators'` and id `mod` produces the route `/planning/gas-calculators/mod`, and that one with a non-default `presentation` is rendered by `PlanningTile` with navigation rather than a selection callback. Run it and see it fail. +- [x] Add both fields to `PlanningTool` in `lib/features/planning/presentation/planning_tools.dart`, defaulting to `'/planning'` and `false`. Update the `route` getter to use `routePrefix`. +- [x] In `planning_list_content.dart`, have `PlanningTile` treat a non-default `tool.presentation` as "navigate, do not select" by ignoring `onToolSelected`, and pick the entry verb from it: `go` for `splitViewPage`, `push` otherwise. Document that this generalizes the `kDivePlannerToolId` exception. +- [x] Set `presentation: PlanningToolPresentation.splitViewPage` on the `gas-calculators` entry in `planningToolsOf`. +- [x] Run the test and see it pass. Run `flutter test test/features/planning/` and see it pass. +- [x] Commit. + +## Task 3: `PlanningToolPane` gains a `leading` slot + +- [x] Write a test in `test/features/planning/planning_tool_embedding_test.dart` asserting that a `PlanningToolPane` given a `leading` widget renders it before the title, and that omitting it changes nothing about the existing layout. Run it and see it fail. +- [x] Add the optional `leading` parameter to `PlanningToolPane`, rendered ahead of the title and replacing the current fixed `SizedBox(width: 8)` when present. Keep the title the row's only flexible child, per the existing comment. +- [x] Run the test and see it pass. +- [x] Commit. + +## Task 4: The six calculator entries + +- [x] Write `lib/features/gas_calculators/presentation/gas_calculator_tools.dart` with `gasCalculatorToolsOf(BuildContext)` returning six `PlanningTool`s in this order and with these ids, icons, and title keys: + + | id | icon | title key | subtitle key | + | --- | --- | --- | --- | + | `mod` | `Icons.arrow_downward` | `gasCalculators_tab_mod` | `gasCalculators_desc_mod` | + | `best-mix` | `Icons.science` | `gasCalculators_tab_bestMix` | `gasCalculators_desc_bestMix` | + | `consumption` | `Icons.local_gas_station` | `gasCalculators_tab_consumption` | `gasCalculators_desc_consumption` | + | `rock-bottom` | `Icons.warning_amber` | `gasCalculators_tab_rockBottom` | `gasCalculators_desc_rockBottom` | + | `mnd` | `Icons.psychology` | `gasCalculators_tab_mnd` | `gasCalculators_desc_mnd` | + | `blender` | `Icons.gas_meter` | `gasCalculators_tab_blender` | `gasCalculators_desc_blender` | + + Every entry sets `routePrefix: '/planning/gas-calculators'`. Icons are the current tab icons, so the calculators keep the glyphs users already associate with them. Colors come from the active `ColorScheme`, following `planningToolsOf`. +- [x] Add a `kGasCalculatorIds` constant (the six ids in order) so the router, the detail page, and the tests share one source of truth. +- [x] Commit. + +## Task 5: The detail page + +- [x] Write a test asserting that `GasCalculatorDetailPage` renders the right calculator widget for each of the six ids, wraps it in `PlanningToolPane` when embedded and in a `Scaffold` with an `AppBar` when not, and falls back to the summary for an unknown id (ids arrive from the URL, so a stale link must land somewhere useful, matching `PlanningPage._buildTool`). Run it and see it fail. +- [x] Write `lib/features/gas_calculators/presentation/pages/gas_calculator_detail_page.dart` with an `embedded` flag, mirroring the chrome contract the other planning tools follow. +- [x] Run the test and see it pass. +- [x] Commit. + +## Task 6: The list and summary + +- [x] Write a test asserting `GasCalculatorsListContent` renders six tiles in the documented order, shows the safety disclaimer, renders its own `AppBar` when `showAppBar` is true, and renders a `PlanningToolPane` header with a back button and the reset action when it is false. Run it and see it fail. +- [x] Write `gas_calculators_list_content.dart` and `gas_calculators_summary_widget.dart`. The summary shows the page title, `gasCalculators_summary_prompt`, and the safety disclaimer; it deliberately carries no calculator shortcuts, since the list sits permanently to its left (the reasoning `PlanningSummaryWidget` records). +- [x] The reset action calls the existing `resetGasCalculators(ref)` and lives only in the master header, never in the detail pane, because it resets all six. +- [x] Run the test and see it pass. +- [x] Commit. + +## Task 7: Rewrite `GasCalculatorsPage` + +- [x] Write `test/features/gas_calculators/gas_calculators_page_test.dart`: below 1100px the page shows the six rows and no `TabBar`; at 1400px it shows the list and the summary side by side; selecting a row shows that calculator and still no `TabBar` anywhere in the tree. Run it and see it fail. +- [x] Rewrite `gas_calculators_page.dart` as a `MasterDetailScaffold` consumer with `sectionId: 'gas-calculators'`, `queryParamKey: 'calc'`, and `mobileDetailRoute: (id) => '/planning/gas-calculators/$id'`, falling back to bare `GasCalculatorsListContent` below the breakpoint. Delete the `TabController`, the `TabBar`, the `TabBarView`, and the `embedded` field. +- [x] Run the test and see it pass. +- [x] Commit. + +## Task 8: Routing and the Planning hand-off + +- [x] Update `test/features/planning/planning_tool_embedding_test.dart`: remove Gas Calculators from the embedded-tools table (it no longer has an embedded form) and assert instead that its Planning tile pushes. Run it and see it fail on the current code. +- [x] In `app_router.dart`, add six child `GoRoute`s under `gas-calculators`, each building `GasCalculatorDetailPage(toolId: )`. +- [x] In `planning_page.dart`, delete the `gas-calculators` case from `_buildTool` and its now-unused import. +- [x] Run the updated tests and see them pass. +- [x] Commit. + +## Task 9: Blender width check + +- [x] Pump `GasCalculatorDetailPage(toolId: 'blender', embedded: true)` at 400px wide, the detail pane's reserved minimum (`_kDetailPaneReservedWidth`), and assert no overflow. +- [x] If it overflows, fix it in the blender's own layout and note the fix in the PR body. If it does not, say so explicitly rather than silently dropping the check. +- [x] Commit. + +## Task 10: Verification + +- [x] `dart format .` from the worktree root. +- [x] `flutter analyze` over the whole project, treating infos as failures (CI does). +- [x] One full `flutter test` run. Cross-check any failure against `INDEX_FLAKY_TESTS.md` before treating it as real, and rerun a suspected flake as a lone file. +- [x] Launch the macOS app and confirm by hand: Planning to Gas Calculators pushes; the six rows are there; selecting one fills the pane with no tab strip; back returns to Planning; the window narrowed below 1100px shows the list alone and a row pushes one calculator. +- [x] Open the PR. No attribution line, no session URL. + +--- + +## Notes for the implementer + +- Do not touch the six calculator widgets or `gas_calculators_providers.dart`. If a task seems to need a change there, stop: it means an assumption in the spec (F4) is wrong, and that is worth reporting rather than working around. +- `MasterDetailScaffold` selects with `router.go`, not `push`. That is correct here: `go` rebuilds the stack as `[/planning, /planning/gas-calculators]`, so the back gesture still returns to Planning. +- The schema version ladder is not involved. This change touches no database code and must not bump `schemaVersion`. + +--- + +## As built + +Three things went differently from the plan. All three are in the commits. + +**Tasks 5 and 6 swapped one file.** `GasCalculatorDetailPage` falls back to +the summary for an unknown id, so `gas_calculators_summary_widget.dart` had to +exist before Task 5 could go green. It landed in Task 5's commit rather than +Task 6's. + +**Tasks 7 and 8 landed as one commit.** Removing `GasCalculatorsPage.embedded` +breaks `PlanningPage._buildTool` in the same edit, so the page rewrite, the +router children, and the Planning hand-off could not be separated without a +commit that does not compile. + +**Task 9 found more than the blender, and none of it was new.** The blender +fits 400px unchanged; the plan's suspicion was wrong. Three other Rows did +overflow, each the same shape (an icon and a long label with nothing +flexible): `unit_slider.dart`'s label, and the section headings on Best Mix +and Rock Bottom. Pumping the bare calculator widgets with no pane chrome at +all reproduced every one of them, and the old tabbed page sat in a pane with +the same 400px floor, so these predate the change rather than following from +it. Fixed anyway, since a detail pane is where the calculators now live, and +pinned by a test that pumps all six at 400px. + +One assertion in `gas_calculators_page_test.dart` reads +`currentConfiguration.last.matchedLocation` rather than +`currentConfiguration.uri`: an imperative `push` does not move `uri`, which +still reports the last `go`. The split-view assertions use `uri` because +`MasterDetailScaffold` selects with `go`. + +## Verification + +- `flutter analyze` over the whole project: no issues. +- ARB parity across all eleven locales: green. +- Full `flutter test`: 21503 passing, 19 skipped, 1 failing. The failure is + `media_share_helper_test.dart`, "iPad share popover anchor falls back to the + calling widget rather than nothing", which is the known flake recorded as + `share-bytes` and passes when the file is run alone. Nothing in this branch + touches media sharing. + +## Follow-up: the first-selection re-animation + +Found by hand after the first implementation, and fixed before merge. + +**Symptom:** tapping a calculator for the first time slid the whole Gas +Calculators page in from the right again. Every later tap was instant. + +**Root cause**, from page keys dumped either side of the first selection: +`PlanningTile` entered the page with `context.push`, which go_router keys with +a generated string, while `MasterDetailScaffold._onItemSelected` selects with +`router.go`, which rebuilds the stack from the declarative match and keys the +page by its path. The first selection therefore changed the page key, and +Flutter's Navigator animated what it saw as a different page. Mid-transition +both copies of the list were mounted; afterwards the key was stable, so +nothing animated again. + +**Fix:** `fullPage` became `PlanningToolPresentation`, and a `splitViewPage` +tool is entered with `go`. Verified that `go` on a nested child route still +leaves the hub on the stack, so the back button keeps working. The dive +planner keeps its `push`; it drives no URL after arrival and #647 wants it +poppable. + +**Guarded by:** `planning_page_test.dart` asserts the entry verb and the +resulting declarative page key, plus that the planner still pushes; +`gas_calculators_page_test.dart` counts the mounted lists mid-transition, +which is what distinguishes the two behaviours. That assertion was confirmed +to fail against the old `push` entry before being kept. diff --git a/docs/superpowers/plans/2026-08-29-diver-buddy-profile-photos.md b/docs/superpowers/plans/2026-08-29-diver-buddy-profile-photos.md new file mode 100644 index 0000000000..2524d7df4c --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-diver-buddy-profile-photos.md @@ -0,0 +1,3174 @@ +# Diver and Buddy Profile Photos Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give divers and buddies a profile photo stored as a size-bounded JPEG blob in the database, so it syncs across devices without growing the database without limit. + +**Architecture:** A nullable `BlobColumn photo` is added to the `divers` and `buddies` Drift tables, mirroring the existing `certifications.photoFront` blob. All resizing happens in Dart on the write path through a new shared codec, because `image_picker`'s size arguments are silently ignored on desktop. A pan-and-zoom crop dialog returns a rectangle in source-image pixels, which the codec crops, downscales to 512x512, and JPEG-encodes. One shared `ProfileAvatar` widget replaces 13 duplicated `CircleAvatar` sites. + +**Tech Stack:** Flutter, Drift (SQLite/SQLCipher), Riverpod, `image: ^4.3.0`, `image_picker: ^1.1.2`, `flutter_contacts: ^2.0.2`, GoRouter. + +**Spec:** `docs/superpowers/specs/2026-08-29-diver-buddy-profile-photos-design.md` + +## Global Constraints + +- **Worktree:** all work happens in `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-photos` on branch `worktree-profile-photos`. Use absolute paths; the shell's working directory can reset to the main checkout between commands. +- **No em-dashes** (U+2014) in any output: code, comments, docs, commit messages, ARB strings. En-dashes (U+2013) used as prose punctuation, double hyphens, and spaced hyphens are equally forbidden. Use commas, colons, semicolons, parentheses, or two sentences. Hyphens inside compound words and CLI flags are fine. +- **No emojis** in code, comments, or documentation. +- **Stored avatar format:** 512x512 square JPEG, quality 85. +- **Stored certification card format:** 2000px longest edge, quality 85, aspect ratio preserved. +- **Schema:** claim rung **181**. Re-verify against `origin/main` before Task 1. PR #1374 already landed and took 180, which is why this is 181. `minimumCompatibleSchemaVersion` stays **170** and must not be changed. +- **Line numbers are advisory, the quoted code is authoritative.** They were re-derived after rebasing onto `origin/main` at v180. If a number does not match, locate the quoted snippet by text and proceed. +- **Analyze must be run as** `flutter analyze --fatal-infos` (CI fails on info-level diagnostics). +- **Format must be run as** `dart format .` before committing; CI runs `dart format --set-exit-if-changed .`. +- **Never pipe `flutter test` through `grep`.** The pipeline returns grep's exit status, so a failing suite reads as success. +- **ARB parity:** `test/l10n/arb_parity_test.dart` requires all 10 non-English locales to define exactly the English key set. Any string change lands in all 11 files in the same commit. +- **Generated files split two ways.** `*.g.dart` is gitignored (`.gitignore:9`), so Drift and mockito output is never committed and a fresh worktree must run `build_runner` before anything compiles. But `lib/l10n/arb/app_localizations*.dart` comes from `flutter gen-l10n`, does not match that pattern, and IS tracked, so l10n regeneration must be committed. +- **Immutability:** never mutate objects or arrays; all domain entities have `copyWith`. +- **Commit only what the task names.** Never `git add -A` or `git add -u` in this repo: sibling worktrees share the checkout and a broad add can commit another session's work or restage a stale submodule. + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +| --- | --- | +| `lib/core/services/images/profile_photo_codec.dart` | Bytes-in/bytes-out decode, orientation-bake, crop, resize, JPEG-encode on an isolate | +| `lib/shared/widgets/profile_photo/profile_photo_crop_geometry.dart` | Pure viewport-to-source-pixel rectangle math | +| `lib/shared/widgets/profile_photo/profile_photo_crop_dialog.dart` | Full-screen pan/zoom crop surface | +| `lib/shared/widgets/profile_photo/profile_photo_source_sheet.dart` | Source chooser (camera, library, contacts, remove) | +| `lib/shared/widgets/profile_photo/profile_photo_picker.dart` | Orchestrates source sheet, byte acquisition, crop dialog, encode | +| `lib/shared/widgets/profile_photo/profile_avatar.dart` | Shared avatar display with initials fallback | +| `lib/shared/utils/contact_import_support.dart` | Shared iOS/Android platform guard | + +**Modified:** `lib/core/database/database.dart`, `lib/features/divers/domain/entities/diver.dart`, `lib/features/buddies/domain/entities/buddy.dart`, both repositories, `buddy_merge_repository.dart`, `sync_data_serializer.dart`, the 13 avatar call sites, `buddy_list_content.dart`, `certification_edit_page.dart`, `entity_table_view.dart`, `app_router.dart`, and all 11 ARB files. + +--- + +## Task 1: Schema column and v181 migration + +**Files:** +- Modify: `lib/core/database/database.dart` (table classes, helper, ladder, `migrationVersions`, `currentSchemaVersion`, `beforeOpen`) +- Test: `test/core/database/migration_v181_profile_photo_test.dart` (create) + +**Interfaces:** +- Consumes: nothing. +- Produces: `divers.photo` and `buddies.photo` as `BlobColumn` (`Uint8List?`), generated Drift row fields `Diver.photo` and `Buddy.photo`, and `AppDatabase.currentSchemaVersion == 181`. + +- [ ] **Step 1: Confirm rung 181 is still free** + +Run: +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-photos +git fetch origin main +git show origin/main:lib/core/database/database.dart | grep -n "currentSchemaVersion = " +``` +Expected: `currentSchemaVersion = 180`. If it reports 181 or higher, use the next free integer above it everywhere this task says 181, and say so in the commit message. + +- [ ] **Step 2: Write the failing migration test** + +Create `test/core/database/migration_v181_profile_photo_test.dart`: + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v181 shape: divers and buddies without the photo column, +/// stamped at v180 so the upgrade to 181 runs. +NativeDatabase _dbAt180() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 180'); + rawDb.execute(''' + CREATE TABLE divers ( + id TEXT NOT NULL PRIMARY KEY + ) + '''); + rawDb.execute(''' + CREATE TABLE buddies ( + id TEXT NOT NULL PRIMARY KEY + ) + '''); + rawDb.execute("INSERT INTO divers (id) VALUES ('d1')"); + rawDb.execute("INSERT INTO buddies (id) VALUES ('b1')"); + }, + ); +} + +Future> _columns(AppDatabase db, String table) async { + final cols = await db.customSelect("PRAGMA table_info('$table')").get(); + return cols.map((c) => c.read('name')).toSet(); +} + +void main() { + test('v181 adds a nullable photo column to divers and buddies', () async { + final db = AppDatabase(_dbAt180()); + addTearDown(() => db.close()); + + expect(await _columns(db, 'divers'), contains('photo')); + expect(await _columns(db, 'buddies'), contains('photo')); + + // Pre-existing rows survive and default to no photo. + final diver = await db + .customSelect("SELECT photo FROM divers WHERE id = 'd1'") + .getSingle(); + expect(diver.data['photo'], isNull); + }); + + test('fresh databases get the photo column', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + expect(await _columns(db, 'divers'), contains('photo')); + expect(await _columns(db, 'buddies'), contains('photo')); + }); + + test('the helper no-ops when the tables are absent', () async { + final native = NativeDatabase.memory( + setup: (rawDb) => rawDb.execute('PRAGMA user_version = 180'), + ); + final db = AppDatabase(native); + addTearDown(db.close); + + await expectLater(db.customSelect('SELECT 1').get(), completes); + }); + + test('v181 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(181)); + expect(AppDatabase.migrationVersions, contains(181)); + }); + + test('the sync compatibility floor is unchanged', () { + expect(AppDatabase.minimumCompatibleSchemaVersion, 170); + }); +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `flutter test test/core/database/migration_v181_profile_photo_test.dart` +Expected: FAIL. The `photo` column is missing and `migrationVersions` does not contain 181. + +- [ ] **Step 4: Add the Drift columns** + +In `lib/core/database/database.dart`, in `class Divers extends Table` (around line 20), replace the `photoPath` line with these two lines: + +```dart + /// Deprecated, superseded by [photo]. Never written for divers; kept so a + /// database that predates v181 still maps. + TextColumn get photoPath => text().nullable()(); + + /// Profile photo: a 512x512 square JPEG produced by + /// `lib/core/services/images/profile_photo_codec.dart`. Stored on the row so + /// it syncs with the diver rather than depending on a device-local path. + BlobColumn get photo => blob().nullable()(); +``` + +In `class Buddies extends Table` (around line 1983), make the identical change, with "buddy" in place of "diver" in the second comment. + +- [ ] **Step 5: Add the idempotent DDL helper** + +In `lib/core/database/database.dart`, directly after `_assertBuddyFavoriteColumn` (around line 5569), add: + +```dart + /// Idempotent DDL for the v181 divers.photo and buddies.photo columns. + /// Holds a 512x512 square JPEG, so it is nullable with no default. Self- + /// guards on each table existing, which is what makes it safe to call from + /// both the ladder and beforeOpen. + Future _assertProfilePhotoColumns() async { + for (final table in const ['divers', 'buddies']) { + final cols = await customSelect("PRAGMA table_info('$table')").get(); + if (cols.isEmpty) continue; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('photo')) { + await customStatement('ALTER TABLE $table ADD COLUMN photo BLOB'); + } + } + } +``` + +- [ ] **Step 6: Add the ladder rung** + +In `lib/core/database/database.dart`, immediately after the `if (from < 180) await reportProgress();` line (around line 9265), add: + +```dart + // v181: divers.photo and buddies.photo, the profile photo blobs. + if (from < 181) { + await _assertProfilePhotoColumns(); + } + if (from < 181) await reportProgress(); +``` + +- [ ] **Step 7: Append to migrationVersions** + +In `lib/core/database/database.dart`, change the tail of `migrationVersions` (around line 3717) from ` 180,\n ];` to: + +```dart + 180, + // v181: divers.photo and buddies.photo, the profile photo blobs. Claimed + // against origin/main at 180. A rung at or below the shipped version + // merges with no conflict marker and its onUpgrade step then never runs, + // so re-verify this number if this branch sits open while main advances. + 181, + ]; +``` + +- [ ] **Step 8: Bump currentSchemaVersion** + +In `lib/core/database/database.dart` line 3329, change `static const int currentSchemaVersion = 180;` to `static const int currentSchemaVersion = 181;`. Do **not** touch `minimumCompatibleSchemaVersion`. + +- [ ] **Step 9: Add the beforeOpen backstop** + +In `lib/core/database/database.dart`, in the `beforeOpen` block after the `_assertSiteSuggestionDismissedAtColumn()` backstop call (around line 9472), add: + +```dart + // v181 backstop: re-assert divers.photo and buddies.photo. A database + // that arrives by restore or sync-adopt never runs onUpgrade, and + // every read of a diver or buddy row would throw without the column. + await _assertProfilePhotoColumns(); +``` + +- [ ] **Step 10: Regenerate Drift code** + +Run: +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-photos +dart run build_runner build --delete-conflicting-outputs +``` +Expected: succeeds. `lib/core/database/database.g.dart` now has `photo` on the divers and buddies table and data classes. + +Do NOT try to commit `database.g.dart`. `.gitignore:9` is a blanket `*.g.dart`, so every Drift and mockito output is untracked and each worktree regenerates its own. `git add` on it fails with "paths are ignored". + +- [ ] **Step 11: Run the test to verify it passes** + +Run: `flutter test test/core/database/migration_v181_profile_photo_test.dart` +Expected: PASS, all five tests. + +- [ ] **Step 12: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/core/database/database.dart test/core/database/migration_v181_profile_photo_test.dart +git commit -m "feat(db): add profile photo blob columns to divers and buddies (v181)" +``` + +--- + +## Task 2: Domain entities + +**Files:** +- Modify: `lib/features/divers/domain/entities/diver.dart` +- Modify: `lib/features/buddies/domain/entities/buddy.dart` +- Test: `test/features/divers/domain/diver_copywith_clear_test.dart` (modify) +- Test: `test/features/buddies/domain/buddy_photo_test.dart` (create) + +**Interfaces:** +- Consumes: nothing from Task 1 at compile time. +- Produces: `Diver.photo` (`Uint8List?`, clearable via `copyWith(photo: null)`), `Buddy.photo` (`Uint8List?`), and `Buddy clearPhoto()`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/buddies/domain/buddy_photo_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; + +Buddy _buddy({Uint8List? photo}) => Buddy( + id: 'b1', + name: 'Jane Doe', + photo: photo, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), +); + +void main() { + test('photo defaults to null', () { + expect(_buddy().photo, isNull); + }); + + test('copyWith carries the photo through', () { + final bytes = Uint8List.fromList([1, 2, 3]); + expect(_buddy().copyWith(photo: bytes).photo, bytes); + }); + + test('clearPhoto removes the photo and keeps every other field', () { + final bytes = Uint8List.fromList([1, 2, 3]); + final withPhoto = _buddy(photo: bytes); + final cleared = withPhoto.clearPhoto(); + + expect(cleared.photo, isNull); + expect(cleared.id, withPhoto.id); + expect(cleared.name, withPhoto.name); + expect(cleared.createdAt, withPhoto.createdAt); + expect(cleared.updatedAt, withPhoto.updatedAt); + }); + + test('photo participates in equality', () { + final a = _buddy(photo: Uint8List.fromList([1])); + final b = _buddy(photo: Uint8List.fromList([1])); + expect(a, isNot(equals(b)), + reason: 'Uint8List equality is by identity, so two distinct byte ' + 'lists make distinct buddies; this documents the behaviour so a ' + 'future change to props is deliberate'); + expect(a, equals(a)); + }); +} +``` + +In `test/features/divers/domain/diver_copywith_clear_test.dart`, add this test inside the existing `main()`: + +```dart + test('copyWith clears the photo when passed null', () { + final bytes = Uint8List.fromList([1, 2, 3]); + final d = Diver( + id: 'd1', + name: 'Jane', + photo: bytes, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + expect(d.photo, bytes); + expect(d.copyWith(photo: null).photo, isNull); + expect(d.copyWith().photo, bytes); + }); +``` + +Add `import 'dart:typed_data';` to that file if it is not already imported. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +flutter test test/features/buddies/domain/buddy_photo_test.dart test/features/divers/domain/diver_copywith_clear_test.dart +``` +Expected: FAIL, "No named parameter with the name 'photo'". + +- [ ] **Step 3: Add photo to Diver** + +In `lib/features/divers/domain/entities/diver.dart`: + +Add `import 'dart:typed_data';` at the top if absent. + +After the `final String? photoPath;` field declaration (line 69), add: + +```dart + /// Profile photo: a 512x512 square JPEG. Supersedes [photoPath]. + final Uint8List? photo; +``` + +In the constructor, after `this.photoPath,`, add `this.photo,`. + +In `copyWith`'s parameter list, after `Object? photoPath = _unset,`, add: + +```dart + Object? photo = _unset, +``` + +In the returned `Diver(...)`, after the `photoPath:` line, add: + +```dart + photo: _resolve(photo, this.photo, 'photo'), +``` + +In `props`, after `photoPath,`, add `photo,`. + +- [ ] **Step 4: Add photo and clearPhoto to Buddy** + +In `lib/features/buddies/domain/entities/buddy.dart`: + +Add `import 'dart:typed_data';` at the top. + +After `final String? photoPath;` (line 15), add: + +```dart + /// Profile photo: a 512x512 square JPEG. Supersedes [photoPath]. + final Uint8List? photo; +``` + +In the constructor, after `this.photoPath,`, add `this.photo,`. + +In `copyWith`'s parameter list, after `String? photoPath,`, add `Uint8List? photo,`. In the returned `Buddy(...)`, after the `photoPath:` line, add `photo: photo ?? this.photo,`. + +Then add this method directly after `copyWith`: + +```dart + /// Create a copy with the profile photo explicitly removed. + /// + /// [copyWith] uses the plain `??` idiom, so `copyWith(photo: null)` keeps + /// the current value rather than clearing it. This mirrors + /// `Certification.clearPhotos`, which solves the same problem for the + /// certification card blobs. + Buddy clearPhoto() { + return Buddy( + id: id, + diverId: diverId, + name: name, + email: email, + phone: phone, + certificationLevel: certificationLevel, + certificationAgency: certificationAgency, + photoPath: photoPath, + photo: null, + notes: notes, + isFavorite: isFavorite, + createdAt: createdAt, + updatedAt: updatedAt, + ); + } +``` + +In `props`, after `photoPath,`, add `photo,`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: +```bash +flutter test test/features/buddies/domain/buddy_photo_test.dart test/features/divers/domain/diver_copywith_clear_test.dart +``` +Expected: PASS. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/features/divers/domain/entities/diver.dart lib/features/buddies/domain/entities/buddy.dart test/features/buddies/domain/buddy_photo_test.dart test/features/divers/domain/diver_copywith_clear_test.dart +git commit -m "feat(entities): carry a profile photo on Diver and Buddy" +``` + +--- + +## Task 3: Repository wiring + +**Files:** +- Modify: `lib/features/buddies/data/repositories/buddy_repository.dart` (every `photoPath` site: lines 152, 182, 244, 292, 385, 447, 838, 1117) +- Modify: `lib/features/divers/data/repositories/diver_repository.dart` (every `photoPath` site: lines 144, 208, 676) +- Modify: `lib/features/buddies/data/repositories/buddy_merge_repository.dart` (lines 115, 446, 594) +- Modify: `lib/features/buddies/presentation/pages/buddy_merge_form_controller.dart` (line 81) +- Test: `test/features/buddies/data/buddy_photo_persistence_test.dart` (create) + +**Interfaces:** +- Consumes: `Buddy.photo`, `Diver.photo` from Task 2; `buddies.photo`, `divers.photo` columns from Task 1. +- Produces: photos that survive a create, read, update, and clear round-trip. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/buddies/data/buddy_photo_persistence_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; + +import '../../../helpers/test_database.dart'; + +Buddy _buddy({Uint8List? photo}) => Buddy( + id: 'b-photo-1', + name: 'Jane Doe', + photo: photo, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), +); + +void main() { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() { + DatabaseService.instance.resetForTesting(); + }); + + test('a photo survives create and read', () async { + final repo = BuddyRepository(); + final bytes = Uint8List.fromList(List.generate(64, (i) => i)); + + await repo.createBuddy(_buddy(photo: bytes)); + final read = await repo.getBuddyById('b-photo-1'); + + expect(read, isNotNull); + expect(read!.photo, bytes); + }); + + test('updating to a new photo replaces the stored bytes', () async { + final repo = BuddyRepository(); + await repo.createBuddy(_buddy(photo: Uint8List.fromList([1, 2, 3]))); + + final replacement = Uint8List.fromList([9, 9, 9, 9]); + await repo.updateBuddy(_buddy(photo: replacement)); + final read = await repo.getBuddyById('b-photo-1'); + + expect(read!.photo, replacement); + }); + + test('updating with a null photo clears the stored bytes', () async { + final repo = BuddyRepository(); + await repo.createBuddy(_buddy(photo: Uint8List.fromList([1, 2, 3]))); + + await repo.updateBuddy(_buddy().clearPhoto()); + final read = await repo.getBuddyById('b-photo-1'); + + expect(read!.photo, isNull); + }); + + test('a buddy with no photo reads back null, not empty bytes', () async { + final repo = BuddyRepository(); + await repo.createBuddy(_buddy()); + final read = await repo.getBuddyById('b-photo-1'); + + expect(read!.photo, isNull); + }); +} +``` + +If `getBuddyById` is not the accessor name in this repository, open `buddy_repository.dart` and use the single-buddy read method it actually defines. Do not add a new one. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/buddies/data/buddy_photo_persistence_test.dart` +Expected: FAIL. The photo reads back null after create, because the mapper and companion drop it. + +- [ ] **Step 3: Wire the buddy repository** + +In `lib/features/buddies/data/repositories/buddy_repository.dart`: + +In `_mapRowToBuddy` (line 1106, the `photoPath: row.photoPath,` at line 1117), add `photo: row.photo,` directly after it. + +In the inline row-to-`Buddy` construction feeding `_withPrimaryCerts` (lines 140-162), which reads columns via `row.data[...]`, after the `photoPath:` entry add: + +```dart + photo: row.data['photo'] as Uint8List?, +``` + +In `createBuddy`'s `BuddiesCompanion` (method at line 167, the `photoPath:` line at 182), after `photoPath: Value(buddy.photoPath),` add `photo: Value(buddy.photo),`. + +In `updateBuddy`'s `BuddiesCompanion` (method at line 279, the `photoPath:` line at 292), make the identical addition. + +There are four further `photoPath` sites in this file, and every one of them needs a `photo` sibling or the photo silently vanishes on that path: + +- **Lines 244, 385, 838** are row mappers reading `row.data['photo_path'] as String?`. Add `photo: row.data['photo'] as Uint8List?,` directly after each. +- **Line 447** is an entity-to-entity copy reading `photoPath: b.photoPath,`. Add `photo: b.photo,` after it. + +Use `grep -n "photoPath" lib/features/buddies/data/repositories/buddy_repository.dart` and confirm you end with exactly eight `photo` siblings, one per `photoPath`. + +Add `import 'dart:typed_data';` if the file does not already import it. + +- [ ] **Step 4: Wire the diver repository** + +In `lib/features/divers/data/repositories/diver_repository.dart`: + +In `_mapRowToDiver` (method at line 670, the `photoPath: row.photoPath,` at line 676), add `photo: row.photo,` directly after it. + +In `createDiver`'s `DiversCompanion` (method at line 130, companion at 139, the `photoPath:` line at 144), after `photoPath: Value(diver.photoPath),` add `photo: Value(diver.photo),`. + +In `updateDiver`'s `DiversCompanion` (method at line 198, companion at 204, the `photoPath:` line at 208), make the identical addition. + +Leave the `DiversCompanion` at line 558 alone: it writes only `isDefault` and `updatedAt`. + +Add `import 'dart:typed_data';` if absent. + +- [ ] **Step 5: Wire the merge paths** + +In `lib/features/buddies/data/repositories/buddy_merge_repository.dart`, at each of lines 115, 446, and 594, add a `photo` line beside the existing `photoPath` line, in the same style as its neighbour (`photo: row.photo,` for a mapper, `photo: Value(buddy.photo),` for a companion). + +In `lib/features/buddies/presentation/pages/buddy_merge_form_controller.dart`, line 81 selects a winning `photoPath` with `(buddy) => buddy.photoPath`. Add an equivalent selector for `photo` immediately after it, following the surrounding code's shape exactly. + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `flutter test test/features/buddies/data/buddy_photo_persistence_test.dart` +Expected: PASS, all four tests. + +- [ ] **Step 7: Run the surrounding suites for regressions** + +Run: +```bash +flutter test test/features/buddies test/features/divers +``` +Expected: PASS. If `buddy_list_tile_test.dart` fails, leave it: Task 11 rewrites it. Note the failure and move on only if it is that one file. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/features/buddies/data lib/features/divers/data lib/features/buddies/presentation/pages/buddy_merge_form_controller.dart test/features/buddies/data/buddy_photo_persistence_test.dart +git commit -m "feat(repos): persist diver and buddy profile photos" +``` + +--- + +## Task 4: Sync serialization + +**Files:** +- Modify: `lib/core/services/sync/sync_data_serializer.dart` (12 sites: lines 654, 718, 1555, 1650, 1971, 2026, 2444, 2549, 2951, 3115, 4535, 4803) +- Test: `test/core/services/sync/sync_blob_base64_test.dart` (modify) + +**Interfaces:** +- Consumes: `divers.photo`, `buddies.photo` from Task 1. +- Produces: diver and buddy photos encoded as base64 strings on the sync wire. + +- [ ] **Step 1: Write the failing test** + +In `test/core/services/sync/sync_blob_base64_test.dart`, add these tests inside the existing `group('Sync BLOB base64 encoding', ...)`: + +```dart + test('a buddy photo is exported as a base64 string', () async { + final serializer = SyncDataSerializer(); + final photo = Uint8List.fromList(List.generate(256, (i) => i % 256)); + + await serializer.upsertRecord('buddies', { + 'id': 'buddy-b64-1', + 'name': 'Jane Doe', + 'notes': '', + 'isFavorite': false, + 'createdAt': 1700000000000, + 'updatedAt': 1700000000000, + 'photo': photo, + }); + + final deviceId = await SyncRepository().getDeviceId(); + await buildService().performSync(); + + final payload = await cloudBasePayload(cloud, deviceId); + final row = payload!.data.buddies.firstWhere( + (r) => r['id'] == 'buddy-b64-1', + ); + + expect( + row['photo'], + isA(), + reason: 'a buddy photo must serialize as base64, not a byte array', + ); + expect(row['photo'], base64Encode(photo)); + }); + + test('a diver photo round-trips through fetchRecord', () async { + final serializer = SyncDataSerializer(); + final photo = Uint8List.fromList(List.generate(128, (i) => 255 - i % 256)); + + await serializer.upsertRecord('divers', { + 'id': 'diver-b64-1', + 'name': 'Jane Doe', + 'medicalNotes': '', + 'notes': '', + 'isDefault': false, + 'createdAt': 1700000000000, + 'updatedAt': 1700000000000, + 'photo': photo, + }); + + final restored = await serializer.fetchRecord('divers', 'diver-b64-1'); + expect(restored, isNotNull); + final blob = restored!['photo']; + final bytes = blob is String + ? base64Decode(blob) + : Uint8List.fromList((blob as List).cast()); + expect(bytes, photo); + }); + + test('a legacy array-encoded buddy photo still imports', () async { + final serializer = SyncDataSerializer(); + await serializer.upsertRecord('buddies', { + 'id': 'buddy-legacy-1', + 'name': 'Jane Doe', + 'notes': '', + 'isFavorite': false, + 'createdAt': 1700000000000, + 'updatedAt': 1700000000000, + 'photo': [0x01, 0x02, 0x03], + }); + + final restored = await serializer.fetchRecord('buddies', 'buddy-legacy-1'); + final blob = restored!['photo']; + final bytes = blob is String + ? base64Decode(blob) + : Uint8List.fromList((blob as List).cast()); + expect(bytes, [0x01, 0x02, 0x03]); + }); + + test('a null buddy photo exports as null', () async { + final serializer = SyncDataSerializer(); + await serializer.upsertRecord('buddies', { + 'id': 'buddy-nullphoto', + 'name': 'Jane Doe', + 'notes': '', + 'isFavorite': false, + 'createdAt': 1700000000000, + 'updatedAt': 1700000000000, + 'photo': null, + }); + + final deviceId = await SyncRepository().getDeviceId(); + await buildService().performSync(); + final payload = await cloudBasePayload(cloud, deviceId); + final row = payload!.data.buddies.firstWhere( + (r) => r['id'] == 'buddy-nullphoto', + ); + expect(row['photo'], isNull); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/services/sync/sync_blob_base64_test.dart` +Expected: FAIL. `row['photo']` is a `List`, not a `String`. + +- [ ] **Step 3: Flip the base table flags** + +In `lib/core/services/sync/sync_data_serializer.dart`: + +Line 654, change to: +```dart + (key: 'divers', table: _db.divers, blob: true, full: null), +``` + +Line 718, change to: +```dart + (key: 'buddies', table: _db.buddies, blob: true, full: null), +``` + +- [ ] **Step 4: Update the delta exporters** + +In `_exportDivers` (line 4535), change the final line to: + +```dart + // Divers carry the profile photo BLOB; base64-encode it. + return rows.map((r) => r.toJson(serializer: _syncBlobSerializer)).toList(); +``` + +In `_exportBuddies` (line 4803), make the identical change with "Buddies" in the comment. + +- [ ] **Step 5: Update fetchRecord** + +At line 1555 (`case 'divers':`) and line 1650 (`case 'buddies':`), change `return row?.toJson();` to: + +```dart + return row?.toJson(serializer: _syncBlobSerializer); +``` + +- [ ] **Step 6: Update fetchRecords (the batch fetch)** + +At line 1971 (`case 'divers':`) and line 2026 (`case 'buddies':`), change the map comprehension to: + +```dart + return { + for (final r in rows) r.id: r.toJson(serializer: _syncBlobSerializer), + }; +``` + +- [ ] **Step 7: Update upsertRecord** + +At line 2444, change the divers case body to: + +```dart + await _db + .into(_db.divers) + .insertOnConflictUpdate( + Diver.fromJson( + data, + serializer: _syncBlobSerializer, + ).toCompanion(false), + ); + return; +``` + +At line 2549, make the identical change for `buddies`, using `Buddy.fromJson` and `_db.buddies`. + +- [ ] **Step 8: Update upsertRecords (the batch upsert)** + +At line 2951, change the divers case body to: + +```dart + await _db.batch( + (b) => b.insertAllOnConflictUpdate( + _db.divers, + records + .map( + (r) => Diver.fromJson( + r, + serializer: _syncBlobSerializer, + ).toCompanion(false), + ) + .toList(), + ), + ); + return; +``` + +At line 3115, make the identical change for `buddies`. + +- [ ] **Step 9: Run the test to verify it passes** + +Run: `flutter test test/core/services/sync/sync_blob_base64_test.dart` +Expected: PASS. + +- [ ] **Step 10: Run the sync suite for regressions** + +Run: `flutter test test/core/services/sync` +Expected: PASS. + +- [ ] **Step 11: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/core/services/sync/sync_data_serializer.dart test/core/services/sync/sync_blob_base64_test.dart +git commit -m "feat(sync): base64-encode diver and buddy profile photos" +``` + +--- + +## Task 5: The image codec + +**Files:** +- Create: `lib/core/services/images/profile_photo_codec.dart` +- Test: `test/core/services/images/profile_photo_codec_test.dart` (create) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `class ImageEncodeSpec { final int maxDimension; final int jpegQuality; final bool square; static const avatar; static const certificationCard; }` + - `class ImageEncodeRequest` with `.fromBytes({required Uint8List bytes, required ImageEncodeSpec spec, Rect? cropRect, String? declaredName, int maxSourcePixels})` + - `enum ImageEncodeOutcome { encoded, undecodable, tooLarge }` + - `class ImageEncodeResult { final ImageEncodeOutcome outcome; final Uint8List? bytes; final int sizeBytes; final String? error; }` + - `Future encodeStoredImage(ImageEncodeRequest request)` + - `@visibleForTesting ImageEncodeResult runImageEncodeRequest(ImageEncodeRequest request)` + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/images/profile_photo_codec_test.dart`: + +```dart +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:submersion/core/services/images/profile_photo_codec.dart'; + +/// A solid-colour JPEG of the given size, for feeding the codec. +Uint8List _jpeg(int width, int height) { + final image = img.Image(width: width, height: height); + img.fill(image, color: img.ColorRgb8(120, 60, 30)); + return Uint8List.fromList(img.encodeJpg(image, quality: 90)); +} + +img.Image _decode(Uint8List bytes) => img.decodeImage(bytes)!; + +void main() { + test('a landscape source becomes a 512x512 square', () { + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: _jpeg(1600, 900), + spec: ImageEncodeSpec.avatar, + ), + ); + + expect(result.outcome, ImageEncodeOutcome.encoded); + final out = _decode(result.bytes!); + expect(out.width, 512); + expect(out.height, 512); + }); + + test('a portrait source becomes a 512x512 square', () { + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: _jpeg(900, 1600), + spec: ImageEncodeSpec.avatar, + ), + ); + + final out = _decode(result.bytes!); + expect(out.width, 512); + expect(out.height, 512); + }); + + test('a source smaller than the ceiling is not upscaled', () { + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: _jpeg(200, 200), + spec: ImageEncodeSpec.avatar, + ), + ); + + final out = _decode(result.bytes!); + expect(out.width, 200); + expect(out.height, 200); + }); + + test('an explicit crop rect selects that region', () { + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: _jpeg(1000, 1000), + spec: ImageEncodeSpec.avatar, + cropRect: const Rect.fromLTWH(100, 100, 400, 400), + ), + ); + + final out = _decode(result.bytes!); + expect(out.width, 400, reason: '400px crop is under the 512 ceiling'); + expect(out.height, 400); + }); + + test('the certification spec preserves aspect ratio', () { + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: _jpeg(4000, 2000), + spec: ImageEncodeSpec.certificationCard, + ), + ); + + final out = _decode(result.bytes!); + expect(out.width, 2000); + expect(out.height, 1000); + }); + + test('undecodable bytes report undecodable, not a throw', () { + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: Uint8List.fromList([0, 1, 2, 3, 4]), + spec: ImageEncodeSpec.avatar, + declaredName: 'broken.jpg', + ), + ); + + expect(result.outcome, ImageEncodeOutcome.undecodable); + expect(result.bytes, isNull); + }); + + test('a source above maxSourcePixels reports tooLarge', () { + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: _jpeg(1000, 1000), + spec: ImageEncodeSpec.avatar, + maxSourcePixels: 500 * 500, + ), + ); + + expect(result.outcome, ImageEncodeOutcome.tooLarge); + expect(result.bytes, isNull); + }); + + test('EXIF orientation 6 is baked before cropping', () { + // Orientation 6 means "rotate 90 degrees clockwise to display upright". + // Build a 200x100 image whose LEFT half is red, then declare orientation + // 6. Displayed upright it is 100x200 with red on TOP, so a crop of the + // top half must come back red. + final source = img.Image(width: 200, height: 100); + img.fill(source, color: img.ColorRgb8(0, 0, 255)); + img.fillRect(source, + x1: 0, y1: 0, x2: 99, y2: 99, color: img.ColorRgb8(255, 0, 0)); + source.exif.imageIfd.orientation = 6; + final bytes = Uint8List.fromList(img.encodeJpg(source, quality: 95)); + + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: bytes, + spec: ImageEncodeSpec.avatar, + declaredName: 'rotated.jpg', + cropRect: const Rect.fromLTWH(0, 0, 100, 100), + ), + ); + + expect(result.outcome, ImageEncodeOutcome.encoded); + final out = _decode(result.bytes!); + final pixel = out.getPixel(50, 50); + expect(pixel.r, greaterThan(pixel.b), + reason: 'orientation must be baked before the crop is applied, or ' + 'the crop selects the wrong region'); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/services/images/profile_photo_codec_test.dart` +Expected: FAIL, "Target of URI doesn't exist: profile_photo_codec.dart". + +- [ ] **Step 3: Write the codec** + +Create `lib/core/services/images/profile_photo_codec.dart`: + +```dart +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +/// How a stored image blob is bounded. +/// +/// Sizing is enforced HERE, in Dart, and never through `ImagePicker`'s +/// `maxWidth` / `maxHeight` / `imageQuality`: those arguments are silently +/// ignored by image_picker_macos, image_picker_windows and image_picker_linux, +/// so a desktop pick would enter the database at full size and ride into every +/// sync changeset as base64. +@immutable +class ImageEncodeSpec { + const ImageEncodeSpec({ + required this.maxDimension, + required this.jpegQuality, + required this.square, + }); + + /// Longest edge of the output, in pixels. A source already within this is + /// never upscaled. + final int maxDimension; + + final int jpegQuality; + + /// When true the output is cropped to a square before resizing, so every + /// avatar render site can assume a 1:1 aspect and never letterbox. + final bool square; + + /// Profile photo for a diver or buddy: 512x512, roughly 50-80 KB. The + /// largest avatar in the app draws at radius 50, so 300 physical pixels at + /// 3x; 512 is deliberate headroom for a future enlarged view, since the + /// source is discarded at pick time. + static const avatar = + ImageEncodeSpec(maxDimension: 512, jpegQuality: 85, square: true); + + /// Certification card face. Card text must stay readable, so the ceiling is + /// higher and the source aspect ratio is preserved. + static const certificationCard = + ImageEncodeSpec(maxDimension: 2000, jpegQuality: 85, square: false); +} + +/// One encode job, sendable to an isolate. +@immutable +class ImageEncodeRequest { + const ImageEncodeRequest.fromBytes({ + required this.bytes, + required this.spec, + this.cropRect, + this.declaredName, + this.maxSourcePixels = _defaultMaxSourcePixels, + }); + + /// 80 megapixels. Decoding is what allocates, and nothing caps a desktop + /// pick, so a source beyond this is refused rather than risking an isolate + /// out-of-memory that would surface as an unexplained failure. + static const int _defaultMaxSourcePixels = 80 * 1000 * 1000; + + final Uint8List bytes; + final ImageEncodeSpec spec; + + /// Region of the DECODED, orientation-corrected source to keep, in source + /// pixels. Null means the largest centered square when [ImageEncodeSpec + /// .square] is set, or the whole image otherwise. + final Rect? cropRect; + + /// Filename whose extension picks the decoder. package:image's generic + /// probe tries every format and the permissive ones accept arbitrary bytes, + /// so pass this whenever the source name is known. + final String? declaredName; + + final int maxSourcePixels; +} + +/// What [encodeStoredImage] did. +enum ImageEncodeOutcome { + /// A JPEG was produced and is in [ImageEncodeResult.bytes]. + encoded, + + /// package:image could not decode the source. + undecodable, + + /// The decoded source exceeded [ImageEncodeRequest.maxSourcePixels]. + tooLarge, +} + +@immutable +class ImageEncodeResult { + const ImageEncodeResult( + this.outcome, { + this.bytes, + this.sizeBytes = 0, + this.error, + }); + + final ImageEncodeOutcome outcome; + + /// The encoded JPEG, non-null only for [ImageEncodeOutcome.encoded]. + final Uint8List? bytes; + + final int sizeBytes; + + /// Why the source could not be decoded, carried as a String rather than + /// letting the exception cross the isolate boundary and lose the outcome + /// the caller switches on. + final String? error; +} + +/// Runs [request] on a background isolate. +Future encodeStoredImage(ImageEncodeRequest request) => + compute(runImageEncodeRequest, request); + +/// The isolate body. Top-level and public so a test can exercise the work +/// itself without paying for an isolate spawn per case. +@visibleForTesting +ImageEncodeResult runImageEncodeRequest(ImageEncodeRequest request) { + final name = request.declaredName; + final img.Image? decoded; + try { + decoded = name != null && name.contains('.') + ? img.decodeNamedImage(name, request.bytes) + : img.decodeImage(request.bytes); + } on Exception catch (e) { + return ImageEncodeResult( + ImageEncodeOutcome.undecodable, + error: e.toString(), + ); + } + if (decoded == null) { + return const ImageEncodeResult(ImageEncodeOutcome.undecodable); + } + + if (decoded.width * decoded.height > request.maxSourcePixels) { + return const ImageEncodeResult(ImageEncodeOutcome.tooLarge); + } + + // Orientation MUST be resolved before any geometry is applied. The JPEG + // decoder does no orientation handling, so a portrait phone photo arrives + // as a sideways buffer carrying exif.orientation = 6. Cropping first would + // apply a rectangle chosen in the upright preview to a rotated buffer and + // select the wrong region. + final upright = img.bakeOrientation(decoded); + + final cropped = _crop(upright, request); + + final longest = + cropped.width > cropped.height ? cropped.width : cropped.height; + final resized = longest > request.spec.maxDimension + ? img.copyResize( + cropped, + // Width or height only: passing both resizes to exact bounds and + // distorts the aspect ratio. + width: cropped.width >= cropped.height + ? request.spec.maxDimension + : null, + height: + cropped.height > cropped.width ? request.spec.maxDimension : null, + ) + : cropped; + + // Re-encoding drops EXIF, so GPS embedded in a gallery or contact photo + // cannot ride into a blob that syncs to every device and cloud provider. + final jpeg = Uint8List.fromList( + img.encodeJpg(resized, quality: request.spec.jpegQuality), + ); + return ImageEncodeResult( + ImageEncodeOutcome.encoded, + bytes: jpeg, + sizeBytes: jpeg.length, + ); +} + +img.Image _crop(img.Image source, ImageEncodeRequest request) { + final rect = request.cropRect; + if (rect != null) { + final x = rect.left.round().clamp(0, source.width - 1); + final y = rect.top.round().clamp(0, source.height - 1); + final w = rect.width.round().clamp(1, source.width - x); + final h = rect.height.round().clamp(1, source.height - y); + return img.copyCrop(source, x: x, y: y, width: w, height: h); + } + + if (!request.spec.square) return source; + + final side = + source.width < source.height ? source.width : source.height; + return img.copyCrop( + source, + x: (source.width - side) ~/ 2, + y: (source.height - side) ~/ 2, + width: side, + height: side, + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/core/services/images/profile_photo_codec_test.dart` +Expected: PASS, all eight tests. If the orientation test fails, the `bakeOrientation` call is in the wrong position relative to `_crop`. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/core/services/images/profile_photo_codec.dart test/core/services/images/profile_photo_codec_test.dart +git commit -m "feat(images): add a bounded profile photo codec" +``` + +--- + +## Task 6: Crop geometry + +**Files:** +- Create: `lib/shared/widgets/profile_photo/profile_photo_crop_geometry.dart` +- Test: `test/shared/widgets/profile_photo/profile_photo_crop_geometry_test.dart` (create) + +**Interfaces:** +- Consumes: nothing. +- Produces: `Rect cropRectInSourcePixels({required Matrix4 transform, required Size viewport, required Size childSize, required Size sourceSize})`. + +- [ ] **Step 1: Write the failing test** + +Create `test/shared/widgets/profile_photo/profile_photo_crop_geometry_test.dart`: + +```dart +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/shared/widgets/profile_photo/profile_photo_crop_geometry.dart'; + +void main() { + test('identity transform over a square child selects the whole source', () { + final rect = cropRectInSourcePixels( + transform: Matrix4.identity(), + viewport: const Size(300, 300), + childSize: const Size(300, 300), + sourceSize: const Size(1200, 1200), + ); + + expect(rect.left, closeTo(0, 0.01)); + expect(rect.top, closeTo(0, 0.01)); + expect(rect.width, closeTo(1200, 0.01)); + expect(rect.height, closeTo(1200, 0.01)); + }); + + test('a 2x zoom selects the centre quarter when centred', () { + // InteractiveViewer scales about the origin, so a 2x zoom with the + // viewport centred means translating back by half the viewport. + final transform = Matrix4.identity() + ..translate(-150.0, -150.0) + ..scale(2.0, 2.0); + + final rect = cropRectInSourcePixels( + transform: transform, + viewport: const Size(300, 300), + childSize: const Size(300, 300), + sourceSize: const Size(1200, 1200), + ); + + expect(rect.width, closeTo(600, 0.01)); + expect(rect.height, closeTo(600, 0.01)); + }); + + test('the result is independent of device pixel ratio', () { + // The same gesture expressed at two viewport sizes must select the same + // region of the SOURCE, which is what makes the stored image identical + // on a 1x and a 3x screen. + final small = cropRectInSourcePixels( + transform: Matrix4.identity(), + viewport: const Size(200, 200), + childSize: const Size(200, 200), + sourceSize: const Size(1000, 1000), + ); + final large = cropRectInSourcePixels( + transform: Matrix4.identity(), + viewport: const Size(600, 600), + childSize: const Size(600, 600), + sourceSize: const Size(1000, 1000), + ); + + expect(small.width, closeTo(large.width, 0.01)); + expect(small.height, closeTo(large.height, 0.01)); + }); + + test('a pan beyond the image is clamped into bounds', () { + final transform = Matrix4.identity()..translate(500.0, 500.0); + + final rect = cropRectInSourcePixels( + transform: transform, + viewport: const Size(300, 300), + childSize: const Size(300, 300), + sourceSize: const Size(1200, 1200), + ); + + expect(rect.left, greaterThanOrEqualTo(0)); + expect(rect.top, greaterThanOrEqualTo(0)); + expect(rect.right, lessThanOrEqualTo(1200)); + expect(rect.bottom, lessThanOrEqualTo(1200)); + expect(rect.width, greaterThan(0)); + expect(rect.height, greaterThan(0)); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/shared/widgets/profile_photo/profile_photo_crop_geometry_test.dart` +Expected: FAIL, URI does not exist. + +- [ ] **Step 3: Write the geometry** + +Create `lib/shared/widgets/profile_photo/profile_photo_crop_geometry.dart`: + +```dart +import 'package:flutter/widgets.dart'; + +/// Maps the square crop viewport back into source-image pixel coordinates. +/// +/// The crop is computed as GEOMETRY rather than by rasterizing the widget +/// through a `RepaintBoundary`. Rasterizing would bake in the device pixel +/// ratio, cap the output at whatever the viewport happened to render, and be +/// almost untestable. A rectangle is a pure value: the same gesture yields +/// the same stored image on a 1x desktop window and a 3x phone, and this +/// function unit-tests with no widget tree at all. +/// +/// [transform] is `TransformationController.value` from the InteractiveViewer. +/// [viewport] is the square crop window in logical pixels, [childSize] is the +/// image as laid out inside the viewer, and [sourceSize] is the decoded image's +/// pixel dimensions. +Rect cropRectInSourcePixels({ + required Matrix4 transform, + required Size viewport, + required Size childSize, + required Size sourceSize, +}) { + // Undo the viewer's transform to find which part of the child the viewport + // is showing. + final inverse = Matrix4.inverted(transform); + final topLeft = MatrixUtils.transformPoint(inverse, Offset.zero); + final bottomRight = MatrixUtils.transformPoint( + inverse, + Offset(viewport.width, viewport.height), + ); + + // Child (logical) pixels to source pixels. + final scaleX = sourceSize.width / childSize.width; + final scaleY = sourceSize.height / childSize.height; + + final rawLeft = topLeft.dx * scaleX; + final rawTop = topLeft.dy * scaleY; + final rawRight = bottomRight.dx * scaleX; + final rawBottom = bottomRight.dy * scaleY; + + // Clamp into the image. minScale is set to the cover scale at the call + // site, so a gap is not normally representable; this is a guard, not + // routine control flow. + final left = rawLeft.clamp(0.0, sourceSize.width); + final top = rawTop.clamp(0.0, sourceSize.height); + final right = rawRight.clamp(0.0, sourceSize.width); + final bottom = rawBottom.clamp(0.0, sourceSize.height); + + // Never hand back a degenerate rect: the codec would crop to nothing. + final width = (right - left).clamp(1.0, sourceSize.width); + final height = (bottom - top).clamp(1.0, sourceSize.height); + + return Rect.fromLTWH( + left.clamp(0.0, sourceSize.width - width), + top.clamp(0.0, sourceSize.height - height), + width, + height, + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/shared/widgets/profile_photo/profile_photo_crop_geometry_test.dart` +Expected: PASS, all four tests. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/shared/widgets/profile_photo/profile_photo_crop_geometry.dart test/shared/widgets/profile_photo/profile_photo_crop_geometry_test.dart +git commit -m "feat(profile-photo): add pure crop geometry" +``` + +--- + +## Task 7: Localization strings + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb` and all 10 other locale ARB files in `lib/l10n/arb/` +- Test: `test/l10n/arb_parity_test.dart` (existing, must stay green) + +**Interfaces:** +- Consumes: nothing. +- Produces: `context.l10n.profilePhoto_*` getters used by Tasks 8, 9, 12, 13, 14. + +- [ ] **Step 1: Add the English keys** + +Append these to `lib/l10n/arb/app_en.arb`, immediately before the closing `}`. Remember to add a comma to the previous last entry. + +```json + "profilePhoto_sheet_title": "Profile Photo", + "profilePhoto_source_camera": "Take Photo", + "profilePhoto_source_library": "Choose from Library", + "profilePhoto_source_file": "Choose File", + "profilePhoto_source_contacts": "Choose from Contacts", + "profilePhoto_action_remove": "Remove Photo", + "profilePhoto_crop_title": "Adjust Photo", + "profilePhoto_crop_hint": "Drag to reposition, pinch to zoom", + "profilePhoto_error_tooLarge": "That image is too large to use. Try a smaller one.", + "profilePhoto_error_undecodable": "That file could not be read as an image.", + "profilePhoto_error_contactNoPhoto": "That contact does not have a photo." +``` + +Reuse the existing `common_action_cancel`, `common_action_save`, and `common_action_remove` rather than adding new ones. + +- [ ] **Step 2: Add the same keys to all 10 other locales** + +Add the identical 11 keys to `app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, and `app_zh.arb`, translated into each language. Do not leave English values in place: the parity test checks presence, but leaving English text in a translated file ships an untranslated string. + +Suggested German, as a shape reference: + +```json + "profilePhoto_sheet_title": "Profilfoto", + "profilePhoto_source_camera": "Foto aufnehmen", + "profilePhoto_source_library": "Aus Mediathek wählen", + "profilePhoto_source_file": "Datei wählen", + "profilePhoto_source_contacts": "Aus Kontakten wählen", + "profilePhoto_action_remove": "Foto entfernen", + "profilePhoto_crop_title": "Foto anpassen", + "profilePhoto_crop_hint": "Ziehen zum Verschieben, zwei Finger zum Zoomen", + "profilePhoto_error_tooLarge": "Dieses Bild ist zu groß. Bitte ein kleineres wählen.", + "profilePhoto_error_undecodable": "Diese Datei konnte nicht als Bild gelesen werden.", + "profilePhoto_error_contactNoPhoto": "Dieser Kontakt hat kein Foto." +``` + +- [ ] **Step 3: Regenerate localizations** + +Run: +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-photos +flutter gen-l10n +``` +Expected: succeeds, `lib/l10n/arb/app_localizations*.dart` are regenerated. + +- [ ] **Step 4: Run the parity test to verify it passes** + +Run: `flutter test test/l10n/arb_parity_test.dart` +Expected: PASS. A failure naming "N missing" against every locale means English has keys the others do not. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/l10n/arb +git commit -m "feat(l10n): add profile photo strings in all locales" +``` + +--- + +## Task 8: The crop dialog + +**Files:** +- Create: `lib/shared/widgets/profile_photo/profile_photo_crop_dialog.dart` +- Test: `test/shared/widgets/profile_photo/profile_photo_crop_dialog_test.dart` (create) + +**Interfaces:** +- Consumes: `cropRectInSourcePixels` (Task 6), `encodeStoredImage` / `ImageEncodeSpec` / `ImageEncodeOutcome` (Task 5), `profilePhoto_crop_*` strings (Task 7). +- Produces: `Future showProfilePhotoCropDialog({required BuildContext context, required Uint8List sourceBytes, String? declaredName})`, returning encoded bytes or null on cancel. + +- [ ] **Step 1: Write the failing test** + +Create `test/shared/widgets/profile_photo/profile_photo_crop_dialog_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:submersion/shared/widgets/profile_photo/profile_photo_crop_dialog.dart'; + +import '../../../helpers/test_app.dart'; + +Uint8List _jpeg(int width, int height) { + final image = img.Image(width: width, height: height); + img.fill(image, color: img.ColorRgb8(120, 60, 30)); + return Uint8List.fromList(img.encodeJpg(image, quality: 90)); +} + +void main() { + testWidgets('cancel returns null', (tester) async { + Uint8List? result; + var called = false; + + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await showProfilePhotoCropDialog( + context: context, + sourceBytes: _jpeg(800, 600), + declaredName: 'pick.jpg', + ); + called = true; + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Adjust Photo'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + // Pump manually: the dialog runs a progress state, so pumpAndSettle can + // time out on an animating spinner. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(called, isTrue); + expect(result, isNull); + }); + + testWidgets('save returns encoded square bytes', (tester) async { + Uint8List? result; + + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await showProfilePhotoCropDialog( + context: context, + sourceBytes: _jpeg(800, 600), + declaredName: 'pick.jpg', + ); + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.text('Save')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + + expect(result, isNotNull); + final out = img.decodeImage(result!)!; + expect(out.width, out.height, reason: 'the stored photo must be square'); + expect(out.width, lessThanOrEqualTo(512)); + }); + + testWidgets('the dialog shows the repositioning hint', (tester) async { + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showProfilePhotoCropDialog( + context: context, + sourceBytes: _jpeg(400, 400), + declaredName: 'pick.jpg', + ), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Drag to reposition, pinch to zoom'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/shared/widgets/profile_photo/profile_photo_crop_dialog_test.dart` +Expected: FAIL, URI does not exist. + +- [ ] **Step 3: Write the dialog** + +Create `lib/shared/widgets/profile_photo/profile_photo_crop_dialog.dart`: + +```dart +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:submersion/core/services/images/profile_photo_codec.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/widgets/profile_photo/profile_photo_crop_geometry.dart'; + +/// Shows the pan and zoom crop surface and returns the encoded 512x512 JPEG, +/// or null if the user cancelled. +/// +/// A full-screen dialog rather than a bottom sheet: a crop wants maximum area, +/// and `showModalBottomSheet(isScrollControlled: true)` removes the height +/// ceiling entirely, which puts a drag handle inside Android's notification +/// shade zone (issue #1188). Popping happens from inside this builder's own +/// context, which addresses the root navigator that `showDialog` already +/// defaults to, so this is not the pattern that blanked master-detail in +/// PR #1312. +Future showProfilePhotoCropDialog({ + required BuildContext context, + required Uint8List sourceBytes, + String? declaredName, +}) { + return showDialog( + context: context, + builder: (dialogContext) => _ProfilePhotoCropDialog( + sourceBytes: sourceBytes, + declaredName: declaredName, + ), + ); +} + +class _ProfilePhotoCropDialog extends StatefulWidget { + const _ProfilePhotoCropDialog({ + required this.sourceBytes, + this.declaredName, + }); + + final Uint8List sourceBytes; + final String? declaredName; + + @override + State<_ProfilePhotoCropDialog> createState() => + _ProfilePhotoCropDialogState(); +} + +class _ProfilePhotoCropDialogState extends State<_ProfilePhotoCropDialog> { + final TransformationController _controller = TransformationController(); + ui.Image? _decoded; + bool _busy = false; + + @override + void initState() { + super.initState(); + _decode(); + } + + Future _decode() async { + final codec = await ui.instantiateImageCodec(widget.sourceBytes); + final frame = await codec.getNextFrame(); + if (!mounted) return; + setState(() => _decoded = frame.image); + } + + @override + void dispose() { + _controller.dispose(); + _decoded?.dispose(); + super.dispose(); + } + + Future _save(Size viewport, Size childSize, Size sourceSize) async { + setState(() => _busy = true); + final rect = cropRectInSourcePixels( + transform: _controller.value, + viewport: viewport, + childSize: childSize, + sourceSize: sourceSize, + ); + final result = await encodeStoredImage( + ImageEncodeRequest.fromBytes( + bytes: widget.sourceBytes, + spec: ImageEncodeSpec.avatar, + cropRect: rect, + declaredName: widget.declaredName, + ), + ); + if (!mounted) return; + if (result.outcome != ImageEncodeOutcome.encoded) { + setState(() => _busy = false); + final l10n = context.l10n; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.outcome == ImageEncodeOutcome.tooLarge + ? l10n.profilePhoto_error_tooLarge + : l10n.profilePhoto_error_undecodable, + ), + ), + ); + return; + } + Navigator.of(context).pop(result.bytes); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final decoded = _decoded; + + return Dialog.fullscreen( + child: Scaffold( + appBar: AppBar( + title: Text(l10n.profilePhoto_crop_title), + leading: TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: Text(l10n.common_action_cancel), + ), + leadingWidth: 96, + ), + body: decoded == null + ? const Center(child: CircularProgressIndicator()) + : LayoutBuilder( + builder: (context, constraints) { + final side = constraints.maxWidth < constraints.maxHeight + ? constraints.maxWidth + : constraints.maxHeight; + final viewport = Size(side, side); + final sourceSize = Size( + decoded.width.toDouble(), + decoded.height.toDouble(), + ); + // Lay the child out at cover scale so the square viewport is + // always fully covered; minScale 1.0 then makes a gap + // unrepresentable rather than something to validate against. + final coverScale = decoded.width < decoded.height + ? side / decoded.width + : side / decoded.height; + final childSize = Size( + decoded.width * coverScale, + decoded.height * coverScale, + ); + + return Column( + children: [ + Expanded( + child: Center( + child: SizedBox( + width: side, + height: side, + child: ClipOval( + child: InteractiveViewer( + transformationController: _controller, + minScale: 1, + maxScale: 5, + constrained: false, + child: SizedBox( + width: childSize.width, + height: childSize.height, + child: Image.memory( + widget.sourceBytes, + fit: BoxFit.cover, + ), + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Text( + l10n.profilePhoto_crop_hint, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: FilledButton( + onPressed: _busy + ? null + : () => _save(viewport, childSize, sourceSize), + child: _busy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.common_action_save), + ), + ), + ], + ); + }, + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/shared/widgets/profile_photo/profile_photo_crop_dialog_test.dart` +Expected: PASS, all three tests. If a test hangs, add another `await tester.pump(const Duration(milliseconds: 500));` rather than switching to `pumpAndSettle`. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/shared/widgets/profile_photo/profile_photo_crop_dialog.dart test/shared/widgets/profile_photo/profile_photo_crop_dialog_test.dart +git commit -m "feat(profile-photo): add the pan and zoom crop dialog" +``` + +--- + +## Task 9: Source sheet and picker orchestration + +**Files:** +- Create: `lib/shared/utils/contact_import_support.dart` +- Create: `lib/shared/widgets/profile_photo/profile_photo_source_sheet.dart` +- Create: `lib/shared/widgets/profile_photo/profile_photo_picker.dart` +- Modify: `lib/features/buddies/presentation/widgets/buddy_list_content.dart` (line 80, remove the private getter) +- Test: `test/shared/widgets/profile_photo/profile_photo_source_sheet_test.dart` (create) + +**Interfaces:** +- Consumes: `showProfilePhotoCropDialog` (Task 8), `profilePhoto_*` strings (Task 7). +- Produces: + - `bool get isContactImportSupported` in `lib/shared/utils/contact_import_support.dart` + - `enum ProfilePhotoSource { camera, library, contacts, remove }` + - `Future showProfilePhotoSourceSheet({required BuildContext context, required bool hasPhoto, required bool allowContacts})` + - `class ProfilePhotoResult { const ProfilePhotoResult({Uint8List? bytes, bool removed = false}); final Uint8List? bytes; final bool removed; }` + - `Future pickProfilePhoto({required BuildContext context, required bool hasPhoto, required bool allowContacts, Future Function(BuildContext context)? contactPhotoLoader})`. The `contactPhotoLoader` is left null by Task 9 and supplied by Task 14, which is what keeps this shared widget free of a `flutter_contacts` dependency. + +- [ ] **Step 1: Write the failing test** + +Create `test/shared/widgets/profile_photo/profile_photo_source_sheet_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/shared/widgets/profile_photo/profile_photo_source_sheet.dart'; + +import '../../../helpers/test_app.dart'; + +Future _open( + WidgetTester tester, { + required bool hasPhoto, + required bool allowContacts, +}) async { + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showProfilePhotoSourceSheet( + context: context, + hasPhoto: hasPhoto, + allowContacts: allowContacts, + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); +} + +void main() { + testWidgets('hides Remove Photo when there is no photo', (tester) async { + await _open(tester, hasPhoto: false, allowContacts: false); + expect(find.text('Remove Photo'), findsNothing); + }); + + testWidgets('shows Remove Photo when a photo exists', (tester) async { + await _open(tester, hasPhoto: true, allowContacts: false); + expect(find.text('Remove Photo'), findsOneWidget); + }); + + testWidgets('hides Choose from Contacts when not allowed', (tester) async { + await _open(tester, hasPhoto: false, allowContacts: false); + expect(find.text('Choose from Contacts'), findsNothing); + }); + + testWidgets('shows Choose from Contacts when allowed', (tester) async { + await _open(tester, hasPhoto: false, allowContacts: true); + expect(find.text('Choose from Contacts'), findsOneWidget); + }); + + testWidgets('always offers a library option', (tester) async { + await _open(tester, hasPhoto: false, allowContacts: false); + expect( + find.text('Choose from Library').evaluate().isNotEmpty || + find.text('Choose File').evaluate().isNotEmpty, + isTrue, + ); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/shared/widgets/profile_photo/profile_photo_source_sheet_test.dart` +Expected: FAIL, URI does not exist. + +- [ ] **Step 3: Extract the platform guard** + +Create `lib/shared/utils/contact_import_support.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +/// Whether the device address book can be reached at all. +/// +/// flutter_contacts ships iOS and Android implementations only. Lifted out of +/// `buddy_list_content.dart` so the buddy import flow and the profile photo +/// source sheet cannot drift apart on which platforms they offer contacts on. +bool get isContactImportSupported { + if (kIsWeb) return false; + return Platform.isIOS || Platform.isAndroid; +} +``` + +In `lib/features/buddies/presentation/widgets/buddy_list_content.dart`, delete the private `_isContactImportSupported` getter at lines 80-83 (it has one use, at line 331), add the import for the new file, and replace the single use at line 331 with `isContactImportSupported`. + +- [ ] **Step 4: Write the source sheet** + +Create `lib/shared/widgets/profile_photo/profile_photo_source_sheet.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Where a profile photo comes from. +enum ProfilePhotoSource { camera, library, contacts, remove } + +/// Asks the user where the profile photo should come from. +/// +/// Returns null if the sheet was dismissed without a choice. +Future showProfilePhotoSourceSheet({ + required BuildContext context, + required bool hasPhoto, + required bool allowContacts, +}) { + final isMobile = !kIsWeb && (Platform.isIOS || Platform.isAndroid); + + return showModalBottomSheet( + context: context, + useSafeArea: true, + builder: (sheetContext) { + final l10n = sheetContext.l10n; + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + l10n.profilePhoto_sheet_title, + style: Theme.of(sheetContext).textTheme.titleMedium, + ), + ), + if (isMobile) + ListTile( + leading: const Icon(Icons.camera_alt), + title: Text(l10n.profilePhoto_source_camera), + onTap: () => + Navigator.pop(sheetContext, ProfilePhotoSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: Text( + isMobile + ? l10n.profilePhoto_source_library + : l10n.profilePhoto_source_file, + ), + onTap: () => + Navigator.pop(sheetContext, ProfilePhotoSource.library), + ), + if (allowContacts) + ListTile( + leading: const Icon(Icons.contacts), + title: Text(l10n.profilePhoto_source_contacts), + onTap: () => + Navigator.pop(sheetContext, ProfilePhotoSource.contacts), + ), + if (hasPhoto) + ListTile( + leading: const Icon(Icons.delete_outline), + title: Text(l10n.profilePhoto_action_remove), + onTap: () => + Navigator.pop(sheetContext, ProfilePhotoSource.remove), + ), + ], + ), + ); + }, + ); +} +``` + +- [ ] **Step 5: Write the orchestrator** + +Create `lib/shared/widgets/profile_photo/profile_photo_picker.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/widgets/profile_photo/profile_photo_crop_dialog.dart'; +import 'package:submersion/shared/widgets/profile_photo/profile_photo_source_sheet.dart'; + +/// Outcome of running the profile photo flow. +/// +/// [removed] distinguishes "the user asked to delete the photo" from "the user +/// cancelled", which a bare `Uint8List?` cannot express. +@immutable +class ProfilePhotoResult { + const ProfilePhotoResult({this.bytes, this.removed = false}); + + final Uint8List? bytes; + final bool removed; +} + +/// Runs the whole flow: source sheet, byte acquisition, crop dialog, encode. +/// +/// Returns null if the user cancelled at any point. Size bounding happens in +/// the crop dialog's encode step, never through ImagePicker's maxWidth / +/// maxHeight / imageQuality, which desktop silently ignores. +Future pickProfilePhoto({ + required BuildContext context, + required bool hasPhoto, + required bool allowContacts, + Future Function(BuildContext context)? contactPhotoLoader, +}) async { + final source = await showProfilePhotoSourceSheet( + context: context, + hasPhoto: hasPhoto, + allowContacts: allowContacts, + ); + if (source == null || !context.mounted) return null; + + if (source == ProfilePhotoSource.remove) { + return const ProfilePhotoResult(removed: true); + } + + Uint8List? raw; + String? declaredName; + + if (source == ProfilePhotoSource.contacts) { + raw = await contactPhotoLoader?.call(context); + declaredName = 'contact.jpg'; + if (raw == null) return null; + } else { + final picked = await ImagePicker().pickImage( + source: source == ProfilePhotoSource.camera + ? ImageSource.camera + : ImageSource.gallery, + ); + if (picked == null) return null; + raw = await File(picked.path).readAsBytes(); + declaredName = picked.name; + } + + if (!context.mounted) return null; + + final encoded = await showProfilePhotoCropDialog( + context: context, + sourceBytes: raw, + declaredName: declaredName, + ); + if (encoded == null) return null; + return ProfilePhotoResult(bytes: encoded); +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `flutter test test/shared/widgets/profile_photo/profile_photo_source_sheet_test.dart` +Expected: PASS, all five tests. + +- [ ] **Step 7: Verify the extracted guard did not break the buddy list** + +Run: `flutter test test/features/buddies` +Expected: PASS except possibly `buddy_list_tile_test.dart`, which Task 11 rewrites. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/shared/utils/contact_import_support.dart lib/shared/widgets/profile_photo lib/features/buddies/presentation/widgets/buddy_list_content.dart test/shared/widgets/profile_photo/profile_photo_source_sheet_test.dart +git commit -m "feat(profile-photo): add the source sheet and picker orchestration" +``` + +--- + +## Task 10: The ProfileAvatar widget + +**Files:** +- Create: `lib/shared/widgets/profile_photo/profile_avatar.dart` +- Test: `test/shared/widgets/profile_photo/profile_avatar_test.dart` (create) + +**Interfaces:** +- Consumes: nothing. +- Produces: `class ProfileAvatar extends StatelessWidget` with `const ProfileAvatar({Key? key, required Uint8List? photo, required String initials, double radius = 20, Color? backgroundColor, Color? foregroundColor, Color? ringColor, TextStyle? textStyle})`. Note `photo` is `required` but nullable, so no call site can forget to pass it, and `textStyle` exists because two sites (buddy detail and buddy edit) render initials at 36pt. + +- [ ] **Step 1: Write the failing test** + +Create `test/shared/widgets/profile_photo/profile_avatar_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:submersion/shared/widgets/profile_photo/profile_avatar.dart'; + +import '../../../helpers/test_app.dart'; + +Uint8List _jpeg() { + final image = img.Image(width: 64, height: 64); + img.fill(image, color: img.ColorRgb8(10, 20, 30)); + return Uint8List.fromList(img.encodeJpg(image, quality: 80)); +} + +void main() { + testWidgets('falls back to initials when there is no photo', (tester) async { + await tester.pumpWidget( + testApp(child: const ProfileAvatar(photo: null, initials: 'JD')), + ); + await tester.pump(); + + expect(find.text('JD'), findsOneWidget); + final avatar = tester.widget(find.byType(CircleAvatar)); + expect(avatar.backgroundImage, isNull); + }); + + testWidgets('renders the photo and hides initials when present', + (tester) async { + await tester.pumpWidget( + testApp(child: ProfileAvatar(photo: _jpeg(), initials: 'JD')), + ); + await tester.pump(); + + expect(find.text('JD'), findsNothing); + final avatar = tester.widget(find.byType(CircleAvatar)); + expect(avatar.backgroundImage, isNotNull); + }); + + testWidgets('decodes through ResizeImage, not a bare MemoryImage', + (tester) async { + await tester.pumpWidget( + testApp(child: ProfileAvatar(photo: _jpeg(), initials: 'JD', radius: 20)), + ); + await tester.pump(); + + final avatar = tester.widget(find.byType(CircleAvatar)); + expect( + avatar.backgroundImage, + isA(), + reason: 'a bare MemoryImage decodes at intrinsic size, so a list of ' + 'avatars would hold megabytes of bitmaps each', + ); + }); + + testWidgets('draws a ring when ringColor is given', (tester) async { + await tester.pumpWidget( + testApp( + child: const ProfileAvatar( + photo: null, + initials: 'JD', + ringColor: Color(0xFF00FF00), + ), + ), + ); + await tester.pump(); + + expect(find.byType(Container), findsWidgets); + expect(find.byType(CircleAvatar), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/shared/widgets/profile_photo/profile_avatar_test.dart` +Expected: FAIL, URI does not exist. + +- [ ] **Step 3: Write the widget** + +Create `lib/shared/widgets/profile_photo/profile_avatar.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +/// One avatar for divers and buddies: the stored photo when there is one, +/// their initials otherwise. +/// +/// Replaces 13 hand-rolled CircleAvatar sites that each re-implemented the +/// initials fallback, two of which had drifted into disagreeing about how a +/// photo is even loaded. +class ProfileAvatar extends StatelessWidget { + const ProfileAvatar({ + super.key, + required this.photo, + required this.initials, + this.radius = 20, + this.backgroundColor, + this.foregroundColor, + this.ringColor, + this.textStyle, + }); + + /// The stored 512x512 square JPEG, or null to show [initials]. + /// + /// Pass the SAME Uint8List instance the repository handed you. MemoryImage + /// equality is identity-based on the byte list and that is what keys + /// Flutter's image cache, so a defensive copy would mint a fresh cache key + /// every rebuild and re-decode every avatar on every frame. + final Uint8List? photo; + + final String initials; + final double radius; + final Color? backgroundColor; + final Color? foregroundColor; + + /// Draws a coloured ring around the avatar (the buddy list uses this for + /// the usual-role indicator). + final Color? ringColor; + + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bytes = photo; + + // Decode at the size actually drawn. Flutter decodes to the image's + // INTRINSIC size, so a bare MemoryImage on a 512x512 JPEG holds about 1 MB + // of bitmap for a 40 logical pixel tile; a list of 200 buddies would hold + // roughly 200 MB. + final devicePixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0; + final target = (radius * 2 * devicePixelRatio).round().clamp(1, 512); + + final avatar = CircleAvatar( + radius: ringColor == null ? radius : radius - 2, + backgroundColor: backgroundColor ?? theme.colorScheme.primaryContainer, + foregroundColor: + foregroundColor ?? theme.colorScheme.onPrimaryContainer, + backgroundImage: bytes == null + ? null + : ResizeImage( + MemoryImage(bytes), + width: target, + height: target, + ), + child: bytes == null + ? Text( + initials, + style: textStyle ?? + TextStyle( + color: foregroundColor ?? + theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ) + : null, + ); + + if (ringColor == null) return avatar; + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: ringColor!, width: 2), + ), + child: avatar, + ); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/shared/widgets/profile_photo/profile_avatar_test.dart` +Expected: PASS, all four tests. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/shared/widgets/profile_photo/profile_avatar.dart test/shared/widgets/profile_photo/profile_avatar_test.dart +git commit -m "feat(profile-photo): add the shared ProfileAvatar widget" +``` + +--- + +## Task 11: Roll ProfileAvatar out to existing sites + +**Files:** +- Modify: `lib/features/buddies/presentation/widgets/buddy_summary_widget.dart` (line 177) +- Modify: `lib/features/buddies/presentation/widgets/buddy_list_tile.dart` (lines 293-321) +- Modify: `lib/features/buddies/presentation/pages/buddy_detail_page.dart` (lines 232, 413) +- Modify: `lib/features/buddies/presentation/widgets/buddy_picker.dart` (lines 201, 264, 615) +- Modify: `lib/features/divers/presentation/widgets/diver_switcher_sheet.dart` (line 55) +- Modify: `lib/features/divers/presentation/widgets/diver_summary_widget.dart` (lines 195, 281) +- Modify: `lib/features/settings/presentation/pages/diver_profile_hub_page.dart` (line 130) +- Test: `test/features/buddies/presentation/widgets/buddy_list_tile_test.dart` (rewrite the photo test) + +**Interfaces:** +- Consumes: `ProfileAvatar` (Task 10), `Buddy.photo` / `Diver.photo` (Task 2). +- Produces: no new API. Every avatar site now renders the stored photo. + +- [ ] **Step 1: Rewrite the failing photo test** + +In `test/features/buddies/presentation/widgets/buddy_list_tile_test.dart`: + +Change the `_buddy` helper's `String? photoPath` parameter to `Uint8List? photo` and pass `photo: photo` to the `Buddy(...)` constructor instead of `photoPath: photoPath`. Add `import 'dart:typed_data';` and `import 'package:image/image.dart' as img;`, and remove `import 'dart:io';` if nothing else in the file uses it. + +Replace the whole `'loads an existing photo file and falls back to initials'` test with: + +```dart + testWidgets('renders the stored photo and falls back to initials', ( + tester, + ) async { + final image = img.Image(width: 64, height: 64); + img.fill(image, color: img.ColorRgb8(10, 20, 30)); + final bytes = Uint8List.fromList(img.encodeJpg(image, quality: 80)); + + await tester.pumpWidget( + testApp( + overrides: await _overrides(), + child: BuddyListTile( + entry: BuddyWithDiveCount(buddy: _buddy(photo: bytes), diveCount: 1), + onTap: () {}, + ), + ), + ); + await tester.pump(); + + final avatar = tester.widget(find.byType(CircleAvatar)); + expect(avatar.backgroundImage, isA()); + expect(find.text('JD'), findsNothing); + + await tester.pumpWidget( + testApp( + overrides: await _overrides(), + child: BuddyListTile( + entry: BuddyWithDiveCount(buddy: _buddy(), diveCount: 1), + onTap: () {}, + ), + ), + ); + await tester.pump(); + + expect(find.text('JD'), findsOneWidget); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/buddies/presentation/widgets/buddy_list_tile_test.dart` +Expected: FAIL. `backgroundImage` is a `FileImage` built from `photoPath`, not a `ResizeImage`. + +- [ ] **Step 3: Replace the buddy list tile avatar** + +In `lib/features/buddies/presentation/widgets/buddy_list_tile.dart`, replace the whole `build` body of the avatar widget (lines 292-321, starting at `final path = buddy.photoPath;`) with: + +```dart + @override + Widget build(BuildContext context) { + return ProfileAvatar( + photo: buddy.photo, + initials: buddy.initials, + radius: 20, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + ringColor: ringColor, + ); + } +``` + +Add `import 'package:submersion/shared/widgets/profile_photo/profile_avatar.dart';` and remove the now-unused `dart:io` import. + +- [ ] **Step 4: Replace the buddy detail page avatars** + +In `lib/features/buddies/presentation/pages/buddy_detail_page.dart`: + +At line 413, replace the whole `CircleAvatar(...)` (including its `backgroundImage: buddy.photoPath != null ? AssetImage(buddy.photoPath!) : null` and its `child:` ternary) with: + +```dart + ProfileAvatar( + photo: buddy.photo, + initials: buddy.initials, + radius: 50, + textStyle: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), +``` + +At line 232, replace that `CircleAvatar` with an equivalent `ProfileAvatar`, keeping whatever `radius` and colours it already passes and adding `photo: buddy.photo, initials: buddy.initials`. + +Add the `ProfileAvatar` import. + +- [ ] **Step 5: Replace the remaining nine sites** + +For each of these, replace the `CircleAvatar(...)` with `ProfileAvatar(...)`, keeping the existing `radius`, `backgroundColor`, and `foregroundColor` arguments, replacing `child: Text(x.initials)` with `initials: x.initials`, and adding `photo: x.photo`: + +- `lib/features/buddies/presentation/widgets/buddy_summary_widget.dart:177` +- `lib/features/buddies/presentation/widgets/buddy_picker.dart:201`, `:264`, `:615` +- `lib/features/divers/presentation/widgets/diver_switcher_sheet.dart:55` +- `lib/features/divers/presentation/widgets/diver_summary_widget.dart:195`, `:281` +- `lib/features/settings/presentation/pages/diver_profile_hub_page.dart:130` + +Add the `ProfileAvatar` import to each file. Leave `buddy_edit_page.dart` alone: Task 12 rewrites it. + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `flutter test test/features/buddies/presentation/widgets/buddy_list_tile_test.dart` +Expected: PASS. + +- [ ] **Step 7: Run the buddy, diver, and settings suites** + +Run: +```bash +flutter test test/features/buddies test/features/divers test/features/settings +``` +Expected: PASS. Fix any test that asserted on the old `CircleAvatar` internals by asserting on `ProfileAvatar` instead. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/features/buddies lib/features/divers lib/features/settings test/features/buddies +git commit -m "refactor(avatars): render diver and buddy photos through ProfileAvatar" +``` + +--- + +## Task 12: Edit page entry points + +**Files:** +- Modify: `lib/features/buddies/presentation/pages/buddy_edit_page.dart` (lines 305-355, 701) +- Modify: `lib/features/settings/presentation/pages/diver_profile_hub_page.dart` (the active diver card) +- Modify: all 11 ARB files (remove `buddies_label_photoComingSoon`) +- Test: `test/features/buddies/presentation/pages/buddy_edit_photo_test.dart` (create) + +**Interfaces:** +- Consumes: `pickProfilePhoto` (Task 9), `ProfileAvatar` (Task 10), `Buddy.photo` (Task 2). +- Produces: `BuddyEditPage` gains an `initialPhoto` field on its form state, consumed by Task 14. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/buddies/presentation/pages/buddy_edit_photo_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/buddies/presentation/pages/buddy_edit_page.dart'; +import 'package:submersion/shared/widgets/profile_photo/profile_avatar.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_app.dart'; + +void main() { + testWidgets('the edit page shows a tappable ProfileAvatar, not a stub', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: await getBaseOverrides(), + locale: const Locale('en'), + child: const BuddyEditPage(), + ), + ); + await tester.pump(); + + expect(find.byType(ProfileAvatar), findsOneWidget); + expect(find.textContaining('coming'), findsNothing); + expect(find.byIcon(Icons.camera_alt), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/buddies/presentation/pages/buddy_edit_photo_test.dart` +Expected: FAIL. The page renders a `CircleAvatar` stub and the "coming soon" text. + +- [ ] **Step 3: Add photo state to the edit page** + +In `lib/features/buddies/presentation/pages/buddy_edit_page.dart`, add a field beside the other form state: + +```dart + Uint8List? _photo; +``` + +Add `import 'dart:typed_data';`. Wherever the page seeds form state from `_originalBuddy` (the same place it currently reads `photoPath`), add `_photo = _originalBuddy?.photo;`. If the widget accepts prefill parameters, add `this.initialPhoto` to its constructor and seed `_photo = widget.initialPhoto ?? _originalBuddy?.photo;`. + +- [ ] **Step 4: Replace the stub with a real control** + +Replace the whole `Center(child: Stack(...))` photo placeholder block (lines 306-343) plus the `photoComingSoon` `Center(child: Text(...))` that follows it with: + +```dart + Center( + child: GestureDetector( + onTap: () async { + final result = await pickProfilePhoto( + context: context, + hasPhoto: _photo != null, + allowContacts: isContactImportSupported, + ); + if (result == null || !mounted) return; + setState(() { + _photo = result.removed ? null : result.bytes; + _hasChanges = true; + }); + }, + child: Stack( + children: [ + ProfileAvatar( + photo: _photo, + initials: _nameController.text.isNotEmpty + ? _getInitials(_nameController.text) + : '?', + radius: 50, + textStyle: TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + Positioned( + bottom: 0, + right: 0, + child: ExcludeSemantics( + child: CircleAvatar( + radius: 16, + backgroundColor: Theme.of(context).colorScheme.primary, + child: Icon( + Icons.camera_alt, + size: 16, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ), + ], + ), + ), + ), +``` + +Add these imports: +```dart +import 'package:submersion/shared/utils/contact_import_support.dart'; +import 'package:submersion/shared/widgets/profile_photo/profile_avatar.dart'; +import 'package:submersion/shared/widgets/profile_photo/profile_photo_picker.dart'; +``` + +At line 701, where the saved `Buddy` is constructed, add `photo: _photo,` beside the existing `photoPath:` line. + +- [ ] **Step 5: Make the diver profile hub card tappable** + +In `lib/features/settings/presentation/pages/diver_profile_hub_page.dart`, wrap the `ProfileAvatar` in `_buildActiveDiverCard` in a `GestureDetector` whose `onTap` runs `pickProfilePhoto(context: context, hasPhoto: diver.photo != null, allowContacts: false)` and, on a non-null result, calls the diver repository's update with `diver.copyWith(photo: result.removed ? null : result.bytes)`. Use whichever provider or notifier the surrounding page already uses to persist a diver edit; do not add a new one. + +Note `allowContacts: false`: contacts hold buddies, not the logged-in diver. + +- [ ] **Step 6: Remove the dead string from all 11 locales** + +Delete the `"buddies_label_photoComingSoon": ...` line from `app_en.arb` and from all 10 other locale files. + +Run: `flutter gen-l10n` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: +```bash +flutter test test/features/buddies/presentation/pages/buddy_edit_photo_test.dart test/l10n/arb_parity_test.dart +``` +Expected: PASS. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/features/buddies/presentation/pages/buddy_edit_page.dart lib/features/settings/presentation/pages/diver_profile_hub_page.dart lib/l10n/arb test/features/buddies/presentation/pages/buddy_edit_photo_test.dart +git commit -m "feat(profile-photo): set diver and buddy photos from the edit screens" +``` + +--- + +## Task 13: Table view leading avatars + +**Files:** +- Modify: `lib/shared/widgets/entity_table/entity_table_view.dart` +- Modify: the buddy and diver table hosts (found in Step 3) +- Test: `test/shared/widgets/entity_table/entity_table_leading_test.dart` (create) + +**Interfaces:** +- Consumes: `ProfileAvatar` (Task 10). +- Produces: `EntityTableView` gains `final Widget Function(T entity)? leadingBuilder`, defaulting to null so every existing table is unchanged. + +- [ ] **Step 1: Write the failing test** + +Create `test/shared/widgets/entity_table/entity_table_leading_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/models/app_settings.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/buddies/domain/constants/buddy_field.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy_with_dive_count.dart'; +import 'package:submersion/shared/models/entity_table_config.dart'; +import 'package:submersion/shared/widgets/entity_table/entity_table_view.dart'; + +import '../../../helpers/test_app.dart'; + +BuddyWithDiveCount _entry() => BuddyWithDiveCount( + buddy: Buddy( + id: 'b1', + name: 'Jane Doe', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ), + diveCount: 3, +); + +Widget _table({Widget Function(BuddyWithDiveCount entity)? leadingBuilder}) { + return EntityTableView( + entities: [_entry()], + idExtractor: (e) => e.buddy.id, + adapter: BuddyFieldAdapter.instance, + config: EntityTableViewConfig( + columns: [EntityTableColumnConfig(field: BuddyField.buddyName)], + ), + units: const UnitFormatter(AppSettings()), + onSortFieldChanged: (_) {}, + onResizeColumn: (_, __) {}, + onEntityTap: (_) {}, + leadingBuilder: leadingBuilder, + ); +} + +void main() { + testWidgets('renders no leading widget when leadingBuilder is null', ( + tester, + ) async { + await tester.pumpWidget(testApp(child: _table())); + await tester.pump(); + + expect(find.text('Jane Doe'), findsOneWidget); + expect(find.byKey(const ValueKey('lead')), findsNothing); + }); + + testWidgets('renders one leading widget per row when given a builder', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + child: _table( + leadingBuilder: (_) => + const Icon(Icons.person, key: ValueKey('lead')), + ), + ), + ); + await tester.pump(); + + expect(find.text('Jane Doe'), findsOneWidget); + expect(find.byKey(const ValueKey('lead')), findsOneWidget); + }); +} +``` + +If `AppSettings` lives at a different path, find it with `grep -rn "class AppSettings" lib/` and correct the import. Do not construct a settings object any other way. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/shared/widgets/entity_table/entity_table_leading_test.dart` +Expected: FAIL, "No named parameter with the name 'leadingBuilder'". + +- [ ] **Step 3: Add the hook** + +In `lib/shared/widgets/entity_table/entity_table_view.dart`: + +Add to the widget's fields and constructor: + +```dart + /// Optional widget rendered at the start of each row, before the first + /// column. Null for every table that does not opt in, so existing tables + /// are unchanged. Used for profile photos, which are not a sortable text + /// value and therefore do not belong in the field enum. + final Widget Function(T entity)? leadingBuilder; +``` + +In `_buildCell` or the row builder that composes cells (around line 214), prepend the leading widget to the row's children when `widget.leadingBuilder != null`, with an 8 logical pixel gap after it. + +- [ ] **Step 4: Wire the buddy and diver tables** + +Find the table hosts: + +```bash +grep -rn "EntityTableView" lib/features/buddies lib/features/divers +``` + +For each, pass: + +```dart + leadingBuilder: (entry) => ProfileAvatar( + photo: entry.buddy.photo, + initials: entry.buddy.initials, + radius: 14, + ), +``` + +adjusting `entry.buddy` to whatever the entity type actually is. Add the `ProfileAvatar` import. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: +```bash +flutter test test/shared/widgets/entity_table test/features/buddies test/features/divers +``` +Expected: PASS. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/shared/widgets/entity_table lib/features/buddies lib/features/divers test/shared/widgets/entity_table +git commit -m "feat(tables): show profile photos in buddy and diver table rows" +``` + +--- + +## Task 14: Contact photos and the master-detail fix + +**Files:** +- Modify: `lib/features/buddies/presentation/widgets/buddy_list_content.dart` (`_importFromContacts` at 330, `FlutterContacts.get` at 365, master-detail branch at 386) +- Modify: `lib/core/router/app_router.dart` (the `newBuddy` route builder, around line 599, `initialName:` at 609) +- Modify: `lib/features/buddies/presentation/pages/buddy_edit_page.dart` (constructor) +- Test: `test/features/buddies/presentation/widgets/buddy_contact_import_test.dart` (create) + +**Interfaces:** +- Consumes: `encodeStoredImage` / `ImageEncodeSpec.avatar` (Task 5), `BuddyEditPage.initialPhoto` (Task 12). +- Produces: contact photos reaching the new-buddy form; no new public API. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/buddies/presentation/widgets/buddy_contact_import_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/features/buddies/presentation/pages/buddy_edit_page.dart'; + +import '../../../../helpers/test_app.dart'; + +void main() { + testWidgets( + 'the new-buddy route carries name, email, phone and photo through extra', + (tester) async { + late GoRouter router; + router = GoRouter( + initialLocation: '/buddies', + routes: [ + GoRoute( + path: '/buddies', + builder: (context, state) => ElevatedButton( + onPressed: () => context.push( + '/buddies/new', + extra: { + 'name': 'Jane Doe', + 'email': 'jane@example.com', + 'phone': '+1 555 0100', + 'photo': null, + }, + ), + child: const Text('import'), + ), + routes: [ + GoRoute( + path: 'new', + builder: (context, state) { + final extra = state.extra as Map?; + return BuddyEditPage( + initialName: extra?['name'] as String?, + initialEmail: extra?['email'] as String?, + initialPhone: extra?['phone'] as String?, + ); + }, + ), + ], + ), + ], + ); + + await tester.pumpWidget( + testAppRouter(router: router, locale: const Locale('en')), + ); + await tester.pump(); + + await tester.tap(find.text('import')); + await tester.pumpAndSettle(); + + expect(find.text('Jane Doe'), findsOneWidget); + }, + ); +} +``` + +This test is a guard on the route contract. The master-detail branch removal is verified by Step 4's code reading, because `_importFromContacts` needs a live address book and cannot be driven from a widget test. + +- [ ] **Step 2: Run the test to verify it fails or passes** + +Run: `flutter test test/features/buddies/presentation/widgets/buddy_contact_import_test.dart` +Expected: FAIL if `BuddyEditPage` does not yet accept `initialName`; PASS if Task 12 already added it. If it passes, that is fine: it is a regression guard, and the real change is Steps 3 and 4. + +- [ ] **Step 3: Fetch the contact photo** + +In `lib/features/buddies/presentation/widgets/buddy_list_content.dart`, replace the `FlutterContacts.get(contactId)` call (line 365) with: + +```dart + final fullContact = await FlutterContacts.get( + contactId, + properties: { + ContactProperty.name, + ContactProperty.email, + ContactProperty.phone, + ContactProperty.photoFullRes, + ContactProperty.photoThumbnail, + }, + ); +``` + +After the existing `name`, `email`, and `phone` extraction, add: + +```dart + // Full resolution first: the thumbnail is only 96x96 or 150x150, which + // is a fallback, not a preference. Centered automatically with no crop + // dialog, because the user is mid-import and did not ask to frame a + // photo; they can adjust it afterwards from the edit page. + final rawPhoto = + fullContact.photo?.fullSize ?? fullContact.photo?.thumbnail; + Uint8List? photo; + if (rawPhoto != null) { + final encoded = await encodeStoredImage( + ImageEncodeRequest.fromBytes( + bytes: rawPhoto, + spec: ImageEncodeSpec.avatar, + declaredName: 'contact.jpg', + ), + ); + if (encoded.outcome == ImageEncodeOutcome.encoded) { + photo = encoded.bytes; + } + } +``` + +Add `import 'dart:typed_data';` and `import 'package:submersion/core/services/images/profile_photo_codec.dart';`. + +- [ ] **Step 4: Delete the master-detail branch** + +Replace the whole `if (ResponsiveBreakpoints.isMasterDetail(context)) { ... } else { ... }` block inside `_importFromContacts` (starts line 386) with the code below. Note there is a SECOND `isMasterDetail` at line 888 that belongs to a different flow: leave it alone. + +```dart + if (context.mounted) { + // One push for every layout. `/buddies/new` declares + // parentNavigatorKey: rootNavigatorKey so it renders in the + // foreground rather than under the shell, which is why the old + // master-detail special case was not just buggy (it carried no data, + // so an iPad in landscape landed on a blank form) but unnecessary. + context.push( + '/buddies/new', + extra: { + 'name': name, + 'email': email, + 'phone': phone, + 'photo': photo, + }, + ); + } +``` + +Remove the now-unused `ResponsiveBreakpoints` and `GoRouterState` imports if nothing else in the file uses them. + +- [ ] **Step 5: Carry the photo through the route** + +In `lib/core/router/app_router.dart`, in the `newBuddy` route builder (the `initialName:` line at 609), add: + +```dart + initialPhoto: extra?['photo'] as Uint8List?, +``` + +Add `import 'dart:typed_data';`. + +In `lib/features/buddies/presentation/pages/buddy_edit_page.dart`, add `final Uint8List? initialPhoto;` and `this.initialPhoto,` to the constructor if Task 12 did not already. + +- [ ] **Step 6: Wire the contacts source in the picker** + +In `lib/features/buddies/presentation/pages/buddy_edit_page.dart`, pass a `contactPhotoLoader` to the `pickProfilePhoto` call added in Task 12. The loader opens the same permission check and native picker, requests only the two photo properties, and returns the raw bytes: + +```dart + contactPhotoLoader: (context) async { + if (!await FlutterContacts.permissions + .has(PermissionType.read)) { + await FlutterContacts.permissions + .request(PermissionType.read); + if (!await FlutterContacts.permissions + .has(PermissionType.read)) { + return null; + } + } + final id = + (await FlutterContacts.native.showPicker())?.trim(); + if (id == null || id.isEmpty) return null; + final contact = await FlutterContacts.get( + id, + properties: { + ContactProperty.photoFullRes, + ContactProperty.photoThumbnail, + }, + ); + final bytes = contact?.photo?.fullSize ?? + contact?.photo?.thumbnail; + if (bytes == null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.profilePhoto_error_contactNoPhoto, + ), + ), + ); + } + return bytes; + }, +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: +```bash +flutter test test/features/buddies +``` +Expected: PASS. + +- [ ] **Step 8: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/features/buddies lib/core/router/app_router.dart test/features/buddies/presentation/widgets/buddy_contact_import_test.dart +git commit -m "feat(buddies): import contact photos and fix master-detail data loss" +``` + +--- + +## Task 15: Certification photo reroute + +**Files:** +- Modify: `lib/features/certifications/presentation/pages/certification_edit_page.dart` (`_pickPhoto` at 175, `pickImage` block at 201-212) +- Test: `test/features/certifications/certification_photo_bound_test.dart` (create) + +**Interfaces:** +- Consumes: `encodeStoredImage` / `ImageEncodeSpec.certificationCard` (Task 5). +- Produces: certification card photos bounded at 2000px on every platform. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/certifications/certification_photo_bound_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:submersion/core/services/images/profile_photo_codec.dart'; + +/// Guards the contract certification photos rely on: the cap is enforced in +/// Dart, not by ImagePicker's maxWidth / maxHeight / imageQuality, which +/// image_picker_macos, image_picker_windows and image_picker_linux silently +/// ignore. +void main() { + test('an oversized card photo is bounded to 2000px on any platform', () { + final source = img.Image(width: 6000, height: 4000); + img.fill(source, color: img.ColorRgb8(200, 200, 200)); + final bytes = Uint8List.fromList(img.encodeJpg(source, quality: 90)); + + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: bytes, + spec: ImageEncodeSpec.certificationCard, + declaredName: 'card.jpg', + ), + ); + + expect(result.outcome, ImageEncodeOutcome.encoded); + final out = img.decodeImage(result.bytes!)!; + expect(out.width, 2000); + expect(out.height, 1333, reason: 'aspect ratio must be preserved'); + }); + + test('a card photo is not cropped to a square', () { + final source = img.Image(width: 1000, height: 600); + img.fill(source, color: img.ColorRgb8(200, 200, 200)); + final bytes = Uint8List.fromList(img.encodeJpg(source, quality: 90)); + + final result = runImageEncodeRequest( + ImageEncodeRequest.fromBytes( + bytes: bytes, + spec: ImageEncodeSpec.certificationCard, + declaredName: 'card.jpg', + ), + ); + + final out = img.decodeImage(result.bytes!)!; + expect(out.width, 1000); + expect(out.height, 600); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `flutter test test/features/certifications/certification_photo_bound_test.dart` +Expected: PASS. This test guards the codec spec, which Task 5 already built. If `expect(out.height, 1333)` fails by one pixel, adjust the expectation to the value `copyResize` actually produces and note it in the test. + +- [ ] **Step 3: Reroute the picker** + +In `lib/features/certifications/presentation/pages/certification_edit_page.dart`, replace the `pickImage` call and the byte read inside `_pickPhoto` (lines 201-212, from `final picked = await _imagePicker.pickImage(` through `return await file.readAsBytes();`) with: + +```dart + // No maxWidth / maxHeight / imageQuality here: image_picker_macos, + // image_picker_windows and image_picker_linux silently ignore them, so + // a desktop pick entered the database at full size and rode into every + // sync changeset as base64. The cap is enforced below instead. + final picked = await _imagePicker.pickImage(source: source); + + if (picked == null) return null; + + final raw = await File(picked.path).readAsBytes(); + final encoded = await encodeStoredImage( + ImageEncodeRequest.fromBytes( + bytes: raw, + spec: ImageEncodeSpec.certificationCard, + declaredName: picked.name, + ), + ); + if (encoded.outcome != ImageEncodeOutcome.encoded) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + encoded.outcome == ImageEncodeOutcome.tooLarge + ? context.l10n.profilePhoto_error_tooLarge + : context.l10n.profilePhoto_error_undecodable, + ), + ), + ); + } + return null; + } + return encoded.bytes; +``` + +Add `import 'package:submersion/core/services/images/profile_photo_codec.dart';`. + +- [ ] **Step 4: Run the certification suite** + +Run: `flutter test test/features/certifications` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze --fatal-infos +git add lib/features/certifications test/features/certifications/certification_photo_bound_test.dart +git commit -m "fix(certifications): bound card photo size on desktop" +``` + +--- + +## Task 16: Full verification + +**Files:** none modified unless a failure requires it. + +- [ ] **Step 1: Regenerate everything** + +```bash +cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-photos +dart run build_runner build --delete-conflicting-outputs +flutter gen-l10n +``` + +- [ ] **Step 2: Format and analyze the whole project** + +```bash +dart format . +flutter analyze --fatal-infos +``` +Expected: no issues. Analyze the WHOLE project, not just changed files: CI does, and info-level diagnostics are fatal. + +- [ ] **Step 3: Run the full suite once** + +Run: `flutter test` +Expected: PASS. Do not pipe through `grep`: the pipeline returns grep's exit status and a failing suite would read as success. + +If a test fails that is listed in the repo's known-flaky index, rerun that one file alone before treating it as real. Do not run overlapping test processes: a concurrent run makes a passing test look like a lone failure. + +- [ ] **Step 4: Confirm the schema rung is still uncontested** + +```bash +git fetch origin main +git show origin/main:lib/core/database/database.dart | grep -n "currentSchemaVersion = " +``` +Expected: still 180. If main has advanced to 181 or beyond, renumber this branch's rung above it in `database.dart` (the ladder `if (from < N)`, the `migrationVersions` entry, `currentSchemaVersion`) and in `migration_v181_profile_photo_test.dart`, then rerun Step 3. + +- [ ] **Step 5: Commit any fixes** + +```bash +dart format . +git add +git commit -m "chore: full-suite verification fixes" +``` + +Never `git add -A` or `git add -u`: sibling worktrees share this checkout. + +--- + +## Deferred, to be filed as issues + +These are deliberately out of scope. File them rather than building them: + +1. **PDF export** does not embed profile photos. +2. **Bulk address-book matching** of existing buddies to contacts. +3. **`Species.photoPath`** stays a path; species photos are reference data. +4. **Oversized existing certification photos** are not backfilled; only new picks are bounded. +5. **`image_resize_job.dart` never calls `img.bakeOrientation`**, so media-store thumbnails of EXIF-rotated photos render sideways. One-line fix, separate change, needs its own test. diff --git a/docs/superpowers/plans/2026-08-29-profile-series-2a-foundation.md b/docs/superpowers/plans/2026-08-29-profile-series-2a-foundation.md new file mode 100644 index 0000000000..5a88a9a0b8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-profile-series-2a-foundation.md @@ -0,0 +1,3004 @@ +# Profile Series 2a: Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the two packed-series tables with their v182 rung, the packer that fills them from the legacy row-per-sample tables with deterministic ids, and the two repositories that read and write series rows, leaving the legacy tables and every existing consumer untouched. + +**Architecture:** `dive_profile_series` and `tank_pressure_series` are ordinary Drift tables declared next to their legacy siblings, created by an idempotent `_assertProfileSeriesSchema()` helper that the v182 rung and the `beforeOpen` backstop both call. `packLegacyProfileRows` (its own file under `lib/core/database/`, raw SQL only so it survives the later removal of the legacy table classes) groups legacy rows by identity tuple, drops exact duplicates, encodes each group with the PR 1 codecs, and inserts with uuid v5 ids derived from the tuple, so every device converges. `ProfileSeriesRepository` and `TankPressureSeriesRepository` follow the `TankPressureRepository` shape (zero-arg, singleton database getter, private `SyncRepository`) and are the only production code that encodes or decodes. Plans 2b to 2e move the writers, readers, sync, and retirement onto this foundation. + +**Tech Stack:** Drift 2.34 (raw `customSelect`/`customUpdate` in the packer; generated companions in the repositories), `package:uuid` v5, the PR 1 codecs under `lib/features/dive_log/domain/codecs/`, `flutter_test` with `NativeDatabase.memory(setup:)` fixtures. + +**Spec:** `docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md`, sections 3 (architecture), 4 (schema), 6 (write and read API), 8 (migration: the rung, deterministic ids), 10 (migration tests). Section 8's "drop the old tables", "purge retired tombstones", and the `VACUUM` belong to plan 2e; section 7 (sync) to plan 2d. + +## Global Constraints + +- Work only in the worktree `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-sample-storage-2` on branch `worktree-profile-sample-storage-2` (stacked on PR #1387's branch, merged with `origin/main` at schema v180). The shell's working directory does not persist between commands; prefix every command with `cd` into that directory. +- Schema rung is **182**: `origin/main` is at 180 and open PR #1390 (profile photos) holds 181, so 181 is absent from this branch's ladder until that PR lands. Re-check every open PR head (`git fetch origin`, then `git show "origin/${h}:lib/core/database/database.dart"`; in zsh the braces matter, `$h:l` is a modifier) immediately before the PR opens. `currentSchemaVersion` becomes 182; `migrationVersions` gains `182`; `minimumCompatibleSchemaVersion` stays at 170 in this plan (the tables are additive; plan 2d raises it when the legacy sync entities retire). +- Never use an em-dash (U+2014) or an en-dash as punctuation anywhere: code, comments, tests, commit messages, this plan. No emojis. +- Lints: `package:flutter_lints/flutter.yaml` plus `prefer_const_constructors`, `prefer_const_declarations`, `prefer_final_fields`, `prefer_final_locals`, `avoid_print`, `require_trailing_commas`, `always_use_package_imports`. Project imports use `package:submersion/...`; test helpers use the relative `../../../helpers/test_database.dart` form as every existing repository test does. +- `lib/core/database/database.dart` and everything it imports stay Flutter-free. `profile_series_pack.dart` imports only `package:drift` (which re-exports `dart:typed_data`), the codecs, `profile_series.dart`, `profile_sample_dedupe.dart`, and `hlc.dart`. +- Legacy tables `dive_profiles` and `tank_pressure_profiles`, their Drift classes, their indexes, and every existing consumer are NOT modified in this plan. The packer reads them by raw SQL and never by their Drift classes. +- Series identity columns mirror the legacy tables exactly: profile series `(dive_id, computer_id, source_id, is_primary)`; tank series `(dive_id, tank_id, computer_id)`. Summary scalars are exactly spec section 4's set. +- Migrated series ids are uuid v5 over the identity tuple with the literal `null` for absent members (spec section 8). Repository-minted ids are uuid v4. +- Both series tables carry an `hlc` column, so `SyncRepository.hlcTargets` must gain `diveProfileSeries` and `tankPressureSeries` entries, or `sync_hlc_target_registration_test` fails. The sync serializer, `parentRefs`, and `SyncData` are NOT touched in this plan (plan 2d); in this intermediate state a pending `sync_records` row for a series is inert. +- Under `PRAGMA foreign_keys = ON` (set in `beforeOpen`), inserting a series row requires its FK parents to exist. Every fixture that inserts series rows creates `dives`, `dive_computers`, `dive_data_sources`, and `dive_tanks` tables and the parent rows it references. +- Run tests per file (`flutter test `), never a directory; never pipe `flutter test` through grep, tail, or head. Before every commit: `dart format .` from the worktree root and `flutter analyze` (must report "No issues found!"). After editing `database.dart`, run `dart run build_runner build` before tests (the `--delete-conflicting-outputs` flag is ignored by this build_runner version and prints a warning; that is fine). +- Commit with `git add` of explicit paths only (never `-A` or `.`). Conventional prefixes, no `Co-Authored-By` trailer, no session URL. No push in this plan. +- No new file over 400 lines. `database.dart` is already 9,600 lines; add only the two table classes, the helper, the rung lines, and the backstop line there. + +--- + +## File structure + +Create: + +| file | responsibility | +|---|---| +| `lib/features/dive_log/domain/entities/profile_series.dart` | `ProfileSeries`, `TankPressureSeries` entities; the two uuid v5 namespaces and `profileSeriesMigratedId` / `tankPressureSeriesMigratedId` | +| `lib/features/dive_log/domain/services/profile_sample_dedupe.dart` | `dedupeExactSamples` / `dedupeExactPressureSamples`, the write-side exact-duplicate drop | +| `lib/core/database/profile_series_pack.dart` | `packLegacyProfileRows`: legacy rows to series rows, raw SQL, idempotent | +| `lib/features/dive_log/data/repositories/profile_series_repository.dart` | `ProfileSeriesRepository` | +| `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart` | `TankPressureSeriesRepository` | +| `test/features/dive_log/domain/entities/profile_series_test.dart` | | +| `test/features/dive_log/domain/services/profile_sample_dedupe_test.dart` | | +| `test/core/database/migration_v182_profile_series_test.dart` | schema on upgrade and fresh; rung registered; packing on upgrade; fleet convergence | +| `test/core/database/profile_series_pack_test.dart` | the packer in isolation | +| `test/features/dive_log/data/repositories/profile_series_repository_test.dart` | | +| `test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart` | | + +Modify: + +| file | change | +|---|---| +| `lib/core/database/database.dart` | two table classes after `TankPressureProfiles`; registration in `@DriftDatabase(tables:)`; `currentSchemaVersion = 182`; `migrationVersions` entry; `_assertProfileSeriesSchema()`; v182 rung; backstop line | +| `lib/core/database/performance_indexes.dart` | two entries | +| `lib/core/data/repositories/sync_repository.dart` | two `hlcTargets` entries | +| `test/core/database/query_plan_test.dart` | two plan assertions | + +--- + +### Task 1: Series entities, deterministic ids, and exact-duplicate dedupe + +**Files:** +- Create: `lib/features/dive_log/domain/entities/profile_series.dart` +- Create: `lib/features/dive_log/domain/services/profile_sample_dedupe.dart` +- Test: `test/features/dive_log/domain/entities/profile_series_test.dart` +- Test: `test/features/dive_log/domain/services/profile_sample_dedupe_test.dart` + +**Interfaces:** +- Consumes: `ProfileSample` (`lib/features/dive_log/domain/codecs/profile_sample.dart`), `ProfileSeriesSummary` (`.../profile_series_summary.dart`), `TankPressureSample` and `TankPressureSeriesSummary` (`.../tank_pressure_series_codec.dart`), `DiveProfilePoint` (`.../entities/dive.dart`), `package:uuid`. +- Produces: + - `class ProfileSeries extends Equatable { id, diveId, computerId, sourceId, isPrimary, summary, samples, codecVersion, createdAt, updatedAt, hlc; List get points; copyWith }` + - `class TankPressureSeries extends Equatable { id, diveId, tankId, computerId, summary, samples, codecVersion, createdAt, updatedAt, hlc; copyWith }` + - `const String kProfileSeriesNamespace`, `const String kTankPressureSeriesNamespace` + - `String profileSeriesMigratedId({required String diveId, required String? computerId, required String? sourceId, required bool isPrimary})` + - `String tankPressureSeriesMigratedId({required String diveId, required String tankId, required String? computerId})` + - `List dedupeExactSamples(List samples)` + - `List dedupeExactPressureSamples(List samples)` + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/dive_log/domain/entities/profile_series_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_summary.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_series.dart'; +import 'package:uuid/uuid.dart'; + +void main() { + group('profileSeriesMigratedId', () { + test('is deterministic over the identity tuple', () { + final a = profileSeriesMigratedId( + diveId: 'd1', + computerId: 'c1', + sourceId: 's1', + isPrimary: true, + ); + final b = profileSeriesMigratedId( + diveId: 'd1', + computerId: 'c1', + sourceId: 's1', + isPrimary: true, + ); + expect(a, b); + expect(a, hasLength(36)); + expect(a[14], '5', reason: 'uuid v5 version nibble'); + }); + + test('every tuple member changes the id', () { + final base = profileSeriesMigratedId( + diveId: 'd1', + computerId: 'c1', + sourceId: 's1', + isPrimary: true, + ); + expect( + profileSeriesMigratedId( + diveId: 'd2', + computerId: 'c1', + sourceId: 's1', + isPrimary: true, + ), + isNot(base), + ); + expect( + profileSeriesMigratedId( + diveId: 'd1', + computerId: null, + sourceId: 's1', + isPrimary: true, + ), + isNot(base), + ); + expect( + profileSeriesMigratedId( + diveId: 'd1', + computerId: 'c1', + sourceId: null, + isPrimary: true, + ), + isNot(base), + ); + expect( + profileSeriesMigratedId( + diveId: 'd1', + computerId: 'c1', + sourceId: 's1', + isPrimary: false, + ), + isNot(base), + ); + }); + + test('absent members are spelled null in the key, per the spec', () { + // Spec section 8: uuid v5 over `dive_id|computer_id|source_id| + // is_primary` with the literal `null` for absent members. Pinning the + // key format keeps every device deriving the same id. + expect( + profileSeriesMigratedId( + diveId: 'd1', + computerId: null, + sourceId: null, + isPrimary: true, + ), + const Uuid().v5(kProfileSeriesNamespace, 'd1|null|null|1'), + ); + }); + }); + + group('tankPressureSeriesMigratedId', () { + test('is deterministic and distinct from the profile namespace', () { + final a = tankPressureSeriesMigratedId( + diveId: 'd1', + tankId: 't1', + computerId: null, + ); + final b = tankPressureSeriesMigratedId( + diveId: 'd1', + tankId: 't1', + computerId: null, + ); + expect(a, b); + expect( + a, + isNot( + profileSeriesMigratedId( + diveId: 'd1', + computerId: null, + sourceId: 't1', + isPrimary: true, + ), + ), + ); + }); + }); + + group('ProfileSeries', () { + const samples = [ + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 10, depth: 12.5, temperature: 20.0), + ]; + final series = ProfileSeries( + id: 'ps1', + diveId: 'd1', + computerId: 'c1', + sourceId: 's1', + isPrimary: true, + summary: ProfileSeriesSummary.of(samples), + samples: samples, + codecVersion: 1, + createdAt: 1000, + updatedAt: 1000, + ); + + test('points converts every sample to a DiveProfilePoint', () { + final points = series.points; + expect(points, hasLength(2)); + expect(points[1].timestamp, 10); + expect(points[1].depth, 12.5); + expect(points[1].temperature, 20.0); + }); + + test('copyWith replaces only what is given', () { + final demoted = series.copyWith(isPrimary: false, updatedAt: 2000); + expect(demoted.isPrimary, isFalse); + expect(demoted.updatedAt, 2000); + expect(demoted.id, 'ps1'); + expect(demoted.samples, samples); + expect(demoted, isNot(series)); + }); + + test('copyWith can clear nullable members', () { + final cleared = series.copyWith(clearComputerId: true, clearSourceId: true); + expect(cleared.computerId, isNull); + expect(cleared.sourceId, isNull); + }); + }); +} +``` + +Create `test/features/dive_log/domain/services/profile_sample_dedupe_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/services/profile_sample_dedupe.dart'; + +void main() { + test('exact duplicates are dropped, first occurrence kept, order kept', () { + const samples = [ + ProfileSample(timestamp: 0, depth: 1.0), + ProfileSample(timestamp: 10, depth: 2.0, temperature: 20.0), + ProfileSample(timestamp: 10, depth: 2.0, temperature: 20.0), + ProfileSample(timestamp: 20, depth: 3.0), + ProfileSample(timestamp: 0, depth: 1.0), + ]; + expect(dedupeExactSamples(samples), [ + const ProfileSample(timestamp: 0, depth: 1.0), + const ProfileSample(timestamp: 10, depth: 2.0, temperature: 20.0), + const ProfileSample(timestamp: 20, depth: 3.0), + ]); + }); + + test('samples that share a timestamp but differ are all kept', () { + const samples = [ + ProfileSample(timestamp: 10, depth: 2.0), + ProfileSample(timestamp: 10, depth: 2.5), + ProfileSample(timestamp: 10, depth: 2.0, temperature: 19.0), + ]; + expect(dedupeExactSamples(samples), samples); + }); + + test('an empty list stays empty', () { + expect(dedupeExactSamples(const []), isEmpty); + }); + + test('pressure duplicates are dropped the same way', () { + const samples = [ + TankPressureSample(timestamp: 0, pressure: 200.0), + TankPressureSample(timestamp: 0, pressure: 200.0), + TankPressureSample(timestamp: 5, pressure: 199.0), + ]; + expect(dedupeExactPressureSamples(samples), [ + const TankPressureSample(timestamp: 0, pressure: 200.0), + const TankPressureSample(timestamp: 5, pressure: 199.0), + ]); + }); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/dive_log/domain/entities/profile_series_test.dart` and `flutter test test/features/dive_log/domain/services/profile_sample_dedupe_test.dart` +Expected: compilation failures, the two new library files not found. + +- [ ] **Step 3: Create the entities and identity helpers** + +Create `lib/features/dive_log/domain/entities/profile_series.dart`: + +```dart +import 'package:equatable/equatable.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_summary.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:uuid/uuid.dart'; + +/// Namespace for [profileSeriesMigratedId]. Fixed forever: changing it would +/// make two devices that migrate the same rows disagree on the series id. +const String kProfileSeriesNamespace = '7c2d9b1e-4f3a-4e8b-9c5d-2a1f6e8b3d47'; + +/// Namespace for [tankPressureSeriesMigratedId]. +const String kTankPressureSeriesNamespace = + 'b8e1f2c3-5d6a-4b7c-8e9f-1a2b3c4d5e6f'; + +/// The series id the v182 migration assigns to a packed +/// (dive, computer, source, is_primary) group. +/// +/// Every device runs the migration independently. A random id per device +/// would let sync union two primary series per dive, the duplicate +/// dive-types shape of issue #1360. Deriving the id from the identity tuple +/// makes devices that hold the same synced sample rows converge on upsert. +/// Only the migration uses this; repository writes mint uuid v4, because a +/// fresh download or edit genuinely is a new series. +/// +/// Absent members are spelled `null` in the key (spec section 8). A member +/// that is literally the string "null" would collide with absence, and +/// cannot occur: every member is a uuid. +String profileSeriesMigratedId({ + required String diveId, + required String? computerId, + required String? sourceId, + required bool isPrimary, +}) => const Uuid().v5( + kProfileSeriesNamespace, + '$diveId|${computerId ?? 'null'}|${sourceId ?? 'null'}|${isPrimary ? 1 : 0}', +); + +/// The series id the v182 migration assigns to a packed +/// (dive, tank, computer) pressure group. See [profileSeriesMigratedId]. +String tankPressureSeriesMigratedId({ + required String diveId, + required String tankId, + required String? computerId, +}) => const Uuid().v5( + kTankPressureSeriesNamespace, + '$diveId|$tankId|${computerId ?? 'null'}', +); + +/// One packed profile series: the identity columns of a +/// `dive_profile_series` row, its summary scalars, and its decoded samples. +class ProfileSeries extends Equatable { + const ProfileSeries({ + required this.id, + required this.diveId, + this.computerId, + this.sourceId, + required this.isPrimary, + required this.summary, + required this.samples, + required this.codecVersion, + required this.createdAt, + required this.updatedAt, + this.hlc, + }); + + final String id; + final String diveId; + final String? computerId; + final String? sourceId; + final bool isPrimary; + final ProfileSeriesSummary summary; + final List samples; + final int codecVersion; + final int createdAt; + final int updatedAt; + final String? hlc; + + /// The samples as the chart and analysis pipeline consume them. + List get points => [ + for (final sample in samples) sample.toPoint(), + ]; + + ProfileSeries copyWith({ + String? id, + String? diveId, + String? computerId, + bool clearComputerId = false, + String? sourceId, + bool clearSourceId = false, + bool? isPrimary, + ProfileSeriesSummary? summary, + List? samples, + int? codecVersion, + int? createdAt, + int? updatedAt, + String? hlc, + bool clearHlc = false, + }) { + return ProfileSeries( + id: id ?? this.id, + diveId: diveId ?? this.diveId, + computerId: clearComputerId ? null : (computerId ?? this.computerId), + sourceId: clearSourceId ? null : (sourceId ?? this.sourceId), + isPrimary: isPrimary ?? this.isPrimary, + summary: summary ?? this.summary, + samples: samples ?? this.samples, + codecVersion: codecVersion ?? this.codecVersion, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + hlc: clearHlc ? null : (hlc ?? this.hlc), + ); + } + + @override + List get props => [ + id, + diveId, + computerId, + sourceId, + isPrimary, + summary, + samples, + codecVersion, + createdAt, + updatedAt, + hlc, + ]; +} + +/// One packed tank pressure series: a `tank_pressure_series` row decoded. +class TankPressureSeries extends Equatable { + const TankPressureSeries({ + required this.id, + required this.diveId, + required this.tankId, + this.computerId, + required this.summary, + required this.samples, + required this.codecVersion, + required this.createdAt, + required this.updatedAt, + this.hlc, + }); + + final String id; + final String diveId; + final String tankId; + final String? computerId; + final TankPressureSeriesSummary summary; + final List samples; + final int codecVersion; + final int createdAt; + final int updatedAt; + final String? hlc; + + TankPressureSeries copyWith({ + String? id, + String? diveId, + String? tankId, + String? computerId, + bool clearComputerId = false, + TankPressureSeriesSummary? summary, + List? samples, + int? codecVersion, + int? createdAt, + int? updatedAt, + String? hlc, + bool clearHlc = false, + }) { + return TankPressureSeries( + id: id ?? this.id, + diveId: diveId ?? this.diveId, + tankId: tankId ?? this.tankId, + computerId: clearComputerId ? null : (computerId ?? this.computerId), + summary: summary ?? this.summary, + samples: samples ?? this.samples, + codecVersion: codecVersion ?? this.codecVersion, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + hlc: clearHlc ? null : (hlc ?? this.hlc), + ); + } + + @override + List get props => [ + id, + diveId, + tankId, + computerId, + summary, + samples, + codecVersion, + createdAt, + updatedAt, + hlc, + ]; +} +``` + +Create `lib/features/dive_log/domain/services/profile_sample_dedupe.dart`: + +```dart +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; + +/// Drops samples that repeat one already seen, comparing every field. +/// +/// This is the write-side home of what `DiveRepository._dropDuplicateSamples` +/// does on every read today: a repeated import stored the same sample twice, +/// and analysis curves are index-aligned against the list, so the duplicate +/// has to go before the series is packed. Samples that share a timestamp but +/// differ in any field are all kept, in insertion order. +List dedupeExactSamples(List samples) { + final seen = {}; + return [ + for (final sample in samples) + if (seen.add(sample)) sample, + ]; +} + +/// [dedupeExactSamples] for tank pressure readings. +List dedupeExactPressureSamples( + List samples, +) { + final seen = {}; + return [ + for (final sample in samples) + if (seen.add(sample)) sample, + ]; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run both test files. Expected: 7 and 4 tests pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/domain/entities/profile_series.dart lib/features/dive_log/domain/services/profile_sample_dedupe.dart test/features/dive_log/domain/entities/profile_series_test.dart test/features/dive_log/domain/services/profile_sample_dedupe_test.dart +git commit -m "feat: ProfileSeries entities, deterministic migrated ids, exact-duplicate dedupe" +``` + +--- + +### Task 2: Series tables, v182 rung, indexes, HLC registration + +**Files:** +- Modify: `lib/core/database/database.dart` (table classes after `class TankPressureProfiles`; `@DriftDatabase(tables:)` list; `currentSchemaVersion`; `migrationVersions`; a new `_assertProfileSeriesSchema()` next to `_assertTripDayWeatherSchema()`; the rung after the v180 rung; the backstop line after the v180 backstop) +- Modify: `lib/core/database/performance_indexes.dart` (after the `idx_tank_pressure_dive_tank` entry) +- Modify: `lib/core/data/repositories/sync_repository.dart` (`hlcTargets`) +- Modify: `test/core/database/query_plan_test.dart` +- Test: `test/core/database/migration_v182_profile_series_test.dart` (schema half; Task 4 adds the packing half) + +**Interfaces:** +- Consumes: nothing from Task 1 (the rung's packing call is added in Task 4). +- Produces: Drift tables `DiveProfileSeries` (data class `DiveProfileSeriesRow`, companion `DiveProfileSeriesCompanion`, accessor `db.diveProfileSeries`) and `TankPressureSeries` (`TankPressureSeriesRow`, `TankPressureSeriesCompanion`, `db.tankPressureSeries`); SQL tables `dive_profile_series` and `tank_pressure_series`; indexes `idx_dive_profile_series_dive_primary` and `idx_tank_pressure_series_dive_tank`; `AppDatabase.currentSchemaVersion == 182`; `hlcTargets['diveProfileSeries']` and `hlcTargets['tankPressureSeries']`. + +- [ ] **Step 1: Write the failing test (schema half)** + +Create `test/core/database/migration_v182_profile_series_test.dart`: + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v182 shape stamped at v180 so only the 182 rung runs. The FK +/// parents exist because the series tables reference them and foreign keys +/// are on once the database opens. Shared by [dbAt180] and the two-open +/// tests, which need the same DDL on a raw handle. +/// +/// is_primary/imported_at/created_at on dive_data_sources are not part of +/// the series schema, but the unconditional beforeOpen self-heal +/// _backfillMissingDataSources (test/core/database/ +/// backfill_missing_data_sources_test.dart) runs on every open once all of +/// dives, dive_profiles, and dive_data_sources exist, and needs them. +void legacyDdlAt180(dynamic rawDb) { + rawDb.execute('PRAGMA user_version = 180'); + rawDb.execute('CREATE TABLE dives (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute('CREATE TABLE dive_computers (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute(''' + CREATE TABLE dive_data_sources ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 0, + imported_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + '''); + rawDb.execute( + 'CREATE TABLE dive_tanks (id TEXT NOT NULL PRIMARY KEY, ' + 'dive_id TEXT NOT NULL)', + ); + rawDb.execute(''' + CREATE TABLE dive_profiles ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + source_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 1, + timestamp INTEGER NOT NULL, + depth REAL NOT NULL, + temperature REAL, + ndl INTEGER, + ceiling REAL, + deco_type INTEGER, + heart_rate_source TEXT + ) + '''); + rawDb.execute(''' + CREATE TABLE tank_pressure_profiles ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + tank_id TEXT NOT NULL, + timestamp INTEGER NOT NULL, + pressure REAL NOT NULL, + computer_id TEXT + ) + '''); +} + +NativeDatabase dbAt180({void Function(dynamic rawDb)? seed}) { + return NativeDatabase.memory( + setup: (rawDb) { + legacyDdlAt180(rawDb); + seed?.call(rawDb); + }, + ); +} + +Future> tableNames(AppDatabase db) async { + final rows = await db + .customSelect("SELECT name FROM sqlite_master WHERE type = 'table'") + .get(); + return rows.map((r) => r.read('name')).toSet(); +} + +Future> indexNames(AppDatabase db) async { + final rows = await db + .customSelect("SELECT name FROM sqlite_master WHERE type = 'index'") + .get(); + return rows.map((r) => r.read('name')).toSet(); +} + +Future> columnsOf(AppDatabase db, String table) async { + final cols = await db.customSelect("PRAGMA table_info('$table')").get(); + return cols.map((c) => c.read('name')).toSet(); +} + +const profileSeriesColumns = { + 'id', + 'dive_id', + 'computer_id', + 'source_id', + 'is_primary', + 'sample_count', + 'start_timestamp', + 'end_timestamp', + 'max_depth', + 'first_depth', + 'last_depth', + 'has_deco_type', + 'has_deco_stop', + 'has_positive_ceiling', + 'codec_version', + 'samples', + 'created_at', + 'updated_at', + 'hlc', +}; + +const tankSeriesColumns = { + 'id', + 'dive_id', + 'tank_id', + 'computer_id', + 'sample_count', + 'start_timestamp', + 'end_timestamp', + 'codec_version', + 'samples', + 'created_at', + 'updated_at', + 'hlc', +}; + +void main() { + group('schema', () { + test('v182 creates both series tables and their indexes on upgrade', () async { + final db = AppDatabase(dbAt180()); + addTearDown(db.close); + + final tables = await tableNames(db); + expect(tables, containsAll(['dive_profile_series', 'tank_pressure_series'])); + expect(await columnsOf(db, 'dive_profile_series'), profileSeriesColumns); + expect(await columnsOf(db, 'tank_pressure_series'), tankSeriesColumns); + final indexes = await indexNames(db); + expect( + indexes, + containsAll([ + 'idx_dive_profile_series_dive_primary', + 'idx_tank_pressure_series_dive_tank', + ]), + ); + // The legacy tables survive this plan untouched. + expect(tables, containsAll(['dive_profiles', 'tank_pressure_profiles'])); + }); + + test('a fresh database has the tables with the same columns', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + expect(await columnsOf(db, 'dive_profile_series'), profileSeriesColumns); + expect(await columnsOf(db, 'tank_pressure_series'), tankSeriesColumns); + // The Drift declaration and the raw DDL must agree, or a fresh install + // and an upgraded one would diverge. + final driftProfile = db.diveProfileSeries.$columns + .map((c) => c.$name) + .toSet(); + final driftTank = db.tankPressureSeries.$columns + .map((c) => c.$name) + .toSet(); + expect(driftProfile, profileSeriesColumns); + expect(driftTank, tankSeriesColumns); + }); + + test('the backstop is idempotent across a second open of one database', () async { + // Two Drift executors over one SQLite handle: the first open runs the + // ladder, the second runs only beforeOpen, so the backstop's IF NOT + // EXISTS DDL genuinely executes a second time. A second query on the + // same executor would never re-enter beforeOpen. + final raw = sqlite3.sqlite3.openInMemory(); + addTearDown(raw.close); + legacyDdlAt180(raw); + + final first = AppDatabase( + NativeDatabase.opened(raw, closeUnderlyingOnClose: false), + ); + await first.customSelect('SELECT 1').get(); + await first.close(); + + final second = AppDatabase( + NativeDatabase.opened(raw, closeUnderlyingOnClose: false), + ); + addTearDown(second.close); + await expectLater(second.customSelect('SELECT 1').get(), completes); + expect( + await tableNames(second), + containsAll(['dive_profile_series', 'tank_pressure_series']), + ); + final version = await second + .customSelect('PRAGMA user_version') + .getSingle(); + expect(version.data.values.first, 182); + }); + + test('v182 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, 182); + expect(AppDatabase.migrationVersions, contains(182)); + expect(AppDatabase.minimumCompatibleSchemaVersion, 170); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/database/migration_v182_profile_series_test.dart` +Expected: compilation failure (`db.diveProfileSeries` undefined) or, before codegen, the tables missing. + +- [ ] **Step 3: Declare the tables** + +In `lib/core/database/database.dart`, immediately after the closing brace of `class TankPressureProfiles extends Table { ... }`, add: + +```dart +/// One packed series of profile samples: every sample a +/// (dive, computer, source, is_primary) group holds, encoded by +/// `ProfileSeriesCodec` (spec 2026-08-28-profile-sample-storage). Replaces +/// row-per-sample `dive_profiles`, which stays until plan 2e retires it. +/// +/// The identity columns mirror `dive_profiles` exactly so every ownership +/// predicate ports one for one. The summary scalars are the values the SQL +/// consumers read instead of decoding the blob; they are computed from the +/// same samples the blob packs, so they can never disagree with it. +@DataClassName('DiveProfileSeriesRow') +class DiveProfileSeries extends Table { + // coverage:ignore-start + TextColumn get id => text()(); + TextColumn get diveId => + text().references(Dives, #id, onDelete: KeyAction.cascade)(); + TextColumn get computerId => text().nullable().references( + DiveComputers, + #id, + onDelete: KeyAction.setNull, + )(); + TextColumn get sourceId => text().nullable().references( + DiveDataSources, + #id, + onDelete: KeyAction.setNull, + )(); + BoolColumn get isPrimary => boolean().withDefault(const Constant(true))(); + IntColumn get sampleCount => integer()(); + + /// Seconds from dive start of the first and last sample. + IntColumn get startTimestamp => integer()(); + IntColumn get endTimestamp => integer()(); + + /// Metres. + RealColumn get maxDepth => real()(); + RealColumn get firstDepth => real()(); + RealColumn get lastDepth => real()(); + + /// Any sample carries deco_type; any carries deco_type = 2; any carries + /// ceiling > 0. The deco classification and deco-signal predicates read + /// these instead of scanning samples. + BoolColumn get hasDecoType => boolean().withDefault(const Constant(false))(); + BoolColumn get hasDecoStop => boolean().withDefault(const Constant(false))(); + BoolColumn get hasPositiveCeiling => + boolean().withDefault(const Constant(false))(); + IntColumn get codecVersion => integer()(); + + /// `ProfileSeriesCodec` output. + BlobColumn get samples => blob()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + + /// Hybrid Logical Clock for cross-device conflict resolution. + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; + // coverage:ignore-end +} + +/// One packed series of tank pressure readings for a (dive, tank, computer) +/// group, encoded by `TankPressureSeriesCodec`. Replaces row-per-sample +/// `tank_pressure_profiles`, which stays until plan 2e retires it. +@DataClassName('TankPressureSeriesRow') +class TankPressureSeries extends Table { + // coverage:ignore-start + TextColumn get id => text()(); + TextColumn get diveId => + text().references(Dives, #id, onDelete: KeyAction.cascade)(); + TextColumn get tankId => + text().references(DiveTanks, #id, onDelete: KeyAction.cascade)(); + TextColumn get computerId => text().nullable().references( + DiveComputers, + #id, + onDelete: KeyAction.setNull, + )(); + IntColumn get sampleCount => integer()(); + IntColumn get startTimestamp => integer()(); + IntColumn get endTimestamp => integer()(); + IntColumn get codecVersion => integer()(); + BlobColumn get samples => blob()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; + // coverage:ignore-end +} +``` + +In the `@DriftDatabase(tables: [...])` list, add `DiveProfileSeries,` on the line after `DiveProfiles,` and `TankPressureSeries,` on the line after `TankPressureProfiles,`. + +- [ ] **Step 4: Bump the version and register the rung** + +Change `static const int currentSchemaVersion = 180;` to `182`. + +In `migrationVersions`, after the `180,` entry and before `];`, add: + +```dart + // v182 (packed profile series, spec 2026-08-28-profile-sample-storage): + // dive_profile_series and tank_pressure_series, one zlib columnar blob + // per (dive, computer, source, is_primary) group and per (dive, tank, + // computer) group, packed from the row-per-sample tables by + // packLegacyProfileRows with ids derived from the identity tuple so every + // device converges (the #1360 lesson). The legacy tables stay until the + // consumers move; the same PR retires them in a later plan. 176 remains + // skipped; the ladder is non-contiguous by design. + 182, +``` + +Next to `_assertTripDayWeatherSchema()`, add: + +```dart + /// v182: the packed profile series tables. + /// + /// Raw idempotent DDL so it doubles as the beforeOpen backstop for a + /// database stranded at 182 by a parallel branch. The DDL must agree with + /// the Drift declarations column for column; the v182 migration test + /// compares the two on a fresh database. + Future _assertProfileSeriesSchema() async { + await customStatement(''' + CREATE TABLE IF NOT EXISTS dive_profile_series ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL REFERENCES dives (id) ON DELETE CASCADE, + computer_id TEXT REFERENCES dive_computers (id) ON DELETE SET NULL, + source_id TEXT REFERENCES dive_data_sources (id) ON DELETE SET NULL, + is_primary INTEGER NOT NULL DEFAULT 1 CHECK (is_primary IN (0, 1)), + sample_count INTEGER NOT NULL, + start_timestamp INTEGER NOT NULL, + end_timestamp INTEGER NOT NULL, + max_depth REAL NOT NULL, + first_depth REAL NOT NULL, + last_depth REAL NOT NULL, + has_deco_type INTEGER NOT NULL DEFAULT 0 + CHECK (has_deco_type IN (0, 1)), + has_deco_stop INTEGER NOT NULL DEFAULT 0 + CHECK (has_deco_stop IN (0, 1)), + has_positive_ceiling INTEGER NOT NULL DEFAULT 0 + CHECK (has_positive_ceiling IN (0, 1)), + codec_version INTEGER NOT NULL, + samples BLOB NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc TEXT + ) + '''); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_dive_profile_series_dive_primary ' + 'ON dive_profile_series (dive_id, is_primary)', + ); + await customStatement(''' + CREATE TABLE IF NOT EXISTS tank_pressure_series ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL REFERENCES dives (id) ON DELETE CASCADE, + tank_id TEXT NOT NULL REFERENCES dive_tanks (id) ON DELETE CASCADE, + computer_id TEXT REFERENCES dive_computers (id) ON DELETE SET NULL, + sample_count INTEGER NOT NULL, + start_timestamp INTEGER NOT NULL, + end_timestamp INTEGER NOT NULL, + codec_version INTEGER NOT NULL, + samples BLOB NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc TEXT + ) + '''); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_tank_pressure_series_dive_tank ' + 'ON tank_pressure_series (dive_id, tank_id)', + ); + } +``` + +In `onUpgrade`, immediately after `if (from < 180) await reportProgress();`, add: + +```dart + // v182: packed profile series tables. Idempotent DDL; the packing + // step that fills them from the legacy tables is added by plan 2a + // Task 4 and is idempotent too (INSERT OR IGNORE on derived ids). + if (from < 182) { + await _assertProfileSeriesSchema(); + } + if (from < 182) await reportProgress(); +``` + +In `beforeOpen`, immediately after the v180 backstop's `await _assertDiveStatsExclusionColumns();`, add: + +```dart + // v182 backstop: re-assert the packed profile series tables (same + // parallel-branch version-collision self-heal). Schema only: packing + // is not re-run on open. + await _assertProfileSeriesSchema(); +``` + +- [ ] **Step 5: Register the indexes and the HLC targets** + +In `lib/core/database/performance_indexes.dart`, after the `idx_tank_pressure_dive_tank` entry, add: + +```dart + ( + name: 'idx_dive_profile_series_dive_primary', + ddl: + 'CREATE INDEX IF NOT EXISTS idx_dive_profile_series_dive_primary ' + 'ON dive_profile_series (dive_id, is_primary)', + ), + ( + name: 'idx_tank_pressure_series_dive_tank', + ddl: + 'CREATE INDEX IF NOT EXISTS idx_tank_pressure_series_dive_tank ' + 'ON tank_pressure_series (dive_id, tank_id)', + ), +``` + +In `lib/core/data/repositories/sync_repository.dart`, inside `hlcTargets`, after the `'tripDayWeather'` entry, add: + +```dart + 'diveProfileSeries': (table: 'dive_profile_series', pk: 'id'), + 'tankPressureSeries': (table: 'tank_pressure_series', pk: 'id'), +``` + +In `test/core/database/query_plan_test.dart`, after the `per-dive pressure fetch` test, add: + +```dart + test('per-dive series fetch uses idx_dive_profile_series_dive_primary', () async { + final p = await plan( + db, + "SELECT * FROM dive_profile_series WHERE dive_id = 'x' AND is_primary = 1", + ); + expect(p, contains('idx_dive_profile_series_dive_primary')); + }); + + test('per-tank series fetch uses idx_tank_pressure_series_dive_tank', () async { + final p = await plan( + db, + "SELECT * FROM tank_pressure_series WHERE dive_id = 'x' AND tank_id = 't'", + ); + expect(p, contains('idx_tank_pressure_series_dive_tank')); + }); +``` + +- [ ] **Step 6: Regenerate and run the tests** + +```bash +dart run build_runner build +flutter test test/core/database/migration_v182_profile_series_test.dart +flutter test test/core/database/query_plan_test.dart +flutter test test/core/database/performance_indexes_test.dart +flutter test test/core/services/sync/sync_hlc_target_registration_test.dart +flutter test test/core/database/migration_v180_stats_exclusion_test.dart +``` + +Expected: all pass. If the fresh-database column comparison fails, the raw DDL and the Drift class disagree on a column name; fix the DDL, never the Drift class. If `performance_indexes_test` fails on a fresh database, the index DDL references a column name that differs from the table DDL. + +- [ ] **Step 7: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/database/database.dart lib/core/database/performance_indexes.dart lib/core/data/repositories/sync_repository.dart test/core/database/query_plan_test.dart test/core/database/migration_v182_profile_series_test.dart +git commit -m "feat(db): dive_profile_series and tank_pressure_series tables at schema v182" +``` + +Do not add `database.g.dart`; it is generated and gitignored. + +--- + +### Task 3: The packer + +**Files:** +- Create: `lib/core/database/profile_series_pack.dart` +- Test: `test/core/database/profile_series_pack_test.dart` + +**Interfaces:** +- Consumes: `ProfileSeriesCodec`, `ProfileSample`, `TankPressureSeriesCodec`, `TankPressureSample` (PR 1); `profileSeriesMigratedId`, `tankPressureSeriesMigratedId`, `dedupeExactSamples`, `dedupeExactPressureSamples` (Task 1); `Hlc` (`lib/core/services/sync/hlc.dart`); tables from Task 2. +- Produces: `typedef ProfilePackReport = ({int profileSeries, int tankSeries, int droppedSamples});` and `Future packLegacyProfileRows(DatabaseConnectionUser db, {int? nowMs})`. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/database/profile_series_pack_test.dart`: + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/database/profile_series_pack.dart'; +import 'package:submersion/core/services/sync/hlc.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_series.dart'; + +/// A database already at v182 (no ladder runs) with the legacy tables and +/// the FK parents the series tables reference. `beforeOpen` creates the +/// series tables through the backstop. +NativeDatabase legacyFixture({void Function(dynamic rawDb)? seed}) { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 182'); + rawDb.execute('CREATE TABLE dives (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute( + 'CREATE TABLE dive_computers (id TEXT NOT NULL PRIMARY KEY)', + ); + // is_primary/imported_at/created_at are not part of what the packer + // reads, but the unconditional beforeOpen self-heal + // _backfillMissingDataSources runs once dives, dive_profiles, and + // dive_data_sources all exist and inserts rows naming those columns. + // It never touches dive_profiles.source_id, so the packer's + // expectations below are unaffected. + rawDb.execute(''' + CREATE TABLE dive_data_sources ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 0, + imported_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + '''); + rawDb.execute( + 'CREATE TABLE dive_tanks (id TEXT NOT NULL PRIMARY KEY, ' + 'dive_id TEXT NOT NULL)', + ); + rawDb.execute(''' + CREATE TABLE dive_profiles ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + source_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 1, + timestamp INTEGER NOT NULL, + depth REAL NOT NULL, + temperature REAL, + ndl INTEGER, + ceiling REAL, + deco_type INTEGER, + heart_rate_source TEXT + ) + '''); + rawDb.execute(''' + CREATE TABLE tank_pressure_profiles ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + tank_id TEXT NOT NULL, + timestamp INTEGER NOT NULL, + pressure REAL NOT NULL, + computer_id TEXT + ) + '''); + seed?.call(rawDb); + }, + ); +} + +void seedParents(dynamic rawDb) { + rawDb.execute("INSERT INTO dives (id) VALUES ('d1'), ('d2')"); + rawDb.execute("INSERT INTO dive_computers (id) VALUES ('c1'), ('c2')"); + rawDb.execute( + "INSERT INTO dive_data_sources (id, dive_id, computer_id, imported_at, " + "created_at) VALUES ('s1', 'd1', 'c1', 0, 0), ('s2', 'd1', 'c2', 0, 0)", + ); + rawDb.execute( + "INSERT INTO dive_tanks (id, dive_id) VALUES ('t1', 'd1'), ('t2', 'd1')", + ); +} + +/// dive d1: computer c1 / source s1, primary, 3 samples with the second +/// duplicated exactly and a third row at the same timestamp that differs; +/// computer c2 / source s2, non-primary, 2 samples (a multi-source dive); +/// a manual edit (null computer, source s1, primary), 2 samples. +/// dive d2: legacy rows with null computer and null source, 2 samples. +void seedProfiles(dynamic rawDb) { + void row( + String id, + String dive, + String? computer, + String? source, + int primary, + int ts, + double depth, { + double? temp, + int? ndl, + double? ceiling, + int? decoType, + String? hrs, + }) { + rawDb.execute( + 'INSERT INTO dive_profiles (id, dive_id, computer_id, source_id, ' + 'is_primary, timestamp, depth, temperature, ndl, ceiling, deco_type, ' + 'heart_rate_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, dive, computer, source, primary, ts, depth, temp, ndl, ceiling, decoType, hrs], + ); + } + + row('p1', 'd1', 'c1', 's1', 1, 0, 0.0, temp: 20.0, ndl: 3000); + row('p2', 'd1', 'c1', 's1', 1, 10, 12.5, temp: 19.5, ndl: 2900); + row('p3', 'd1', 'c1', 's1', 1, 10, 12.5, temp: 19.5, ndl: 2900); + row('p4', 'd1', 'c1', 's1', 1, 10, 12.7, temp: 19.5, ndl: 2900); + row('p5', 'd1', 'c1', 's1', 1, 20, 18.0, ceiling: 3.0, decoType: 2); + row('p6', 'd1', 'c2', 's2', 0, 0, 0.0); + row('p7', 'd1', 'c2', 's2', 0, 10, 12.4); + row('p8', 'd1', null, 's1', 1, 0, 0.0, hrs: 'appleWatch'); + row('p9', 'd1', null, 's1', 1, 10, 12.0, hrs: 'appleWatch'); + row('p10', 'd2', null, null, 1, 0, 0.0); + row('p11', 'd2', null, null, 1, 30, 9.0); +} + +void seedPressures(dynamic rawDb) { + void row(String id, String tank, String? computer, int ts, double bar) { + rawDb.execute( + 'INSERT INTO tank_pressure_profiles (id, dive_id, tank_id, timestamp, ' + 'pressure, computer_id) VALUES (?, ?, ?, ?, ?, ?)', + [id, 'd1', tank, ts, bar, computer], + ); + } + + row('q1', 't1', 'c1', 0, 200.0); + row('q2', 't1', 'c1', 0, 200.0); + row('q3', 't1', 'c1', 60, 190.0); + row('q4', 't2', null, 0, 210.0); +} + +Future>> rows(AppDatabase db, String sql) async { + final result = await db.customSelect(sql).get(); + return result.map((r) => r.data).toList(); +} + +void main() { + const codec = ProfileSeriesCodec(); + const tankCodec = TankPressureSeriesCodec(); + + test('packs each identity group into one series with derived ids', () async { + final db = AppDatabase( + legacyFixture( + seed: (raw) { + seedParents(raw); + seedProfiles(raw); + seedPressures(raw); + }, + ), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + final report = await packLegacyProfileRows(db, nowMs: 1700000000000); + expect(report.profileSeries, 4); + expect(report.tankSeries, 2); + expect(report.droppedSamples, 2, reason: 'p3 and q2 are exact duplicates'); + + final series = await rows( + db, + 'SELECT * FROM dive_profile_series ORDER BY dive_id, computer_id, ' + 'source_id, is_primary', + ); + expect(series, hasLength(4)); + + final c1 = series.singleWhere( + (r) => r['computer_id'] == 'c1' && r['dive_id'] == 'd1', + ); + expect( + c1['id'], + profileSeriesMigratedId( + diveId: 'd1', + computerId: 'c1', + sourceId: 's1', + isPrimary: true, + ), + ); + expect(c1['is_primary'], 1); + expect(c1['sample_count'], 4); + expect(c1['start_timestamp'], 0); + expect(c1['end_timestamp'], 20); + expect(c1['max_depth'], 18.0); + expect(c1['first_depth'], 0.0); + expect(c1['last_depth'], 18.0); + expect(c1['has_deco_type'], 1); + expect(c1['has_deco_stop'], 1); + expect(c1['has_positive_ceiling'], 1); + expect(c1['codec_version'], 1); + expect(c1['created_at'], 1700000000000); + expect(c1['updated_at'], 1700000000000); + final decoded = codec.decode(c1['samples'] as dynamic); + expect(decoded, [ + const ProfileSample(timestamp: 0, depth: 0.0, temperature: 20.0, ndl: 3000), + const ProfileSample(timestamp: 10, depth: 12.5, temperature: 19.5, ndl: 2900), + const ProfileSample(timestamp: 10, depth: 12.7, temperature: 19.5, ndl: 2900), + const ProfileSample(timestamp: 20, depth: 18.0, ceiling: 3.0, decoType: 2), + ]); + + final edit = series.singleWhere( + (r) => r['computer_id'] == null && r['dive_id'] == 'd1', + ); + expect(edit['source_id'], 's1'); + expect(edit['is_primary'], 1); + expect( + codec.decode(edit['samples'] as dynamic).map((s) => s.heartRateSource), + ['appleWatch', 'appleWatch'], + ); + + final legacy = series.singleWhere((r) => r['dive_id'] == 'd2'); + expect(legacy['computer_id'], isNull); + expect(legacy['source_id'], isNull); + expect( + legacy['id'], + profileSeriesMigratedId( + diveId: 'd2', + computerId: null, + sourceId: null, + isPrimary: true, + ), + ); + + final tanks = await rows( + db, + 'SELECT * FROM tank_pressure_series ORDER BY tank_id', + ); + expect(tanks, hasLength(2)); + expect( + tanks[0]['id'], + tankPressureSeriesMigratedId(diveId: 'd1', tankId: 't1', computerId: 'c1'), + ); + expect(tanks[0]['sample_count'], 2); + expect(tankCodec.decode(tanks[0]['samples'] as dynamic), [ + const TankPressureSample(timestamp: 0, pressure: 200.0), + const TankPressureSample(timestamp: 60, pressure: 190.0), + ]); + expect(tanks[1]['computer_id'], isNull); + }); + + test('re-running the packer inserts nothing new', () async { + final db = AppDatabase( + legacyFixture( + seed: (raw) { + seedParents(raw); + seedProfiles(raw); + seedPressures(raw); + }, + ), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + await packLegacyProfileRows(db, nowMs: 1); + final again = await packLegacyProfileRows(db, nowMs: 2); + expect(again.profileSeries, 0); + expect(again.tankSeries, 0); + final count = await db + .customSelect('SELECT COUNT(*) AS n FROM dive_profile_series') + .getSingle(); + expect(count.read('n'), 4); + }); + + test('two independently packed copies produce identical series ids', () async { + Future> idsOf() async { + final db = AppDatabase( + legacyFixture( + seed: (raw) { + seedParents(raw); + seedProfiles(raw); + seedPressures(raw); + }, + ), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + await packLegacyProfileRows(db, nowMs: 5); + final a = await rows(db, 'SELECT id FROM dive_profile_series'); + final b = await rows(db, 'SELECT id FROM tank_pressure_series'); + return {for (final r in [...a, ...b]) r['id'] as String}; + } + + expect(await idsOf(), await idsOf()); + }); + + test('stamps the migration hlc from sync_metadata when a device id exists', () async { + final db = AppDatabase( + legacyFixture( + seed: (raw) { + seedParents(raw); + seedProfiles(raw); + // A fixture stamped at 182 never runs onCreate, so the table a + // synced device would have is created here with its global row. + raw.execute( + 'CREATE TABLE sync_metadata (id TEXT NOT NULL PRIMARY KEY, ' + 'device_id TEXT NOT NULL, created_at INTEGER NOT NULL, ' + 'updated_at INTEGER NOT NULL)', + ); + raw.execute( + "INSERT INTO sync_metadata (id, device_id, created_at, updated_at) " + "VALUES ('global', 'dev-1', 0, 0)", + ); + }, + ), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + await packLegacyProfileRows(db, nowMs: 1700000000000); + final row = await db + .customSelect('SELECT hlc FROM dive_profile_series LIMIT 1') + .getSingle(); + expect( + row.read('hlc'), + const Hlc(1700000000000, 0, 'dev-1').toString(), + ); + }); + + test('leaves hlc null when the device has never synced', () async { + final db = AppDatabase( + legacyFixture( + seed: (raw) { + seedParents(raw); + seedProfiles(raw); + }, + ), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + await packLegacyProfileRows(db, nowMs: 1); + final row = await db + .customSelect('SELECT hlc FROM dive_profile_series LIMIT 1') + .getSingle(); + expect(row.readNullable('hlc'), isNull); + }); + + test('no-ops when the legacy tables are absent', () async { + final db = AppDatabase( + NativeDatabase.memory( + setup: (raw) => raw.execute('PRAGMA user_version = 182'), + ), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + final report = await packLegacyProfileRows(db, nowMs: 1); + expect(report.profileSeries, 0); + expect(report.tankSeries, 0); + expect(report.droppedSamples, 0); + }); + + test('tolerates legacy tables that lack optional sample columns', () async { + // A minimal fixture like the older migration tests: the identity columns + // and the two required sample columns only. The identity columns are + // always present by the time the v182 rung runs (earlier rungs add + // source_id first, and rungs run in order), so the packer may name them + // in ORDER BY; every optional sample column is absent here and must + // decode as null. + final db = AppDatabase( + NativeDatabase.memory( + setup: (raw) { + raw.execute('PRAGMA user_version = 182'); + raw.execute('CREATE TABLE dives (id TEXT NOT NULL PRIMARY KEY)'); + raw.execute("INSERT INTO dives (id) VALUES ('d1')"); + // Under PRAGMA foreign_keys = ON (set by beforeOpen) SQLite resolves + // an INSERT's foreign keys when the statement is prepared, so the + // series tables' parents must exist even when no row references + // them; dive_data_sources carries the columns the beforeOpen + // self-heal _backfillMissingDataSources writes. + raw.execute( + 'CREATE TABLE dive_computers (id TEXT NOT NULL PRIMARY KEY)', + ); + raw.execute(''' + CREATE TABLE dive_data_sources ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 0, + imported_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + '''); + raw.execute( + 'CREATE TABLE dive_profiles (id TEXT NOT NULL PRIMARY KEY, ' + 'dive_id TEXT NOT NULL, computer_id TEXT, source_id TEXT, ' + 'is_primary INTEGER NOT NULL DEFAULT 1, ' + 'timestamp INTEGER NOT NULL, depth REAL NOT NULL)', + ); + raw.execute( + "INSERT INTO dive_profiles (id, dive_id, timestamp, depth) " + "VALUES ('p1', 'd1', 0, 0), ('p2', 'd1', 5, 4)", + ); + }, + ), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + final report = await packLegacyProfileRows(db, nowMs: 1); + expect(report.profileSeries, 1); + final row = await db + .customSelect('SELECT samples FROM dive_profile_series') + .getSingle(); + expect(codec.decode(row.read('samples')), [ + const ProfileSample(timestamp: 0, depth: 0.0), + const ProfileSample(timestamp: 5, depth: 4.0), + ]); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/database/profile_series_pack_test.dart` +Expected: compilation failure, `profile_series_pack.dart` not found. + +- [ ] **Step 3: Create the packer** + +Create `lib/core/database/profile_series_pack.dart`: + +```dart +import 'package:drift/drift.dart'; +import 'package:submersion/core/services/sync/hlc.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_series.dart'; +import 'package:submersion/features/dive_log/domain/services/profile_sample_dedupe.dart'; + +/// What one packing pass inserted and dropped. +typedef ProfilePackReport = ({ + int profileSeries, + int tankSeries, + int droppedSamples, +}); + +/// Packs every legacy `dive_profiles` and `tank_pressure_profiles` row into +/// the series tables (v182, spec 2026-08-28-profile-sample-storage). +/// +/// Raw SQL throughout, never the legacy Drift classes: plan 2e removes those +/// classes and drops the tables, and this function must keep compiling and +/// keep no-oping on a database that has already lost them. +/// +/// Memory is bounded by one dive's rows, never the table. Each identity +/// group becomes one row whose id is derived from the tuple +/// ([profileSeriesMigratedId]), so a second run, a retry after a failed +/// ladder, or a second device migrating the same synced rows all converge: +/// the insert is `INSERT OR IGNORE`. +/// +/// Exact duplicate samples (a repeated import) are dropped before packing, +/// which is what every read did on the way out until now. +/// +/// [nowMs] stamps `created_at` and `updated_at`. `hlc` is minted from the +/// device id in `sync_metadata` when one exists, so the first sync after the +/// upgrade publishes the rows; a device that never synced has nothing to +/// publish to and its rows stay unstamped until a base publish, which +/// exports everything regardless. +Future packLegacyProfileRows( + DatabaseConnectionUser db, { + int? nowMs, +}) async { + final now = nowMs ?? DateTime.now().millisecondsSinceEpoch; + final hlc = await _migrationHlc(db, now); + var profileSeries = 0; + var tankSeries = 0; + var dropped = 0; + + if (await _tableExists(db, 'dive_profiles')) { + const codec = ProfileSeriesCodec(); + for (final diveId in await _diveIds(db, 'dive_profiles')) { + final rows = await db + .customSelect( + 'SELECT * FROM dive_profiles WHERE dive_id = ? ' + 'ORDER BY computer_id, source_id, is_primary, timestamp, rowid', + variables: [Variable(diveId)], + ) + .get(); + final groups = <_ProfileKey, List>{}; + for (final row in rows) { + final key = _ProfileKey( + computerId: row.data['computer_id'] as String?, + sourceId: row.data['source_id'] as String?, + isPrimary: _boolOf(row.data['is_primary'], fallback: true), + ); + groups.putIfAbsent(key, () => []).add(_profileSampleOf(row.data)); + } + for (final entry in groups.entries) { + final key = entry.key; + final samples = dedupeExactSamples(entry.value); + dropped += entry.value.length - samples.length; + final encoded = codec.encode(samples); + final summary = encoded.summary; + final inserted = await db.customUpdate( + 'INSERT OR IGNORE INTO dive_profile_series (' + 'id, dive_id, computer_id, source_id, is_primary, sample_count, ' + 'start_timestamp, end_timestamp, max_depth, first_depth, last_depth, ' + 'has_deco_type, has_deco_stop, has_positive_ceiling, codec_version, ' + 'samples, created_at, updated_at, hlc) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + variables: [ + Variable( + profileSeriesMigratedId( + diveId: diveId, + computerId: key.computerId, + sourceId: key.sourceId, + isPrimary: key.isPrimary, + ), + ), + Variable(diveId), + Variable(key.computerId), + Variable(key.sourceId), + Variable(key.isPrimary ? 1 : 0), + Variable(summary.sampleCount), + Variable(summary.startTimestamp), + Variable(summary.endTimestamp), + Variable(summary.maxDepth), + Variable(summary.firstDepth), + Variable(summary.lastDepth), + Variable(summary.hasDecoType ? 1 : 0), + Variable(summary.hasDecoStop ? 1 : 0), + Variable(summary.hasPositiveCeiling ? 1 : 0), + Variable(encoded.codecVersion), + Variable(encoded.bytes), + Variable(now), + Variable(now), + Variable(hlc), + ], + updateKind: UpdateKind.insert, + ); + profileSeries += inserted; + } + } + } + + if (await _tableExists(db, 'tank_pressure_profiles')) { + const codec = TankPressureSeriesCodec(); + for (final diveId in await _diveIds(db, 'tank_pressure_profiles')) { + final rows = await db + .customSelect( + 'SELECT * FROM tank_pressure_profiles WHERE dive_id = ? ' + 'ORDER BY tank_id, computer_id, timestamp, rowid', + variables: [Variable(diveId)], + ) + .get(); + final groups = <_TankKey, List>{}; + for (final row in rows) { + final key = _TankKey( + tankId: row.data['tank_id'] as String, + computerId: row.data['computer_id'] as String?, + ); + groups + .putIfAbsent(key, () => []) + .add( + TankPressureSample( + timestamp: (row.data['timestamp'] as num).toInt(), + pressure: (row.data['pressure'] as num).toDouble(), + ), + ); + } + for (final entry in groups.entries) { + final key = entry.key; + final samples = dedupeExactPressureSamples(entry.value); + dropped += entry.value.length - samples.length; + final encoded = codec.encode(samples); + final inserted = await db.customUpdate( + 'INSERT OR IGNORE INTO tank_pressure_series (' + 'id, dive_id, tank_id, computer_id, sample_count, start_timestamp, ' + 'end_timestamp, codec_version, samples, created_at, updated_at, hlc) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + variables: [ + Variable( + tankPressureSeriesMigratedId( + diveId: diveId, + tankId: key.tankId, + computerId: key.computerId, + ), + ), + Variable(diveId), + Variable(key.tankId), + Variable(key.computerId), + Variable(encoded.summary.sampleCount), + Variable(encoded.summary.startTimestamp), + Variable(encoded.summary.endTimestamp), + Variable(encoded.codecVersion), + Variable(encoded.bytes), + Variable(now), + Variable(now), + Variable(hlc), + ], + updateKind: UpdateKind.insert, + ); + tankSeries += inserted; + } + } + } + + return ( + profileSeries: profileSeries, + tankSeries: tankSeries, + droppedSamples: dropped, + ); +} + +class _ProfileKey { + const _ProfileKey({ + required this.computerId, + required this.sourceId, + required this.isPrimary, + }); + + final String? computerId; + final String? sourceId; + final bool isPrimary; + + @override + bool operator ==(Object other) => + other is _ProfileKey && + other.computerId == computerId && + other.sourceId == sourceId && + other.isPrimary == isPrimary; + + @override + int get hashCode => Object.hash(computerId, sourceId, isPrimary); +} + +class _TankKey { + const _TankKey({required this.tankId, required this.computerId}); + + final String tankId; + final String? computerId; + + @override + bool operator ==(Object other) => + other is _TankKey && + other.tankId == tankId && + other.computerId == computerId; + + @override + int get hashCode => Object.hash(tankId, computerId); +} + +Future _tableExists(DatabaseConnectionUser db, String table) async { + final rows = await db + .customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(table)], + ) + .get(); + return rows.isNotEmpty; +} + +Future> _diveIds(DatabaseConnectionUser db, String table) async { + final rows = await db + .customSelect('SELECT DISTINCT dive_id FROM $table ORDER BY dive_id') + .get(); + return [for (final row in rows) row.read('dive_id')]; +} + +/// The clock value migrated rows carry. Null when this device has never +/// synced: there is no device id to stamp with, and nothing to publish to. +Future _migrationHlc(DatabaseConnectionUser db, int nowMs) async { + if (!await _tableExists(db, 'sync_metadata')) return null; + final rows = await db + .customSelect( + "SELECT device_id FROM sync_metadata WHERE id = 'global' LIMIT 1", + ) + .get(); + if (rows.isEmpty) return null; + final deviceId = rows.first.readNullable('device_id'); + if (deviceId == null || deviceId.isEmpty) return null; + return Hlc(nowMs, 0, deviceId).toString(); +} + +bool _boolOf(Object? value, {required bool fallback}) { + if (value == null) return fallback; + if (value is bool) return value; + if (value is num) return value != 0; + return fallback; +} + +double? _realOf(Object? value) => (value as num?)?.toDouble(); + +int? _intOf(Object? value) => (value as num?)?.toInt(); + +/// Reads a legacy `dive_profiles` row by column name. Absent columns (an +/// older fixture or a partially migrated table) read as null. +ProfileSample _profileSampleOf(Map data) { + return ProfileSample( + timestamp: (data['timestamp'] as num).toInt(), + depth: (data['depth'] as num).toDouble(), + pressure: _realOf(data['pressure']), + temperature: _realOf(data['temperature']), + heartRate: _intOf(data['heart_rate']), + ascentRate: _realOf(data['ascent_rate']), + ceiling: _realOf(data['ceiling']), + ndl: _intOf(data['ndl']), + setpoint: _realOf(data['setpoint']), + ppO2: _realOf(data['pp_o2']), + o2Sensor1: _realOf(data['o2_sensor1']), + o2Sensor2: _realOf(data['o2_sensor2']), + o2Sensor3: _realOf(data['o2_sensor3']), + o2Sensor4: _realOf(data['o2_sensor4']), + o2Sensor5: _realOf(data['o2_sensor5']), + o2Sensor6: _realOf(data['o2_sensor6']), + cns: _realOf(data['cns']), + tts: _intOf(data['tts']), + rbt: _intOf(data['rbt']), + decoType: _intOf(data['deco_type']), + heartRateSource: data['heart_rate_source'] as String?, + heading: _realOf(data['heading']), + o2SensorMv1: _intOf(data['o2_sensor_mv1']), + o2SensorMv2: _intOf(data['o2_sensor_mv2']), + o2SensorMv3: _intOf(data['o2_sensor_mv3']), + o2SensorMv4: _intOf(data['o2_sensor_mv4']), + o2SensorMv5: _intOf(data['o2_sensor_mv5']), + o2SensorMv6: _intOf(data['o2_sensor_mv6']), + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/core/database/profile_series_pack_test.dart` +Expected: 7 tests pass. If `customUpdate` complains that `updates` is required, pass `updates: const {}` in addition to `updateKind`. If the `samples` column comes back as a `List` rather than `Uint8List` in the test's raw row map, wrap it with `Uint8List.fromList` in the test helper rather than changing the packer. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/database/profile_series_pack.dart test/core/database/profile_series_pack_test.dart +git commit -m "feat(db): packLegacyProfileRows, legacy sample rows to packed series" +``` + +--- + +### Task 4: Wire the packer into the v182 rung + +**Files:** +- Modify: `lib/core/database/database.dart` (the v182 rung; the import) +- Test: `test/core/database/migration_v182_profile_series_test.dart` (packing half) + +**Interfaces:** +- Consumes: `packLegacyProfileRows` (Task 3). +- Produces: a v182 upgrade that leaves every legacy row packed. + +- [ ] **Step 1: Write the failing tests** + +Append a second group to `test/core/database/migration_v182_profile_series_test.dart`, inside `main()` after the `schema` group: + +```dart + group('packing on upgrade', () { + void seed(dynamic rawDb) { + rawDb.execute("INSERT INTO dives (id) VALUES ('d1')"); + rawDb.execute("INSERT INTO dive_computers (id) VALUES ('c1')"); + rawDb.execute( + "INSERT INTO dive_data_sources (id, dive_id, computer_id, " + "imported_at, created_at) VALUES ('s1', 'd1', 'c1', 0, 0)", + ); + rawDb.execute("INSERT INTO dive_tanks (id, dive_id) VALUES ('t1', 'd1')"); + rawDb.execute( + "INSERT INTO dive_profiles (id, dive_id, computer_id, source_id, " + "is_primary, timestamp, depth) VALUES " + "('p1', 'd1', 'c1', 's1', 1, 0, 0.0), " + "('p2', 'd1', 'c1', 's1', 1, 10, 15.0), " + "('p3', 'd1', 'c1', 's1', 1, 10, 15.0)", + ); + rawDb.execute( + "INSERT INTO tank_pressure_profiles (id, dive_id, tank_id, timestamp, " + "pressure, computer_id) VALUES ('q1', 'd1', 't1', 0, 200.0, 'c1')", + ); + } + + test('upgrading from 180 packs the legacy rows', () async { + final db = AppDatabase(dbAt180(seed: seed)); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + final series = await db + .customSelect('SELECT * FROM dive_profile_series') + .get(); + expect(series, hasLength(1)); + expect(series.single.read('sample_count'), 2); + expect(series.single.read('max_depth'), 15.0); + final tanks = await db + .customSelect('SELECT * FROM tank_pressure_series') + .get(); + expect(tanks, hasLength(1)); + expect(tanks.single.read('tank_id'), 't1'); + // Legacy rows are untouched in this plan. + final legacy = await db + .customSelect('SELECT COUNT(*) AS n FROM dive_profiles') + .getSingle(); + expect(legacy.read('n'), 3); + }); + + test('two devices upgrading the same rows converge on the same ids', () async { + Future> idsAfterUpgrade() async { + final db = AppDatabase(dbAt180(seed: seed)); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + final a = await db + .customSelect('SELECT id FROM dive_profile_series ORDER BY id') + .get(); + final b = await db + .customSelect('SELECT id FROM tank_pressure_series ORDER BY id') + .get(); + return [for (final r in [...a, ...b]) r.read('id')]; + } + + expect(await idsAfterUpgrade(), await idsAfterUpgrade()); + }); + + test('a database already at 182 is not packed again on open', () async { + // Two executors over one handle (see the schema group): the first + // open runs the ladder and packs; a legacy row written afterwards must + // survive the second open unpacked, because the backstop is schema + // only. + final raw = sqlite3.sqlite3.openInMemory(); + addTearDown(raw.close); + legacyDdlAt180(raw); + seed(raw); + + final first = AppDatabase( + NativeDatabase.opened(raw, closeUnderlyingOnClose: false), + ); + await first.customSelect('SELECT 1').get(); + await first.close(); + + raw.execute( + "INSERT INTO dive_profiles (id, dive_id, computer_id, source_id, " + "is_primary, timestamp, depth) VALUES ('p9', 'd1', NULL, NULL, 1, 0, 1.0)", + ); + + final second = AppDatabase( + NativeDatabase.opened(raw, closeUnderlyingOnClose: false), + ); + addTearDown(second.close); + await second.customSelect('SELECT 1').get(); + final series = await second + .customSelect('SELECT COUNT(*) AS n FROM dive_profile_series') + .getSingle(); + expect(series.read('n'), 1); + }); + }); +``` + +- [ ] **Step 2: Run the test to verify the new group fails** + +Run: `flutter test test/core/database/migration_v182_profile_series_test.dart` +Expected: the `packing on upgrade` tests fail (zero series rows); the `schema` group still passes. + +- [ ] **Step 3: Call the packer from the rung** + +In `lib/core/database/database.dart`, add the import next to the other `package:submersion/core/database/...` imports: + +```dart +import 'package:submersion/core/database/profile_series_pack.dart'; +``` + +Change the v182 rung to: + +```dart + // v182: packed profile series tables, then pack every legacy + // row-per-sample row into them. Both steps are idempotent (IF NOT + // EXISTS DDL; INSERT OR IGNORE on ids derived from the identity + // tuple), so a retry after a failed ladder, or a collision re-run, + // is safe. The legacy tables stay until plan 2e retires them. + if (from < 182) { + await _assertProfileSeriesSchema(); + await packLegacyProfileRows(this); + } + if (from < 182) await reportProgress(); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +flutter test test/core/database/migration_v182_profile_series_test.dart +flutter test test/core/database/profile_series_pack_test.dart +``` + +Expected: all pass (7 in the migration file, 7 in the packer file). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/database/database.dart test/core/database/migration_v182_profile_series_test.dart +git commit -m "feat(db): v182 packs legacy profile and pressure rows into series" +``` + +--- + +### Task 5: ProfileSeriesRepository + +**Files:** +- Create: `lib/features/dive_log/data/repositories/profile_series_repository.dart` +- Test: `test/features/dive_log/data/repositories/profile_series_repository_test.dart` + +**Interfaces:** +- Consumes: `db.diveProfileSeries`, `DiveProfileSeriesCompanion`, `DiveProfileSeriesRow` (Task 2); `ProfileSeries`, `dedupeExactSamples` (Task 1); codec and summary (PR 1); `SyncRepository.markRecordPending` / `logDeletion`; `SyncEventBus.notifyLocalChange`. +- Produces: + - `class ProfileSeriesRepository { ProfileSeriesRepository({SyncRepository? syncRepository}); static const String entityType = 'diveProfileSeries'; }` + - `Future insertSeries({required String diveId, String? computerId, String? sourceId, bool isPrimary = true, required List samples, String? id, int? now})` + - `Future> getSeriesForDive(String diveId, {bool primaryOnly = false})` + - `Future getSeriesById(String id)` + - `Future demoteAll(String diveId, {int? now})` + - `Future promoteOwnedBy(String diveId, {required String? sourceId, required String? computerId, int? now})` + - `Future> deleteOwnedBy(String diveId, {required String? sourceId, required String? computerId})` + - `Future> deleteForDive(String diveId)` + - Plan 2b calls these from every write site. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/data/repositories/profile_series_repository_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late ProfileSeriesRepository repo; + const now = 1750000000000; + + const samples = [ + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 10, depth: 12.0, temperature: 21.0, ndl: 2000), + ProfileSample(timestamp: 20, depth: 18.5, ceiling: 3.0, decoType: 2), + ProfileSample(timestamp: 30, depth: 5.0), + ]; + + setUp(() async { + db = await setUpTestDatabase(); + repo = ProfileSeriesRepository(); + await db + .into(db.dives) + .insert( + const DivesCompanion( + id: Value('dive-1'), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: 'comp-1', + name: 'Perdix', + createdAt: now, + updatedAt: now, + ), + ); + }); + + tearDown(tearDownTestDatabase); + + group('insert and read', () { + test('insertSeries stores the encoded samples and summary', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + samples: samples, + now: now, + ); + + final row = await (db.select(db.diveProfileSeries) + ..where((t) => t.id.equals(id))) + .getSingle(); + expect(row.diveId, 'dive-1'); + expect(row.computerId, 'comp-1'); + expect(row.sourceId, isNull); + expect(row.isPrimary, isTrue); + expect(row.sampleCount, 4); + expect(row.startTimestamp, 0); + expect(row.endTimestamp, 30); + expect(row.maxDepth, 18.5); + expect(row.firstDepth, 0.0); + expect(row.lastDepth, 5.0); + expect(row.hasDecoType, isTrue); + expect(row.hasDecoStop, isTrue); + expect(row.hasPositiveCeiling, isTrue); + expect(row.codecVersion, 1); + expect(row.createdAt, now); + expect(row.updatedAt, now); + + final read = await repo.getSeriesForDive('dive-1'); + expect(read, hasLength(1)); + expect(read.single.id, id); + expect(read.single.samples, samples); + expect(read.single.summary.sampleCount, 4); + expect(read.single.points.map((p) => p.depth), [0.0, 12.0, 18.5, 5.0]); + }); + + test('insertSeries stamps an hlc through the sync repository', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + samples: samples, + now: now, + ); + final row = await (db.select(db.diveProfileSeries) + ..where((t) => t.id.equals(id))) + .getSingle(); + expect(row.hlc, isNotNull); + final pending = await (db.select(db.syncRecords) + ..where((t) => t.recordId.equals(id))) + .getSingle(); + expect(pending.entityType, ProfileSeriesRepository.entityType); + }); + + test('insertSeries drops exact duplicate samples', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + samples: [samples[0], samples[1], samples[1], samples[2]], + now: now, + ); + final read = await repo.getSeriesById(id); + expect(read!.samples, [samples[0], samples[1], samples[2]]); + expect(read.summary.sampleCount, 3); + }); + + test('insertSeries accepts a caller-supplied id', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + samples: samples, + id: 'fixed-id', + now: now, + ); + expect(id, 'fixed-id'); + expect(await repo.getSeriesById('fixed-id'), isNotNull); + }); + + test('an empty sample list is refused', () async { + expect( + () => repo.insertSeries(diveId: 'dive-1', samples: const []), + throwsArgumentError, + ); + }); + + test('getSeriesForDive orders by start then id and can filter primary', () async { + await repo.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 5, depth: 1.0)], + id: 'b', + now: now, + ); + await repo.insertSeries( + diveId: 'dive-1', + samples: samples, + id: 'a', + now: now, + ); + final all = await repo.getSeriesForDive('dive-1'); + expect(all.map((s) => s.id), ['a', 'b']); + final primary = await repo.getSeriesForDive('dive-1', primaryOnly: true); + expect(primary.map((s) => s.id), ['a']); + }); + + test('getSeriesById returns null for an unknown id', () async { + expect(await repo.getSeriesById('nope'), isNull); + }); + }); + + group('flags and deletes', () { + late String computerSeries; + late String editSeries; + + setUp(() async { + await db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: 'src-1', + diveId: 'dive-1', + computerId: const Value('comp-1'), + isPrimary: const Value(true), + importedAt: DateTime.fromMillisecondsSinceEpoch(now), + createdAt: DateTime.fromMillisecondsSinceEpoch(now), + ), + ); + computerSeries = await repo.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + samples: samples, + now: now, + ); + editSeries = await repo.insertSeries( + diveId: 'dive-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: now, + ); + }); + + test('demoteAll clears is_primary on every series of the dive', () async { + expect(await repo.demoteAll('dive-1', now: now + 1), 2); + final rows = await repo.getSeriesForDive('dive-1'); + expect(rows.map((s) => s.isPrimary), everyElement(isFalse)); + expect(rows.map((s) => s.updatedAt), everyElement(now + 1)); + }); + + test('promoteOwnedBy matches the source FK first', () async { + await repo.demoteAll('dive-1'); + final promoted = await repo.promoteOwnedBy( + 'dive-1', + sourceId: 'src-1', + computerId: 'comp-1', + now: now + 2, + ); + expect(promoted, 2, reason: 'both series carry source src-1'); + }); + + test('promoteOwnedBy falls back to computer id for rows without a source', () async { + final legacy = await repo.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 3.0)], + now: now, + ); + await repo.demoteAll('dive-1'); + final promoted = await repo.promoteOwnedBy( + 'dive-1', + sourceId: 'other-source', + computerId: 'comp-1', + ); + expect(promoted, 1); + final row = await repo.getSeriesById(legacy); + expect(row!.isPrimary, isTrue); + }); + + test('promoteOwnedBy with a null computer matches null, not everything', () async { + final manual = await repo.insertSeries( + diveId: 'dive-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 4.0)], + now: now, + ); + await repo.demoteAll('dive-1'); + final promoted = await repo.promoteOwnedBy( + 'dive-1', + sourceId: 'other-source', + computerId: null, + ); + expect(promoted, 1); + expect((await repo.getSeriesById(manual))!.isPrimary, isTrue); + expect((await repo.getSeriesById(computerSeries))!.isPrimary, isFalse); + }); + + test('deleteOwnedBy removes the matching series and logs tombstones', () async { + final deleted = await repo.deleteOwnedBy( + 'dive-1', + sourceId: 'src-1', + computerId: 'comp-1', + ); + expect(deleted.toSet(), {computerSeries, editSeries}); + expect(await repo.getSeriesForDive('dive-1'), isEmpty); + final tombstones = await (db.select(db.deletionLog) + ..where((t) => t.entityType.equals(ProfileSeriesRepository.entityType))) + .get(); + expect(tombstones.map((t) => t.recordId).toSet(), {computerSeries, editSeries}); + }); + + test('deleteForDive removes every series of the dive', () async { + final deleted = await repo.deleteForDive('dive-1'); + expect(deleted, hasLength(2)); + expect(await repo.getSeriesForDive('dive-1'), isEmpty); + }); + }); +} +``` + +If `DiveComputersCompanion.insert` or `DiveDataSourcesCompanion.insert` require other fields, read the generated companion in `database.g.dart` and supply the minimum required arguments; do not change the assertions. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/profile_series_repository_test.dart` +Expected: compilation failure, repository file not found. + +- [ ] **Step 3: Create the repository** + +Create `lib/features/dive_log/data/repositories/profile_series_repository.dart`: + +```dart +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_summary.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_series.dart'; +import 'package:submersion/features/dive_log/domain/services/profile_sample_dedupe.dart'; + +/// Every read and write of `dive_profile_series`. The only production code +/// that encodes or decodes profile samples, apart from the migration packer. +/// +/// Zero-arg like `TankPressureRepository`: the database is the +/// `DatabaseService` singleton, so `setUpTestDatabase()` composes with it. +class ProfileSeriesRepository { + ProfileSeriesRepository({SyncRepository? syncRepository}) + : _syncRepository = syncRepository ?? SyncRepository(); + + /// The sync entity type; also the `hlcTargets` key. + static const String entityType = 'diveProfileSeries'; + + static const ProfileSeriesCodec _codec = ProfileSeriesCodec(); + + AppDatabase get _db => DatabaseService.instance.database; + final SyncRepository _syncRepository; + final _uuid = const Uuid(); + + /// Inserts one series and marks it pending so it gets an HLC. + /// + /// [samples] must be non-empty and timestamp-ordered; exact duplicates are + /// dropped. Throws [ArgumentError] on an empty list. Returns the row id. + Future insertSeries({ + required String diveId, + String? computerId, + String? sourceId, + bool isPrimary = true, + required List samples, + String? id, + int? now, + }) async { + final encoded = _codec.encode(dedupeExactSamples(samples)); + final summary = encoded.summary; + final rowId = id ?? _uuid.v4(); + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + await _db + .into(_db.diveProfileSeries) + .insert( + DiveProfileSeriesCompanion.insert( + id: rowId, + diveId: diveId, + computerId: Value(computerId), + sourceId: Value(sourceId), + isPrimary: Value(isPrimary), + sampleCount: summary.sampleCount, + startTimestamp: summary.startTimestamp, + endTimestamp: summary.endTimestamp, + maxDepth: summary.maxDepth, + firstDepth: summary.firstDepth, + lastDepth: summary.lastDepth, + hasDecoType: Value(summary.hasDecoType), + hasDecoStop: Value(summary.hasDecoStop), + hasPositiveCeiling: Value(summary.hasPositiveCeiling), + codecVersion: encoded.codecVersion, + samples: encoded.bytes, + createdAt: nowMs, + updatedAt: nowMs, + ), + ); + await _markPending(rowId, nowMs); + SyncEventBus.notifyLocalChange(); + return rowId; + } + + /// Every series of [diveId], decoded, oldest start first and then by id + /// so the order is stable. [primaryOnly] keeps only `is_primary` rows. + Future> getSeriesForDive( + String diveId, { + bool primaryOnly = false, + }) async { + final query = _db.select(_db.diveProfileSeries) + ..where((t) => t.diveId.equals(diveId)) + ..orderBy([ + (t) => OrderingTerm.asc(t.startTimestamp), + (t) => OrderingTerm.asc(t.id), + ]); + if (primaryOnly) { + query.where((t) => t.isPrimary.equals(true)); + } + final rows = await query.get(); + return [for (final row in rows) _decode(row)]; + } + + Future getSeriesById(String id) async { + final row = await (_db.select(_db.diveProfileSeries) + ..where((t) => t.id.equals(id))) + .getSingleOrNull(); + return row == null ? null : _decode(row); + } + + /// Clears `is_primary` on every series of [diveId]. Returns how many rows + /// changed; each changed row is marked pending. + Future demoteAll(String diveId, {int? now}) async { + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + final ids = await _ids((t) => t.diveId.equals(diveId)); + if (ids.isEmpty) return 0; + await (_db.update(_db.diveProfileSeries) + ..where((t) => t.id.isIn(ids))) + .write( + DiveProfileSeriesCompanion( + isPrimary: const Value(false), + updatedAt: Value(nowMs), + ), + ); + for (final id in ids) { + await _markPending(id, nowMs); + } + SyncEventBus.notifyLocalChange(); + return ids.length; + } + + /// Sets `is_primary` on the series [sourceId] or [computerId] own. + /// + /// Ownership is the FK first, then the pre-v154 computer convention for + /// rows that carry no source: `source_id = ?` OR (`source_id IS NULL` AND + /// `computer_id IS ?`). The IS-semantics on the computer id are load + /// bearing: `=` never matches NULL, which is how issue #1149 began. + Future promoteOwnedBy( + String diveId, { + required String? sourceId, + required String? computerId, + int? now, + }) async { + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + final ids = await _ids( + (t) => + t.diveId.equals(diveId) & + _ownedBy(t, sourceId: sourceId, computerId: computerId), + ); + if (ids.isEmpty) return 0; + await (_db.update(_db.diveProfileSeries) + ..where((t) => t.id.isIn(ids))) + .write( + DiveProfileSeriesCompanion( + isPrimary: const Value(true), + updatedAt: Value(nowMs), + ), + ); + for (final id in ids) { + await _markPending(id, nowMs); + } + SyncEventBus.notifyLocalChange(); + return ids.length; + } + + /// Deletes the series [sourceId] or [computerId] own (see + /// [promoteOwnedBy] for the predicate), one tombstone per series. + /// Returns the deleted ids. + Future> deleteOwnedBy( + String diveId, { + required String? sourceId, + required String? computerId, + }) => _delete( + (t) => + t.diveId.equals(diveId) & + _ownedBy(t, sourceId: sourceId, computerId: computerId), + ); + + /// Deletes every series of [diveId], one tombstone per series. + Future> deleteForDive(String diveId) => + _delete((t) => t.diveId.equals(diveId)); + + Expression _ownedBy( + $DiveProfileSeriesTable t, { + required String? sourceId, + required String? computerId, + }) { + final bySource = sourceId == null + ? const Constant(false) + : t.sourceId.equals(sourceId); + final byComputer = + t.sourceId.isNull() & + (computerId == null + ? t.computerId.isNull() + : t.computerId.equals(computerId)); + return bySource | byComputer; + } + + Future> _ids( + Expression Function($DiveProfileSeriesTable t) where, + ) async { + final query = _db.selectOnly(_db.diveProfileSeries) + ..addColumns([_db.diveProfileSeries.id]) + ..where(where(_db.diveProfileSeries)); + final rows = await query.get(); + return [for (final row in rows) row.read(_db.diveProfileSeries.id)!]; + } + + Future> _delete( + Expression Function($DiveProfileSeriesTable t) where, + ) async { + final ids = await _ids(where); + if (ids.isEmpty) return ids; + for (final id in ids) { + await _syncRepository.logDeletion(entityType: entityType, recordId: id); + } + await (_db.delete(_db.diveProfileSeries)..where((t) => t.id.isIn(ids))) + .go(); + SyncEventBus.notifyLocalChange(); + return ids; + } + + Future _markPending(String id, int nowMs) => + _syncRepository.markRecordPending( + entityType: entityType, + recordId: id, + localUpdatedAt: nowMs, + ); + + ProfileSeries _decode(DiveProfileSeriesRow row) { + return ProfileSeries( + id: row.id, + diveId: row.diveId, + computerId: row.computerId, + sourceId: row.sourceId, + isPrimary: row.isPrimary, + summary: ProfileSeriesSummary( + sampleCount: row.sampleCount, + startTimestamp: row.startTimestamp, + endTimestamp: row.endTimestamp, + maxDepth: row.maxDepth, + firstDepth: row.firstDepth, + lastDepth: row.lastDepth, + hasDecoType: row.hasDecoType, + hasDecoStop: row.hasDecoStop, + hasPositiveCeiling: row.hasPositiveCeiling, + ), + samples: _codec.decode(row.samples), + codecVersion: row.codecVersion, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + hlc: row.hlc, + ); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/dive_log/data/repositories/profile_series_repository_test.dart` +Expected: 13 tests pass. If `row.read(_db.diveProfileSeries.id)` in `_ids` does not compile, use `row.read(_db.diveProfileSeries.id)`. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/profile_series_repository.dart test/features/dive_log/data/repositories/profile_series_repository_test.dart +git commit -m "feat: ProfileSeriesRepository over dive_profile_series" +``` + +--- + +### Task 6: TankPressureSeriesRepository + +**Files:** +- Create: `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart` +- Test: `test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart` + +**Interfaces:** +- Consumes: `db.tankPressureSeries`, `TankPressureSeriesCompanion`, `TankPressureSeriesRow` (Task 2); `TankPressureSeries`, `dedupeExactPressureSamples` (Task 1); `TankPressureSeriesCodec`, `TankPressureSample`, `TankPressureSeriesSummary` (PR 1). +- Produces: + - `class TankPressureSeriesRepository { TankPressureSeriesRepository({SyncRepository? syncRepository}); static const String entityType = 'tankPressureSeries'; }` + - `Future insertSeries({required String diveId, required String tankId, String? computerId, required List samples, String? id, int? now})` + - `Future> getSeriesForDive(String diveId)` + - `Future> getSeriesForTank(String diveId, String tankId)` + - `Future> deleteForDive(String diveId)` + - `Future> deleteForTank(String diveId, String tankId)` + - `Future> deleteOwnedByComputer(String diveId, String? computerId)` + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late TankPressureSeriesRepository repo; + const now = 1750000000000; + + const samples = [ + TankPressureSample(timestamp: 0, pressure: 200.0), + TankPressureSample(timestamp: 60, pressure: 190.5), + TankPressureSample(timestamp: 120, pressure: 182.0), + ]; + + setUp(() async { + db = await setUpTestDatabase(); + repo = TankPressureSeriesRepository(); + await db + .into(db.dives) + .insert( + const DivesCompanion( + id: Value('dive-1'), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: 'comp-1', + name: 'Perdix', + createdAt: now, + updatedAt: now, + ), + ); + for (final tank in ['tank-a', 'tank-b']) { + await db + .into(db.diveTanks) + .insert(DiveTanksCompanion.insert(id: tank, diveId: 'dive-1')); + } + }); + + tearDown(tearDownTestDatabase); + + test('insertSeries stores the encoded readings and summary', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + computerId: 'comp-1', + samples: samples, + now: now, + ); + final row = await (db.select(db.tankPressureSeries) + ..where((t) => t.id.equals(id))) + .getSingle(); + expect(row.tankId, 'tank-a'); + expect(row.computerId, 'comp-1'); + expect(row.sampleCount, 3); + expect(row.startTimestamp, 0); + expect(row.endTimestamp, 120); + expect(row.hlc, isNotNull); + + final read = await repo.getSeriesForTank('dive-1', 'tank-a'); + expect(read.single.samples, samples); + expect(read.single.summary.sampleCount, 3); + }); + + test('exact duplicates are dropped and an empty list is refused', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: [samples[0], samples[0], samples[1]], + now: now, + ); + final read = await repo.getSeriesForTank('dive-1', 'tank-a'); + expect(read.single.id, id); + expect(read.single.samples, [samples[0], samples[1]]); + expect( + () => repo.insertSeries(diveId: 'dive-1', tankId: 'tank-a', samples: const []), + throwsArgumentError, + ); + }); + + test('getSeriesForDive orders by tank then start', () async { + await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-b', + samples: samples, + id: 'b', + now: now, + ); + await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: samples, + id: 'a', + now: now, + ); + final all = await repo.getSeriesForDive('dive-1'); + expect(all.map((s) => s.id), ['a', 'b']); + }); + + test('deleteForTank and deleteOwnedByComputer tombstone what they remove', () async { + final a = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + computerId: 'comp-1', + samples: samples, + now: now, + ); + final b = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-b', + samples: samples, + now: now, + ); + + expect(await repo.deleteForTank('dive-1', 'tank-a'), [a]); + expect((await repo.getSeriesForDive('dive-1')).map((s) => s.id), [b]); + + expect(await repo.deleteOwnedByComputer('dive-1', null), [b]); + expect(await repo.getSeriesForDive('dive-1'), isEmpty); + + final tombstones = await (db.select(db.deletionLog) + ..where( + (t) => t.entityType.equals(TankPressureSeriesRepository.entityType), + )) + .get(); + expect(tombstones.map((t) => t.recordId).toSet(), {a, b}); + }); + + test('deleteForDive removes every series of the dive', () async { + await repo.insertSeries(diveId: 'dive-1', tankId: 'tank-a', samples: samples); + await repo.insertSeries(diveId: 'dive-1', tankId: 'tank-b', samples: samples); + expect(await repo.deleteForDive('dive-1'), hasLength(2)); + expect(await repo.getSeriesForDive('dive-1'), isEmpty); + }); +} +``` + +If `DiveTanksCompanion.insert` requires more fields, supply the minimum from `database.g.dart`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart` +Expected: compilation failure, repository file not found. + +- [ ] **Step 3: Create the repository** + +Create `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart`: + +```dart +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; +// The Drift table class and the domain entity are both named +// TankPressureSeries; the project resolves that with an alias. +import 'package:submersion/features/dive_log/domain/entities/profile_series.dart' + as domain; +import 'package:submersion/features/dive_log/domain/services/profile_sample_dedupe.dart'; + +/// Every read and write of `tank_pressure_series`. See +/// `ProfileSeriesRepository` for the conventions; this is its two-field +/// sibling keyed by (dive, tank, computer). +class TankPressureSeriesRepository { + TankPressureSeriesRepository({SyncRepository? syncRepository}) + : _syncRepository = syncRepository ?? SyncRepository(); + + static const String entityType = 'tankPressureSeries'; + + static const TankPressureSeriesCodec _codec = TankPressureSeriesCodec(); + + AppDatabase get _db => DatabaseService.instance.database; + final SyncRepository _syncRepository; + final _uuid = const Uuid(); + + /// Inserts one series and marks it pending. [samples] must be non-empty + /// and timestamp-ordered; exact duplicates are dropped. Returns the id. + Future insertSeries({ + required String diveId, + required String tankId, + String? computerId, + required List samples, + String? id, + int? now, + }) async { + final encoded = _codec.encode(dedupeExactPressureSamples(samples)); + final rowId = id ?? _uuid.v4(); + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + await _db + .into(_db.tankPressureSeries) + .insert( + TankPressureSeriesCompanion.insert( + id: rowId, + diveId: diveId, + tankId: tankId, + computerId: Value(computerId), + sampleCount: encoded.summary.sampleCount, + startTimestamp: encoded.summary.startTimestamp, + endTimestamp: encoded.summary.endTimestamp, + codecVersion: encoded.codecVersion, + samples: encoded.bytes, + createdAt: nowMs, + updatedAt: nowMs, + ), + ); + await _syncRepository.markRecordPending( + entityType: entityType, + recordId: rowId, + localUpdatedAt: nowMs, + ); + SyncEventBus.notifyLocalChange(); + return rowId; + } + + /// Every series of [diveId], by tank then start then id. + Future> getSeriesForDive(String diveId) async { + final rows = + await (_db.select(_db.tankPressureSeries) + ..where((t) => t.diveId.equals(diveId)) + ..orderBy([ + (t) => OrderingTerm.asc(t.tankId), + (t) => OrderingTerm.asc(t.startTimestamp), + (t) => OrderingTerm.asc(t.id), + ])) + .get(); + return [for (final row in rows) _decode(row)]; + } + + Future> getSeriesForTank( + String diveId, + String tankId, + ) async { + final rows = + await (_db.select(_db.tankPressureSeries) + ..where((t) => t.diveId.equals(diveId) & t.tankId.equals(tankId)) + ..orderBy([ + (t) => OrderingTerm.asc(t.startTimestamp), + (t) => OrderingTerm.asc(t.id), + ])) + .get(); + return [for (final row in rows) _decode(row)]; + } + + Future> deleteForDive(String diveId) => + _delete((t) => t.diveId.equals(diveId)); + + Future> deleteForTank(String diveId, String tankId) => + _delete((t) => t.diveId.equals(diveId) & t.tankId.equals(tankId)); + + /// Deletes the series [computerId] contributed; a null [computerId] + /// matches the null-computer (manual or primary-source) rows only. + Future> deleteOwnedByComputer(String diveId, String? computerId) => + _delete( + (t) => + t.diveId.equals(diveId) & + (computerId == null + ? t.computerId.isNull() + : t.computerId.equals(computerId)), + ); + + Future> _delete( + Expression Function($TankPressureSeriesTable t) where, + ) async { + final query = _db.selectOnly(_db.tankPressureSeries) + ..addColumns([_db.tankPressureSeries.id]) + ..where(where(_db.tankPressureSeries)); + final ids = [ + for (final row in await query.get()) + row.read(_db.tankPressureSeries.id)!, + ]; + if (ids.isEmpty) return ids; + for (final id in ids) { + await _syncRepository.logDeletion(entityType: entityType, recordId: id); + } + await (_db.delete(_db.tankPressureSeries)..where((t) => t.id.isIn(ids))) + .go(); + SyncEventBus.notifyLocalChange(); + return ids; + } + + domain.TankPressureSeries _decode(TankPressureSeriesRow row) { + return domain.TankPressureSeries( + id: row.id, + diveId: row.diveId, + tankId: row.tankId, + computerId: row.computerId, + summary: TankPressureSeriesSummary( + sampleCount: row.sampleCount, + startTimestamp: row.startTimestamp, + endTimestamp: row.endTimestamp, + ), + samples: _codec.decode(row.samples), + codecVersion: row.codecVersion, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + hlc: row.hlc, + ); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart` +Expected: 5 tests pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart +git commit -m "feat: TankPressureSeriesRepository over tank_pressure_series" +``` + +--- + +### Task 7: Verification + +**Files:** none new. + +**Interfaces:** consumes everything above; produces a green branch ready for plan 2b. No push, no PR: plans 2b to 2e continue on this branch. + +- [ ] **Step 1: Run every test file this plan touched, individually** + +```bash +flutter test test/features/dive_log/domain/entities/profile_series_test.dart +flutter test test/features/dive_log/domain/services/profile_sample_dedupe_test.dart +flutter test test/core/database/migration_v182_profile_series_test.dart +flutter test test/core/database/profile_series_pack_test.dart +flutter test test/features/dive_log/data/repositories/profile_series_repository_test.dart +flutter test test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart +flutter test test/core/database/query_plan_test.dart +flutter test test/core/database/performance_indexes_test.dart +flutter test test/core/services/sync/sync_hlc_target_registration_test.dart +flutter test test/core/data/repositories/sync_repository_hlc_stamp_test.dart +flutter test test/core/database/migration_v180_stats_exclusion_test.dart +flutter test test/core/database/pre_migration_backup_integration_test.dart +``` + +Expected: every file reports `All tests passed!`. + +- [ ] **Step 2: Format and analyze project-wide** + +```bash +dart format --output=none --set-exit-if-changed . +flutter analyze +``` + +Expected: both exit 0; analyze reports `No issues found!`. + +- [ ] **Step 3: Confirm the layering constraints** + +```bash +grep -l "package:flutter" lib/core/database/profile_series_pack.dart lib/features/dive_log/domain/entities/profile_series.dart lib/features/dive_log/domain/services/profile_sample_dedupe.dart +grep -c "diveProfiles\b\|tankPressureProfiles\b" lib/core/database/profile_series_pack.dart +wc -l lib/core/database/profile_series_pack.dart lib/features/dive_log/data/repositories/profile_series_repository.dart lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart lib/features/dive_log/domain/entities/profile_series.dart +``` + +Expected: the first grep prints nothing; the second prints `0` (the packer never names the legacy Drift accessors); every file is under 400 lines. + +- [ ] **Step 4: Run the full suite once** + +```bash +flutter test --reporter compact --concurrency=16 > /private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/5b0068b6-136c-4277-89c4-30a25ed89d1c/scratchpad/full-suite-2a.log 2>&1; echo "exit=$?" >> /private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/5b0068b6-136c-4277-89c4-30a25ed89d1c/scratchpad/full-suite-2a.log +``` + +Run with `run_in_background: true` and a 600000 ms timeout, then read the last 40 lines. Expected: `exit=0` or failures limited to documented flakes, each green when rerun alone (`test/features/media/data/resolvers/local_file_resolver_test.dart` fails on this machine because `$TMPDIR` is a mounted volume; it passes with `TMPDIR=/private/tmp/claude-501/sysvol-tmp flutter test `). Any failure in a file this plan touched, or any failure that repeats alone, is real. + +- [ ] **Step 5: Report** + +Reply with: the commit list (`git log --oneline origin/worktree-profile-sample-storage..HEAD`), the per-file test counts, the full-suite summary line and exit, any flake reruns, and the line counts from Step 3. + +--- + +## Post-review amendments (2026-08-29) + +The whole-plan review after Task 7 changed the following on top of the +tasks above. They are recorded here so the plan matches the branch. + +- Rung renumbered 181 to 182: open PR #1390 (profile photos) holds 181. + The migration test is `migration_v182_profile_series_test.dart`. +- The `beforeOpen` backstop now calls `packLegacyProfileRows` after the + schema helper. The packer visits only dives that have legacy rows and no + series row (an indexed `NOT EXISTS`), so it is cheap once packed and a + no-op after the legacy tables are gone; a device that took a parallel + branch's rung number self-heals instead of losing its samples. +- The packer reads both legacy tables through `PRAGMA table_info`: missing + identity columns read as null computer, null source, primary; a table + missing `dive_id`, `timestamp`, or `depth` (or `tank_id`, `pressure`) is + skipped. Nine older migration tests build such minimal fixtures and let + the ladder run to the top. +- The packer resolves FK parents before inserting: a group whose dive (or + tank) is gone is skipped and counted in `ProfilePackReport.skippedOrphans`; + an unresolvable `computer_id` or `source_id` becomes null. `INSERT OR + IGNORE` does not swallow FK violations, and one orphan would otherwise + abort every retry of the ladder. +- The migration HLC advances the persisted `sync_metadata.hlc` + (`Hlc.parse(persisted).increment(nowMs)`) and falls back to a fresh clock + from the device id; a wall-clock stamp can sort below the publish + watermark after a peer merge and never ride a changeset. +- `database.dart`'s import graph stays Flutter-free: the `DiveProfilePoint` + conversions moved to `codecs/profile_sample_point.dart`, the id helpers + moved to `entities/profile_series_identity.dart`, and + `test/core/database/database_import_graph_test.dart` walks the transitive + graph. +- `ProfileSeriesRepository` gained `promoteWinnerOwnedBy` (null computer + first, then greatest id, one series; the primary-swap rule), + `deleteEditedSeries`, `promoteByComputer`, `promoteAll`, + `hasPrimarySeries`, and `restoreSeriesRow`; `TankPressureSeriesRepository` + gained `restoreSeriesRow`. Multi-step writes run in a transaction; + `demoteAll` touches only primary rows. +- Shared fixtures live in `test/helpers/legacy_profile_fixtures.dart`. +- The `TankPressureSeries` name is shared by the Drift table class and the + domain entity by decision: the table name is fixed by the spec and the + entity mirrors `ProfileSeries`; consumers import the entity `as domain`. + +Carried into plan 2b (from the review): `_ownedBy` is `_ownedBySourceSql`, +not `isPrimaryFamily` (the dangling-source rule is ported separately in 2c); +a computer delete must stamp the affected series so the `SET NULL` syncs; a +group split across `is_primary` values packs into two series and 2c's read +must reach the same answer as `_dropSupersededOriginals`; consider wrapping +the pack in a transaction for the benchmark; the per-series LWW window +during a mixed-version fleet needs an explicit decision in 2d. + +## Self-review + +**Spec coverage.** Section 4's two tables with exactly the listed columns, FKs, and indexes: Task 2 (Drift classes and raw DDL agree, tested on a fresh database). Section 8's rung, its idempotence, deterministic uuid v5 ids over the identity tuple with `null` for absent members, `created_at`/`updated_at` from the migration time, and the HLC from the local device: Tasks 1, 3, 4 (the HLC is minted from `sync_metadata.device_id` because `SyncClock` is not configured inside the ladder; a never-synced device stays unstamped and is covered by base publish). Section 8's "memory bounded by one dive": the packer selects per dive. Section 6's dedupe moving to encode time: Task 1's `dedupeExactSamples`, used by the packer and both repositories. Section 6's repository API surface (`insertSeries`, `demoteAll`, `promote`, `deleteSeriesOwnedBy`, `deleteSeriesForDive`, reads): Tasks 5 and 6, with `replaceSeriesOwnedBy` deliberately deferred to plan 2b where its first caller (reparse) lands. Section 10's migration tests (upgrade fixture with duplicates, null source and computer, a multi-source dive, a manual-edit series; two independently migrated copies produce identical ids; `query_plan_test` and `performance_indexes_test` updated): Tasks 2, 3, 4. The registration test for `hlcTargets`: Task 2. + +Deliberately not in this plan, by the 2a/2b/2c/2d/2e split: any change to the legacy tables or their consumers, sync serializer registration, dropping tables, purging retired tombstones, `VACUUM`, the fixture script, benchmarks. + +**Placeholder scan.** Every code step carries the full file or test. The conditional instructions ("if `customUpdate` requires `updates`", "if a companion requires more fields") name the exact fix and forbid changing assertions. + +**Type consistency.** `profileSeriesMigratedId({diveId, computerId, sourceId, isPrimary})` and `tankPressureSeriesMigratedId({diveId, tankId, computerId})` are defined in Task 1 and called with those names in Task 3 and its tests. `ProfilePackReport` fields `profileSeries`, `tankSeries`, `droppedSamples` match Task 3's test. `ProfileSeriesRepository.entityType` and `TankPressureSeriesRepository.entityType` equal the `hlcTargets` keys added in Task 2. `DiveProfileSeriesCompanion.insert` required arguments in Task 5 are exactly the non-defaulted, non-nullable columns declared in Task 2; the same holds for `TankPressureSeriesCompanion.insert` in Task 6. `ProfileSeries.copyWith` flags `clearComputerId` and `clearSourceId` are used by Task 1's test. `dedupeExactPressureSamples` is named identically in Tasks 1, 3, 6. diff --git a/docs/superpowers/plans/2026-08-29-profile-series-2b-reads.md b/docs/superpowers/plans/2026-08-29-profile-series-2b-reads.md new file mode 100644 index 0000000000..07ba3a9903 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-profile-series-2b-reads.md @@ -0,0 +1,2003 @@ +# Profile Series 2b: Reads Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every profile and tank-pressure read series-first (decode from `dive_profile_series` / `tank_pressure_series` when a dive has series rows, fall back to the legacy row tables when it does not), after first hardening the foundation plan 2a left, so that plan 2c can move writers one at a time with every existing test staying green. + +**Architecture:** Each read method in `DiveRepository`, `TankPressureRepository`, and `DiveComputerRepositoryImpl` asks the series repositories first; an empty result means "not yet migrated to series" and the method runs its existing legacy body, moved verbatim into a `_...Legacy` private method that plan 2e deletes together with the tables. The merge of several series into one timestamp-ordered point list, and the series-level twin of the edited-profile "superseded originals" rule, live in one pure-Dart helper so `getDiveById`, `getMergedProfile`, and `getDiveForAnalysis` stay in step by construction. Task 1 is the carry-over from plan 2a's final re-review: guard the backstop's packer call, skip malformed legacy rows, check for work before loading parent sets, drop tombstones on restore, and pin the import-graph test's seed. + +**Tech Stack:** Drift 2.34, the plan 2a repositories (`ProfileSeriesRepository`, `TankPressureSeriesRepository`), the PR 1 codecs, `flutter_test` with `setUpTestDatabase()`. + +**Spec:** `docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md`, section 6 (read paths: "every existing read method selects series rows for the dive and decodes; merge order across sources and the promote tiebreaker are reproduced in Dart"; `getBatchProfileSummaries` decodes one series per dive). Section 6's removal of `TankPressurePoint.id` is deferred to plan 2e: it touches about a hundred test constructions and belongs with the other mechanical cleanups, section 3 (decode only inside the repositories), section 9 (the nine SQL consumers are plan 2d, not this plan). + +## Global Constraints + +- Work only in the worktree `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-sample-storage-2` on branch `worktree-profile-sample-storage-2` (HEAD 9be092d38b5 at planning time). The shell's working directory does not persist between commands; prefix every command with `cd` into that directory. +- Never use an em-dash (U+2014) or an en-dash as punctuation anywhere. No emojis. +- Lints: `package:flutter_lints/flutter.yaml` plus `prefer_const_constructors`, `prefer_const_declarations`, `prefer_final_fields`, `prefer_final_locals`, `avoid_print`, `require_trailing_commas`, `always_use_package_imports`. Project imports use `package:submersion/...`; test helpers use the relative `../../../../helpers/test_database.dart` form. +- The legacy tables, their Drift classes, the packer's writes, and every writer stay as they are. This plan changes reads only. Legacy read bodies move into `_...Legacy` methods unchanged; they are deleted in plan 2e, never here. +- Series-first rule, applied identically everywhere: fetch the dive's series once; an empty list means legacy fallback; a non-empty list is authoritative (no union with legacy rows). +- Decoding happens only inside `ProfileSeriesRepository` / `TankPressureSeriesRepository` (spec section 3). Consumers receive `ProfileSeries` / `TankPressureSeries` entities. +- `getDiveById`, `getMergedProfile`, and `getDiveForAnalysis` must return the same point list for the same dive (the existing parity test); they share one private helper. +- `database.dart`'s import graph stays Flutter-free (`test/core/database/database_import_graph_test.dart`). +- The `TankPressureSeries` name is shared by the Drift table class and the domain entity by decision; consumers import the entity `as domain`. +- Run tests per file, never a directory, never piped through grep, tail, or head. `dart format .` and `flutter analyze` ("No issues found!") before every commit. `git add` explicit paths only. No push. No `Co-Authored-By`. +- No new file over 400 lines; `dive_repository_impl.dart` is already large (7,000 lines), add only what the tasks list there and put every new helper in its own file. + +--- + +## File structure + +Create: + +| file | responsibility | +|---|---| +| `lib/features/dive_log/domain/services/profile_series_merge.dart` | `mergeSeriesPoints` (interleave several series by timestamp, stable) and `dropSupersededSeries` (series-level twin of the edited-profile rule) | +| `test/core/database/backstop_resilience_test.dart` | the backstop survives a packer failure; malformed legacy rows are skipped | +| `test/features/dive_log/domain/services/profile_series_merge_test.dart` | | +| `test/features/dive_log/data/repositories/dive_repository_series_reads_test.dart` | `getDiveProfile`, `getMergedProfile`, `getDiveById` parity, supersede rule, fallback | +| `test/features/dive_log/data/repositories/profiles_by_data_source_series_test.dart` | series path of `getProfilesByDataSource` | +| `test/features/dive_log/data/repositories/batch_summaries_series_test.dart` | series path of `getBatchProfileSummaries` | +| `test/features/dive_log/data/repositories/tank_pressure_series_reads_test.dart` | tank reads, `getDiveById` start/end pressure | +| `test/features/dive_log/data/repositories/dive_computer_series_reads_test.dart` | computer id reads | + +Modify: + +| file | change | +|---|---| +| `lib/core/database/database.dart` | backstop packer call guarded | +| `lib/core/database/profile_series_pack.dart` | work check before parent loads; malformed rows skipped and counted; node id from `device_id` | +| `lib/features/dive_log/data/repositories/profile_series_repository.dart` | `getSeriesForDives`; `restoreSeriesRow` drops the tombstone | +| `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart` | `hasSeriesForDive`; `restoreSeriesRow` drops the tombstone | +| `test/core/database/database_import_graph_test.dart` | seed path asserted | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart` | series-first `getDiveProfile`, `getMergedProfile`, `_mapRowToDive` (profile and tank pressures), `getProfilesByDataSource`, `getBatchProfileSummaries`; watchers | +| `lib/features/dive_log/data/repositories/tank_pressure_repository.dart` | series-first reads | +| `lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart` | series-first `getComputerIdsForDive`, `getPrimaryComputerId`; `getProfilesForDive` removed | +| `test/features/dive_log/data/repositories/dive_computer_repository_error_test.dart` | `getProfilesForDive` expectation removed | + +--- + +### Task 1: Foundation hardening (carry-over from plan 2a's review) + +**Files:** +- Modify: `lib/core/database/database.dart` (the v182 backstop lines: `await _assertProfileSeriesSchema(); await packLegacyProfileRows(this);`) +- Modify: `lib/core/database/profile_series_pack.dart` +- Modify: `lib/features/dive_log/data/repositories/profile_series_repository.dart` (`restoreSeriesRow`) +- Modify: `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart` (`restoreSeriesRow`) +- Modify: `test/core/database/database_import_graph_test.dart` +- Test: `test/core/database/backstop_resilience_test.dart` +- Test: `test/core/database/profile_series_pack_test.dart` (two added tests) +- Test: `test/features/dive_log/data/repositories/profile_series_repository_restore_test.dart` (one added test) +- Test: `test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart` (one added test) + +**Interfaces:** +- Consumes: `packLegacyProfileRows(DatabaseConnectionUser, {int? nowMs})`, `ProfilePackReport` record with `profileSeries`, `tankSeries`, `droppedSamples`, `skippedOrphans`; `SyncRepository.removeDeletion({required String entityType, required String recordId})`; `Hlc(physicalTime, counter, nodeId)`. +- Produces: `ProfilePackReport` gains `int skippedRows`; the backstop never fails an open because of the packer; `restoreSeriesRow` leaves no tombstone. + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/database/backstop_resilience_test.dart`: + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/database/profile_series_pack.dart'; + +import '../../helpers/legacy_profile_fixtures.dart'; + +/// The v182 backstop packs on every open. A legacy row it cannot pack, or a +/// series table it cannot insert into, must never turn into a database that +/// cannot open: the ladder is where a packing failure is visible and retried, +/// the backstop is a self-heal that has to stay best-effort. +void main() { + test('a series table missing a column does not fail the open', () async { + final raw = sqlite3.sqlite3.openInMemory(); + addTearDown(raw.close); + legacyDdlAt180(raw, userVersion: 182); + seedParents(raw); + seedProfiles(raw); + // A pre-existing dive_profile_series without its samples column: the + // IF NOT EXISTS DDL leaves it alone and every packer INSERT fails. + raw.execute(''' + CREATE TABLE dive_profile_series ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + source_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 1 + ) + '''); + + final db = AppDatabase( + NativeDatabase.opened(raw, closeUnderlyingOnClose: false), + ); + addTearDown(db.close); + await expectLater(db.customSelect('SELECT 1').get(), completes); + }); + + test('a legacy row with a null timestamp is skipped, counted, and does not fail the open', () async { + final raw = sqlite3.sqlite3.openInMemory(); + addTearDown(raw.close); + raw.execute('PRAGMA user_version = 182'); + raw.execute('CREATE TABLE dives (id TEXT NOT NULL PRIMARY KEY)'); + raw.execute('CREATE TABLE dive_computers (id TEXT NOT NULL PRIMARY KEY)'); + raw.execute(''' + CREATE TABLE dive_data_sources ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 0, + imported_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + '''); + raw.execute( + 'CREATE TABLE dive_tanks (id TEXT NOT NULL PRIMARY KEY, dive_id TEXT NOT NULL)', + ); + // timestamp and depth nullable on purpose: a restored or hand-repaired + // legacy table can hold such rows and the packer must step over them. + raw.execute(''' + CREATE TABLE dive_profiles ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + source_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 1, + timestamp INTEGER, + depth REAL + ) + '''); + raw.execute("INSERT INTO dives (id) VALUES ('d1')"); + raw.execute( + "INSERT INTO dive_profiles (id, dive_id, timestamp, depth) VALUES " + "('p1', 'd1', 0, 1.0), ('p2', 'd1', NULL, 2.0), ('p3', 'd1', 10, NULL), " + "('p4', 'd1', 20, 3.0)", + ); + + final db = AppDatabase( + NativeDatabase.opened(raw, closeUnderlyingOnClose: false), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + + final row = await db + .customSelect('SELECT sample_count FROM dive_profile_series') + .getSingle(); + expect(row.read('sample_count'), 2, reason: 'p2 and p3 are skipped'); + // A second explicit pack reports nothing new and no skipped rows, because + // the dive already has a series row and is not revisited. + final again = await packLegacyProfileRows(db, nowMs: 1); + expect(again.profileSeries, 0); + expect(again.skippedRows, 0); + }); +} +``` + +Add to `test/core/database/profile_series_pack_test.dart` (inside `main`, using the existing fixture helpers): + +```dart + test('skippedRows counts legacy rows without a timestamp or depth', () async { + final raw = sqlite3.sqlite3.openInMemory(); + addTearDown(raw.close); + legacyDdlAt180(raw, userVersion: 182); + seedParents(raw); + // Loosen the columns the fixture declares NOT NULL by rebuilding the + // table without those constraints. + raw.execute('DROP TABLE dive_profiles'); + raw.execute(''' + CREATE TABLE dive_profiles ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + computer_id TEXT, + source_id TEXT, + is_primary INTEGER NOT NULL DEFAULT 1, + timestamp INTEGER, + depth REAL + ) + '''); + raw.execute( + "INSERT INTO dive_profiles (id, dive_id, timestamp, depth) VALUES " + "('p1', 'd1', 0, 1.0), ('p2', 'd1', NULL, 2.0), ('p3', 'd1', 10, 3.0)", + ); + final db = AppDatabase( + NativeDatabase.opened(raw, closeUnderlyingOnClose: false), + ); + addTearDown(db.close); + // The backstop already packed on open; measure a fresh pack instead. + await db.customSelect('SELECT 1').get(); + await db.customStatement('DELETE FROM dive_profile_series'); + final report = await packLegacyProfileRows(db, nowMs: 1); + expect(report.profileSeries, 1); + expect(report.skippedRows, 1); + }); + + test('the migration hlc carries the device id, not the persisted node id', () async { + final db = AppDatabase( + legacyFixture( + seed: (raw) { + seedParents(raw); + seedProfiles(raw); + raw.execute( + 'CREATE TABLE sync_metadata (id TEXT NOT NULL PRIMARY KEY, ' + 'device_id TEXT NOT NULL, hlc TEXT, created_at INTEGER NOT NULL, ' + 'updated_at INTEGER NOT NULL)', + ); + raw.execute( + "INSERT INTO sync_metadata (id, device_id, hlc, created_at, updated_at) " + "VALUES ('global', 'dev-1', '000001800000000000:000005:other', 0, 0)", + ); + }, + ), + ); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + await db.customStatement('DELETE FROM dive_profile_series'); + await packLegacyProfileRows(db, nowMs: 1700000000000); + final row = await db + .customSelect('SELECT hlc FROM dive_profile_series LIMIT 1') + .getSingle(); + expect(row.read('hlc'), const Hlc(1800000000000, 6, 'dev-1').toString()); + }); +``` + +If `legacyFixture` in that test file does not accept a `seed` callback in this form any more (the plan 2a fix wave moved fixtures into `test/helpers/legacy_profile_fixtures.dart`), build the database the way the neighbouring `stamps the migration hlc` test does and keep the assertions. + +Add to `test/features/dive_log/data/repositories/profile_series_repository_restore_test.dart` (a new test in its existing `main`, reusing its `setUp` fixture; read the file first and follow its naming): + +```dart + test('restoreSeriesRow removes the tombstone the delete logged', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: now, + ); + final row = await (db.select(db.diveProfileSeries) + ..where((t) => t.id.equals(id))) + .getSingle(); + await repo.deleteForDive('dive-1'); + var tombstones = await (db.select(db.deletionLog) + ..where((t) => t.recordId.equals(id))) + .get(); + expect(tombstones, hasLength(1)); + + await repo.restoreSeriesRow(row, now: now + 1); + + tombstones = await (db.select(db.deletionLog) + ..where((t) => t.recordId.equals(id))) + .get(); + expect(tombstones, isEmpty); + expect(await repo.getSeriesById(id), isNotNull); + }); +``` + +Add the mirror to `test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart` (`insertSeries` with `tankId: 'tank-a'`, `deleteForDive`, `restoreSeriesRow`, tombstone gone). + +In `test/core/database/database_import_graph_test.dart`, before the `while` loop add: + +```dart + expect( + File(queue.single).existsSync(), + isTrue, + reason: 'the walk must start at the real database.dart; a wrong ' + 'working directory would make this test pass vacuously', + ); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +flutter test test/core/database/backstop_resilience_test.dart +flutter test test/core/database/profile_series_pack_test.dart +flutter test test/features/dive_log/data/repositories/profile_series_repository_restore_test.dart +flutter test test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart +``` + +Expected: the resilience tests fail (the first with a failed open, the second with a `TypeError` or a `skippedRows` compile error); the `skippedRows` and node-id tests fail; the two tombstone tests fail on `expect(tombstones, isEmpty)`. + +- [ ] **Step 3: Guard the backstop and harden the packer** + +In `lib/core/database/database.dart`, replace the backstop's `await packLegacyProfileRows(this);` with: + +```dart + // Best effort: the ladder's own call is where a packing failure is + // visible and retried. Here a malformed legacy table, a series table + // a parallel branch shaped differently, or a busy lock from the + // second isolate must not turn into a database that cannot open. + try { + await packLegacyProfileRows(this); + } catch (e, stackTrace) { + developer.log( + 'Backstop pack of legacy profile rows failed; continuing', + name: 'AppDatabase', + error: e, + stackTrace: stackTrace, + ); + } +``` + +In `lib/core/database/profile_series_pack.dart`: + +1. Add `int skippedRows,` to the `ProfilePackReport` typedef and document it: `/// Legacy rows without a timestamp or depth (pressure, tank id for tanks), stepped over.` +2. Restructure the top of `packLegacyProfileRows` so the work check comes first: + +```dart + final now = nowMs ?? DateTime.now().millisecondsSinceEpoch; + final profileColumns = await _columnNames(db, 'dive_profiles'); + final canPackProfiles = + profileColumns.containsAll(const {'dive_id', 'timestamp', 'depth'}); + final tankColumns = await _columnNames(db, 'tank_pressure_profiles'); + final canPackTanks = tankColumns.containsAll( + const {'dive_id', 'tank_id', 'timestamp', 'pressure'}, + ); + final unpackedProfileDives = canPackProfiles + ? await _unpackedDiveIds( + db, + legacyTable: 'dive_profiles', + seriesTable: 'dive_profile_series', + ) + : const []; + final unpackedTankDives = canPackTanks + ? await _unpackedDiveIds( + db, + legacyTable: 'tank_pressure_profiles', + seriesTable: 'tank_pressure_series', + ) + : const []; + if (unpackedProfileDives.isEmpty && unpackedTankDives.isEmpty) { + // The common case on every open once a database is packed: nothing to + // do, so nothing else is loaded. + return ( + profileSeries: 0, + tankSeries: 0, + droppedSamples: 0, + skippedOrphans: 0, + skippedRows: 0, + ); + } + final hlc = await _migrationHlc(db, now); + final diveIds = await _parentIds(db, 'dives'); + final computerIds = await _parentIds(db, 'dive_computers'); + final sourceIds = await _parentIds(db, 'dive_data_sources'); + final tankIds = await _parentIds(db, 'dive_tanks'); +``` + + and make the two table passes iterate `unpackedProfileDives` / `unpackedTankDives` instead of calling `_unpackedDiveIds` themselves. Return `skippedRows: skippedRows` in the final record. + +3. Malformed rows: change `_profileSampleOf` to return `ProfileSample?`, returning null when `data['timestamp']` or `data['depth']` is null (before any cast); in the profile loop, `final sample = _profileSampleOf(row.data); if (sample == null) { skippedRows++; continue; }`. In the tank loop, skip and count when `tank_id`, `timestamp`, or `pressure` is null. + +4. Node id: in `_migrationHlc`, replace `return Hlc.parse(persisted).increment(nowMs).toString();` with + +```dart + final advanced = Hlc.parse(persisted).increment(nowMs); + // The device id column is the authority on this device's node id. + return Hlc(advanced.physicalTime, advanced.counter, deviceId).toString(); +``` + +In both repositories' `restoreSeriesRow`, wrap the insert in a transaction that also removes the tombstone: + +```dart + await _db.transaction(() async { + await _db + .into(_db.diveProfileSeries) + .insertOnConflictUpdate(row.toCompanion(false)); + // The delete that preceded a restore logged a tombstone; left in place + // it would ride the next changeset beside the upsert and delete the + // restored row on every peer. + await _syncRepository.removeDeletion( + entityType: entityType, + recordId: row.id, + ); + if (markPending) { + await _markPending( + row.id, + now ?? DateTime.now().millisecondsSinceEpoch, + ); + } + }); + SyncEventBus.notifyLocalChange(); +``` + +(the tank repository has no `_markPending` helper; call `_syncRepository.markRecordPending(entityType: entityType, recordId: row.id, localUpdatedAt: ...)` directly as its `insertSeries` does). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run the four files from Step 2 plus `flutter test test/core/database/database_import_graph_test.dart`, `flutter test test/core/database/migration_v182_profile_series_test.dart`, and `flutter test test/core/database/profile_series_pack_orphans_test.dart`. Expected: all pass. Fix any test in `profile_series_pack_orphans_test.dart` that constructs a `ProfilePackReport` literal without `skippedRows` by adding `skippedRows: 0`. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/database/database.dart lib/core/database/profile_series_pack.dart lib/features/dive_log/data/repositories/profile_series_repository.dart lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart test/core/database/database_import_graph_test.dart test/core/database/backstop_resilience_test.dart test/core/database/profile_series_pack_test.dart test/core/database/profile_series_pack_orphans_test.dart test/features/dive_log/data/repositories/profile_series_repository_restore_test.dart test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart +git commit -m "fix(db): best-effort backstop pack, skip malformed legacy rows, drop tombstones on restore" +``` + +--- + +### Task 2: Merge helper and batch read on the repositories + +**Files:** +- Create: `lib/features/dive_log/domain/services/profile_series_merge.dart` +- Modify: `lib/features/dive_log/data/repositories/profile_series_repository.dart` (add `getSeriesForDives`) +- Modify: `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart` (add `hasSeriesForDive`) +- Test: `test/features/dive_log/domain/services/profile_series_merge_test.dart` +- Test: `test/features/dive_log/data/repositories/profile_series_repository_test.dart` (one added test) +- Test: `test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart` (one added test) + +**Interfaces:** +- Consumes: `ProfileSeries` (`samples`, `isPrimary`, `computerId`, `sourceId`, `points`), `ProfileSample.toPoint()` from `codecs/profile_sample_point.dart`. +- Produces: + - `List mergeSeriesPoints(List series)`: every sample of every series, ordered by timestamp, ties keeping the order of `series` and then within-series order. + - `List dropSupersededSeries(List series, {required bool hasSources, required String? primaryComputerId})`: the series-level twin of `DiveRepository._dropSupersededOriginals`. + - `Future>> ProfileSeriesRepository.getSeriesForDives(List diveIds)`: series grouped by dive id, each list in `(start_timestamp, id)` order; dives with no series are absent from the map. + - `Future TankPressureSeriesRepository.hasSeriesForDive(String diveId)`: a count, no decode. + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/dive_log/domain/services/profile_series_merge_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_summary.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_series.dart'; +import 'package:submersion/features/dive_log/domain/services/profile_series_merge.dart'; + +ProfileSeries series( + String id, { + String? computerId, + String? sourceId, + bool isPrimary = true, + required List samples, +}) => ProfileSeries( + id: id, + diveId: 'd1', + computerId: computerId, + sourceId: sourceId, + isPrimary: isPrimary, + summary: ProfileSeriesSummary.of(samples), + samples: samples, + codecVersion: 1, + createdAt: 0, + updatedAt: 0, +); + +void main() { + group('mergeSeriesPoints', () { + test('a single series maps straight to points', () { + final s = series( + 'a', + samples: const [ + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 10, depth: 5.0), + ], + ); + expect(mergeSeriesPoints([s]).map((p) => p.timestamp), [0, 10]); + }); + + test('two series interleave by timestamp', () { + final a = series( + 'a', + computerId: 'c1', + samples: const [ + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 20, depth: 10.0), + ], + ); + final b = series( + 'b', + computerId: 'c2', + samples: const [ + ProfileSample(timestamp: 10, depth: 4.0), + ProfileSample(timestamp: 30, depth: 2.0), + ], + ); + expect( + mergeSeriesPoints([a, b]).map((p) => p.timestamp), + [0, 10, 20, 30], + ); + }); + + test('ties keep series order, then within-series order', () { + final a = series( + 'a', + samples: const [ + ProfileSample(timestamp: 10, depth: 1.0), + ProfileSample(timestamp: 10, depth: 1.5), + ], + ); + final b = series( + 'b', + samples: const [ProfileSample(timestamp: 10, depth: 2.0)], + ); + expect(mergeSeriesPoints([a, b]).map((p) => p.depth), [1.0, 1.5, 2.0]); + expect(mergeSeriesPoints([b, a]).map((p) => p.depth), [2.0, 1.0, 1.5]); + }); + + test('an empty list merges to an empty list', () { + expect(mergeSeriesPoints(const []), isEmpty); + }); + }); + + group('dropSupersededSeries', () { + final original = series( + 'orig', + computerId: 'c1', + sourceId: 's1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + ); + final edit = series( + 'edit', + sourceId: 's1', + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + ); + final other = series( + 'other', + computerId: 'c2', + sourceId: 's2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 3.0)], + ); + + test('an edit drops the demoted original of the primary family', () { + final kept = dropSupersededSeries( + [original, edit, other], + hasSources: true, + primaryComputerId: 'c1', + ); + expect(kept.map((s) => s.id), ['edit', 'other']); + }); + + test('without an edit nothing is dropped', () { + final kept = dropSupersededSeries( + [original, other], + hasSources: true, + primaryComputerId: 'c1', + ); + expect(kept.map((s) => s.id), ['orig', 'other']); + }); + + test('a dive with no primary series keeps everything', () { + final kept = dropSupersededSeries( + [original, other], + hasSources: true, + primaryComputerId: null, + ); + expect(kept, hasLength(2)); + }); + + test('with no data sources every series is family', () { + final demotedManual = series( + 'old', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + ); + final kept = dropSupersededSeries( + [demotedManual, edit, other], + hasSources: false, + primaryComputerId: null, + ); + expect(kept.map((s) => s.id), ['edit']); + }); + }); +} +``` + +Add to `profile_series_repository_test.dart` (in the `insert and read` group): + +```dart + test('getSeriesForDives groups by dive and omits dives without series', () async { + await db + .into(db.dives) + .insert( + const DivesCompanion( + id: Value('dive-2'), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + await repo.insertSeries(diveId: 'dive-1', samples: samples, id: 'a', now: now); + await repo.insertSeries( + diveId: 'dive-2', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + id: 'b', + now: now, + ); + final byDive = await repo.getSeriesForDives(['dive-1', 'dive-2', 'dive-9']); + expect(byDive.keys.toSet(), {'dive-1', 'dive-2'}); + expect(byDive['dive-1']!.single.id, 'a'); + expect(byDive['dive-2']!.single.id, 'b'); + expect(await repo.getSeriesForDives(const []), isEmpty); + }); +``` + +Add to `tank_pressure_series_repository_test.dart`: + +```dart + test('hasSeriesForDive answers without decoding', () async { + expect(await repo.hasSeriesForDive('dive-1'), isFalse); + await repo.insertSeries(diveId: 'dive-1', tankId: 'tank-a', samples: samples); + expect(await repo.hasSeriesForDive('dive-1'), isTrue); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run the three test files. Expected: the merge file fails to compile (library missing); the two repository tests fail on the missing methods. + +- [ ] **Step 3: Create the helper and the repository methods** + +Create `lib/features/dive_log/domain/services/profile_series_merge.dart`: + +```dart +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample_point.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_series.dart'; + +/// Every sample of every series in [series], interleaved by timestamp. +/// +/// This is what `ORDER BY timestamp` over the legacy row table produced for +/// a multi-source dive. Stable: samples that share a timestamp keep the +/// order of [series] and then their order within the series, so callers +/// that pass series in `(start_timestamp, id)` order get a deterministic +/// list on every device. +List mergeSeriesPoints(List series) { + if (series.isEmpty) return const []; + if (series.length == 1) return series.single.points; + final entries = <(int timestamp, int order, ProfileSample sample)>[]; + var order = 0; + for (final s in series) { + for (final sample in s.samples) { + entries.add((sample.timestamp, order++, sample)); + } + } + entries.sort((a, b) { + final byTime = a.$1.compareTo(b.$1); + return byTime != 0 ? byTime : a.$2.compareTo(b.$2); + }); + return [for (final entry in entries) entry.$3.toPoint()]; +} + +/// Series-level twin of `DiveRepository._dropSupersededOriginals`. +/// +/// A saved profile edit demotes the originals and inserts a null-computer +/// series under the same source; the demoted originals of the primary +/// family are superseded and must not render next to the edit. Family +/// membership follows the legacy rule exactly: with no data sources every +/// series is family; otherwise a null-computer series or one on the primary +/// computer is. Nothing is dropped unless the family holds both a primary +/// and a demoted member. +List dropSupersededSeries( + List series, { + required bool hasSources, + required String? primaryComputerId, +}) { + if (!series.any((s) => !s.isPrimary) || !series.any((s) => s.isPrimary)) { + return series; + } + bool isFamily(ProfileSeries s) => + !hasSources || s.computerId == null || s.computerId == primaryComputerId; + final family = series.where(isFamily); + final edited = + family.any((s) => s.isPrimary) && family.any((s) => !s.isPrimary); + if (!edited) return series; + return [ + for (final s in series) + if (s.isPrimary || !isFamily(s)) s, + ]; +} +``` + +Note one deliberate simplification versus the legacy code: the legacy version only looked up the primary source when a demoted row had a computer id; the series version takes `hasSources` and `primaryComputerId` from the caller, which looks them up once. `DiveRepository` supplies them from `_primarySourceComputer` (Task 3). + +In `profile_series_repository.dart`, after `getSeriesForDive`, add: + +```dart + /// Every series of every dive in [diveIds], grouped by dive, each list in + /// the same order [getSeriesForDive] uses. Dives without series are absent + /// from the map, which is how a caller tells "not yet migrated" apart + /// from "no samples". One statement for the whole batch. + Future>> getSeriesForDives( + List diveIds, + ) async { + if (diveIds.isEmpty) return const {}; + final rows = + await (_db.select(_db.diveProfileSeries) + ..where((t) => t.diveId.isIn(diveIds)) + ..orderBy([ + (t) => OrderingTerm.asc(t.diveId), + (t) => OrderingTerm.asc(t.startTimestamp), + (t) => OrderingTerm.asc(t.id), + ])) + .get(); + final byDive = >{}; + for (final row in rows) { + byDive.putIfAbsent(row.diveId, () => []).add(_decode(row)); + } + return byDive; + } +``` + +In `tank_pressure_series_repository.dart`, after `getSeriesForTank`, add: + +```dart + /// Whether [diveId] has any tank pressure series. A count, no decode. + Future hasSeriesForDive(String diveId) async { + final query = _db.selectOnly(_db.tankPressureSeries) + ..addColumns([_db.tankPressureSeries.id.count()]) + ..where(_db.tankPressureSeries.diveId.equals(diveId)); + final row = await query.getSingle(); + return (row.read(_db.tankPressureSeries.id.count()) ?? 0) > 0; + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run the three files. Expected: 8 merge tests, the new repository tests, and every pre-existing test pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/domain/services/profile_series_merge.dart lib/features/dive_log/data/repositories/profile_series_repository.dart lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart test/features/dive_log/domain/services/profile_series_merge_test.dart test/features/dive_log/data/repositories/profile_series_repository_test.dart test/features/dive_log/data/repositories/tank_pressure_series_repository_test.dart +git commit -m "feat: series merge helper, batch series read, tank series existence check" +``` + +--- + +### Task 3: Series-first `getDiveProfile`, `getMergedProfile`, and `getDiveById` + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart`: fields; `getDiveProfile`; `getMergedProfile`; the profile part of `_mapRowToDive`; the three watchers +- Test: `test/features/dive_log/data/repositories/dive_repository_series_reads_test.dart` + +**Interfaces:** +- Consumes: `ProfileSeriesRepository.getSeriesForDive(diveId)`, `mergeSeriesPoints`, `dropSupersededSeries`, `_primarySourceComputer(diveId)` (existing, returns `({bool hasSources, String? computerId})`). +- Produces: `Future?> _mergedSeriesPoints(String diveId)` (null when the dive has no series) used by `getMergedProfile` and `_mapRowToDive`; `_getDiveProfileLegacy`, `_getMergedProfileLegacy` holding the moved bodies; watchers registering `diveProfileSeries` and `tankPressureSeries`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/data/repositories/dive_repository_series_reads_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; + +import '../../../../helpers/test_database.dart'; + +/// The series-first read paths. Fixtures are written through the series +/// repository; the legacy-row fixtures in the neighbouring test files keep +/// covering the fallback branch until plan 2e removes it. +void main() { + late AppDatabase db; + late DiveRepository dives; + late ProfileSeriesRepository series; + const now = 1750000000000; + + setUp(() async { + db = await setUpTestDatabase(); + dives = DiveRepository(); + series = ProfileSeriesRepository(); + await db + .into(db.dives) + .insert( + const DivesCompanion( + id: Value('dive-1'), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + for (final computer in ['comp-1', 'comp-2']) { + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: computer, + name: computer, + createdAt: now, + updatedAt: now, + ), + ); + } + }); + + tearDown(tearDownTestDatabase); + + Future source(String id, String? computerId, {bool primary = false}) => + db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: id, + diveId: 'dive-1', + computerId: Value(computerId), + isPrimary: Value(primary), + importedAt: DateTime.fromMillisecondsSinceEpoch(now), + createdAt: DateTime.fromMillisecondsSinceEpoch(now), + ), + ); + + test('getDiveProfile returns only primary series, merged by timestamp', () async { + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + samples: const [ + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 20, depth: 10.0), + ], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 10, depth: 99.0)], + now: now, + ); + final profile = await dives.getDiveProfile('dive-1'); + expect(profile.map((p) => p.timestamp), [0, 20]); + expect(profile.map((p) => p.depth), [0.0, 10.0]); + }); + + test('getMergedProfile keeps every source and getDiveById stays in step', () async { + await source('src-1', 'comp-1', primary: true); + await source('src-2', 'comp-2'); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + samples: const [ + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 20, depth: 10.0), + ], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-2', + sourceId: 'src-2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 10, depth: 4.0)], + now: now, + ); + final merged = await dives.getMergedProfile('dive-1'); + expect(merged.map((p) => p.timestamp), [0, 10, 20]); + final byId = await dives.getDiveById('dive-1'); + expect(byId!.profile, merged); + final analysis = await dives.getDiveForAnalysis('dive-1'); + expect(analysis!.profile, merged); + }); + + test('an edit supersedes the demoted original of the primary family', () async { + await source('src-1', 'comp-1', primary: true); + await source('src-2', 'comp-2'); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + isPrimary: false, + samples: const [ + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 10, depth: 30.0), + ], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 0.0)], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-2', + sourceId: 'src-2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 5, depth: 7.0)], + now: now, + ); + final merged = await dives.getMergedProfile('dive-1'); + // The trimmed original (30 m at t=10) is gone; the other computer stays. + expect(merged.map((p) => p.depth), [0.0, 7.0]); + expect((await dives.getDiveById('dive-1'))!.profile, merged); + }); + + test('a dive with no series falls back to the legacy rows', () async { + await db + .into(db.diveProfiles) + .insert( + const DiveProfilesCompanion( + id: Value('legacy-1'), + diveId: Value('dive-1'), + timestamp: Value(5), + depth: Value(3.0), + ), + ); + expect((await dives.getDiveProfile('dive-1')).single.depth, 3.0); + expect((await dives.getMergedProfile('dive-1')).single.depth, 3.0); + expect((await dives.getDiveById('dive-1'))!.profile.single.depth, 3.0); + }); + + test('series rows win over legacy rows when both exist', () async { + await db + .into(db.diveProfiles) + .insert( + const DiveProfilesCompanion( + id: Value('legacy-1'), + diveId: Value('dive-1'), + timestamp: Value(5), + depth: Value(3.0), + ), + ); + await series.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 9.0)], + now: now, + ); + expect((await dives.getDiveProfile('dive-1')).single.depth, 9.0); + }); + + test('a series write ticks the detail and analysis watchers', () async { + final detail = dives.watchDiveDetailChanges().first; + final analysis = dives.watchAnalysisInputChanges().first; + await series.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: now, + ); + await expectLater(detail, completes); + await expectLater(analysis, completes); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_series_reads_test.dart` +Expected: the series-path tests fail (empty profiles); the fallback test passes; the watcher test may time out (no tick from the series table yet). + +- [ ] **Step 3: Port the reads** + +In `lib/features/dive_log/data/repositories/dive_repository_impl.dart`: + +1. Imports: add +```dart +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_series.dart'; +import 'package:submersion/features/dive_log/domain/services/profile_series_merge.dart'; +``` + (`profile_series.dart` may already be imported `as domain` elsewhere in the file; if a name clash arises with `TankPressureSeries`, import the entity file with the alias the file already uses for `dive.dart`, which is `domain`, and refer to `domain.ProfileSeries`.) + +2. Fields, next to `final SyncRepository _syncRepository = SyncRepository();`: +```dart + final ProfileSeriesRepository _profileSeries = ProfileSeriesRepository(); + final TankPressureSeriesRepository _tankSeries = TankPressureSeriesRepository(); +``` + +3. Rename the existing `getDiveProfile` body: move everything inside its `try` (the `PerfTimer.measure('getDiveProfile', ...)` call and the row mapping) into a new private method `Future> _getDiveProfileLegacy(String diveId)` with no try/catch of its own, and make `getDiveProfile`: + +```dart + /// Get profile data for a single dive (for lazy loading in list views). + /// + /// Series-first: a dive with series rows is read from them (primary series + /// only, interleaved by timestamp); a dive with none is read from the + /// legacy row table until plan 2e removes it. + Future> getDiveProfile(String diveId) async { + try { + final series = await _profileSeries.getSeriesForDive(diveId); + if (series.isNotEmpty) { + return mergeSeriesPoints([ + for (final s in series) + if (s.isPrimary) s, + ]); + } + return await _getDiveProfileLegacy(diveId); + } catch (e, stackTrace) { + _log.error( + 'Failed to get profile for dive: $diveId', + error: e, + stackTrace: stackTrace, + ); + return []; + } + } +``` + +4. Add the shared merged read: + +```dart + /// The merged profile from series rows, or null when [diveId] has none. + /// + /// Shared by [getMergedProfile] and [_mapRowToDive] so `getDiveById`, + /// `getMergedProfile`, and `getDiveForAnalysis` return the same list by + /// construction (the parity test locks this in). Every source is kept, + /// except the demoted originals a saved edit superseded + /// ([dropSupersededSeries], the series twin of + /// [_dropSupersededOriginals]). + Future?> _mergedSeriesPoints( + String diveId, + ) async { + final series = await _profileSeries.getSeriesForDive(diveId); + if (series.isEmpty) return null; + final needsPrimary = + series.any((s) => !s.isPrimary) && series.any((s) => s.isPrimary); + var hasSources = true; + String? primaryComputerId; + if (needsPrimary) { + final primary = await _primarySourceComputer(diveId); + hasSources = primary.hasSources; + primaryComputerId = primary.computerId; + } + return mergeSeriesPoints( + dropSupersededSeries( + series, + hasSources: hasSources, + primaryComputerId: primaryComputerId, + ), + ); + } +``` + +5. Move `getMergedProfile`'s body into `Future> _getMergedProfileLegacy(String diveId)` unchanged and make: + +```dart + Future> getMergedProfile(String diveId) async { + return await _mergedSeriesPoints(diveId) ?? + await _getMergedProfileLegacy(diveId); + } +``` + + Keep the existing doc comment on `getMergedProfile`. + +6. In `_mapRowToDive`, replace the profile query and `_dropSupersededOriginals` call with: + +```dart + final seriesProfile = await _mergedSeriesPoints(row.id); + final profileQuery = _db.select(_db.diveProfiles) + ..where((t) => t.diveId.equals(row.id)) + ..orderBy([(t) => OrderingTerm.asc(t.timestamp)]); + final profileRows = seriesProfile != null + ? const [] + : await _dropSupersededOriginals(row.id, await profileQuery.get()); +``` + + and where the `Dive` is built, replace `profile: _dropDuplicateSamples(profileRows).map(_profilePointFromRow).toList(),` with: + +```dart + profile: + seriesProfile ?? + _dropDuplicateSamples(profileRows).map(_profilePointFromRow).toList(), +``` + + (Tank pressures in `_mapRowToDive` are Task 5.) + +7. Watchers: in `watchDiveDetailChanges` add `TableUpdateQuery.onTable(_db.diveProfileSeries),` after the `diveProfiles` line and `TableUpdateQuery.onTable(_db.tankPressureSeries),` after the `tankPressureProfiles` line; the same two lines in `watchAnalysisInputChanges`. `watchDivesChanges` watches only `dives` and stays as it is. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +flutter test test/features/dive_log/data/repositories/dive_repository_series_reads_test.dart +flutter test test/features/dive_log/data/repositories/dive_profile_duplicate_rows_test.dart +flutter test test/features/dive_log/data/repositories/edited_profile_supersedes_originals_test.dart +flutter test test/features/dive_log/data/repositories/dive_repository_test.dart +flutter test test/features/dive_log/data/repositories/dive_repository_error_test.dart +flutter test test/features/dive_log/profile_analysis_tick_reactivity_test.dart +``` + +Expected: all pass. The legacy-fixture files exercise the fallback branch and must not change. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart test/features/dive_log/data/repositories/dive_repository_series_reads_test.dart +git commit -m "feat: series-first getDiveProfile, getMergedProfile, and getDiveById" +``` + +--- + +### Task 4: Series-first `getProfilesByDataSource` + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`getProfilesByDataSource`) +- Test: `test/features/dive_log/data/repositories/profiles_by_data_source_series_test.dart` + +**Interfaces:** +- Consumes: `_profileSeries.getSeriesForDive`, `_canonicalDataSourceRows`, `mergeSeriesPoints`, `legacyDataSourceId(diveId)`, `domain.SourceProfile`. +- Produces: `_getProfilesByDataSourceLegacy` holding the moved body; the series path. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/data/repositories/profiles_by_data_source_series_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Series path of getProfilesByDataSource; mirrors the cases the legacy +/// profiles_by_data_source_test pins. +void main() { + late AppDatabase db; + late DiveRepository dives; + late ProfileSeriesRepository series; + const now = 1750000000000; + + setUp(() async { + db = await setUpTestDatabase(); + dives = DiveRepository(); + series = ProfileSeriesRepository(); + await db + .into(db.dives) + .insert( + const DivesCompanion( + id: Value('dive-1'), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + for (final computer in ['comp-1', 'comp-2']) { + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: computer, + name: computer, + createdAt: now, + updatedAt: now, + ), + ); + } + }); + + tearDown(tearDownTestDatabase); + + Future source(String id, String? computerId, {bool primary = false}) => + db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: id, + diveId: 'dive-1', + computerId: Value(computerId), + isPrimary: Value(primary), + importedAt: DateTime.fromMillisecondsSinceEpoch(now), + createdAt: DateTime.fromMillisecondsSinceEpoch(now), + ), + ); + + test('series attribute to their source, null-computer series to the primary', () async { + await source('src-1', 'comp-1', primary: true); + await source('src-2', 'comp-2'); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-2', + sourceId: 'src-2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: now, + ); + // A pre-v154 series: no source, no computer. + await series.insertSeries( + diveId: 'dive-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 5, depth: 3.0)], + now: now, + ); + final bySource = await dives.getProfilesByDataSource('dive-1'); + expect(bySource.keys.toSet(), {'src-1', 'src-2'}); + expect(bySource['src-1']!.points.map((p) => p.depth), [1.0, 3.0]); + expect(bySource['src-2']!.points.map((p) => p.depth), [2.0]); + expect(bySource['src-1']!.isEdited, isFalse); + }); + + test('an edited primary replaces the original and sets isEdited', () async { + await source('src-1', 'comp-1', primary: true); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 9.0)], + now: now, + ); + final bySource = await dives.getProfilesByDataSource('dive-1'); + expect(bySource['src-1']!.isEdited, isTrue); + expect(bySource['src-1']!.points.single.depth, 9.0); + }); + + test('a metadata-only source keeps an entry with no points', () async { + await source('src-1', 'comp-1', primary: true); + await source('src-2', 'comp-2'); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: now, + ); + final bySource = await dives.getProfilesByDataSource('dive-1'); + expect(bySource['src-2']!.points, isEmpty); + }); + + test('with no data sources a synthetic primary source is produced', () async { + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: now, + ); + final bySource = await dives.getProfilesByDataSource('dive-1'); + final only = bySource.values.single; + expect(only.sourceId, legacyDataSourceId('dive-1')); + expect(only.computerId, 'comp-1'); + expect(only.isEdited, isTrue); + expect(only.points.single.depth, 1.0); + }); + + test('no series and no legacy rows gives an empty map', () async { + expect(await dives.getProfilesByDataSource('dive-1'), isEmpty); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/profiles_by_data_source_series_test.dart` +Expected: the four series tests fail (empty or missing entries); the last passes. + +- [ ] **Step 3: Port the read** + +Move the existing body of `getProfilesByDataSource` (everything inside its `try`) into `Future> _getProfilesByDataSourceLegacy(String diveId)` unchanged, and make the public method: + +```dart + Future> getProfilesByDataSource( + String diveId, + ) async { + try { + final series = await _profileSeries.getSeriesForDive(diveId); + if (series.isEmpty) return await _getProfilesByDataSourceLegacy(diveId); + return _profilesBySourceFromSeries(diveId, series); + } catch (e, stackTrace) { + _log.error( + 'Failed to get profiles by data source for dive: $diveId', + error: e, + stackTrace: stackTrace, + ); + return {}; + } + } + + /// Series path of [getProfilesByDataSource]; the same rules as the legacy + /// body, applied to whole series instead of rows. + Future> _profilesBySourceFromSeries( + String diveId, + List series, + ) async { + final sourceRows = _canonicalDataSourceRows( + await (_db.select(_db.diveDataSources) + ..where((t) => t.diveId.equals(diveId)) + ..orderBy([ + (t) => OrderingTerm.desc(t.isPrimary), + (t) => OrderingTerm.asc(t.createdAt), + ])) + .get(), + ); + if (sourceRows.isEmpty) { + // Dives with series but no dive_data_sources row: synthesize the + // primary source the way the legacy read does. + final primary = [ + for (final s in series) + if (s.isPrimary) s, + ]; + if (primary.isEmpty) return {}; + final syntheticSourceId = legacyDataSourceId(diveId); + return { + syntheticSourceId: domain.SourceProfile( + sourceId: syntheticSourceId, + computerId: primary.first.computerId, + isEdited: series.any((s) => !s.isPrimary), + points: mergeSeriesPoints(primary), + ), + }; + } + + final primary = sourceRows.first; + final sourceIdByComputer = { + for (final s in sourceRows) + if (s.computerId != null) s.computerId!: s.id, + }; + final sourceIds = {for (final s in sourceRows) s.id}; + // The FK is authoritative; a series without one follows the pre-v154 + // computer convention (issue #1149). + bool isPrimaryFamily(ProfileSeries s) => sourceIds.contains(s.sourceId) + ? s.sourceId == primary.id + : s.computerId == null || s.computerId == primary.computerId; + final family = series.where(isPrimaryFamily); + final hasEditedProfile = + family.any((s) => s.isPrimary) && family.any((s) => !s.isPrimary); + + final grouped = >{ + for (final s in sourceRows) s.id: [], + }; + var primaryIsEdited = false; + for (final s in series) { + final owner = sourceIds.contains(s.sourceId) + ? s.sourceId! + : s.computerId == null + ? primary.id + : (sourceIdByComputer[s.computerId!] ?? primary.id); + if (hasEditedProfile && isPrimaryFamily(s)) { + if (!s.isPrimary) continue; + primaryIsEdited = true; + } + grouped[owner]!.add(s); + } + return { + for (final s in sourceRows) + s.id: domain.SourceProfile( + sourceId: s.id, + computerId: s.computerId, + isEdited: s.id == primary.id && primaryIsEdited, + points: mergeSeriesPoints(grouped[s.id]!), + ), + }; + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +flutter test test/features/dive_log/data/repositories/profiles_by_data_source_series_test.dart +flutter test test/features/dive_log/data/repositories/profiles_by_data_source_test.dart +flutter test test/features/dive_log/data/repositories/canonical_data_sources_test.dart +flutter test test/features/dive_log/presentation/providers/profile_analysis_deco_stop_wiring_test.dart +``` + +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart test/features/dive_log/data/repositories/profiles_by_data_source_series_test.dart +git commit -m "feat: series-first getProfilesByDataSource" +``` + +--- + +### Task 5: Batch summaries and tank pressure reads + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`getBatchProfileSummaries`; tank pressures in `_mapRowToDive`) +- Modify: `lib/features/dive_log/data/repositories/tank_pressure_repository.dart` (`getTankPressuresForDive`, `getPressuresForTank`, `hasTankPressures`) +- Test: `test/features/dive_log/data/repositories/batch_summaries_series_test.dart` +- Test: `test/features/dive_log/data/repositories/tank_pressure_series_reads_test.dart` + +**Interfaces:** +- Consumes: `_profileSeries.getSeriesForDives`, `_tankSeries.getSeriesForDive`, `_tankSeries.getSeriesForTank`, `_tankSeries.hasSeriesForDive`, `mergeSeriesPoints`, `domain.TankPressureSeries`. +- Produces: series-path `TankPressurePoint`s carry `id: ':'` (the field stays until plan 2e removes it); `_getBatchProfileSummariesLegacy(List diveIds, int maxSamples)`; `_downsample(List, int maxSamples)`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/dive_log/data/repositories/batch_summaries_series_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveRepository dives; + late ProfileSeriesRepository series; + const now = 1750000000000; + + setUp(() async { + db = await setUpTestDatabase(); + dives = DiveRepository(); + series = ProfileSeriesRepository(); + for (final id in ['dive-1', 'dive-2', 'dive-3']) { + await db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveDateTime: const Value(now), + createdAt: const Value(now), + updatedAt: const Value(now), + ), + ); + } + }); + + tearDown(tearDownTestDatabase); + + test('series-backed and legacy-backed dives are both summarised', () async { + await series.insertSeries( + diveId: 'dive-1', + samples: [ + for (var i = 0; i < 300; i++) + ProfileSample(timestamp: i, depth: i.toDouble()), + ], + now: now, + ); + await db + .into(db.diveProfiles) + .insert( + const DiveProfilesCompanion( + id: Value('legacy-1'), + diveId: Value('dive-2'), + timestamp: Value(7), + depth: Value(3.0), + ), + ); + final summaries = await dives.getBatchProfileSummaries( + ['dive-1', 'dive-2', 'dive-3'], + maxSamples: 120, + ); + expect(summaries.keys.toSet(), {'dive-1', 'dive-2'}); + expect(summaries['dive-1'], hasLength(120)); + expect(summaries['dive-1']!.first.timestamp, 0); + expect(summaries['dive-1']!.last.timestamp, 299); + expect(summaries['dive-2']!.single.depth, 3.0); + }); + + test('every series of a dive contributes, primary or not', () async { + await series.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + isPrimary: false, + computerId: null, + samples: const [ProfileSample(timestamp: 10, depth: 2.0)], + now: now, + ); + final summaries = await dives.getBatchProfileSummaries(['dive-1']); + expect(summaries['dive-1']!.map((p) => p.timestamp), [0, 10]); + }); + + test('an empty id list returns an empty map', () async { + expect(await dives.getBatchProfileSummaries(const []), isEmpty); + }); +} +``` + +Create `test/features/dive_log/data/repositories/tank_pressure_series_reads_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late TankPressureRepository tanks; + late TankPressureSeriesRepository series; + const now = 1750000000000; + + setUp(() async { + db = await setUpTestDatabase(); + tanks = TankPressureRepository(); + series = TankPressureSeriesRepository(); + await db + .into(db.dives) + .insert( + const DivesCompanion( + id: Value('dive-1'), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + for (final tank in ['tank-a', 'tank-b']) { + await db + .into(db.diveTanks) + .insert( + DiveTanksCompanion.insert(id: tank, diveId: 'dive-1'), + ); + } + }); + + tearDown(tearDownTestDatabase); + + test('getTankPressuresForDive groups series by tank', () async { + await series.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: const [ + TankPressureSample(timestamp: 0, pressure: 200.0), + TankPressureSample(timestamp: 60, pressure: 190.0), + ], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + tankId: 'tank-b', + samples: const [TankPressureSample(timestamp: 0, pressure: 210.0)], + now: now, + ); + final byTank = await tanks.getTankPressuresForDive('dive-1'); + expect(byTank.keys.toSet(), {'tank-a', 'tank-b'}); + expect(byTank['tank-a']!.map((p) => p.pressure), [200.0, 190.0]); + expect(byTank['tank-a']!.first.tankId, 'tank-a'); + expect((await tanks.getPressuresForTank('dive-1', 'tank-b')).single.pressure, 210.0); + expect(await tanks.hasTankPressures('dive-1'), isTrue); + }); + + test('getDiveById derives start and end pressure from the series', () async { + await series.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: const [ + TankPressureSample(timestamp: 0, pressure: 200.0), + TankPressureSample(timestamp: 60, pressure: 190.0), + TankPressureSample(timestamp: 120, pressure: 150.0), + ], + now: now, + ); + final dive = await DiveRepository().getDiveById('dive-1'); + final tankA = dive!.tanks.singleWhere((t) => t.id == 'tank-a'); + expect(tankA.startPressure, 200.0); + expect(tankA.endPressure, 150.0); + }); + + test('a dive with no tank series falls back to the legacy rows', () async { + await db + .into(db.tankPressureProfiles) + .insert( + TankPressureProfilesCompanion.insert( + id: 'legacy-1', + diveId: 'dive-1', + tankId: 'tank-a', + timestamp: 0, + pressure: 180.0, + ), + ); + expect((await tanks.getTankPressuresForDive('dive-1'))['tank-a']!.single.pressure, 180.0); + expect(await tanks.hasTankPressures('dive-1'), isTrue); + final dive = await DiveRepository().getDiveById('dive-1'); + expect(dive!.tanks.singleWhere((t) => t.id == 'tank-a').startPressure, 180.0); + }); + + test('no series and no legacy rows', () async { + expect(await tanks.getTankPressuresForDive('dive-1'), isEmpty); + expect(await tanks.hasTankPressures('dive-1'), isFalse); + }); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run both files. Expected: series-path tests fail; fallback and empty tests pass. + +- [ ] **Step 3: Port the reads** + +1. `TankPressurePoint` keeps its `id` field in this plan (plan 2e removes it together with the legacy tables); series-path points get `id: '${s.id}:$index'`, unique within one read and never persisted. +2. Nothing changes in `dive.dart` or the synthesizer. +3. In `tank_pressure_repository.dart`, add `final TankPressureSeriesRepository _tankSeries = TankPressureSeriesRepository();` (import `tank_pressure_series_repository.dart` and the entity file `as domain` if the file needs `TankPressureSeries`), move the three read bodies into `_getTankPressuresForDiveLegacy`, `_getPressuresForTankLegacy`, `_hasTankPressuresLegacy`, and write: + +```dart + Future>> getTankPressuresForDive( + String diveId, + ) async { + final series = await _tankSeries.getSeriesForDive(diveId); + if (series.isEmpty) return _getTankPressuresForDiveLegacy(diveId); + final result = >{}; + for (final s in series) { + final points = result.putIfAbsent(s.tankId, () => []); + for (var i = 0; i < s.samples.length; i++) { + final sample = s.samples[i]; + points.add( + TankPressurePoint( + id: '${s.id}:$i', + tankId: s.tankId, + timestamp: sample.timestamp, + pressure: sample.pressure, + ), + ); + } + } + return result; + } + + Future> getPressuresForTank( + String diveId, + String tankId, + ) async { + final series = await _tankSeries.getSeriesForTank(diveId, tankId); + if (series.isEmpty) return _getPressuresForTankLegacy(diveId, tankId); + return [ + for (final s in series) + for (var i = 0; i < s.samples.length; i++) + TankPressurePoint( + id: '${s.id}:$i', + tankId: s.tankId, + timestamp: s.samples[i].timestamp, + pressure: s.samples[i].pressure, + ), + ]; + } + + Future hasTankPressures(String diveId) async => + await _tankSeries.hasSeriesForDive(diveId) || + await _hasTankPressuresLegacy(diveId); +``` + + `getSeriesForDive` orders by tank, start, id and `getSeriesForTank` by start, id, so points come out timestamp-ordered within a tank as long as a tank's series do not overlap in time (a tank has one series per computer; two computers on one tank is the multi-transmitter case, whose series are concatenated in start order, the same order the legacy `ORDER BY timestamp` produced when their ranges did not overlap). + +4. In `dive_repository_impl.dart`, `_mapRowToDive`: replace the tank pressure query and grouping with: + +```dart + final tankSeries = await _tankSeries.getSeriesForDive(row.id); + final startPressureByTank = {}; + final endPressureByTank = {}; + if (tankSeries.isNotEmpty) { + for (final s in tankSeries) { + if (s.samples.isEmpty) continue; + startPressureByTank.putIfAbsent(s.tankId, () => s.samples.first.pressure); + endPressureByTank[s.tankId] = s.samples.last.pressure; + } + } else { + final tankPressureRows = + await (_db.select(_db.tankPressureProfiles) + ..where((t) => t.diveId.equals(row.id)) + ..orderBy([(t) => OrderingTerm.asc(t.timestamp)])) + .get(); + for (final p in tankPressureRows) { + startPressureByTank.putIfAbsent(p.tankId, () => p.pressure); + endPressureByTank[p.tankId] = p.pressure; + } + } +``` + + and in the tank mapping replace the `profilePoints`/`profileStartPressure`/`profileEndPressure` block with `startPressure: t.startPressure ?? startPressureByTank[t.id], endPressure: t.endPressure ?? endPressureByTank[t.id],`. Delete the now-unused `tankPressuresByTankId` map. + +5. `getBatchProfileSummaries`: move the existing body into `_getBatchProfileSummariesLegacy(List diveIds, int maxSamples)` (returning the map for exactly those ids), extract its downsampling loop into `static List _downsample(List points, int maxSamples)`, and write: + +```dart + Future>> getBatchProfileSummaries( + List diveIds, { + int maxSamples = 120, + }) async { + if (diveIds.isEmpty) return {}; + try { + return await PerfTimer.measure('batchProfileSummaries', () async { + final byDive = await _profileSeries.getSeriesForDives(diveIds); + final result = >{ + for (final entry in byDive.entries) + entry.key: _downsample( + [ + for (final p in mergeSeriesPoints(entry.value)) + domain.DiveProfilePoint(timestamp: p.timestamp, depth: p.depth), + ], + maxSamples, + ), + }; + final legacyIds = [ + for (final id in diveIds) + if (!byDive.containsKey(id)) id, + ]; + if (legacyIds.isNotEmpty) { + result.addAll( + await _getBatchProfileSummariesLegacy(legacyIds, maxSamples), + ); + } + return result; + }); + } catch (e, stackTrace) { + _log.error( + 'Failed to get batch profiles', + error: e, + stackTrace: stackTrace, + ); + return {}; + } + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +flutter test test/features/dive_log/data/repositories/batch_summaries_series_test.dart +flutter test test/features/dive_log/data/repositories/tank_pressure_series_reads_test.dart +flutter test test/features/dive_log/data/repositories/dive_repository_test.dart +flutter test test/features/dive_log/data/repositories/dive_computer_multi_transmitter_pressure_test.dart +flutter test test/features/data_quality/repairs/tank_pressure_repairs_test.dart +flutter test test/features/settings/presentation/providers/load_tank_pressures_test.dart +flutter test test/features/dive_log/presentation/pages/dive_edit_preserves_sac_widget_test.dart +``` + +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart lib/features/dive_log/data/repositories/tank_pressure_repository.dart test/features/dive_log/data/repositories/batch_summaries_series_test.dart test/features/dive_log/data/repositories/tank_pressure_series_reads_test.dart +git commit -m "feat: series-first batch summaries and tank pressure reads" +``` + +--- + +### Task 6: Dive computer repository reads + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart` (`getComputerIdsForDive`, `getPrimaryComputerId`; `getProfilesForDive` removed) +- Modify: `test/features/dive_log/data/repositories/dive_computer_repository_error_test.dart` (the `getProfilesForDive` expectation removed) +- Test: `test/features/dive_log/data/repositories/dive_computer_series_reads_test.dart` + +**Interfaces:** +- Consumes: `ProfileSeriesRepository.getSeriesForDive`. +- Produces: series-first `getComputerIdsForDive`, `getPrimaryComputerId`; `getProfilesForDive` gone (no production caller; it returned legacy Drift rows). + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/data/repositories/dive_computer_series_reads_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveComputerRepository computers; + late ProfileSeriesRepository series; + const now = 1750000000000; + + setUp(() async { + db = await setUpTestDatabase(); + computers = DiveComputerRepository(); + series = ProfileSeriesRepository(); + await db + .into(db.dives) + .insert( + const DivesCompanion( + id: Value('dive-1'), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + for (final computer in ['comp-1', 'comp-2']) { + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: computer, + name: computer, + createdAt: now, + updatedAt: now, + ), + ); + } + }); + + tearDown(tearDownTestDatabase); + + test('computer ids and the primary computer come from the series', () async { + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: now, + ); + await series.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 3.0)], + now: now, + ); + expect( + (await computers.getComputerIdsForDive('dive-1')).toSet(), + {'comp-1', 'comp-2'}, + ); + expect(await computers.getPrimaryComputerId('dive-1'), 'comp-1'); + }); + + test('falls back to legacy rows when the dive has no series', () async { + await db + .into(db.diveProfiles) + .insert( + const DiveProfilesCompanion( + id: Value('legacy-1'), + diveId: Value('dive-1'), + computerId: Value('comp-2'), + timestamp: Value(0), + depth: Value(1.0), + ), + ); + expect(await computers.getComputerIdsForDive('dive-1'), ['comp-2']); + expect(await computers.getPrimaryComputerId('dive-1'), 'comp-2'); + }); + + test('no series and no legacy rows', () async { + expect(await computers.getComputerIdsForDive('dive-1'), isEmpty); + expect(await computers.getPrimaryComputerId('dive-1'), isNull); + }); +} +``` + +If the repository class is not named `DiveComputerRepository`, use the name declared in `dive_computer_repository_impl.dart`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/dive_computer_series_reads_test.dart` +Expected: the series test fails; the other two pass. + +- [ ] **Step 3: Port the reads** + +In `dive_computer_repository_impl.dart`: add `final ProfileSeriesRepository _profileSeries = ProfileSeriesRepository();` with its import; delete `getProfilesForDive` entirely; move the bodies of `getComputerIdsForDive` and `getPrimaryComputerId` (the `customSelect` and mapping inside their `try`) into `_getComputerIdsForDiveLegacy` and `_getPrimaryComputerIdLegacy`, and write: + +```dart + Future> getComputerIdsForDive(String diveId) async { + try { + final series = await _profileSeries.getSeriesForDive(diveId); + if (series.isNotEmpty) { + return { + for (final s in series) + if (s.computerId != null) s.computerId!, + }.toList(); + } + return await _getComputerIdsForDiveLegacy(diveId); + } catch (e, stackTrace) { + _log.error( + 'Failed to get computer ids for dive: $diveId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + Future getPrimaryComputerId(String diveId) async { + try { + final series = await _profileSeries.getSeriesForDive(diveId); + if (series.isNotEmpty) { + for (final s in series) { + if (s.isPrimary && s.computerId != null) return s.computerId; + } + return null; + } + return await _getPrimaryComputerIdLegacy(diveId); + } catch (e, stackTrace) { + _log.error( + 'Failed to get primary computer for dive: $diveId', + error: e, + stackTrace: stackTrace, + ); + return null; + } + } +``` + +In `dive_computer_repository_error_test.dart`, delete the `expect(repository.getProfilesForDive('test-id'), throwsA(...))` block (the method no longer exists). + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +flutter test test/features/dive_log/data/repositories/dive_computer_series_reads_test.dart +flutter test test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart +flutter test test/features/dive_log/data/repositories/dive_computer_repository_error_test.dart +``` + +Expected: all pass. If `dive_computer_repository_impl_test.dart` asserts on `getProfilesForDive`, delete that assertion too and note it in the report. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart test/features/dive_log/data/repositories/dive_computer_repository_error_test.dart test/features/dive_log/data/repositories/dive_computer_series_reads_test.dart +git add test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart +git commit -m "feat: series-first computer id reads; drop the unused getProfilesForDive" +``` + +--- + +### Task 7: Verification + +**Files:** none new. + +- [ ] **Step 1: Run every test file this plan created or touched, individually** + +The eight new test files, plus: `profile_series_pack_test.dart`, `profile_series_pack_orphans_test.dart`, `migration_v182_profile_series_test.dart`, `database_import_graph_test.dart`, `profile_series_repository_test.dart`, `profile_series_repository_restore_test.dart`, `tank_pressure_series_repository_test.dart`, `dive_repository_test.dart`, `dive_repository_error_test.dart`, `dive_profile_duplicate_rows_test.dart`, `edited_profile_supersedes_originals_test.dart`, `profiles_by_data_source_test.dart`, `canonical_data_sources_test.dart`, `dive_computer_repository_impl_test.dart`, `dive_computer_repository_error_test.dart`, `dive_computer_multi_transmitter_pressure_test.dart`, `tank_pressure_repairs_test.dart`, `load_tank_pressures_test.dart`, `profile_analysis_tick_reactivity_test.dart`, `profile_analysis_deco_stop_wiring_test.dart`, `profile_analysis_loading_race_test.dart`, `dive_providers_test.dart`, `multi_computer_integration_test.dart`, `consolidation_sync_roundtrip_test.dart`, `uddf_round_trip_test.dart`. + +Expected: every file passes. + +- [ ] **Step 2: Format, analyze, layering** + +```bash +dart format --output=none --set-exit-if-changed . +flutter analyze +flutter test test/core/database/database_import_graph_test.dart +``` + +Expected: exit 0, "No issues found!", pass. + +- [ ] **Step 3: Full suite once** + +Background, 600000 ms timeout, output to `/private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/5b0068b6-136c-4277-89c4-30a25ed89d1c/scratchpad/full-suite-2b.log` with `echo "exit=$?"` appended. Known machine flakes: `local_file_resolver_test.dart` (mounted `$TMPDIR`; passes with `TMPDIR=/private/tmp/claude-501/sysvol-tmp`), `changeset_writer_test.dart` (shared `sync_base_publish` temp path; passes alone), `backup_encryption_service_test.dart`, `database_security_service_test.dart`. Any other failure: rerun alone once; a repeat is real. Any failure in a file this plan touched, or under `test/core/database/`, `test/features/dive_log/`, `test/core/services/sync/`, is real. + +- [ ] **Step 4: Report** + +The commit list from `git log --oneline 9be092d38b5..HEAD`, the per-file results, the full-suite summary and exit, flake reruns. + +--- + +## Self-review + +**Spec coverage.** Section 6 reads: `getDiveProfile`, `getMergedProfile`, `getProfilesByDataSource`, `getDiveForAnalysis` (via `getMergedProfile`), `getBatchProfileSummaries` ("decodes one series per dive and downsamples"), `getTankPressures`, `getPressuresForTank`: Tasks 3, 4, 5. "Merge order across sources and the promote tiebreaker reproduced in Dart": `mergeSeriesPoints` and `dropSupersededSeries` (Task 2), the winner promote already exists on the repository. Parity of `getDiveById` / `getMergedProfile` / `getDiveForAnalysis`: one shared `_mergedSeriesPoints` (Task 3) and a test. `TankPressurePoint.id` removal: deferred to plan 2e (about a hundred test constructions; a mechanical cleanup, not a read change). Section 3 "decode only in the repositories": every consumer takes entities; `getSeriesForDives` added for the batch read. Section 9 SQL consumers: deliberately plan 2d. The plan 2a re-review carry-over (backstop guard, malformed rows, work check, tombstone on restore, seed assert, node id): Task 1. + +Not in this plan by design: any writer (2c), any SQL predicate (2d), fallback removal (2e). + +**Placeholder scan.** Legacy bodies are moved, not rewritten; each move names the source method and the target name. No grep-driven edits remain. No "add handling", no "similar to". + +**Type consistency.** `mergeSeriesPoints(List)` and `dropSupersededSeries(List, {required bool hasSources, required String? primaryComputerId})` are defined in Task 2 and called with those shapes in Tasks 3, 4, 5. `getSeriesForDives` returns `Map>` (Task 2) and is consumed as such in Task 5. `hasSeriesForDive` (Task 2) is used in Task 5. `_primarySourceComputer` returns `({bool hasSources, String? computerId})`, matched in Task 3. `TankPressurePoint({id, tankId, timestamp, pressure})` is unchanged and every series-path construction supplies all four. `ProfilePackReport.skippedRows` (Task 1) matches its tests. diff --git a/docs/superpowers/plans/2026-08-29-profile-series-2c-writes.md b/docs/superpowers/plans/2026-08-29-profile-series-2c-writes.md new file mode 100644 index 0000000000..0c1410681b --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-profile-series-2c-writes.md @@ -0,0 +1,2585 @@ +# Profile Series Plan 2c: Writers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every production write of profile samples and tank pressure samples lands in the packed series tables (`dive_profile_series`, `tank_pressure_series`) through the two series repositories, so that after this plan no code path inserts or updates a `dive_profiles` or `tank_pressure_profiles` row. + +**Architecture:** Plan 2a created the tables, the v182 packer and the repositories; plan 2b made every read series-first with a byte-identical legacy fallback. This plan moves the thirteen legacy write sites, one file per task: the repositories gain the handful of operations the writers need (Task 1), then `DiveRepository`, `DiveComputerRepository`, `TankPressureRepository`, `ReparseService`, `DiveSplitService`, `DiveMergeService` with its snapshot, and `DiveConsolidationService` move in turn. Legacy `INSERT` and `UPDATE` statements are deleted; legacy `DELETE` statements stay in place (they keep the 2b fallback from re-exposing stale rows after a series delete) and go with the tables in plan 2e; legacy per-row tombstones (`logDeletion` with entity type `diveProfiles` or `tankPressureProfiles`) are removed because plan 2d drops those sync entities. Tests that seed legacy rows for a moved writer switch to series seeding in the same task. + +**Tech Stack:** Flutter, Drift 2.34.3, SQLite; `ProfileSeriesCodec` / `TankPressureSeriesCodec` (PR 1); `flutter test`; `dart format`; `flutter analyze`. + +**Spec:** `docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md` (sections 3, 6 and 12 bind this plan; section 7 is plan 2d; section 8 is done; section 9 is plan 2d; section 10's gates run in plan 2e). Plan 2b's hand-off checklist (session scratchpad `2b-handoff-checklist.md`) is folded into the constraints below. + +## Global Constraints + +- Schema version stays 182. No migration, no new table, no new column in this plan. +- Series are the only write target: after each task, the file it names contains no `into(_db.diveProfiles)`, `update(_db.diveProfiles)`, `into(_db.tankPressureProfiles)`, `update(_db.tankPressureProfiles)`, `INSERT INTO dive_profiles`, `UPDATE dive_profiles`, `INSERT INTO tank_pressure_profiles` or `UPDATE tank_pressure_profiles`. Legacy `delete(...)` / `DELETE FROM` statements on those tables STAY exactly as they are. +- Legacy per-row tombstones go: no `logDeletion(entityType: 'diveProfiles', ...)` or `logDeletion(entityType: 'tankPressureProfiles', ...)` survives in a moved file. Series deletes log one tombstone per series inside the repositories. +- "No samples" means "no series row": never call `insertSeries` with an empty list; skip the insert instead (`insertSeries` throws `ArgumentError` on an empty list). +- Ordering is the repository's job: `insertSeries` sorts samples by timestamp (stable, ties keep input order) and drops exact duplicates before encoding. Writers hand over whatever order they have. +- A demote-then-insert (`saveEditedProfile`) and a delete-then-promote (`restoreOriginalProfile`, reparse replace) run inside ONE `_db.transaction` so a dive is never observable with series but no primary series. +- Decode only inside the two series repositories. Services and the other repositories receive `ProfileSeries` / `TankPressureSeries` entities or `ProfileSample` / `TankPressureSample` lists; nothing outside the codecs directory names a codec class. +- Tank writes key on `(dive_id, tank_id, computer_id)`; a tank may legitimately own several series; nothing upserts by `(dive_id, tank_id)`. +- Drift name clash: the table class `TankPressureSeries` (database.dart) and the entity `TankPressureSeries` (entities/profile_series.dart) share a name. Files that import both must import the entity file `as series` and write `series.TankPressureSeries`; `dive_repository_impl.dart` already imports the entity file unaliased and names only `ProfileSeries`, leave it that way. +- No em-dashes (U+2014) or en-dashes used as punctuation anywhere (code, comments, tests, commit messages). No emojis. Immutability: never mutate a list handed in; build new lists. +- TDD per task; `dart format .` from the worktree root; `flutter analyze` zero issues (infos included); tests run per file with `flutter test `, never piped; stage explicit paths; one local commit per task with the message given; no push; no `Co-Authored-By` trailer. +- Worktree: `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-sample-storage-2`, branch `worktree-profile-sample-storage-2`, absolute paths only. + +## Test migration rules (apply in every task that names test files) + +Each heavy write-site test file has a legacy seed helper near its top (`insertTestProfile`, `insertProfile`, `insertProfileRow`, `insertTankPressure`, ...) that inserts ONE `dive_profiles` or `tank_pressure_profiles` row per call, plus assertions that count or read those rows. Because the 2b reads use the series tables whenever a dive has ANY series row and ignore the legacy rows otherwise, a test that seeds legacy rows and then calls a moved writer sees the writer act on an empty series set. The rules: + +- R1 (seed): keep the legacy helper in place for tests whose writer has NOT moved yet, and add a series twin next to it that inserts ONE single-sample series per call through the repository, returning the series id. The twin keeps the same parameters. Tests that call a writer moved in THIS task switch to the twin; nothing else in the file changes for seeding. When a later task moves the remaining writers of that file, it switches the rest and deletes the legacy helper. +- R2 (assert on seeded rows): an assertion that counted or read legacy rows the test SEEDED becomes the same assertion over series rows (`db.select(db.diveProfileSeries)` / `db.select(db.tankPressureSeries)`, or `ProfileSeriesRepository().getSeriesForDive`), one series per seeded row, so counts stay equal. +- R3 (assert on writer output): an assertion that counted legacy rows a WRITER inserted from N points becomes an assertion on the series it inserted (`getSeriesForDive(diveId, primaryOnly: true)` or `getSeriesForDive(diveId)`), checking `samples.length == N` (or `points`), and `isPrimary` / `computerId` / `sourceId` on the series instead of per row. +- R4 (tombstones): an assertion that a `deletion_log` row has entity type `diveProfiles` / `tankPressureProfiles` becomes `diveProfileSeries` / `tankPressureSeries`, one per series rather than one per row. +- R5 (legacy deletes): an assertion that a legacy table is EMPTY after a writer that deletes stays as it is (legacy deletes remain); add the series assertion beside it. +- R6 (reads): assertions that go through `getDiveProfile`, `getMergedProfile`, `getProfilesByDataSource`, `getTankPressuresForDive`, `getPressuresForTank`, `getDiveById` need no change once seeding moved. + +Series twin of a profile seed helper (the shape every task reuses; adapt the parameter list to the file's helper): + +```dart + final profileSeries = ProfileSeriesRepository(); + + Future insertTestSeries({ + required String diveId, + String? sourceId, + String? computerId, + bool isPrimary = true, + int timestamp = 0, + double depth = 5.0, + String? id, + }) => profileSeries.insertSeries( + id: id, + diveId: diveId, + computerId: computerId, + sourceId: sourceId, + isPrimary: isPrimary, + samples: [ProfileSample(timestamp: timestamp, depth: depth)], + now: 1000, + ); +``` + +Series twin of a tank pressure seed helper: + +```dart + final tankSeries = TankPressureSeriesRepository(); + + Future insertTestPressureSeries({ + required String diveId, + required String tankId, + String? computerId, + int timestamp = 0, + double pressure = 200.0, + String? id, + }) => tankSeries.insertSeries( + id: id, + diveId: diveId, + tankId: tankId, + computerId: computerId, + samples: [TankPressureSample(timestamp: timestamp, pressure: pressure)], + now: 1000, + ); +``` + +Imports the twins need: `package:submersion/features/dive_log/data/repositories/profile_series_repository.dart`, `.../tank_pressure_series_repository.dart`, `package:submersion/features/dive_log/domain/codecs/profile_sample.dart` (`ProfileSample`), `package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart` (`TankPressureSample`). Both repositories reach `DatabaseService.instance.database`, which `setUpTestDatabase()` provides; a test file that builds its own `AppDatabase` (the reparse tests) constructs them with `database: db` (Task 1 adds that parameter). + +## File structure + +| File | Responsibility after this plan | +|---|---| +| `lib/core/data/repositories/sync_repository.dart` | gains `SyncRepository({AppDatabase? database})` so a repository bound to a private database stamps hlc into that database | +| `lib/features/dive_log/data/repositories/profile_series_repository.dart` | gains `database:` injection, stable sort in `insertSeries`, `hasAnySeries`, `ownsAny`, `adoptUnattributed`, `deleteByComputer`, `deleteByIds`, `getRowsForDives` | +| `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart` | gains `database:` injection, stable sort, `getRowsForDives`, `reassignTank`, `swapTanks`, `stampComputerWhereNull`, `deleteByIds` | +| `lib/features/dive_log/domain/codecs/profile_sample.dart`, `tank_pressure_series_codec.dart` | gain `shiftedBy(int seconds)` on the sample types (merge and consolidation re-base timestamps) | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart` | `createDive`, `saveEditedProfile`, `restoreOriginalProfile`, `_adoptUnattributedProfiles`, `setPrimaryDataSource` write series; `_sourceOwnsProfiles`, `_promoteProfilesOwnedBySource`, `_ownedBySourceSql`, `_ownershipVars` deleted | +| `lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart` | `importProfile`, `setPrimaryProfile`, `clearSourceAndProfiles` write series; the per-sample `markRecordPending` loop goes | +| `lib/features/dive_log/data/repositories/tank_pressure_repository.dart` | `insertTankPressures`, `deleteTankPressuresForDive`, `replaceTankPressures`, `reassignTankPressureSeries`, `swapTankPressureSeries` write series | +| `lib/features/dive_computer/data/services/reparse_service.dart` | `_replaceDiveProfiles`, the tank-pressure clear, `_replaceTankPressureProfiles` write series through repositories bound to `db` | +| `lib/features/dive_log/data/services/dive_split_service.dart` | selects, copies and deletes series instead of rows | +| `lib/features/dive_log/data/services/dive_merge_snapshot.dart` | captures `profileSeriesRows` and `tankSeriesRows` (raw rows, restored verbatim) beside the legacy row lists, which stay until plan 2e | +| `lib/features/dive_log/data/services/dive_merge_service.dart` | re-bases series into the merged dive, fills gaps into the adjacent series, undo restores series rows | +| `lib/features/dive_log/data/services/dive_consolidation_service.dart` | stamps, copies and restores series | + +--- + +### Task 1: Repository operations the writers need + +**Files:** +- Modify: `lib/core/data/repositories/sync_repository.dart` (constructor and `_db` getter only) +- Modify: `lib/features/dive_log/data/repositories/profile_series_repository.dart` +- Modify: `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart` +- Modify: `lib/features/dive_log/domain/codecs/profile_sample.dart` (`shiftedBy`) +- Modify: `lib/features/dive_log/domain/codecs/tank_pressure_series_codec.dart` (`TankPressureSample.shiftedBy`) +- Test: `test/features/dive_log/data/repositories/profile_series_repository_writers_test.dart` (new) +- Test: `test/features/dive_log/data/repositories/tank_pressure_series_repository_writers_test.dart` (new) +- Test: `test/features/dive_log/domain/codecs/sample_shift_test.dart` (new) + +**Interfaces:** +- Consumes: the existing repositories (`insertSeries`, `_ownedBy`, `_ids`, `_delete`, `_markPending`, `_setPrimary` in the profile repository; `insertSeries`, `_delete` in the tank repository), `SyncRepository.markRecordPending`, `dedupeExactSamples`, `dedupeExactPressureSamples`. +- Produces (every later task relies on these exact names): + - `SyncRepository({AppDatabase? database})` + - `ProfileSeriesRepository({SyncRepository? syncRepository, AppDatabase? database})` + - `Future ProfileSeriesRepository.hasAnySeries(String diveId)` + - `Future ProfileSeriesRepository.ownsAny(String diveId, {required String? sourceId, required String? computerId})` + - `Future ProfileSeriesRepository.adoptUnattributed(String diveId, String sourceId, {int? now})` + - `Future> ProfileSeriesRepository.deleteByComputer(String diveId, String? computerId)` + - `Future> ProfileSeriesRepository.deleteByIds(List ids)` + - `Future> ProfileSeriesRepository.getRowsForDives(List diveIds)` + - `TankPressureSeriesRepository({SyncRepository? syncRepository, AppDatabase? database})` + - `Future> TankPressureSeriesRepository.getRowsForDives(List diveIds)` + - `Future TankPressureSeriesRepository.reassignTank(String diveId, String fromTankId, String toTankId, {int? now})` + - `Future TankPressureSeriesRepository.swapTanks(String diveId, String tankIdA, String tankIdB, {int? now})` + - `Future TankPressureSeriesRepository.stampComputerWhereNull(String diveId, String computerId, {int? now})` + - `Future> TankPressureSeriesRepository.deleteByIds(List ids)` + - `ProfileSample ProfileSample.shiftedBy(int seconds)`, `TankPressureSample TankPressureSample.shiftedBy(int seconds)` + - `insertSeries` on both repositories sorts by timestamp (stable) before dedupe and encode. + +- [ ] **Step 1: Write the failing tests** + +`test/features/dive_log/domain/codecs/sample_shift_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; + +void main() { + test('ProfileSample.shiftedBy moves only the timestamp', () { + const s = ProfileSample(timestamp: 10, depth: 5.0, temperature: 21.0); + final t = s.shiftedBy(90); + expect(t.timestamp, 100); + expect(t.depth, 5.0); + expect(t.temperature, 21.0); + expect(s.timestamp, 10); + }); + + test('TankPressureSample.shiftedBy moves only the timestamp', () { + const s = TankPressureSample(timestamp: 10, pressure: 200.0); + final t = s.shiftedBy(-5); + expect(t.timestamp, 5); + expect(t.pressure, 200.0); + }); +} +``` + +`test/features/dive_log/data/repositories/profile_series_repository_writers_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late ProfileSeriesRepository repo; + + Future seedParents(AppDatabase target) async { + final now = DateTime.now().millisecondsSinceEpoch; + await target + .into(target.dives) + .insert( + DivesCompanion.insert( + id: 'dive-1', + diveDateTime: now, + createdAt: now, + updatedAt: now, + ), + ); + await target + .into(target.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: 'comp-1', + name: 'Comp 1', + createdAt: now, + updatedAt: now, + ), + ); + await target + .into(target.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: 'src-1', + diveId: 'dive-1', + computerId: const Value('comp-1'), + isPrimary: const Value(true), + importedAt: DateTime.fromMillisecondsSinceEpoch(now), + createdAt: DateTime.fromMillisecondsSinceEpoch(now), + ), + ); + } + + setUp(() async { + db = await setUpTestDatabase(); + await seedParents(db); + repo = ProfileSeriesRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(db); + }); + + test('insertSeries sorts by timestamp and keeps input order for ties', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + samples: const [ + ProfileSample(timestamp: 20, depth: 2.0), + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 10, depth: 1.0), + ProfileSample(timestamp: 10, depth: 1.5), + ], + now: 1000, + ); + final series = await repo.getSeriesById(id); + expect( + series!.samples.map((s) => (s.timestamp, s.depth)).toList(), + [(0, 0.0), (10, 1.0), (10, 1.5), (20, 2.0)], + ); + }); + + test('hasAnySeries is false before and true after an insert', () async { + expect(await repo.hasAnySeries('dive-1'), isFalse); + await repo.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + expect(await repo.hasAnySeries('dive-1'), isTrue); + }); + + test('ownsAny follows the FK first, then the null-source computer rule', () async { + await repo.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: null, + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + expect( + await repo.ownsAny('dive-1', sourceId: 'src-1', computerId: 'comp-1'), + isTrue, + ); + expect( + await repo.ownsAny('dive-1', sourceId: 'src-other', computerId: 'comp-x'), + isFalse, + ); + expect( + await repo.ownsAny('dive-1', sourceId: 'src-other', computerId: null), + isFalse, + ); + }); + + test('adoptUnattributed stamps only null-source series and restamps hlc', () async { + final orphan = await repo.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + final owned = await repo.insertSeries( + diveId: 'dive-1', + sourceId: 'src-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: 1000, + ); + final before = (await db.select(db.diveProfileSeries).get()) + .firstWhere((r) => r.id == orphan) + .hlc; + expect(await repo.adoptUnattributed('dive-1', 'src-1', now: 2000), 1); + final rows = await db.select(db.diveProfileSeries).get(); + final adopted = rows.firstWhere((r) => r.id == orphan); + expect(adopted.sourceId, 'src-1'); + expect(adopted.updatedAt, 2000); + expect(adopted.hlc, isNot(before)); + expect(rows.firstWhere((r) => r.id == owned).sourceId, 'src-1'); + expect(await repo.adoptUnattributed('dive-1', 'src-1'), 0); + }); + + test('deleteByComputer matches the computer or the null-computer series', () async { + final byComp = await repo.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + final manual = await repo.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: 1000, + ); + expect(await repo.deleteByComputer('dive-1', 'comp-1'), [byComp]); + expect(await repo.deleteByComputer('dive-1', null), [manual]); + expect(await repo.getSeriesForDive('dive-1'), isEmpty); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.map((t) => t.recordId).toSet(), {byComp, manual}); + expect(tombstones.map((t) => t.entityType).toSet(), {'diveProfileSeries'}); + }); + + test('deleteByIds deletes exactly the ids given and tolerates an empty list', () async { + final a = await repo.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + final b = await repo.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: 1000, + ); + expect(await repo.deleteByIds(const []), isEmpty); + expect(await repo.deleteByIds([a]), [a]); + expect((await repo.getSeriesForDive('dive-1')).map((s) => s.id), [b]); + }); + + test('getRowsForDives returns raw rows for the given dives only', () async { + final a = await repo.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + expect(await repo.getRowsForDives(const []), isEmpty); + final rows = await repo.getRowsForDives(['dive-1', 'dive-none']); + expect(rows.map((r) => r.id), [a]); + expect(rows.single.samples, isNotEmpty); + }); + + test('a repository bound to a private database never touches the global one', () async { + final private = AppDatabase(NativeDatabase.memory()); + addTearDown(private.close); + await seedParents(private); + final bound = ProfileSeriesRepository( + database: private, + syncRepository: SyncRepository(database: private), + ); + final id = await bound.insertSeries( + diveId: 'dive-1', + samples: const [ProfileSample(timestamp: 0, depth: 3.0)], + now: 1000, + ); + expect((await bound.getSeriesForDive('dive-1')).map((s) => s.id), [id]); + expect(await repo.getSeriesForDive('dive-1'), isEmpty); + final privateRow = (await private.select(private.diveProfileSeries).get()).single; + expect(privateRow.hlc, isNotNull); + }); +} +``` + +`test/features/dive_log/data/repositories/tank_pressure_series_repository_writers_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late TankPressureSeriesRepository repo; + + setUp(() async { + db = await setUpTestDatabase(); + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.dives) + .insert( + DivesCompanion.insert( + id: 'dive-1', + diveDateTime: now, + createdAt: now, + updatedAt: now, + ), + ); + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: 'comp-1', + name: 'Comp 1', + createdAt: now, + updatedAt: now, + ), + ); + for (final tank in ['tank-a', 'tank-b']) { + await db + .into(db.diveTanks) + .insert(DiveTanksCompanion.insert(id: tank, diveId: 'dive-1')); + } + repo = TankPressureSeriesRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(db); + }); + + test('insertSeries sorts by timestamp and keeps input order for ties', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: const [ + TankPressureSample(timestamp: 60, pressure: 180.0), + TankPressureSample(timestamp: 0, pressure: 200.0), + TankPressureSample(timestamp: 60, pressure: 181.0), + ], + now: 1000, + ); + final series = (await repo.getSeriesForDive('dive-1')).single; + expect(series.id, id); + expect( + series.samples.map((s) => (s.timestamp, s.pressure)).toList(), + [(0, 200.0), (60, 180.0), (60, 181.0)], + ); + }); + + test('reassignTank moves every series of the tank and restamps hlc', () async { + final id = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], + now: 1000, + ); + final before = (await db.select(db.tankPressureSeries).get()).single.hlc; + expect(await repo.reassignTank('dive-1', 'tank-a', 'tank-b', now: 2000), 1); + final row = (await db.select(db.tankPressureSeries).get()).single; + expect(row.id, id); + expect(row.tankId, 'tank-b'); + expect(row.updatedAt, 2000); + expect(row.hlc, isNot(before)); + expect(await repo.reassignTank('dive-1', 'tank-a', 'tank-b'), 0); + }); + + test('swapTanks exchanges the tank ids of both sets', () async { + final a = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], + now: 1000, + ); + final b = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-b', + samples: const [TankPressureSample(timestamp: 0, pressure: 100.0)], + now: 1000, + ); + await repo.swapTanks('dive-1', 'tank-a', 'tank-b', now: 2000); + final rows = await db.select(db.tankPressureSeries).get(); + expect(rows.firstWhere((r) => r.id == a).tankId, 'tank-b'); + expect(rows.firstWhere((r) => r.id == b).tankId, 'tank-a'); + }); + + test('stampComputerWhereNull touches only null-computer series', () async { + final manual = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], + now: 1000, + ); + final owned = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-b', + computerId: 'comp-1', + samples: const [TankPressureSample(timestamp: 0, pressure: 100.0)], + now: 1000, + ); + expect(await repo.stampComputerWhereNull('dive-1', 'comp-1', now: 2000), 1); + final rows = await db.select(db.tankPressureSeries).get(); + expect(rows.firstWhere((r) => r.id == manual).computerId, 'comp-1'); + expect(rows.firstWhere((r) => r.id == manual).updatedAt, 2000); + expect(rows.firstWhere((r) => r.id == owned).updatedAt, 1000); + }); + + test('deleteByIds and getRowsForDives', () async { + final a = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-a', + samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], + now: 1000, + ); + final b = await repo.insertSeries( + diveId: 'dive-1', + tankId: 'tank-b', + samples: const [TankPressureSample(timestamp: 0, pressure: 100.0)], + now: 1000, + ); + expect((await repo.getRowsForDives(['dive-1'])).map((r) => r.id), [a, b]); + expect(await repo.getRowsForDives(const []), isEmpty); + expect(await repo.deleteByIds(const []), isEmpty); + expect(await repo.deleteByIds([a]), [a]); + expect((await repo.getRowsForDives(['dive-1'])).map((r) => r.id), [b]); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.single.entityType, 'tankPressureSeries'); + expect(tombstones.single.recordId, a); + }); +} +``` + +Check the exact `DivesCompanion.insert` / `DiveComputersCompanion.insert` / `DiveDataSourcesCompanion.insert` / `DiveTanksCompanion.insert` required parameters against `test/features/dive_log/data/repositories/profile_series_repository_test.dart` (2a) and copy its fixture style if the ones above do not compile; the assertions are the contract, the fixture is not. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/dive_log/domain/codecs/sample_shift_test.dart`, then the two repository files. +Expected: compile errors naming `shiftedBy`, `hasAnySeries`, `ownsAny`, `adoptUnattributed`, `deleteByComputer`, `deleteByIds`, `getRowsForDives`, `reassignTank`, `swapTanks`, `stampComputerWhereNull`, the `database:` parameters. + +- [ ] **Step 3: Implement** + +`lib/core/data/repositories/sync_repository.dart`: replace the `_db` getter with + +```dart + SyncRepository({AppDatabase? database}) : _database = database; + + final AppDatabase? _database; + AppDatabase get _db => _database ?? DatabaseService.instance.database; +``` + +(keep every other field; if the class already declares an explicit constructor, extend it with the named parameter instead of adding a second one). + +`lib/features/dive_log/domain/codecs/profile_sample.dart`: add to `ProfileSample` + +```dart + /// The same sample [seconds] later (negative moves it earlier). Merge and + /// consolidation re-base a segment's samples onto the combined timeline. + ProfileSample shiftedBy(int seconds) => ProfileSample( + timestamp: timestamp + seconds, + depth: depth, + pressure: pressure, + temperature: temperature, + heartRate: heartRate, + ascentRate: ascentRate, + ceiling: ceiling, + ndl: ndl, + setpoint: setpoint, + ppO2: ppO2, + o2Sensor1: o2Sensor1, + o2Sensor2: o2Sensor2, + o2Sensor3: o2Sensor3, + o2Sensor4: o2Sensor4, + o2Sensor5: o2Sensor5, + o2Sensor6: o2Sensor6, + cns: cns, + tts: tts, + rbt: rbt, + decoType: decoType, + heartRateSource: heartRateSource, + heading: heading, + o2SensorMv1: o2SensorMv1, + o2SensorMv2: o2SensorMv2, + o2SensorMv3: o2SensorMv3, + o2SensorMv4: o2SensorMv4, + o2SensorMv5: o2SensorMv5, + o2SensorMv6: o2SensorMv6, + ); +``` + +(name every one of the 28 fields; check the constructor's parameter names in the file and match them exactly). + +`lib/features/dive_log/domain/codecs/tank_pressure_series_codec.dart`, on `TankPressureSample`: + +```dart + TankPressureSample shiftedBy(int seconds) => + TankPressureSample(timestamp: timestamp + seconds, pressure: pressure); +``` + +`lib/features/dive_log/data/repositories/profile_series_repository.dart`: + +```dart + ProfileSeriesRepository({ + SyncRepository? syncRepository, + AppDatabase? database, + }) : _syncRepository = syncRepository ?? SyncRepository(database: database), + _database = database; + + final AppDatabase? _database; + AppDatabase get _db => _database ?? DatabaseService.instance.database; +``` + +In `insertSeries`, replace `_codec.encode(dedupeExactSamples(samples))` with `_codec.encode(dedupeExactSamples(_sortedByTimestamp(samples)))` and add + +```dart + /// Timestamp order, ties in input order. Every writer hands over whatever + /// order it has; the codec and every reader assume ascending timestamps. + static List _sortedByTimestamp(List samples) { + final indexed = [for (var i = 0; i < samples.length; i++) (samples[i].timestamp, i)]; + indexed.sort((a, b) { + final byTime = a.$1.compareTo(b.$1); + return byTime != 0 ? byTime : a.$2.compareTo(b.$2); + }); + return [for (final e in indexed) samples[e.$2]]; + } +``` + +New public methods (place them near the related existing ones): + +```dart + /// Whether [diveId] has any series row, primary or not. The writers use it + /// where the legacy code counted `dive_profiles` rows. + Future hasAnySeries(String diveId) async { + final count = await (_db.selectOnly(_db.diveProfileSeries) + ..addColumns([_db.diveProfileSeries.id.count()]) + ..where(_db.diveProfileSeries.diveId.equals(diveId))) + .map((row) => row.read(_db.diveProfileSeries.id.count())) + .getSingle(); + return (count ?? 0) > 0; + } + + /// Whether the source identified by [sourceId] / [computerId] owns at least + /// one series of [diveId]: the FK first, then the legacy null-source + /// computer rule (the same predicate every ownership write uses). + Future ownsAny( + String diveId, { + required String? sourceId, + required String? computerId, + }) async { + final count = await (_db.selectOnly(_db.diveProfileSeries) + ..addColumns([_db.diveProfileSeries.id.count()]) + ..where( + _db.diveProfileSeries.diveId.equals(diveId) & + _ownedBy( + _db.diveProfileSeries, + sourceId: sourceId, + computerId: computerId, + ), + )) + .map((row) => row.read(_db.diveProfileSeries.id.count())) + .getSingle(); + return (count ?? 0) > 0; + } + + /// Stamps [sourceId] on every series of [diveId] that has no source yet + /// (the first `dive_data_sources` row of a dive adopts the unattributed + /// profile, issue #1149). Returns the number of series stamped. + Future adoptUnattributed( + String diveId, + String sourceId, { + int? now, + }) async { + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + final ids = await _ids( + (t) => t.diveId.equals(diveId) & t.sourceId.isNull(), + ); + if (ids.isEmpty) return 0; + await _db.transaction(() async { + await (_db.update(_db.diveProfileSeries)..where((t) => t.id.isIn(ids))) + .write( + DiveProfileSeriesCompanion( + sourceId: Value(sourceId), + updatedAt: Value(nowMs), + ), + ); + for (final id in ids) { + await _markPending(id, nowMs); + } + }); + return ids.length; + } + + /// Deletes the series [computerId] contributed to [diveId]; a null + /// [computerId] matches the null-computer (manual or file) series only. + /// One tombstone per series. Returns the deleted ids. + Future> deleteByComputer(String diveId, String? computerId) => + _delete( + (t) => + t.diveId.equals(diveId) & + (computerId == null + ? t.computerId.isNull() + : t.computerId.equals(computerId)), + ); + + /// Deletes exactly [ids], one tombstone each. Empty input is a no-op. + Future> deleteByIds(List ids) => + ids.isEmpty ? Future.value(const []) : _delete((t) => t.id.isIn(ids)); + + /// Raw rows of every series of [diveIds], undecoded, for snapshots that + /// restore them verbatim through [restoreSeriesRow]. + Future> getRowsForDives( + List diveIds, + ) async { + if (diveIds.isEmpty) return const []; + return (_db.select(_db.diveProfileSeries) + ..where((t) => t.diveId.isIn(diveIds)) + ..orderBy([ + (t) => OrderingTerm.asc(t.diveId), + (t) => OrderingTerm.asc(t.startTimestamp), + (t) => OrderingTerm.asc(t.id), + ])) + .get(); + } +``` + +`lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart`: the same constructor shape (`{SyncRepository? syncRepository, AppDatabase? database}`, `_database`, `_db` getter); in `insertSeries` sort before dedupe with a `_sortedByTimestamp` twin over `TankPressureSample`; and + +```dart + Future> getRowsForDives( + List diveIds, + ) async { + if (diveIds.isEmpty) return const []; + return (_db.select(_db.tankPressureSeries) + ..where((t) => t.diveId.isIn(diveIds)) + ..orderBy([ + (t) => OrderingTerm.asc(t.diveId), + (t) => OrderingTerm.asc(t.tankId), + (t) => OrderingTerm.asc(t.startTimestamp), + (t) => OrderingTerm.asc(t.id), + ])) + .get(); + } + + /// Points every series of [fromTankId] on [diveId] at [toTankId] (the + /// wrong-cylinder repair). Returns the number of series moved. + Future reassignTank( + String diveId, + String fromTankId, + String toTankId, { + int? now, + }) async { + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + final ids = await _ids( + (t) => t.diveId.equals(diveId) & t.tankId.equals(fromTankId), + ); + if (ids.isEmpty) return 0; + await _retarget(ids, toTankId, nowMs); + return ids.length; + } + + /// Exchanges the tank ids of the two series sets (the swapped-cylinders + /// repair). Both sets are read before either is written. + Future swapTanks( + String diveId, + String tankIdA, + String tankIdB, { + int? now, + }) async { + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + final aIds = await _ids( + (t) => t.diveId.equals(diveId) & t.tankId.equals(tankIdA), + ); + final bIds = await _ids( + (t) => t.diveId.equals(diveId) & t.tankId.equals(tankIdB), + ); + await _db.transaction(() async { + if (aIds.isNotEmpty) await _retarget(aIds, tankIdB, nowMs); + if (bIds.isNotEmpty) await _retarget(bIds, tankIdA, nowMs); + }); + } + + /// Stamps [computerId] on the null-computer series of [diveId] + /// (consolidation gives the target's unattributed pressures to its primary + /// computer). Returns the number of series stamped. + Future stampComputerWhereNull( + String diveId, + String computerId, { + int? now, + }) async { + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + final ids = await _ids( + (t) => t.diveId.equals(diveId) & t.computerId.isNull(), + ); + if (ids.isEmpty) return 0; + await _db.transaction(() async { + await (_db.update(_db.tankPressureSeries)..where((t) => t.id.isIn(ids))) + .write( + TankPressureSeriesCompanion( + computerId: Value(computerId), + updatedAt: Value(nowMs), + ), + ); + for (final id in ids) { + await _markPending(id, nowMs); + } + }); + return ids.length; + } + + Future> deleteByIds(List ids) => + ids.isEmpty ? Future.value(const []) : _delete((t) => t.id.isIn(ids)); + + Future _retarget(List ids, String tankId, int nowMs) async { + await _db.transaction(() async { + await (_db.update(_db.tankPressureSeries)..where((t) => t.id.isIn(ids))) + .write( + TankPressureSeriesCompanion( + tankId: Value(tankId), + updatedAt: Value(nowMs), + ), + ); + for (final id in ids) { + await _markPending(id, nowMs); + } + }); + } + + Future> _ids( + Expression Function($TankPressureSeriesTable t) where, + ) async { + final rows = await (_db.selectOnly(_db.tankPressureSeries) + ..addColumns([_db.tankPressureSeries.id]) + ..where(where(_db.tankPressureSeries))) + .get(); + return [for (final r in rows) r.read(_db.tankPressureSeries.id)!]; + } +``` + +If the tank repository already has a `_markPending` or `_ids` helper, reuse it rather than adding a second. The profile repository's `_markPending` and `_ids` exist already. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run the three new files, then the existing `profile_series_repository_test.dart`, `profile_series_repository_restore_test.dart`, `tank_pressure_series_repository_test.dart`, `profile_series_codec_test.dart` (find it under `test/features/dive_log/domain/codecs/`), and `test/core/data/repositories/sync_repository_test.dart` if it exists. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/data/repositories/sync_repository.dart lib/features/dive_log/data/repositories/profile_series_repository.dart lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart lib/features/dive_log/domain/codecs/profile_sample.dart lib/features/dive_log/domain/codecs/tank_pressure_series_codec.dart test/features/dive_log/data/repositories/profile_series_repository_writers_test.dart test/features/dive_log/data/repositories/tank_pressure_series_repository_writers_test.dart test/features/dive_log/domain/codecs/sample_shift_test.dart +git commit -m "feat(series): writer operations on the series repositories, database injection, stable sort on insert" +``` + +--- + +### Task 2: DiveRepository writers (createDive, saveEditedProfile, restoreOriginalProfile, saveComputerReading, setPrimaryDataSource) + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`createDive` profile batch; `saveEditedProfile`; `restoreOriginalProfile`; `_adoptUnattributedProfiles`; `setPrimaryDataSource`; delete `_sourceOwnsProfiles`, `_promoteProfilesOwnedBySource`, `_ownedBySourceSql`, `_ownershipVars`) +- Test: `test/features/dive_log/data/repositories/dive_repository_series_writes_test.dart` (new) +- Modify tests (rules R1 to R6): `test/features/dive_log/data/repositories/dive_repository_test.dart`, `dive_repository_new_methods_test.dart` (legacy helper `insertTestProfile` near line 162), `edited_profile_supersedes_originals_test.dart`, `dive_consolidation_test.dart` (helper `insertTestProfile` near line 118; the recursive `INSERT INTO dive_profiles` test near line 1305), `test/features/dive_log/integration/multi_computer_integration_test.dart`, `dive_computer_data_repository_test.dart`, `dive_repository_error_test.dart`. + +**Interfaces:** +- Consumes (Task 1 and earlier): `ProfileSeriesRepository.insertSeries({diveId, computerId, sourceId, isPrimary, samples, id, now})`, `demoteAll(diveId, {now})`, `deleteEditedSeries(diveId)`, `promoteByComputer(diveId, computerId, {now})`, `promoteAll(diveId, {now})`, `promoteWinnerOwnedBy(diveId, {sourceId, computerId, now})`, `ownsAny(diveId, {sourceId, computerId})`, `adoptUnattributed(diveId, sourceId, {now})`; `profileSampleFromPoint(DiveProfilePoint point, {double? pressure})` from `codecs/profile_sample_point.dart`; the existing `_profileSeries` field (plan 2b). +- Produces: nothing new for later tasks; `_sourceOwnsProfiles`, `_promoteProfilesOwnedBySource`, `_ownedBySourceSql`, `_ownershipVars` are GONE (grep the file and the tests for other callers first; if `_ownedBySourceSql` is used by a read that plan 2d owns, leave that read alone and delete only what becomes unused). + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_log/data/repositories/dive_repository_series_writes_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' as domain; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveRepository dives; + late ProfileSeriesRepository series; + + Future computer(String id) async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: id, + name: 'Comp $id', + createdAt: now, + updatedAt: now, + ), + ); + } + + Future source( + String id, + String diveId, + String? computerId, { + bool primary = false, + }) async { + final now = DateTime.now(); + await db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: id, + diveId: diveId, + computerId: Value(computerId), + isPrimary: Value(primary), + importedAt: now, + createdAt: now, + ), + ); + } + + setUp(() async { + db = await setUpTestDatabase(); + dives = DiveRepository(); + series = ProfileSeriesRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(db); + }); + + domain.Dive dive(String id, List profile) => + domain.Dive(id: id, dateTime: DateTime(2026, 1, 1), profile: profile); + + test('createDive writes one primary null-identity series and no legacy rows', () async { + await dives.createDive( + dive('dive-1', const [ + domain.DiveProfilePoint(timestamp: 10, depth: 5.0), + domain.DiveProfilePoint(timestamp: 0, depth: 0.0), + ]), + ); + final rows = await series.getSeriesForDive('dive-1'); + expect(rows, hasLength(1)); + expect(rows.single.isPrimary, isTrue); + expect(rows.single.computerId, isNull); + expect(rows.single.sourceId, isNull); + expect(rows.single.samples.map((s) => s.timestamp), [0, 10]); + expect(await db.select(db.diveProfiles).get(), isEmpty); + }); + + test('createDive with no profile writes no series row', () async { + await dives.createDive(dive('dive-1', const [])); + expect(await series.getSeriesForDive('dive-1'), isEmpty); + }); + + test('saveEditedProfile demotes every series and inserts the edit under the primary source', () async { + await dives.createDive(dive('dive-1', const [domain.DiveProfilePoint(timestamp: 0, depth: 1.0)])); + await computer('comp-1'); + await source('src-1', 'dive-1', 'comp-1', primary: true); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: 1000, + ); + await dives.saveEditedProfile('dive-1', const [ + domain.DiveProfilePoint(timestamp: 0, depth: 9.0), + domain.DiveProfilePoint(timestamp: 5, depth: 8.0), + ]); + final rows = await series.getSeriesForDive('dive-1'); + final primary = rows.where((s) => s.isPrimary).toList(); + expect(primary, hasLength(1)); + expect(primary.single.computerId, isNull); + expect(primary.single.sourceId, 'src-1'); + expect(primary.single.samples.map((s) => s.depth), [9.0, 8.0]); + expect(rows.where((s) => !s.isPrimary), hasLength(2)); + expect((await dives.getDiveProfile('dive-1')).map((p) => p.depth), [9.0, 8.0]); + final row = await (db.select(db.dives)..where((t) => t.id.equals('dive-1'))).getSingle(); + expect(row.maxDepth, 9.0); + expect(await db.select(db.diveProfiles).get(), isEmpty); + }); + + test('restoreOriginalProfile deletes the edit and re-promotes the primary computer only', () async { + await computer('comp-1'); + await computer('comp-2'); + await dives.createDive(dive('dive-1', const [])); + await source('src-1', 'dive-1', 'comp-1', primary: true); + await source('src-2', 'dive-1', 'comp-2'); + final a = await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + final b = await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-2', + sourceId: 'src-2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: 1000, + ); + final edit = await series.insertSeries( + diveId: 'dive-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 9.0)], + now: 1000, + ); + await dives.restoreOriginalProfile('dive-1'); + final rows = await series.getSeriesForDive('dive-1'); + expect(rows.map((s) => s.id).toSet(), {a, b}); + expect(rows.firstWhere((s) => s.id == a).isPrimary, isTrue); + expect(rows.firstWhere((s) => s.id == b).isPrimary, isFalse); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.map((t) => t.recordId), [edit]); + }); + + test('restoreOriginalProfile on a single-computer dive promotes everything left', () async { + await dives.createDive(dive('dive-1', const [])); + await series.insertSeries( + diveId: 'dive-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + await series.insertSeries( + diveId: 'dive-1', + isPrimary: false, + samples: const [ProfileSample(timestamp: 5, depth: 2.0)], + now: 1000, + ); + await dives.restoreOriginalProfile('dive-1'); + expect((await series.getSeriesForDive('dive-1')).every((s) => s.isPrimary), isTrue); + }); + + test('saveComputerReading adopts the unattributed series of a single-source dive', () async { + await computer('comp-1'); + await dives.createDive(dive('dive-1', const [domain.DiveProfilePoint(timestamp: 0, depth: 1.0)])); + await dives.saveComputerReading( + DiveDataSourcesCompanion.insert( + id: 'src-1', + diveId: 'dive-1', + computerId: const Value('comp-1'), + isPrimary: const Value(true), + importedAt: DateTime(2026), + createdAt: DateTime(2026), + ), + ); + expect((await series.getSeriesForDive('dive-1')).single.sourceId, 'src-1'); + }); + + test('setPrimaryDataSource promotes the winner series owned by the new primary', () async { + await computer('comp-1'); + await computer('comp-2'); + await dives.createDive(dive('dive-1', const [])); + await source('src-1', 'dive-1', 'comp-1', primary: true); + await source('src-2', 'dive-1', 'comp-2'); + await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + final b = await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-2', + sourceId: 'src-2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: 1000, + ); + await dives.setPrimaryDataSource(diveId: 'dive-1', computerReadingId: 'src-2'); + final rows = await series.getSeriesForDive('dive-1'); + expect(rows.where((s) => s.isPrimary).map((s) => s.id), [b]); + expect((await dives.getDiveProfile('dive-1')).single.depth, 2.0); + }); + + test('setPrimaryDataSource leaves the flags alone when the new primary owns nothing', () async { + await computer('comp-1'); + await computer('comp-2'); + await dives.createDive(dive('dive-1', const [])); + await source('src-1', 'dive-1', 'comp-1', primary: true); + await source('src-2', 'dive-1', 'comp-2'); + final a = await series.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + await dives.setPrimaryDataSource(diveId: 'dive-1', computerReadingId: 'src-2'); + expect((await series.getSeriesForDive('dive-1')).single.id, a); + expect((await series.getSeriesForDive('dive-1')).single.isPrimary, isTrue); + }); +} +``` + +Adapt the `domain.Dive` constructor call to its required parameters (read `lib/features/dive_log/domain/entities/dive.dart`; `dive_repository_series_reads_test.dart` from plan 2b shows a working construction). The assertions are the contract. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_series_writes_test.dart` +Expected: the `createDive` tests fail on `getSeriesForDive` being empty (rows land in `dive_profiles`); the edit/restore/adopt/promote tests fail on flags that never changed. + +- [ ] **Step 3: Move the five sites** + +Add the import `package:submersion/features/dive_log/domain/codecs/profile_sample_point.dart` (for `profileSampleFromPoint`) next to the existing series imports. + +`createDive`: delete the `for (final point in dive.profile) { batch.insert(_db.diveProfiles, ...) }` loop from the child-row batch (the comment above it too). After the batch completes add: + +```dart + // Profile samples: one packed primary series with no computer and no + // source, the same identity the legacy rows carried for a manual dive. + if (dive.profile.isNotEmpty) { + await _profileSeries.insertSeries( + diveId: id, + samples: [for (final point in dive.profile) profileSampleFromPoint(point)], + now: now, + ); + } +``` + +`saveEditedProfile`, inside the existing `_db.transaction`: replace the "Demote all existing profiles" `update(_db.diveProfiles)` statement and the whole `_db.batch` insert with + +```dart + // Demote every series, then the edit becomes the one primary series + // of the dive: no computer (a manual correction), owned by the source + // that was primary at the time (issue #1149). + await _profileSeries.demoteAll(diveId, now: now); + if (editedPoints.isNotEmpty) { + await _profileSeries.insertSeries( + diveId: diveId, + sourceId: primarySource?.id, + isPrimary: true, + samples: [for (final point in editedPoints) profileSampleFromPoint(point)], + now: now, + ); + } +``` + +Keep the `primarySource` lookup, the stats recomputation and everything after the transaction unchanged. + +`restoreOriginalProfile`, inside the existing transaction: keep the legacy `delete(_db.diveProfiles)` of edited rows exactly as it is (rule: legacy deletes stay) and add directly after it `await _profileSeries.deleteEditedSeries(diveId);`. Replace the two `update(_db.diveProfiles)` promote statements with + +```dart + if (primaryComputerId != null) { + // Multi-computer dive: only the previously-primary computer's + // series come back. + await _profileSeries.promoteByComputer(diveId, primaryComputerId); + } else { + // Single-computer dive (or no computer reading): everything left is + // the live profile. + await _profileSeries.promoteAll(diveId); + } +``` + +`_adoptUnattributedProfiles`: replace the `update(_db.diveProfiles)` statement with `await _profileSeries.adoptUnattributed(diveId, reading.id.value);`. + +`setPrimaryDataSource`: replace + +```dart + if (await _sourceOwnsProfiles(diveId, newPrimary)) { + await (_db.update(_db.diveProfiles) + ..where((t) => t.diveId.equals(diveId))) + .write(const DiveProfilesCompanion(isPrimary: Value(false))); + await _promoteProfilesOwnedBySource(diveId, newPrimary); + } +``` + +with + +```dart + // Flip the profile flags only when the new primary owns samples; + // otherwise the current primary profile stays on display (#1149). + if (await _profileSeries.ownsAny( + diveId, + sourceId: newPrimary.id, + computerId: newPrimary.computerId, + )) { + await _profileSeries.demoteAll(diveId, now: now); + await _profileSeries.promoteWinnerOwnedBy( + diveId, + sourceId: newPrimary.id, + computerId: newPrimary.computerId, + now: now, + ); + } +``` + +Then delete `_sourceOwnsProfiles` and `_promoteProfilesOwnedBySource`, and `_ownedBySourceSql` / `_ownershipVars` if nothing else in the file uses them (`grep -n "_ownedBySourceSql\|_ownershipVars" lib/features/dive_log/data/repositories/dive_repository_impl.dart`; if a remaining caller is a READ over `dive_profiles`, leave the constant and the read alone, plan 2d owns them). + +- [ ] **Step 4: Run the new test, then migrate the existing tests** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_series_writes_test.dart` +Expected: PASS. + +Then run each file below and apply the rules: + +- `dive_repository_new_methods_test.dart`: add the `insertTestSeries` twin beside `insertTestProfile` (same parameters: `diveId`, `sourceTag`, `isPrimary`, `timestamp`, `depth`, `computerId`; keep returning the deterministic id by passing `id:`). Switch every test that calls `restoreOriginalProfile`, `setPrimaryDataSource`, `saveComputerReading` or `saveEditedProfile` to the twin (R1) and convert their legacy-row assertions (R2, R3). Tests that only READ after seeding stay on the legacy helper (the 2b fallback covers them until plan 2e). +- `dive_consolidation_test.dart`: same twin beside its `insertTestProfile` (parameters `diveId`, `sourceTag`, `sourceId`, `isPrimary`, `timestamp`, `depth`). Tests calling `setPrimaryDataSource` / `saveComputerReading` / `saveEditedProfile` switch (R1, R2, R3). The 32767-row recursive `INSERT INTO dive_profiles` test (near line 1305) becomes one series: `await ProfileSeriesRepository().insertSeries(diveId: diveId, sourceId: 'reading-b', isPrimary: false, samples: [for (var n = 0; n < sampleCount; n++) ProfileSample(timestamp: n, depth: 10.0)], now: 1000);` and the count assertion becomes `expect((await ProfileSeriesRepository().getSeriesForDive(diveId, primaryOnly: true)).single.samples, hasLength(sampleCount));`. Tests that call `DiveSplitService` stay on the legacy helper until Task 6. +- `edited_profile_supersedes_originals_test.dart`, `multi_computer_integration_test.dart`, `dive_repository_test.dart`, `dive_computer_data_repository_test.dart`, `dive_repository_error_test.dart`: run each; apply R1 to R6 to the tests that exercise the five moved writers; leave the rest. + +Run: each migrated file with `flutter test `, plus `dive_repository_series_reads_test.dart`, `profiles_by_data_source_test.dart`, `profiles_by_data_source_series_test.dart`, `dive_profile_duplicate_rows_test.dart`, `canonical_data_sources_test.dart`, `test/features/dive_log/presentation/providers/dive_providers_test.dart`. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart test/features/dive_log/data/repositories/dive_repository_series_writes_test.dart +git commit -m "feat(series): DiveRepository writes profile series (create, edit, restore, adopt, primary swap)" +``` + +--- + +### Task 3: DiveComputerRepository writers (importProfile, setPrimaryProfile, clearSourceAndProfiles) + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart` (`importProfile`: the `hasProfiles` count, the profile batch, the tank-pressure batch; `setPrimaryProfile`; `clearSourceAndProfiles`) +- Test: `test/features/dive_log/data/repositories/dive_computer_repository_series_writes_test.dart` (new) +- Modify tests (rules R1 to R6): `test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart` (legacy helper `insertProfile` near line 89; the test near line 394 about nulling `dive_profiles` FK references on computer delete is plan 2d's and stays on the legacy helper), `dive_computer_repository_import_attribution_test.dart`, `dive_computer_multi_transmitter_pressure_test.dart`, `test/features/dive_computer/data/services/raw_data_persistence_test.dart` (helpers `insertProfile` / `insertTankPressure` near lines 85 and 125; its "tables are empty after clear" assertions stay (R5) and gain series twins), `replace_source_gear_link_test.dart`, `test/features/dive_computer/data/services/dive_import_service_test.dart`, `dive_computer_repository_error_test.dart`, `dive_computer_altitude_enrichment_test.dart`. + +**Interfaces:** +- Consumes: `ProfileSeriesRepository.hasAnySeries`, `insertSeries`, `demoteAll`, `promoteByComputer`, `deleteByComputer`; `TankPressureSeriesRepository.insertSeries({diveId, tankId, computerId, samples, id, now})`, `deleteForDive`; `groupPressuresByTank` (existing); `ProfilePointData` (existing, this file). +- Produces: a private `static codec.ProfileSample _sampleFromPointData(ProfilePointData p)` in this file; a `_tankSeries` field. Nothing for later tasks. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_log/data/repositories/dive_computer_repository_series_writes_test.dart` (reuse the fixture helpers of `dive_computer_repository_impl_test.dart` for computers and dives; the `importProfile` call shape is in that file too): + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveComputerRepository computers; + late ProfileSeriesRepository series; + late TankPressureSeriesRepository tankSeries; + + Future insertComputer(String id) async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion( + id: Value(id), + name: Value('Computer $id'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + } + + setUp(() async { + db = await setUpTestDatabase(); + computers = DiveComputerRepository(); + series = ProfileSeriesRepository(); + tankSeries = TankPressureSeriesRepository(); + await insertComputer('comp-1'); + await insertComputer('comp-2'); + }); + + tearDown(() async { + await tearDownTestDatabase(db); + }); + + // isPrimary is passed false on purpose: a dive with no series makes the + // import primary regardless (the legacy `hasProfiles == 0` rule). + Future importDive({String computerId = 'comp-1'}) => + computers.importProfile( + computerId: computerId, + profileStartTime: DateTime(2026, 1, 1, 10), + points: const [ + ProfilePointData(timestamp: 30, depth: 10.0, pressure: 190.0, tankIndex: 0), + ProfilePointData(timestamp: 0, depth: 0.0, pressure: 200.0, tankIndex: 0), + ], + durationSeconds: 60, + maxDepth: 10.0, + isPrimary: false, + tanks: const [TankData(index: 0, o2Percent: 21.0)], + ); + + test('a first import writes one primary series owned by the computer and its source, and one tank series', () async { + final diveId = await importDive(); + final rows = await series.getSeriesForDive(diveId); + expect(rows, hasLength(1)); + expect(rows.single.isPrimary, isTrue); + expect(rows.single.computerId, 'comp-1'); + expect(rows.single.sourceId, isNotNull); + expect(rows.single.samples.map((s) => s.timestamp), [0, 30]); + expect(await db.select(db.diveProfiles).get(), isEmpty); + final tanks = await tankSeries.getSeriesForDive(diveId); + expect(tanks, hasLength(1)); + expect(tanks.single.computerId, 'comp-1'); + expect(tanks.single.samples.map((s) => s.pressure), [200.0, 190.0]); + expect(await db.select(db.tankPressureProfiles).get(), isEmpty); + }); + + test('setPrimaryProfile flips the flags by computer and writes no tombstone', () async { + final diveId = await importDive(); + final second = await series.insertSeries( + diveId: diveId, + computerId: 'comp-2', + isPrimary: false, + samples: const [ProfileSample(timestamp: 0, depth: 2.0)], + now: 1000, + ); + await computers.setPrimaryProfile(diveId, 'comp-2'); + final rows = await series.getSeriesForDive(diveId); + expect(rows.firstWhere((s) => s.id == second).isPrimary, isTrue); + expect(rows.firstWhere((s) => s.id != second).isPrimary, isFalse); + expect(await db.select(db.deletionLog).get(), isEmpty); + }); + + test('clearSourceAndProfiles deletes the computer profile series and every tank series of the dive', () async { + final diveId = await importDive(); + final imported = (await series.getSeriesForDive(diveId)).single.id; + final tank = (await tankSeries.getSeriesForDive(diveId)).single.id; + final edit = await series.insertSeries( + diveId: diveId, + samples: const [ProfileSample(timestamp: 0, depth: 9.0)], + now: 1000, + ); + await computers.clearSourceAndProfiles(diveId: diveId, computerId: 'comp-1'); + expect((await series.getSeriesForDive(diveId)).map((s) => s.id), [edit]); + expect(await tankSeries.getSeriesForDive(diveId), isEmpty); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.map((t) => t.recordId).toSet(), {imported, tank}); + expect(await db.select(db.diveProfiles).get(), isEmpty); + expect(await db.select(db.tankPressureProfiles).get(), isEmpty); + }); +} +``` + +Imports the file needs: `package:drift/drift.dart` (hide `isNull`), `flutter_test`, `database.dart`, `dive_computer_repository_impl.dart` (`DiveComputerRepository`, `ProfilePointData`, `TankData`), the two series repositories, `codecs/profile_sample.dart`, `codecs/tank_pressure_series_codec.dart`, `../../../../helpers/test_database.dart`. If `TankData` needs more required parameters than `index` and `o2Percent`, copy the construction from `dive_computer_repository_impl_test.dart`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/dive_computer_repository_series_writes_test.dart` +Expected: fails on empty series lists and on `dive_profiles` / `tank_pressure_profiles` not being empty. + +- [ ] **Step 3: Move the three sites** + +Imports: `import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart' as codec;`, `import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart' show TankPressureSample;`, `import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart';`. Field: `final TankPressureSeriesRepository _tankSeries = TankPressureSeriesRepository();` next to `_profileSeries`. + +`importProfile`: +- Replace the `hasProfiles` `customSelect` count with `final hadSeries = await _profileSeries.hasAnySeries(diveId); if (!hadSeries) { isPrimary = true; }`. +- Replace the profile `_db.batch` with + +```dart + if (points.isNotEmpty) { + await _profileSeries.insertSeries( + diveId: diveId, + computerId: computerId, + sourceId: ownerSourceId, + isPrimary: isPrimary, + samples: [for (final point in points) _sampleFromPointData(point)], + ); + } +``` + +- Replace the tank-pressure `_db.batch` (keep `pressuresByTank`, `insertEntries` and the log loop) with + +```dart + for (final entry in insertEntries) { + if (entry.value.isEmpty) continue; + await _tankSeries.insertSeries( + diveId: diveId, + tankId: tankIdsByIndex[entry.key]!, + computerId: computerId, + samples: [ + for (final point in entry.value) + TankPressureSample( + timestamp: point.timestamp, + pressure: point.pressure, + ), + ], + ); + } +``` + +- Add the mapper (a private static method of the class, or a top-level private function): + +```dart + static codec.ProfileSample _sampleFromPointData(ProfilePointData p) => + codec.ProfileSample( + timestamp: p.timestamp, + depth: p.depth, + temperature: p.temperature, + heartRate: p.heartRate, + heading: p.heading, + setpoint: p.setpoint, + ppO2: p.ppO2, + cns: p.cns, + ndl: p.ndl, + ceiling: p.ceiling, + ascentRate: p.ascentRate, + rbt: p.rbt, + decoType: p.decoType, + tts: p.tts, + o2Sensor1: p.o2Sensor1, + o2Sensor2: p.o2Sensor2, + o2Sensor3: p.o2Sensor3, + o2Sensor4: p.o2Sensor4, + o2Sensor5: p.o2Sensor5, + o2Sensor6: p.o2Sensor6, + o2SensorMv1: p.o2SensorMv1, + o2SensorMv2: p.o2SensorMv2, + o2SensorMv3: p.o2SensorMv3, + o2SensorMv4: p.o2SensorMv4, + o2SensorMv5: p.o2SensorMv5, + o2SensorMv6: p.o2SensorMv6, + ); +``` + +(the legacy row left `pressure` null; per-sample pressure lives in the tank series.) + +`setPrimaryProfile`: replace the two `customStatement` updates and the `for (final profile in profiles) markRecordPending(entityType: 'diveProfiles', ...)` loop (and its `profiles` select) with + +```dart + await _profileSeries.demoteAll(diveId, now: now); + await _profileSeries.promoteByComputer(diveId, computerId, now: now); +``` + +Keep the `dives.updatedAt` write, the `dives` `markRecordPending`, the notify and the logging. + +`clearSourceAndProfiles`: keep all five legacy `customStatement` deletes. Directly after the `tank_pressure_profiles` delete add `await _tankSeries.deleteForDive(diveId);`; directly after the `dive_profiles` delete add `await _profileSeries.deleteByComputer(diveId, computerId);`. + +- [ ] **Step 4: Run the new test, then migrate the existing tests** + +Run the new file: PASS. + +Then, per file: add the series twins beside the legacy helpers (R1); switch every test that calls `importProfile`, `setPrimaryProfile` or `clearSourceAndProfiles` and then asserts on `dive_profiles` / `tank_pressure_profiles` rows (R2, R3, R4, R5). In `raw_data_persistence_test.dart` the assertions that the legacy tables are empty after `clearSourceAndProfiles` stay and gain `expect(await tankSeries.getSeriesForDive(diveId), isEmpty)` / profile twins beside them. Leave the computer-delete FK test in `dive_computer_repository_impl_test.dart` on the legacy helper. + +Run: every file in the Files list, plus `dive_computer_series_reads_test.dart`, `dive_computer_repository_error_test.dart`, `test/features/dive_log/integration/multi_computer_integration_test.dart`. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart test/features/dive_log/data/repositories/dive_computer_repository_series_writes_test.dart +git commit -m "feat(series): DiveComputerRepository writes profile and tank series on import, primary swap and clear" +``` + +--- + +### Task 4: TankPressureRepository writers + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/tank_pressure_repository.dart` (`insertTankPressures`, `deleteTankPressuresForDive`, `reassignTankPressureSeries`, `swapTankPressureSeries`; `replaceTankPressures` composes the two and needs no edit) +- Test: `test/features/dive_log/data/repositories/tank_pressure_repository_series_writes_test.dart` (new) +- Verify (no legacy references, should pass unchanged): `test/features/data_quality/repairs/tank_pressure_repairs_test.dart`, `test/features/data_quality/repairs/quality_repair_executor_test.dart`, `test/features/dive_import/data/services/uddf_entity_importer_test.dart`, `test/features/dive_log/presentation/pages/dive_edit_preserves_sac_widget_test.dart`, `test/features/settings/presentation/providers/load_tank_pressures_test.dart`, `test/features/dive_log/data/repositories/tank_pressure_series_reads_test.dart`, `test/integration/uddf_round_trip_test.dart`. + +**Interfaces:** +- Consumes: `TankPressureSeriesRepository.insertSeries`, `deleteForDive`, `reassignTank`, `swapTanks`; `TankPressureSample`. +- Produces: nothing new. The public signatures of the four methods are unchanged (`Map>` in, `void` out). + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late TankPressureRepository repo; + late TankPressureSeriesRepository series; + + setUp(() async { + db = await setUpTestDatabase(); + // seed dive 'dive-1' and tanks 'tank-a', 'tank-b' exactly as + // tank_pressure_series_repository_test.dart does + repo = TankPressureRepository(); + series = TankPressureSeriesRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(db); + }); + + test('insertTankPressures writes one null-computer series per tank and skips empty tanks', () async { + await repo.insertTankPressures('dive-1', { + 'tank-a': [(timestamp: 60, pressure: 180.0), (timestamp: 0, pressure: 200.0)], + 'tank-b': const [], + }); + final rows = await series.getSeriesForDive('dive-1'); + expect(rows, hasLength(1)); + expect(rows.single.tankId, 'tank-a'); + expect(rows.single.computerId, isNull); + expect(rows.single.samples.map((s) => s.pressure), [200.0, 180.0]); + expect(await db.select(db.tankPressureProfiles).get(), isEmpty); + final dive = await (db.select(db.dives)..where((t) => t.id.equals('dive-1'))).getSingle(); + expect(dive.updatedAt, greaterThan(0)); + }); + + test('deleteTankPressuresForDive removes every series with one tombstone each and no legacy tombstones', () async { + final a = await series.insertSeries(diveId: 'dive-1', tankId: 'tank-a', samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], now: 1000); + final b = await series.insertSeries(diveId: 'dive-1', tankId: 'tank-b', samples: const [TankPressureSample(timestamp: 0, pressure: 100.0)], now: 1000); + await repo.deleteTankPressuresForDive('dive-1'); + expect(await series.getSeriesForDive('dive-1'), isEmpty); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.map((t) => t.recordId).toSet(), {a, b}); + expect(tombstones.map((t) => t.entityType).toSet(), {'tankPressureSeries'}); + }); + + test('replaceTankPressures deletes then inserts', () async { + await series.insertSeries(diveId: 'dive-1', tankId: 'tank-a', samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], now: 1000); + await repo.replaceTankPressures('dive-1', { + 'tank-b': [(timestamp: 0, pressure: 150.0)], + }); + final rows = await series.getSeriesForDive('dive-1'); + expect(rows.single.tankId, 'tank-b'); + expect(rows.single.samples.single.pressure, 150.0); + }); + + test('reassignTankPressureSeries and swapTankPressureSeries move series between tanks', () async { + final a = await series.insertSeries(diveId: 'dive-1', tankId: 'tank-a', samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], now: 1000); + await repo.reassignTankPressureSeries(diveId: 'dive-1', fromTankId: 'tank-a', toTankId: 'tank-b'); + expect((await series.getSeriesForTank('dive-1', 'tank-b')).single.id, a); + final b = await series.insertSeries(diveId: 'dive-1', tankId: 'tank-a', samples: const [TankPressureSample(timestamp: 0, pressure: 100.0)], now: 1000); + await repo.swapTankPressureSeries(diveId: 'dive-1', tankIdA: 'tank-a', tankIdB: 'tank-b'); + expect((await series.getSeriesForTank('dive-1', 'tank-a')).single.id, a); + expect((await series.getSeriesForTank('dive-1', 'tank-b')).single.id, b); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/tank_pressure_repository_series_writes_test.dart` +Expected: fails on empty series lists (rows land in `tank_pressure_profiles`). + +- [ ] **Step 3: Move the four sites** + +Add `import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart';` and `import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart' show TankPressureSample;`; field `final TankPressureSeriesRepository _tankSeries = TankPressureSeriesRepository();`. + +`insertTankPressures`: replace the `_db.batch` with + +```dart + for (final entry in pressuresByTank.entries) { + if (entry.value.isEmpty) continue; + await _tankSeries.insertSeries( + diveId: diveId, + tankId: entry.key, + samples: [ + for (final point in entry.value) + TankPressureSample(timestamp: point.timestamp, pressure: point.pressure), + ], + now: now, + ); + } +``` + +Keep the `pressuresByTank.isEmpty` early return, the dive touch, the `dives` `markRecordPending` and the notify. + +`deleteTankPressuresForDive`: delete the `existing` select and the `for (final row in existing) logDeletion(entityType: 'tankPressureProfiles', ...)` loop; keep the legacy `delete(_db.tankPressureProfiles)`; add `await _tankSeries.deleteForDive(diveId);` after it. Keep the touch, mark and notify. + +`reassignTankPressureSeries`: replace the `update(_db.tankPressureProfiles)` with `await _tankSeries.reassignTank(diveId, fromTankId, toTankId, now: now);` keep `_touchDive`. + +`swapTankPressureSeries`: replace the two id selects and two updates with `await _tankSeries.swapTanks(diveId, tankIdA, tankIdB, now: now);` keep `_touchDive`. + +Remove the `_uuid` field if nothing uses it any more (analyze will say). + +- [ ] **Step 4: Run the tests** + +Run the new file and every file in the Verify list, plus `tank_pressure_series_repository_test.dart`. +Expected: all pass. `tank_pressure_repairs_test.dart` seeds through `insertTankPressures`, so it exercises the series path end to end. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/tank_pressure_repository.dart test/features/dive_log/data/repositories/tank_pressure_repository_series_writes_test.dart +git commit -m "feat(series): TankPressureRepository writes tank pressure series" +``` + +--- + +### Task 5: ReparseService writers + +**Files:** +- Modify: `lib/features/dive_computer/data/services/reparse_service.dart` (constructor; `_replaceDiveProfiles`; the tank-pressure clear near line 138; `_replaceTankPressureProfiles`) +- Test: `test/features/dive_computer/data/services/reparse_service_series_test.dart` (new) +- Modify tests (R1 to R6): `test/features/dive_computer/data/services/reparse_service_test.dart` (legacy helper `insertProfile` near line 123, tank seeds near line 1640; it builds its own `AppDatabase(NativeDatabase.memory())` and passes `db:`), `reparse_service_surfacing_test.dart` (no legacy references; verify), `test/features/dive_log/data/services/dive_consolidation_service_test.dart` and `dive_merge_service_test.dart` construct a `ReparseService` too (verify they still pass; their own writers move in Tasks 7 and 8). + +**Interfaces:** +- Consumes: `ProfileSeriesRepository({database, syncRepository})`, `deleteByComputer`, `insertSeries`; `TankPressureSeriesRepository({database, syncRepository})`, `deleteForDive`, `insertSeries`; `SyncRepository({database})`; `libdcRbtToSeconds` (existing); `groupPressuresByTank` (existing); `pigeon.ProfileSample`. +- Produces: `ReparseService({required this.db, this.trimTankPressureAtSurfacing = true, ProfileSeriesRepository? profileSeries, TankPressureSeriesRepository? tankSeries})`; a private `codec.ProfileSample _sampleFromParsed(pigeon.ProfileSample s, int timeOffset)`. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_computer/data/services/reparse_service_series_test.dart`, built on the fixture style of `reparse_service_test.dart` (its own in-memory `AppDatabase`, its `makeParsedDive`, its dive / computer / source seeding; copy those helpers rather than importing the other test file): + +```dart +void main() { + late AppDatabase db; + late ReparseService service; + late ProfileSeriesRepository profileSeries; + late TankPressureSeriesRepository tankSeries; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + service = ReparseService(db: db); + profileSeries = ProfileSeriesRepository( + database: db, + syncRepository: SyncRepository(database: db), + ); + tankSeries = TankPressureSeriesRepository( + database: db, + syncRepository: SyncRepository(database: db), + ); + }); + + tearDown(() => db.close()); + + // Copy insertDive, insertComputer, insertSource and makeParsedDive from + // reparse_service_test.dart verbatim (lines 17 to about 240 of that file). + + Future apply(pigeon.ParsedDive parsed) => service.applyParsedUpdate( + diveId: 'dive-1', + sourceRowId: 'src-1', + parsed: parsed, + descriptorVendor: 'Shearwater', + descriptorProduct: 'Perdix', + descriptorModel: 42, + libdivecomputerVersion: '0.8.0', + ); + + Future seedDive({int? timeOffsetSeconds}) async { + await insertDive('dive-1'); + await insertComputer('comp-1'); + await insertSource( + id: 'src-1', + diveId: 'dive-1', + computerId: 'comp-1', + timeOffsetSeconds: timeOffsetSeconds, + ); + } + + test('applyParsedUpdate replaces the computer series with the parsed samples, offset applied', () async { + await seedDive(timeOffsetSeconds: 5); + final stale = await profileSeries.insertSeries( + diveId: 'dive-1', + computerId: 'comp-1', + sourceId: 'src-1', + samples: const [ProfileSample(timestamp: 0, depth: 99.0)], + now: 1000, + ); + await apply( + makeParsedDive( + samples: [ + pigeon.ProfileSample(timeSeconds: 0, depthMeters: 0.0), + pigeon.ProfileSample(timeSeconds: 30, depthMeters: 12.0), + ], + ), + ); + final rows = await profileSeries.getSeriesForDive('dive-1'); + expect(rows, hasLength(1)); + expect(rows.single.id, isNot(stale)); + expect(rows.single.computerId, 'comp-1'); + expect(rows.single.sourceId, 'src-1'); + expect(rows.single.isPrimary, isTrue); + expect( + rows.single.samples.map((s) => (s.timestamp, s.depth)).toList(), + [(5, 0.0), (35, 12.0)], + ); + expect(await db.select(db.diveProfiles).get(), isEmpty); + final tombstones = await db.select(db.deletionLog).get(); + expect( + tombstones.map((t) => (t.entityType, t.recordId)).toList(), + [('diveProfileSeries', stale)], + ); + }); + + test('a primary single-source reparse replaces the tank pressure series', () async { + await seedDive(); + await db + .into(db.diveTanks) + .insert( + const DiveTanksCompanion( + id: Value('tank-0'), + diveId: Value('dive-1'), + computerId: Value('comp-1'), + tankOrder: Value(0), + o2Percent: Value(32.0), + hePercent: Value(0.0), + tankRole: Value('backGas'), + ), + ); + final stale = await tankSeries.insertSeries( + diveId: 'dive-1', + tankId: 'tank-0', + computerId: 'comp-1', + samples: const [TankPressureSample(timestamp: 0, pressure: 999.0)], + now: 1000, + ); + await apply( + makeParsedDive( + tanks: [pigeon.TankInfo(index: 0, gasMixIndex: 0, volumeLiters: 12.0)], + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 32.0, hePercent: 0.0)], + samples: [ + pigeon.ProfileSample(timeSeconds: 0, depthMeters: 0.0, pressureBar: 200.0, tankIndex: 0), + pigeon.ProfileSample(timeSeconds: 60, depthMeters: 10.0, pressureBar: 150.0, tankIndex: 0), + ], + ), + ); + final rows = await tankSeries.getSeriesForDive('dive-1'); + expect(rows, hasLength(1)); + expect(rows.single.id, isNot(stale)); + expect(rows.single.tankId, 'tank-0'); + expect(rows.single.computerId, 'comp-1'); + expect(rows.single.samples.map((s) => s.pressure), [200.0, 150.0]); + expect(await db.select(db.tankPressureProfiles).get(), isEmpty); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.map((t) => (t.entityType, t.recordId)), contains(('tankPressureSeries', stale))); + }); + + test('ndl, ceiling and rbt derive from decoType exactly as the legacy row did', () async { + await seedDive(); + await apply( + makeParsedDive( + samples: [ + pigeon.ProfileSample(timeSeconds: 0, depthMeters: 0.0, decoType: 0, decoTime: 600, rbt: 12), + pigeon.ProfileSample(timeSeconds: 60, depthMeters: 20.0, decoType: 1, decoTime: 120, decoDepth: 6.0), + ], + ), + ); + final samples = (await profileSeries.getSeriesForDive('dive-1')).single.samples; + expect(samples[0].ndl, 600); + expect(samples[0].ceiling, isNull); + expect(samples[0].rbt, libdcRbtToSeconds(12)); + expect(samples[1].ndl, isNull); + expect(samples[1].ceiling, 6.0); + expect(samples[1].decoType, 1); + }); +} +``` + +Imports: `drift`, `drift/native.dart`, `flutter_test`, `database.dart`, `sync_repository.dart`, `reparse_service.dart`, `libdc_sample_units.dart` (`libdcRbtToSeconds`), the two series repositories, both codec files, and the pigeon API `as pigeon` (copy that import line from `reparse_service_test.dart`). The `pigeon.ProfileSample` parameter names (`timeSeconds`, `depthMeters`, `pressureBar`, `tankIndex`, `decoType`, `decoTime`, `decoDepth`, `rbt`) come from `packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart`; verify before writing. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_computer/data/services/reparse_service_series_test.dart` +Expected: fails on empty series lists. + +- [ ] **Step 3: Move the three sites** + +Imports: `package:submersion/core/data/repositories/sync_repository.dart`, the two series repositories, `package:submersion/features/dive_log/domain/codecs/profile_sample.dart' as codec`, `.../tank_pressure_series_codec.dart' show TankPressureSample`. + +Constructor and fields: + +```dart + ReparseService({ + required this.db, + this.trimTankPressureAtSurfacing = true, + ProfileSeriesRepository? profileSeries, + TankPressureSeriesRepository? tankSeries, + }) : _profileSeries = + profileSeries ?? + ProfileSeriesRepository( + database: db, + syncRepository: SyncRepository(database: db), + ), + _tankSeries = + tankSeries ?? + TankPressureSeriesRepository( + database: db, + syncRepository: SyncRepository(database: db), + ); + + final ProfileSeriesRepository _profileSeries; + final TankPressureSeriesRepository _tankSeries; +``` + +`_replaceDiveProfiles`: keep the two legacy `db.delete(db.diveProfiles)` branches; add `await _profileSeries.deleteByComputer(diveId, computerId);` after them; replace the `db.batch` insert with + +```dart + if (parsed.samples.isNotEmpty) { + await _profileSeries.insertSeries( + diveId: diveId, + computerId: computerId, + sourceId: sourceId, + isPrimary: isPrimary, + samples: [ + for (final s in parsed.samples) _sampleFromParsed(s, timeOffset), + ], + ); + } +``` + +and add + +```dart + codec.ProfileSample _sampleFromParsed(pigeon.ProfileSample s, int timeOffset) => + codec.ProfileSample( + timestamp: s.timeSeconds + timeOffset, + depth: s.depthMeters, + temperature: s.temperatureCelsius, + heartRate: s.heartRate, + heading: s.heading, + setpoint: s.setpoint, + ppO2: s.ppo2, + cns: s.cns, + ndl: s.decoType == 0 ? s.decoTime : null, + ceiling: s.decoType != null && s.decoType != 0 ? s.decoDepth : null, + rbt: libdcRbtToSeconds(s.rbt), + decoType: s.decoType, + tts: s.tts, + o2Sensor1: s.o2Sensor1, + o2Sensor2: s.o2Sensor2, + o2Sensor3: s.o2Sensor3, + o2Sensor4: s.o2Sensor4, + o2Sensor5: s.o2Sensor5, + o2Sensor6: s.o2Sensor6, + o2SensorMv1: s.o2SensorMv1, + o2SensorMv2: s.o2SensorMv2, + o2SensorMv3: s.o2SensorMv3, + o2SensorMv4: s.o2SensorMv4, + o2SensorMv5: s.o2SensorMv5, + o2SensorMv6: s.o2SensorMv6, + ); +``` + +(check `pigeon.ProfileSample`'s field names in `packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart` and the generated Dart; the legacy insert in this file is the reference mapping). + +The tank-pressure clear (inside `if (!isMultiSource && ownsStrand)`): keep `db.delete(db.tankPressureProfiles)` and add `await _tankSeries.deleteForDive(diveId);` after it. + +`_replaceTankPressureProfiles`: replace the `db.batch` with + +```dart + for (final entry in pressuresByTank.entries) { + final tankId = tankIdsByIndex[entry.key]; + if (tankId == null || entry.value.isEmpty) continue; + await _tankSeries.insertSeries( + diveId: diveId, + tankId: tankId, + computerId: computerId, + samples: [ + for (final point in entry.value) + TankPressureSample(timestamp: point.timestamp, pressure: point.pressure), + ], + ); + } +``` + +Keep the start/end backfill loop below it unchanged. Remove `_uuid` if it becomes unused. + +Every series write here runs inside the `db.transaction` that `reparse` opens; the repositories' own transactions nest as savepoints. + +- [ ] **Step 4: Run the tests, migrate reparse_service_test.dart** + +New file: PASS. Then `reparse_service_test.dart`: the repositories the twins use must be `ProfileSeriesRepository(database: db, syncRepository: SyncRepository(database: db))` (same for tanks), because that file never initializes `DatabaseService`. Add the twins beside `insertProfile` and the inline tank-pressure seeds; switch the tests that assert on rows the reparse REPLACED (R2, R3, R4); tests that only read profiles the reparse did not own stay on legacy seeds (fallback). Run `reparse_service_test.dart`, `reparse_service_surfacing_test.dart`, `dive_consolidation_service_test.dart`, `dive_merge_service_test.dart`, `test/features/dive_log/presentation/pages/dive_detail_reparse_menu_test.dart`. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_computer/data/services/reparse_service.dart test/features/dive_computer/data/services/reparse_service_series_test.dart test/features/dive_computer/data/services/reparse_service_test.dart +git commit -m "feat(series): reparse replaces profile and tank pressure series" +``` + +--- + +### Task 6: DiveSplitService + +**Files:** +- Modify: `lib/features/dive_log/data/services/dive_split_service.dart` (the tank / pressure / event selection near line 150; the profile row selection and clone near line 245; the pressure clone near line 296; the tombstone-and-delete block near line 337; the promote-after-split near line 380) +- Test: `test/features/dive_log/data/services/dive_split_service_series_test.dart` (new) +- Modify tests (R1 to R6): `test/features/dive_log/data/services/dive_split_service_test.dart` (helpers `insertProfileRow` and `insertTankPressure` near lines 70 and 106; the `deletion_log` entity-type assertions near line 335), `test/features/dive_log/data/repositories/dive_consolidation_test.dart` (its split tests switch to the series twin added in Task 2; delete the legacy helper if nothing uses it any more), `test/features/dive_log/integration/multi_computer_integration_test.dart`. + +**Interfaces:** +- Consumes: `ProfileSeriesRepository.getSeriesForDive`, `insertSeries`, `deleteByIds`, `hasPrimarySeries`, `promoteOwnedBy(diveId, {sourceId, computerId, now})`; `TankPressureSeriesRepository.getSeriesForDive`, `insertSeries`, `deleteByIds`; entities `ProfileSeries` (fields `id, diveId, computerId, sourceId, isPrimary, samples`) and `series.TankPressureSeries` (`id, diveId, tankId, computerId, samples`). +- Produces: nothing for later tasks. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_log/data/services/dive_split_service_series_test.dart` (fixture: copy `dive_split_service_test.dart`'s dive / computer / source / tank seeding; seed series through the repositories): + +```dart +void main() { + late AppDatabase db; + late DiveSplitService service; + late ProfileSeriesRepository profileSeries; + late TankPressureSeriesRepository tankSeries; + + // Copy insertComputer(id, name), insertDive(id, {computerId}), + // insertSource(id, diveId, computerId, {isPrimary, maxDepth, createdAt}) + // and insertTank(diveId, computerId) from dive_split_service_test.dart + // verbatim (its `baseTime` constant too). + + setUp(() async { + db = await setUpTestDatabase(); + service = DiveSplitService(DiveRepository()); + profileSeries = ProfileSeriesRepository(); + tankSeries = TankPressureSeriesRepository(); + await insertComputer('comp-a', 'A'); + await insertComputer('comp-b', 'B'); + await insertDive('dive-1', computerId: 'comp-a'); + await insertSource('src-a', 'dive-1', 'comp-a', isPrimary: true, createdAt: DateTime.utc(2026, 1, 1)); + await insertSource('src-b', 'dive-1', 'comp-b', isPrimary: false, createdAt: DateTime.utc(2026, 1, 2)); + }); + + tearDown(() async { + await tearDownTestDatabase(db); + }); + + Future sourceIdOf(String diveId) async => + (await (db.select(db.diveDataSources)..where((t) => t.diveId.equals(diveId))).getSingle()).id; + + test('splitting the primary source moves its series and its null-computer family to the new dive', () async { + final a1 = await profileSeries.insertSeries(diveId: 'dive-1', computerId: 'comp-a', sourceId: 'src-a', samples: const [ProfileSample(timestamp: 0, depth: 1.0)], now: 1000); + final b1 = await profileSeries.insertSeries(diveId: 'dive-1', computerId: 'comp-b', sourceId: 'src-b', isPrimary: false, samples: const [ProfileSample(timestamp: 0, depth: 2.0)], now: 1000); + final e = await profileSeries.insertSeries(diveId: 'dive-1', sourceId: 'src-a', isPrimary: false, samples: const [ProfileSample(timestamp: 0, depth: 3.0)], now: 1000); + final ta = await insertTank('dive-1', 'comp-a'); + final tb = await insertTank('dive-1', 'comp-b'); + final pa = await tankSeries.insertSeries(diveId: 'dive-1', tankId: ta, computerId: 'comp-a', samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], now: 1000); + final pb = await tankSeries.insertSeries(diveId: 'dive-1', tankId: tb, computerId: 'comp-b', samples: const [TankPressureSample(timestamp: 0, pressure: 100.0)], now: 1000); + + final newDiveId = await service.split(diveId: 'dive-1', sourceId: 'src-a'); + + final newSourceId = await sourceIdOf(newDiveId); + final moved = await profileSeries.getSeriesForDive(newDiveId); + expect(moved.map((s) => s.samples.single.depth).toSet(), {1.0, 3.0}); + expect(moved.every((s) => s.sourceId == newSourceId), isTrue); + expect(moved.firstWhere((s) => s.samples.single.depth == 1.0).isPrimary, isTrue); + expect(moved.firstWhere((s) => s.samples.single.depth == 3.0).isPrimary, isFalse); + final left = await profileSeries.getSeriesForDive('dive-1'); + expect(left.map((s) => s.id), [b1]); + expect(left.single.isPrimary, isTrue, reason: 'promote-after-split'); + final movedTanks = await tankSeries.getSeriesForDive(newDiveId); + expect(movedTanks.single.samples.single.pressure, 200.0); + expect(movedTanks.single.tankId, isNot(ta), reason: 'the tank was cloned under a fresh id'); + expect((await tankSeries.getSeriesForDive('dive-1')).single.id, pb); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.where((t) => t.entityType == 'diveProfileSeries').map((t) => t.recordId).toSet(), {a1, e}); + expect(tombstones.where((t) => t.entityType == 'tankPressureSeries').map((t) => t.recordId), [pa]); + expect(tombstones.any((t) => t.entityType == 'diveProfiles' || t.entityType == 'tankPressureProfiles'), isFalse); + expect(await db.select(db.diveProfiles).get(), isEmpty); + expect(await db.select(db.tankPressureProfiles).get(), isEmpty); + }); + + test('splitting a non-primary source promotes its series in the new dive', () async { + await profileSeries.insertSeries(diveId: 'dive-1', computerId: 'comp-a', sourceId: 'src-a', samples: const [ProfileSample(timestamp: 0, depth: 1.0)], now: 1000); + final b1 = await profileSeries.insertSeries(diveId: 'dive-1', computerId: 'comp-b', sourceId: 'src-b', isPrimary: false, samples: const [ProfileSample(timestamp: 0, depth: 2.0)], now: 1000); + + final newDiveId = await service.split(diveId: 'dive-1', sourceId: 'src-b'); + + final moved = (await profileSeries.getSeriesForDive(newDiveId)).single; + expect(moved.samples.single.depth, 2.0); + expect(moved.isPrimary, isTrue); + expect(moved.computerId, 'comp-b'); + expect(moved.sourceId, await sourceIdOf(newDiveId)); + expect((await profileSeries.getSeriesForDive('dive-1')).single.isPrimary, isTrue); + expect( + (await db.select(db.deletionLog).get()).where((t) => t.entityType == 'diveProfileSeries').map((t) => t.recordId), + [b1], + ); + }); +} +``` + +Imports: `drift` (hide `isNull`), `flutter_test`, `database.dart`, `dive_repository_impl.dart`, `dive_split_service.dart`, the two series repositories, both codec files, `test_database.dart`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/services/dive_split_service_series_test.dart` +Expected: the new dive has no series. + +- [ ] **Step 3: Move the split** + +Imports: the two series repositories; `import 'package:submersion/features/dive_log/domain/entities/profile_series.dart' as series;`. Fields: `final _profileSeries = ProfileSeriesRepository(); final _tankSeries = TankPressureSeriesRepository();`. + +Read the series BEFORE the transaction deletes the old source row (the `dive_profile_series.source_id` FK is `ON DELETE SET NULL`, so reading after the delete would lose the FK ownership signal). Right after `ownedBySource` is defined (before `await _db.transaction`), add: + +```dart + final allProfileSeries = await _profileSeries.getSeriesForDive(diveId); + final allPressureSeries = await _tankSeries.getSeriesForDive(diveId); + bool ownedByComputer(String? computerId) => + source.computerId != null && computerId == source.computerId; + // The legacy row rule, applied to whole series: not owned by another + // source by FK, then the computer convention for the source's primacy. + bool profileBelongsToSource(series.ProfileSeries s) { + final notOwnedByAnotherSource = s.sourceId == null || s.sourceId == source.id; + final byLegacyRule = source.computerId == null + ? s.computerId == null && !s.isPrimary + : source.isPrimary + ? ownedByComputer(s.computerId) || s.computerId == null + : ownedByComputer(s.computerId); + return notOwnedByAnotherSource && byLegacyRule; + } + final movingProfiles = [ + for (final s in allProfileSeries) + if (profileBelongsToSource(s)) s, + ]; + final movingPressures = [ + for (final s in allPressureSeries) + if (ownedByComputer(s.computerId)) s, + ]; +``` + +Inside the transaction: + +- In the tank block (near line 150): replace `allPressures` (the legacy select) with `allPressureSeries`, and `pressureRows` with `movingPressures`; `hasRemainingRefs` reads `allPressureSeries.any((r) => r.tankId == tank.id && !owned(r.computerId))` (the `owned` closure there is the same rule as `ownedByComputer`; keep one). The loop that clones a tank for pressure rows whose tank is not owned iterates `movingPressures`. +- Profile clone (near line 245): delete the legacy `profileRows` select and the `_db.batch` insert; instead + +```dart + for (final s in movingProfiles) { + await _profileSeries.insertSeries( + diveId: newDiveId, + computerId: s.computerId, + sourceId: newSourceId, + isPrimary: source.isPrimary ? s.isPrimary : true, + samples: s.samples, + now: now, + ); + } +``` + +- Pressure clone (near line 296): delete the `_db.batch`; instead + +```dart + for (final s in movingPressures) { + await _tankSeries.insertSeries( + diveId: newDiveId, + tankId: tankIdMap[s.tankId] ?? s.tankId, + computerId: s.computerId, + samples: s.samples, + now: now, + ); + } +``` + +- Tombstone-and-delete block (near line 337): delete the `for (final row in pressureRows) logDeletion('tankPressureProfiles')` loop and the legacy `delete(_db.tankPressureProfiles)` by id list, and the `for (final row in profileRows) logDeletion('diveProfiles')` loop and the legacy `delete(_db.diveProfiles)` by id list (these delete rows the service SELECTED; with no legacy select left there is nothing to delete). Instead: + +```dart + await _tankSeries.deleteByIds([for (final s in movingPressures) s.id]); + await _profileSeries.deleteByIds([for (final s in movingProfiles) s.id]); +``` + +Keep the event and tank deletes exactly as they are. + +- Promote-after-split (near line 380): replace the `remainingPrimary` select and the `update(_db.diveProfiles)` with + +```dart + if (!await _profileSeries.hasPrimarySeries(diveId)) { + await _profileSeries.promoteOwnedBy( + diveId, + sourceId: promoted.id, + computerId: promoted.computerId, + now: now, + ); + } +``` + +- [ ] **Step 4: Run the tests, migrate the existing ones** + +New file: PASS. Then `dive_split_service_test.dart`: add the twins beside `insertProfileRow` / `insertTankPressure` (same parameters, return the series id), switch every split test (R1 to R4; the `byRecord[...]` entity-type assertions become `'diveProfileSeries'` / `'tankPressureSeries'`); `dive_consolidation_test.dart`: switch its split tests to `insertTestSeries` (Task 2's twin) and delete `insertTestProfile` if unused; `multi_computer_integration_test.dart`: apply R1 to R6 to its split scenario. Run those three plus `dive_repository_series_writes_test.dart`. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/services/dive_split_service.dart test/features/dive_log/data/services/dive_split_service_series_test.dart test/features/dive_log/data/services/dive_split_service_test.dart test/features/dive_log/data/repositories/dive_consolidation_test.dart test/features/dive_log/integration/multi_computer_integration_test.dart +git commit -m "feat(series): dive split moves profile and tank pressure series" +``` + +--- + +### Task 7: DiveMergeSnapshot and DiveMergeService + +**Files:** +- Modify: `lib/features/dive_log/data/services/dive_merge_snapshot.dart` (two new fields with defaults; `capture` fills them) +- Modify: `lib/features/dive_log/data/services/dive_merge_service.dart` (profile copy and gap fill near line 252; tank pressure copy near line 399; undo delete near line 509; undo re-insert near line 592; `_adjacentProfileRow` and `_nativeSampleIntervalSeconds` get series twins) +- Test: `test/features/dive_log/data/services/dive_merge_service_series_test.dart` (new) +- Modify tests (R1 to R6): `test/features/dive_log/data/services/dive_merge_service_test.dart` (the `createDive` helper near line 52 stamps `computer_id` on legacy rows with an `update(db.diveProfiles)` near line 65 and seeds a tank pressure row near line 110). The six tests that construct `DiveMergeSnapshot(...)` by hand (`combine_dives_dialog_test.dart`, `dive_list_selection_test.dart`, `merge_dive_dialog_test.dart`, `dive_computer_adapter_test.dart`, `universal_adapter_test.dart`, `import_consolidation_service_test.dart`) keep compiling because the new fields default to `const []`; run them to prove it. + +**Interfaces:** +- Consumes: `ProfileSeriesRepository.getSeriesForDives`, `getRowsForDives`, `insertSeries`, `deleteForDive`, `restoreSeriesRow(DiveProfileSeriesRow row, {markPending, now})`; `TankPressureSeriesRepository.getSeriesForDive`, `getRowsForDives`, `insertSeries`, `deleteForDive`, `restoreSeriesRow(TankPressureSeriesRow row, {markPending, now})`; `ProfileSample.shiftedBy`, `TankPressureSample.shiftedBy`; `MergeGap` (`afterDiveId`, `beforeDiveId`, `startSeconds`, `endSeconds`), `result.segmentOffsetsSeconds`, `result.tankIdMap` (existing); `mergedSourceIdFor(diveId, sourceId)` (existing local closure). +- Produces: `DiveMergeSnapshot.profileSeriesRows` (`List`, default `const []`) and `DiveMergeSnapshot.tankSeriesRows` (`List`, default `const []`), filled by `capture`; Task 8 reads them. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_log/data/services/dive_merge_service_series_test.dart`, on the fixture of `dive_merge_service_test.dart` (its `createDive` helper builds dives through `DiveRepository.createDive`, which writes series since Task 2; drop the legacy `update(db.diveProfiles)` computer stamp from the copy of the helper and instead re-insert the created series with the computer id: read `ProfileSeriesRepository().getSeriesForDive(id)`, `deleteForDive(id)`, then `insertSeries` again with `computerId`, same samples and flags): + +```dart + // Fixture: copy dive_merge_service_test.dart's createDive helper (it seeds + // 'a' and 'b' fifteen minutes apart with a three-point profile through + // DiveRepository.createDive, plus buddies, sightings, events, switches, + // a data source and a tank) with two changes: delete the + // `update(db.diveProfiles)` computer stamp and, when computerId is given, + // re-insert the created series with that computer instead: + // + // final created = await profileSeries.getSeriesForDive(id); + // await profileSeries.deleteForDive(id); + // for (final s in created) { + // await profileSeries.insertSeries(diveId: id, computerId: computerId, + // sourceId: s.sourceId, isPrimary: s.isPrimary, samples: s.samples, + // now: 1000); + // } + // + // and replace the tank_pressure_profiles insert with + // await tankSeries.insertSeries(diveId: id, tankId: 'tank-$id', + // samples: const [TankPressureSample(timestamp: 60, pressure: 180.0)], + // now: 1000); + + void expectAscending(Iterable timestamps) { + final list = timestamps.toList(); + for (var i = 1; i < list.length; i++) { + expect(list[i], greaterThanOrEqualTo(list[i - 1]), reason: 'index $i'); + } + } + + test('apply re-bases every series onto the merged timeline and fills the gap into the adjacent series', () async { + await createDive('a', runtimeMin: 10, depth: 20); + await createDive('b', runtimeMin: 10, depth: 20 /* fifteen minutes after a, as the fixture does */); + + final outcome = await service.apply(['a', 'b']); + + final merged = await profileSeries.getSeriesForDive(outcome.mergedDive.id); + expect(merged, hasLength(2)); + final first = merged.firstWhere((s) => s.samples.first.timestamp == 0); + final second = merged.firstWhere((s) => s.samples.first.timestamp != 0); + final offsetB = second.samples.first.timestamp; + expect(offsetB, greaterThan(600)); + expect(second.samples.map((s) => s.timestamp), [offsetB, offsetB + 300, offsetB + 600]); + final gap = first.samples.where((s) => s.timestamp > 600 && s.timestamp < offsetB).toList(); + expect(gap, isNotEmpty, reason: 'the surface gap is filled into the segment before it'); + expect(gap.every((s) => s.depth == 0), isTrue); + expect(gap.last.timestamp, offsetB - 1); + expectAscending(first.samples.map((s) => s.timestamp)); + expect(first.sourceId, isNotNull); + expect(await db.select(db.diveProfiles).get(), isEmpty); + }); + + test('apply moves tank pressure series onto the merged tanks with the segment offset', () async { + await createDive('a', runtimeMin: 10, depth: 20); + await createDive('b', runtimeMin: 10, depth: 20); + + final outcome = await service.apply(['a', 'b']); + + final tanks = await tankSeries.getSeriesForDive(outcome.mergedDive.id); + expect(tanks, hasLength(2)); + final mergedTankIds = (await (db.select(db.diveTanks)..where((t) => t.diveId.equals(outcome.mergedDive.id))).get()).map((t) => t.id).toSet(); + expect(tanks.map((s) => s.tankId).toSet(), mergedTankIds); + final timestamps = tanks.map((s) => s.samples.single.timestamp).toList()..sort(); + expect(timestamps.first, 60); + expect(timestamps.last, greaterThan(660)); + expect(await db.select(db.tankPressureProfiles).get(), isEmpty); + }); + + test('undo tombstones the merged series and restores the original rows', () async { + await createDive('a', runtimeMin: 10, depth: 20); + await createDive('b', runtimeMin: 10, depth: 20); + final before = await profileSeries.getRowsForDives(['a', 'b']); + final beforeTanks = await tankSeries.getRowsForDives(['a', 'b']); + + final outcome = await service.apply(['a', 'b']); + final mergedIds = (await profileSeries.getRowsForDives([outcome.mergedDive.id])).map((r) => r.id).toSet(); + final mergedTankIds = (await tankSeries.getRowsForDives([outcome.mergedDive.id])).map((r) => r.id).toSet(); + await service.undo(outcome.snapshot); + + expect(await profileSeries.getRowsForDives([outcome.mergedDive.id]), isEmpty); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.where((t) => t.entityType == 'diveProfileSeries').map((t) => t.recordId).toSet(), mergedIds); + expect(tombstones.where((t) => t.entityType == 'tankPressureSeries').map((t) => t.recordId).toSet(), mergedTankIds); + expect(tombstones.any((t) => before.any((r) => r.id == t.recordId)), isFalse); + final after = await profileSeries.getRowsForDives(['a', 'b']); + expect( + after.map((r) => (r.id, r.diveId, r.computerId, r.sourceId, r.isPrimary, r.samples)).toList(), + before.map((r) => (r.id, r.diveId, r.computerId, r.sourceId, r.isPrimary, r.samples)).toList(), + ); + final afterTanks = await tankSeries.getRowsForDives(['a', 'b']); + expect( + afterTanks.map((r) => (r.id, r.tankId, r.samples)).toList(), + beforeTanks.map((r) => (r.id, r.tankId, r.samples)).toList(), + ); + }); +``` + +`service` is `DiveMergeService(DiveRepository())`, `profileSeries` / `tankSeries` the two zero-arg repositories; `Uint8List` samples compare by content under `expect` because `flutter_test` uses deep equality for lists. Write the fixture and the three tests in full in the file. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/services/dive_merge_service_series_test.dart` +Expected: the merged dive has no series (rows went to `dive_profiles`). + +- [ ] **Step 3: Snapshot fields** + +`dive_merge_snapshot.dart`: add to the constructor `this.profileSeriesRows = const [], this.tankSeriesRows = const [],`, the fields `final List profileSeriesRows; final List tankSeriesRows;`, and in `capture`: + +```dart + profileSeriesRows: await ProfileSeriesRepository( + database: db, + syncRepository: SyncRepository(database: db), + ).getRowsForDives(diveIds), + tankSeriesRows: await TankPressureSeriesRepository( + database: db, + syncRepository: SyncRepository(database: db), + ).getRowsForDives(diveIds), +``` + +(imports: the two repositories and `sync_repository.dart`). The legacy `profileRows` / `tankPressureRows` fields stay and are still captured; plan 2e removes them. + +- [ ] **Step 4: Merge** + +Imports in `dive_merge_service.dart`: the two series repositories; `codecs/profile_sample.dart` (`ProfileSample`); `entities/profile_series.dart` as `series`. Fields: `final _profileSeries = ProfileSeriesRepository(); final _tankSeries = TankPressureSeriesRepository();`. + +Replace the profile `_db.batch` (row copy plus gap fill, near line 252) with a series build. Read the originals live, before anything is deleted: + +```dart + final seriesByDive = await _profileSeries.getSeriesForDives(diveIds); + final originals = [ + for (final id in diveIds) ...(seriesByDive[id] ?? const []), + ]; + // One draft per original series, re-based onto the merged timeline. + final drafts = <_SeriesDraft>[ + for (final s in originals) + _SeriesDraft( + diveId: s.diveId, + computerId: s.computerId, + sourceId: s.sourceId, + isPrimary: s.isPrimary, + samples: [ + for (final p in s.samples) + p.shiftedBy(result.segmentOffsetsSeconds[s.diveId] ?? 0), + ], + ), + ]; + for (final gap in result.gaps) { + if (gap.endSeconds - gap.startSeconds < 2) continue; + final host = + _adjacentDraft(drafts, gap.afterDiveId) ?? + _adjacentDraft(drafts, gap.beforeDiveId); + final interval = _nativeSampleIntervalSecondsOf(originals, gap); + final minStep = ((gap.endSeconds - gap.startSeconds) / 300).ceil(); + final step = interval > minStep ? interval : minStep; + final timestamps = [ + for (var ts = gap.startSeconds + 1; ts < gap.endSeconds; ts += step) ts, + ]; + if (timestamps.last != gap.endSeconds - 1) { + timestamps.add(gap.endSeconds - 1); + } + final surface = [ + for (final ts in timestamps) ProfileSample(timestamp: ts, depth: 0), + ]; + if (host != null) { + host.samples.addAll(surface); + } else { + // No profile on either side: the legacy rows carried a null + // computer, a null source and is_primary true. + drafts.add( + _SeriesDraft( + diveId: gap.afterDiveId, + computerId: null, + sourceId: null, + isPrimary: true, + samples: surface, + ), + ); + } + } + for (final d in drafts) { + if (d.samples.isEmpty) continue; + await _profileSeries.insertSeries( + diveId: mergedId, + computerId: d.computerId, + sourceId: d.sourceId == null && d.computerId == null && !originals.any((s) => s.diveId == d.diveId) + ? null + : mergedSourceIdFor(d.diveId, d.sourceId), + isPrimary: d.isPrimary, + samples: d.samples, + now: now, + ); + } +``` + +The `sourceId` expression reproduces the legacy split: rows copied from an original got `mergedSourceIdFor(row.diveId, row.sourceId)`; gap rows with an adjacent row got `mergedSourceIdFor(adjacent.diveId, adjacent.sourceId)` (the host draft carries that dive and source, so the same call applies); gap rows with NO adjacent row got a null source. Simplify to exactly that: give `_SeriesDraft` a `bool synthetic` flag set only in the no-host branch, and use `sourceId: d.synthetic ? null : mergedSourceIdFor(d.diveId, d.sourceId)`. + +`_SeriesDraft` is a small private class in this file (`diveId`, `computerId`, `sourceId`, `isPrimary`, `synthetic = false`, and a growable `samples` list that only this method mutates before the insert). Add the twins of the two helpers: + +```dart + /// The draft that hosts a gap's surface samples: the segment's primary + /// series, else one with a computer, else its first series. + _SeriesDraft? _adjacentDraft(List<_SeriesDraft> drafts, String diveId) { + final segment = drafts.where((d) => d.diveId == diveId).toList(); + if (segment.isEmpty) return null; + for (final d in segment) { + if (d.isPrimary) return d; + } + for (final d in segment) { + if (d.computerId != null) return d; + } + return segment.first; + } + + /// Median inter-sample delta of the previous segment (then the next, then + /// 60 s), over every series of that segment. + int _nativeSampleIntervalSecondsOf( + List originals, + MergeGap gap, + ) { + for (final diveId in [gap.afterDiveId, gap.beforeDiveId]) { + final timestamps = [ + for (final s in originals) + if (s.diveId == diveId) + for (final p in s.samples) p.timestamp, + ]..sort(); + final deltas = [ + for (var i = 1; i < timestamps.length; i++) + if (timestamps[i] - timestamps[i - 1] > 0) + timestamps[i] - timestamps[i - 1], + ]; + if (deltas.isNotEmpty) { + deltas.sort(); + return deltas[deltas.length ~/ 2]; + } + } + return 60; + } +``` + +Delete `_adjacentProfileRow` and `_nativeSampleIntervalSeconds` once nothing calls them. + +Tank pressures (near line 399): replace the `_db.batch` with + +```dart + for (final id in diveIds) { + for (final s in await _tankSeries.getSeriesForDive(id)) { + final newTankId = result.tankIdMap[s.tankId]; + if (newTankId == null || s.samples.isEmpty) continue; + final offset = result.segmentOffsetsSeconds[s.diveId] ?? 0; + await _tankSeries.insertSeries( + diveId: mergedId, + tankId: newTankId, + computerId: s.computerId, + samples: [for (final p in s.samples) p.shiftedBy(offset)], + now: now, + ); + } + } +``` + +Undo (near line 509): before the `_db.batch` of `deleteWhere`s add `await _profileSeries.deleteForDive(mergedId); await _tankSeries.deleteForDive(mergedId);` (one tombstone per merged series). Keep the legacy `deleteWhere`s. In the re-insert block (near line 592), after the batch that re-inserts `dataSourceRows` (the series FK parents: dives, computers, sources, tanks must be back first), add + +```dart + for (final r in snapshot.profileSeriesRows) { + await _profileSeries.restoreSeriesRow(r, now: now); + } + for (final r in snapshot.tankSeriesRows) { + await _tankSeries.restoreSeriesRow(r, now: now); + } +``` + +Keep the legacy verbatim re-inserts of `profileRows` / `tankPressureRows` (they are empty on a packed database and harmless otherwise). + +- [ ] **Step 5: Run the tests, migrate dive_merge_service_test.dart** + +New file: PASS. `dive_merge_service_test.dart`: in its `createDive` helper replace the `update(db.diveProfiles)` computer stamp with the delete-and-reinsert through `ProfileSeriesRepository` described in Step 1, and the `tank_pressure_profiles` seed with `TankPressureSeriesRepository().insertSeries(...)`; apply R2 to R6 to the assertions (`db.select(db.diveProfiles)` reads become series reads; the FK comment near line 534 about `tankPressureProfiles.tankId` applies to `tank_pressure_series.tank_id` the same way). Run it, the new file, and the six snapshot-constructing tests listed in Files. +Expected: all pass. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/services/dive_merge_snapshot.dart lib/features/dive_log/data/services/dive_merge_service.dart test/features/dive_log/data/services/dive_merge_service_series_test.dart test/features/dive_log/data/services/dive_merge_service_test.dart +git commit -m "feat(series): dive merge re-bases series and snapshots them for undo" +``` + +--- + +### Task 8: DiveConsolidationService + +**Files:** +- Modify: `lib/features/dive_log/data/services/dive_consolidation_service.dart` (the target stamp near line 78; the copy near line 288; the undo id sets near line 653; the undo tombstone-and-delete near line 763; the undo re-insert near line 837) +- Test: `test/features/dive_log/data/services/dive_consolidation_service_series_test.dart` (new) +- Modify tests (R1 to R6): `test/features/dive_log/data/services/dive_consolidation_service_test.dart`; verify the import-wizard adapter tests that drive consolidation (`test/features/import_wizard/data/adapters/dive_computer_adapter_consolidate_integration_test.dart`, `dive_computer_adapter_reimport_test.dart`, `universal_adapter_test.dart`, `dive_computer_adapter_test.dart`, `test/features/universal_import/presentation/providers/import_consolidation_service_test.dart`). + +**Interfaces:** +- Consumes: Task 7's `snapshot.profileSeriesRows` / `snapshot.tankSeriesRows`; `ProfileSeriesRepository.getSeriesForDive`, `insertSeries`, `getRowsForDives`, `restoreSeriesRow`; `TankPressureSeriesRepository.getSeriesForDive`, `insertSeries`, `stampComputerWhereNull`, `getRowsForDives`, `restoreSeriesRow`; `shiftedBy` on both sample types; `sourceIdMap` (`Map`), `tankIdMap`, `secRow`, `offset` (existing locals). +- Produces: nothing for later tasks. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_log/data/services/dive_consolidation_service_series_test.dart`, on the fixture of `dive_consolidation_service_test.dart`: + +```dart + // Fixture: copy dive_consolidation_service_test.dart's setUp seeding for + // the target dive 't' and the secondary dive 's' (dives, computers + // 'comp-t' / 'comp-s', data sources, tanks) so that + // `service.apply(targetDiveId: 't', secondaryDiveIds: ['s'])` succeeds + // there; name the target's primary source 'src-t' and the secondary's + // 'src-s', and give the target dive row computerId 'comp-t'. + + Future copiedSourceId() async => + (await (db.select(db.diveDataSources)..where((t) => t.diveId.equals('t') & t.computerId.equals('comp-s'))).getSingle()).id; + + test('apply copies the secondary series onto the target as demoted, re-owned and offset', () async { + final st = await profileSeries.insertSeries(diveId: 't', computerId: 'comp-t', sourceId: 'src-t', samples: const [ProfileSample(timestamp: 0, depth: 1.0)], now: 1000); + await profileSeries.insertSeries(diveId: 's', computerId: 'comp-s', sourceId: 'src-s', samples: const [ProfileSample(timestamp: 0, depth: 2.0)], now: 1000); + await profileSeries.insertSeries(diveId: 's', sourceId: 'src-s', isPrimary: false, samples: const [ProfileSample(timestamp: 0, depth: 3.0)], now: 1000); + + await service.apply(targetDiveId: 't', secondaryDiveIds: ['s']); + + final rows = await profileSeries.getSeriesForDive('t'); + expect(rows.where((s) => s.isPrimary).map((s) => s.id), [st]); + final copied = rows.where((s) => s.id != st).toList(); + expect(copied, hasLength(2)); + expect(copied.every((s) => !s.isPrimary), isTrue); + expect(copied.map((s) => s.computerId).toSet(), {'comp-s'}, reason: 'a null computer takes the secondary source computer'); + final source = await copiedSourceId(); + expect(copied.every((s) => s.sourceId == source), isTrue); + final offset = (await (db.select(db.diveDataSources)..where((t) => t.id.equals(source))).getSingle()).timeOffsetSeconds ?? 0; + expect(copied.map((s) => s.samples.single.timestamp).toSet(), {offset}); + expect(await profileSeries.getSeriesForDive('s'), isEmpty); + expect(await db.select(db.diveProfiles).get(), isEmpty); + }); + + test('apply stamps the target computer on the target null-computer tank series', () async { + final tankId = /* the target's tank id from the fixture */; + final id = await tankSeries.insertSeries(diveId: 't', tankId: tankId, samples: const [TankPressureSample(timestamp: 0, pressure: 200.0)], now: 1000); + + await service.apply(targetDiveId: 't', secondaryDiveIds: ['s']); + + final row = (await tankSeries.getRowsForDives(['t'])).firstWhere((r) => r.id == id); + expect(row.computerId, 'comp-t'); + expect(await db.select(db.tankPressureProfiles).get(), isEmpty); + }); + + test('undo tombstones the copied series, keeps the originals and restores the secondary rows', () async { + await profileSeries.insertSeries(diveId: 't', computerId: 'comp-t', sourceId: 'src-t', samples: const [ProfileSample(timestamp: 0, depth: 1.0)], now: 1000); + await profileSeries.insertSeries(diveId: 's', computerId: 'comp-s', sourceId: 'src-s', samples: const [ProfileSample(timestamp: 0, depth: 2.0)], now: 1000); + final before = await profileSeries.getRowsForDives(['t', 's']); + + final outcome = await service.apply(targetDiveId: 't', secondaryDiveIds: ['s']); + final copiedIds = (await profileSeries.getRowsForDives(['t'])).map((r) => r.id).where((id) => !before.any((r) => r.id == id)).toSet(); + await service.undo(outcome.snapshot); + + final after = await profileSeries.getRowsForDives(['t', 's']); + expect( + after.map((r) => (r.id, r.diveId, r.computerId, r.sourceId, r.isPrimary, r.samples)).toList(), + before.map((r) => (r.id, r.diveId, r.computerId, r.sourceId, r.isPrimary, r.samples)).toList(), + ); + final tombstones = await db.select(db.deletionLog).get(); + expect(tombstones.where((t) => t.entityType == 'diveProfileSeries').map((t) => t.recordId).toSet(), copiedIds); + expect(tombstones.any((t) => before.any((r) => r.id == t.recordId)), isFalse); + }); +``` + +`service` is `DiveConsolidationService(DiveRepository())`; the fixture's tank id for the target replaces the placeholder in the second test. Write the fixture and the three tests in full in the file. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/services/dive_consolidation_service_series_test.dart` +Expected: the target gains no series. + +- [ ] **Step 3: Move the sites** + +Imports and fields as in Task 7 (`_profileSeries`, `_tankSeries`, `as series`). + +Stamp (near line 78): after the `update(_db.tankPressureProfiles)` computer stamp (which stays until 2e? No: it is an UPDATE on a legacy table and this plan removes those; delete it) add `await _tankSeries.stampComputerWhereNull(targetDiveId, targetRow.computerId!, now: now);` inside the same `if (targetRow.computerId != null)`. + +Copy (near line 288): replace the `_db.batch` with + +```dart + for (final s in await _profileSeries.getSeriesForDive(secondary.id)) { + if (s.samples.isEmpty) continue; + await _profileSeries.insertSeries( + diveId: targetDiveId, + computerId: s.computerId ?? secRow.computerId, + sourceId: sourceIdMap[s.sourceId] ?? sourceIdMap[null], + isPrimary: false, + samples: [for (final p in s.samples) p.shiftedBy(offset)], + now: now, + ); + } + for (final s in await _tankSeries.getSeriesForDive(secondary.id)) { + final mappedTank = tankIdMap[s.tankId]; + if (mappedTank == null || s.samples.isEmpty) continue; + await _tankSeries.insertSeries( + diveId: targetDiveId, + tankId: mappedTank, + computerId: secRow.computerId, + samples: [for (final p in s.samples) p.shiftedBy(offset)], + now: now, + ); + } +``` + +(read the secondary's series BEFORE the code that deletes the secondary dive; place the loops where the legacy batch was, which runs before that delete). + +Undo id sets (near line 653): add `'diveProfileSeries': {for (final r in snapshot.profileSeriesRows) r.id}` and `'tankPressureSeries': {for (final r in snapshot.tankSeriesRows) r.id}` to `snapshotIds`, and to `currentChildIds` add + +```dart + 'diveProfileSeries': [ + for (final r in await _profileSeries.getRowsForDives([mergedId])) r.id, + ], + 'tankPressureSeries': [ + for (final r in await _tankSeries.getRowsForDives([mergedId])) r.id, + ], +``` + +The tombstone loop (near line 763) then logs the copied series under their series entity types with no further change. In the delete batch add `batch.deleteWhere(_db.diveProfileSeries, (t) => t.diveId.equals(mergedId));` and `batch.deleteWhere(_db.tankPressureSeries, (t) => t.diveId.equals(mergedId));` (raw deletes on purpose: the loop above already tombstoned the difference, and `restoreSeriesRow` removes the tombstones of the rows it puts back). In the re-insert block (near line 837), after the `dataSourceRows` batch, add the same two `restoreSeriesRow` loops as Task 7. + +- [ ] **Step 4: Run the tests, migrate dive_consolidation_service_test.dart** + +New file: PASS. `dive_consolidation_service_test.dart` (9 legacy references): add the twins, switch the consolidation tests (R1 to R5). Run it, the new file, and the five import-wizard files listed. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/services/dive_consolidation_service.dart test/features/dive_log/data/services/dive_consolidation_service_series_test.dart test/features/dive_log/data/services/dive_consolidation_service_test.dart +git commit -m "feat(series): consolidation copies, stamps and restores series" +``` + +--- + +### Task 9: Verification + +**Files:** none new. + +- [ ] **Step 1: No legacy writes remain** + +```bash +grep -rn "into(_db.diveProfiles)\|update(_db.diveProfiles)\|into(_db.tankPressureProfiles)\|update(_db.tankPressureProfiles)\|update(db.diveProfiles)\|into(db.diveProfiles)\|into(db.tankPressureProfiles)\|update(db.tankPressureProfiles)\|INSERT INTO dive_profiles\|UPDATE dive_profiles SET is_primary\|INSERT INTO tank_pressure_profiles\|UPDATE tank_pressure_profiles" lib --include='*.dart' | grep -v "database.dart\|database.g.dart\|profile_series_pack\|sync_data_serializer.dart\|diver_repository.dart\|dive_computer_repository_impl.dart:37[0-9]\|dive_computer_repository_impl.dart:52[0-9]" +grep -rn "batch.insert(\s*$" -A 1 lib --include='*.dart' | grep "diveProfiles\|tankPressureProfiles" | grep -v "database.g.dart" +grep -rn "logDeletion(entityType: 'diveProfiles'\|logDeletion(entityType: 'tankPressureProfiles'\|entityType: 'diveProfiles',\|entityType: 'tankPressureProfiles'," lib --include='*.dart' | grep -v sync_data_serializer.dart +``` + +Expected: every command prints nothing. The exclusions are plan 2d's: the sync serializer's apply / delete sites and the `UPDATE dive_profiles SET computer_id = NULL` computer-unlink statements in `diver_repository.dart` and `dive_computer_repository_impl.dart`. + +- [ ] **Step 2: Run every test file this plan created or touched, individually** + +The eleven new files, every migrated file named in Tasks 2 to 8, plus: `profile_series_repository_test.dart`, `profile_series_repository_restore_test.dart`, `tank_pressure_series_repository_test.dart`, `dive_repository_series_reads_test.dart`, `profiles_by_data_source_series_test.dart`, `batch_summaries_series_test.dart`, `tank_pressure_series_reads_test.dart`, `dive_computer_series_reads_test.dart`, `profile_series_pack_test.dart`, `migration_v182_profile_series_test.dart`, `database_import_graph_test.dart`, `test/core/data/repositories/sync_hlc_targets_test.dart` (or whatever the hlc registration test is called), `uddf_round_trip_test.dart`, `dive_providers_test.dart`, `profile_analysis_tick_reactivity_test.dart`. + +Expected: every file passes. + +- [ ] **Step 3: Format, analyze, layering** + +```bash +dart format --output=none --set-exit-if-changed . +flutter analyze +flutter test test/core/database/database_import_graph_test.dart +``` + +Expected: exit 0, "No issues found!", pass. + +- [ ] **Step 4: Full suite once** + +Background, output to the session scratchpad `full-suite-2c.log` with `exit=$?` appended. Known machine flakes: `local_file_resolver_test.dart` (mounted `$TMPDIR`; passes with `TMPDIR=/private/tmp/claude-501/sysvol-tmp`), `changeset_writer_test.dart`, `backup_encryption_service_test.dart`, `database_security_service_test.dart`. Any other failure: rerun the file alone once; a repeat is real. Any failure in a file this plan touched, or under `test/core/database/`, `test/features/dive_log/`, `test/features/dive_computer/`, `test/core/services/sync/`, is real. + +- [ ] **Step 5: Report** + +The commit list from `git log --oneline 879a9cf1624..HEAD`, the grep results, the per-file results, the full-suite summary and exit, flake reruns. + +--- + +## Self-review + +**Spec coverage.** Section 6 "Writes": `insertSeries` (exists), `replaceSeriesOwnedBy` is expressed as delete-then-insert at the two sites that need it (reparse: `deleteByComputer` + `insertSeries`; consolidation copies without deleting, as the legacy did); `demoteAll` and `promote...` (exist, used in Tasks 2, 3, 6); `deleteSeriesOwnedBy` / `deleteSeriesForDive` (`deleteOwnedBy`, `deleteByComputer`, `deleteByIds`, `deleteForDive`); tank equivalents keyed by `(diveId, tankId, computerId)` (Task 1, 4, 5, 6, 7, 8). "Semantics preserved exactly": manual edit (Task 2), restore (Task 2), split and merge (Tasks 6, 7), consolidation undo via snapshot series rows with `insertOrReplace` (`restoreSeriesRow`, Tasks 7, 8), primary-source swap marking per series (Task 2 through `demoteAll` / `promoteWinnerOwnedBy`). "Exact-duplicate dedupe moves to encode time" (the repositories, plus the stable sort added in Task 1). Section 3 "decode only in the repositories": every service consumes entities; the snapshot carries raw rows for verbatim restore and never decodes. Section 12 risk "a writer left behind": Task 9 Step 1 greps for every legacy write shape and names the 2d exclusions explicitly. + +Not in this plan by design: sync apply / delete of the legacy entities and the receive-side packing shim (2d); the `UPDATE dive_profiles SET computer_id = NULL` unlink statements (2d, they become no-ops once the tables go and the series FK is `ON DELETE SET NULL`); `watchStatisticsChanges` and the SQL predicates (2d); dropping the legacy tables, the `_...Legacy` fallbacks, `TankPressurePoint.id`, `DiveMergeSnapshot.profileRows` / `tankPressureRows`, the remaining legacy seed helpers in tests (2e). + +**Placeholder scan.** Every test in Tasks 1 to 8 is written out with its seeds, its call and its assertions. What the plan does not repeat is the 40 to 60 line seeding fixture of a sibling test file (reparse, split, merge, consolidation): each such task names the file and the helper signatures to copy, and the tests above them call those helpers by name. Two spots reference a fixture value rather than a literal (the target tank id in Task 8's stamp test, the fifteen-minute spacing in Task 7's `createDive` calls) and say where it comes from. No "add error handling", no "similar to Task N" (Task 8's restore loops are spelled out by reference to Task 7's code block with the same names). + +**Type consistency.** `insertSeries` parameter names (`diveId`, `computerId`, `sourceId`, `isPrimary`, `samples`, `id`, `now` on profiles; `diveId`, `tankId`, `computerId`, `samples`, `id`, `now` on tanks) match plan 2a and every call in Tasks 2 to 8. `deleteByComputer(String diveId, String? computerId)` (Task 1) is called with `(diveId, computerId)` in Tasks 3 and 5. `reassignTank(diveId, fromTankId, toTankId, {now})` / `swapTanks(diveId, a, b, {now})` (Task 1) match Task 4. `stampComputerWhereNull(diveId, computerId, {now})` (Task 1) matches Task 8. `getRowsForDives(List)` (Task 1) matches Tasks 7 and 8; `restoreSeriesRow(row, {markPending, now})` (2a) matches Tasks 7 and 8. `shiftedBy(int)` (Task 1) matches Tasks 7 and 8. `ProfileSeriesRepository({syncRepository, database})` / `SyncRepository({database})` (Task 1) match Tasks 5 and 7. `promoteOwnedBy(diveId, {sourceId, computerId, now})` and `hasPrimarySeries(diveId)` (2a) match Task 6. `adoptUnattributed(diveId, sourceId, {now})` and `ownsAny(diveId, {sourceId, computerId})` (Task 1) match Task 2. diff --git a/docs/superpowers/plans/2026-08-29-profile-series-2d-consumers-sync.md b/docs/superpowers/plans/2026-08-29-profile-series-2d-consumers-sync.md new file mode 100644 index 0000000000..5d0b2f608c --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-profile-series-2d-consumers-sync.md @@ -0,0 +1,1231 @@ +# Profile Series Plan 2d: SQL Consumers and Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every remaining reader of `dive_profiles` / `tank_pressure_profiles` outside the 2b fallbacks moves to the series tables, the two series entities become first-class synced entities (export by hlc, LWW apply, tombstones, parent guards), the legacy entities stop being exported but still apply when an older peer sends them (packed into series on arrival), the schema floor rises to 182, the computer unlink and relink statements get series twins, and the thirteen tests plan 2c left red go green. + +**Architecture:** Plans 2a to 2c (done) created the tables, made reads series-first with a legacy fallback and moved every writer. What is left reads legacy rows directly: nine SQL consumers (deco signals, profile span, quality context and prefilters, the two whole-library aggregations), the sync serializer, and three `UPDATE dive_profiles` unlink or relink statements. Seven of the nine consumers stay SQL and read the series summary columns (`has_deco_type`, `has_deco_stop`, `has_positive_ceiling`, `first_depth`, `last_depth`, `start_timestamp`, `end_timestamp`); the two aggregations (sustained ascent and descent rates, time-at-depth buckets) move to Dart over decoded primary series on a worker isolate with the same window and threshold constants. Sync registers `diveProfileSeries` and `tankPressureSeries` the way `gpsTracks` is registered (a BLOB entity exported by `hlc > since`, base64 on the wire). The legacy entities leave `_baseTables` and `SyncData.toJson` but stay parseable on the inbound side; after a merge that carried legacy rows, the migration packer runs so a new dive from an older peer lands as series. + +**Tech Stack:** Flutter, Drift 2.34.3, SQLite; `ProfileSeriesCodec` / `TankPressureSeriesCodec` (PR 1); `flutter test`; `dart format`; `flutter analyze`; `compute` from `package:flutter/foundation.dart`. + +**Spec:** `docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md` (sections 3, 6 "Reads", 7, 9, 12 bind this plan; section 10's benchmark gates and section 8's VACUUM are plan 2e). The 2b/2c hand-off notes (session scratchpad `2b-handoff-checklist.md`) are folded into the constraints and Task 9. + +## Global Constraints + +- Schema version stays 182. No migration, no new table, no new column. `AppDatabase.minimumCompatibleSchemaVersion` rises from 170 to 182 (Task 3) with the doc paragraph the existing comment format requires. +- Series summary columns are the SQL surface: `dive_profile_series(id, dive_id, computer_id, source_id, is_primary, sample_count, start_timestamp, end_timestamp, max_depth, first_depth, last_depth, has_deco_type, has_deco_stop, has_positive_ceiling, codec_version, samples, created_at, updated_at, hlc)` and `tank_pressure_series(id, dive_id, tank_id, computer_id, sample_count, start_timestamp, end_timestamp, codec_version, samples, created_at, updated_at, hlc)`. No SQL in this plan decodes `samples`. +- Decode only inside the two series repositories and, for the two whole-library aggregations, inside the worker-isolate entry points of `lib/features/statistics/data/series_profile_aggregates.dart` (spec section 6: "Whole-library aggregations decode on a worker isolate via `compute`"). +- Parity rule: each moved consumer returns exactly what the legacy SQL returned for the equivalent rows. The legacy scanned ALL rows of a dive for the deco flags and the profile span, PRIMARY rows only for quality samples, prefilters and the two aggregations; the series versions scan the same set of series. +- Legacy DELETE statements and the 2b `_...Legacy` fallbacks stay (plan 2e removes them). No task in this plan adds a legacy INSERT or UPDATE; the three existing `UPDATE dive_profiles SET computer_id` statements STAY and gain series twins beside them. +- Sync: the series entities are exported by `hlc > since` with `blob: true` (base64 `samples`), applied by `insertOnConflictUpdate` on the series id (LWW by hlc through the existing `_mergeEntity`), tombstoned one per series, guarded by `parentRefs` (`dives`, `diveComputers`, `diveDataSources`, plus `diveTanks` for tank series). The legacy entities keep their inbound apply, delete, `recordIdsFor` and `tableFor` cases and their `parentRefs` entries, but leave `_baseTables`, `SyncData.toJson`, `exportChangeset` and `entityHasUpdatedAt`. +- Apply-time validation: a series row arriving from a peer is decoded once before it is written; a blob that fails `ProfileSeriesCodec.decode` / `TankPressureSeriesCodec.decode`, or whose decoded sample count differs from `sampleCount`, is logged and skipped (never written). A corrupt peer blob must not reach the readers. +- Drift name clash: files that import both `database.dart` and `entities/profile_series.dart` import the entity file `as series`. +- No em-dashes (U+2014) or en-dashes used as punctuation anywhere (code, comments, tests, commit messages). No emojis. Immutability: never mutate a list handed in; build new lists. +- TDD per task; `dart format .` from the worktree root; `flutter analyze` zero issues (infos included); tests run per file with `flutter test `, never piped; stage explicit paths; one local commit per task with the message given; no push; no `Co-Authored-By` trailer; never `git stash`. +- Worktree: `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-sample-storage-2`, branch `worktree-profile-sample-storage-2`, absolute paths only. +- Known red on entry (plan 2c left them; every one goes green in this plan): `quality_context_builder_test` (2), `quality_prefilters_test` (1), `dashboard_queries_test` (1), `dive_repository_deco_filter_test` (3), `dive_times_accessors_test` (2), `deco_filter_providers_test` (3), `consolidation_sync_roundtrip_test` (1). `local_file_resolver_test` is a machine flake (mounted `$TMPDIR`). + +## File structure + +| File | Responsibility after this plan | +|---|---| +| `lib/core/services/sync/sync_data_serializer.dart` | series entities in `_baseTables`, `SyncData`, `exportChangeset`, `fetchRecord`, `upsertRecord`, `upsertRecords`, `recordIdsFor`, `tableFor`, `deleteRecord`; apply-time blob validation; legacy entities inbound-only; `packLegacySamples()` shim entry | +| `lib/core/services/sync/sync_service.dart` | `mergeOrder`, `entityHasUpdatedAt`, `parentRefs` entries for the series; post-merge packing call | +| `lib/core/database/database.dart` | `minimumCompatibleSchemaVersion = 182` and its doc paragraph | +| `lib/features/statistics/data/dive_filter_sql.dart` | `decoSignalCondition` over series flags | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart` | `had_deco`, `_effectiveRuntimeSql`, `_diveTimesSelect` over series; `readsFrom` sets | +| `lib/features/statistics/data/repositories/statistics_repository.dart` | `scanRecordedDecoSignals` over series flags; `getAscentDescentRates` / `getTimeAtDepthRanges` delegate to the Dart aggregates; `watchStatisticsChanges` watches the series tables | +| `lib/features/statistics/data/series_profile_aggregates.dart` (new) | pure aggregation functions over decoded series plus the `compute` entry points that decode raw blobs | +| `lib/features/data_quality/data/services/quality_context_builder.dart`, `quality_prefilters.dart` | primary samples and pressures from the series repositories; neighbour first/last depth and prefilter EXISTS over series | +| `lib/features/dive_log/data/repositories/profile_series_repository.dart`, `tank_pressure_series_repository.dart` | `clearComputer`, `clearComputersOfDiverForForeignDives`, `relinkComputer` (profile only) | +| `lib/features/divers/data/repositories/diver_repository.dart`, `lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart` | series twins beside the three legacy `UPDATE dive_profiles` statements; `getDiveIdsForComputer` over series | + +--- + +### Task 1: Register the series entities in sync + +**Files:** +- Modify: `lib/core/services/sync/sync_data_serializer.dart` (`SyncData` fields/`toJson`/`fromJson`; `_baseTables`; `exportChangeset`; `fetchRecord`; `upsertRecord`; `upsertRecords`; `recordIdsFor`; `tableFor`; `deleteRecord`) +- Modify: `lib/core/services/sync/sync_service.dart` (`mergeOrder` entries; `entityHasUpdatedAt`; `parentRefs`) +- Modify: `test/core/services/sync/sync_parent_refs_completeness_test.dart` (`syncedTables` gains the two tables) +- Test: `test/core/services/sync/profile_series_sync_test.dart` (new) + +**Interfaces:** +- Consumes: `DiveProfileSeriesRow` / `TankPressureSeriesRow` (Drift data classes with `fromJson(json, {serializer})` / `toJson({serializer})`), `_syncBlobSerializer`, `_exportGpsTracks` as the pattern, `ProfileSeriesCodec().decode(Uint8List)` and `TankPressureSeriesCodec().decode(Uint8List)` (each returns an object exposing `samples`; check the exact return type in `lib/features/dive_log/domain/codecs/`), `ProfileSeriesCodecException` (and the tank codec's exception type; check its name). +- Produces: wire keys `diveProfileSeries` and `tankPressureSeries` (the entity types already used by `SyncRepository.hlcTargets` and the repositories' tombstones); `SyncData.diveProfileSeries` / `SyncData.tankPressureSeries` (`List>`); `SyncDataSerializer` cases for both types in every switch; `SyncService.parentRefs['diveProfileSeries']` and `['tankPressureSeries']`. + +- [ ] **Step 1: Write the failing test** + +`test/core/services/sync/profile_series_sync_test.dart`, modelled on `test/features/dive_log/integration/consolidation_sync_roundtrip_test.dart` (two `AppDatabase`s, `switchTo(db)` via `DatabaseService.instance.setTestDatabase`, `FakeCloudStorageProvider`, `SyncService(syncRepository: SyncRepository(), serializer: SyncDataSerializer(), cloudProvider: cloud)`, `performSync()`), with FK parents seeded on both devices the way that test's `seedFkPrereqs` does (diver, computers, tags): + +```dart + test('a series pushed by A arrives on B byte for byte and reads back', () async { + dbB = await setUpTestDatabase(); + dbA = await setUpTestDatabase(); + switchTo(dbA); + await seedFkPrereqs(dbA); + await DiveRepository().createDive( + domain.Dive( + id: 'd1', + dateTime: DateTime.utc(2026, 1, 1, 10), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 0.0), + domain.DiveProfilePoint(timestamp: 60, depth: 18.5, decoType: 2, ceiling: 3.0), + ], + ), + ); + final rowOnA = (await ProfileSeriesRepository().getRowsForDives(['d1'])).single; + expect((await buildService().performSync()).isSuccess, isTrue); + + switchTo(dbB); + await seedFkPrereqs(dbB); + expect((await buildService().performSync()).isSuccess, isTrue); + final rowOnB = (await ProfileSeriesRepository().getRowsForDives(['d1'])).single; + expect(rowOnB.id, rowOnA.id); + expect(rowOnB.samples, rowOnA.samples); + expect(rowOnB.hasDecoStop, isTrue); + expect((await DiveRepository().getDiveProfile('d1')).map((p) => p.depth), [0.0, 18.5]); + }); + + test('a series deleted on A is tombstoned and removed on B', () async { + // seed as above, sync both ways, then on A: + await ProfileSeriesRepository().deleteForDive('d1'); + expect((await buildService().performSync()).isSuccess, isTrue); + switchTo(dbB); + expect((await buildService().performSync()).isSuccess, isTrue); + expect(await ProfileSeriesRepository().getSeriesForDive('d1'), isEmpty); + }); + + test('fetchRecord carries samples as base64 and upsertRecord round-trips it', () async { + dbA = await setUpTestDatabase(); + switchTo(dbA); + await seedFkPrereqs(dbA); + final id = await ProfileSeriesRepository().insertSeries( + diveId: 'd1', // seed dive d1 first with DivesCompanion.insert + samples: const [ProfileSample(timestamp: 0, depth: 1.0)], + now: 1000, + ); + final json = await SyncDataSerializer().fetchRecord('diveProfileSeries', id); + expect(json!['samples'], isA()); + await ProfileSeriesRepository().deleteForDive('d1'); + await SyncDataSerializer().upsertRecord('diveProfileSeries', json); + expect((await ProfileSeriesRepository().getSeriesForDive('d1')).single.samples.single.depth, 1.0); + }); + + test('a corrupt peer blob is skipped, never written', () async { + // fetchRecord as above, then corrupt: json['samples'] = base64Encode([1, 2, 3]); + await SyncDataSerializer().upsertRecord('diveProfileSeries', corrupted); + expect(await ProfileSeriesRepository().getSeriesForDive('d1'), isEmpty); + }); + + test('a peer blob whose sample count disagrees with the header is skipped', () async { + // fetchRecord, then json['sampleCount'] = 99; upsert; expect no row. + }); +``` + +Add the tank twins of the first and third tests (`TankPressureSeriesRepository`, a tank seeded on both devices, `getRowsForDives`, `fetchRecord('tankPressureSeries', id)`). Write every body in full; the `seedFkPrereqs` / `switchTo` / `buildService` helpers are copied from the consolidation round-trip test. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/services/sync/profile_series_sync_test.dart` +Expected: B holds no series (the entity is not exported); `fetchRecord` returns null for the unknown type. + +- [ ] **Step 3: Serializer registration** + +In `sync_data_serializer.dart`: + +1. `SyncData`: add `final List> diveProfileSeries;` and `final List> tankPressureSeries;` as the LAST two fields, constructor parameters `this.diveProfileSeries = const [], this.tankPressureSeries = const []` last, `toJson` entries `'diveProfileSeries': diveProfileSeries, 'tankPressureSeries': tankPressureSeries` last, `fromJson` entries `diveProfileSeries: _parseList(json['diveProfileSeries']), tankPressureSeries: _parseList(json['tankPressureSeries'])` last. Order matters: `_baseTables` must list exactly `SyncData.toJson` keys in order (`base_publish_streaming_parity_test`). +2. `_baseTables`: append `(key: 'diveProfileSeries', table: _db.diveProfileSeries, blob: true, full: null)` and `(key: 'tankPressureSeries', table: _db.tankPressureSeries, blob: true, full: null)` as the LAST two entries (after `fieldPresets`; every FK parent, `dives`, `diveComputers`, `diveTanks`, `diveDataSources`, precedes them). +3. `exportChangeset`: after the last `_safeExport` add `diveProfileSeries: await _safeExport('diveProfileSeries', () => _exportDiveProfileSeries(hlcSince)), tankPressureSeries: await _safeExport('tankPressureSeries', () => _exportTankPressureSeries(hlcSince)),` and add, next to `_exportGpsTracks`: + +```dart + Future>> _exportDiveProfileSeries( + String? hlcSince, + ) async { + final query = _db.select(_db.diveProfileSeries); + if (hlcSince != null) { + query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); + } + final rows = await query.get(); + // The packed samples BLOB rides as base64, like gps_tracks.points. + return rows.map((r) => r.toJson(serializer: _syncBlobSerializer)).toList(); + } + + Future>> _exportTankPressureSeries( + String? hlcSince, + ) async { + final query = _db.select(_db.tankPressureSeries); + if (hlcSince != null) { + query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); + } + final rows = await query.get(); + return rows.map((r) => r.toJson(serializer: _syncBlobSerializer)).toList(); + } +``` + +4. `fetchRecord`: cases mirroring `gpsTracks` (`select ... where id equals`, `row?.toJson(serializer: _syncBlobSerializer)`). +5. `upsertRecord`: + +```dart + case 'diveProfileSeries': + final row = DiveProfileSeriesRow.fromJson( + data, + serializer: _syncBlobSerializer, + ); + if (!_profileSeriesBlobIsSound(row)) return; + await _db.into(_db.diveProfileSeries).insertOnConflictUpdate(row); + return; + case 'tankPressureSeries': + final row = TankPressureSeriesRow.fromJson( + data, + serializer: _syncBlobSerializer, + ); + if (!_tankSeriesBlobIsSound(row)) return; + await _db.into(_db.tankPressureSeries).insertOnConflictUpdate(row); + return; +``` + +with + +```dart + /// A peer's packed samples are decoded once before they are written so a + /// corrupt or truncated blob never reaches the readers. Returns false (and + /// logs) when the blob does not decode or its sample count disagrees with + /// the header the row carries. + bool _profileSeriesBlobIsSound(DiveProfileSeriesRow row) { + try { + final decoded = const ProfileSeriesCodec().decode(row.samples); + if (decoded.samples.length != row.sampleCount) { + _log.warning( + 'Skipping diveProfileSeries ${row.id}: header says ' + '${row.sampleCount} samples, blob holds ${decoded.samples.length}', + ); + return false; + } + return true; + } on ProfileSeriesCodecException catch (e) { + _log.warning('Skipping diveProfileSeries ${row.id}: $e'); + return false; + } + } +``` + +and the tank twin (`TankPressureSeriesCodec`, its exception type). Check the codec API (`decode` return shape and exception names) in `lib/features/dive_log/domain/codecs/` before writing; if `decode` returns a record or a class with a different accessor, adapt the two lines that read `samples`. `_log` is the serializer's existing logger field (grep `_log` in the file; if the file logs through another name, use that). + +6. `upsertRecords`: batch cases like `gpsTracks` but filtered through the soundness check: + +```dart + case 'diveProfileSeries': + final rows = [ + for (final r in records) + DiveProfileSeriesRow.fromJson(r, serializer: _syncBlobSerializer), + ].where(_profileSeriesBlobIsSound).toList(); + if (rows.isEmpty) return; + await _db.batch( + (b) => b.insertAllOnConflictUpdate(_db.diveProfileSeries, rows), + ); + return; +``` + +and the tank twin. + +7. `recordIdsFor`: `case 'diveProfileSeries': return plain(_db.diveProfileSeries, _db.diveProfileSeries.id);` and the tank twin. `tableFor`: return the two tables. `deleteRecord`: `delete ... where id equals recordId` for both, like `gpsTracks`. + +In `sync_service.dart`: + +8. `mergeOrder`: insert, immediately AFTER the `diveDataSources` entry, `(type: 'diveProfileSeries', records: data.diveProfileSeries, hasUpdatedAt: true), (type: 'tankPressureSeries', records: data.tankPressureSeries, hasUpdatedAt: true),` (every FK parent precedes: `diveComputers`, `dives`, `diveTanks`, `diveDataSources`). +9. `entityHasUpdatedAt`: `'diveProfileSeries': true, 'tankPressureSeries': true,`. +10. `parentRefs`: + +```dart + 'diveProfileSeries': [ + (field: 'diveId', parent: 'dives', nullable: false), + (field: 'computerId', parent: 'diveComputers', nullable: true), + (field: 'sourceId', parent: 'diveDataSources', nullable: true), + ], + 'tankPressureSeries': [ + (field: 'diveId', parent: 'dives', nullable: false), + (field: 'tankId', parent: 'diveTanks', nullable: false), + (field: 'computerId', parent: 'diveComputers', nullable: true), + ], +``` + +11. `test/core/services/sync/sync_parent_refs_completeness_test.dart`: add `'dive_profile_series': 'diveProfileSeries', 'tank_pressure_series': 'tankPressureSeries',` to `syncedTables`. + +- [ ] **Step 4: Run the tests** + +Run the new file, then `test/core/services/sync/base_publish_streaming_parity_test.dart`, `sync_parent_refs_completeness_test.dart`, `sync_hlc_target_registration_test.dart`, the structural test that asserts `entityHasUpdatedAt` covers exactly the `SyncData` entities (`grep -rl "entityHasUpdatedAt" test/core/services/sync`), `cross_version_roundtrip_test.dart`, `test/features/dive_log/integration/consolidation_sync_roundtrip_test.dart` (this was red on entry; it must pass now), and `grep -rl "SyncDataSerializer()" test/core/services/sync | head -20` files. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/sync/sync_data_serializer.dart lib/core/services/sync/sync_service.dart test/core/services/sync/sync_parent_refs_completeness_test.dart test/core/services/sync/profile_series_sync_test.dart +git commit -m "feat(sync): profile and tank pressure series are synced entities with apply-time blob validation" +``` + +--- + +### Task 2: Legacy sample entities become inbound-only, packed on arrival + +**Files:** +- Modify: `lib/core/services/sync/sync_data_serializer.dart` (`_baseTables`, `SyncData.toJson`, `exportChangeset`; delete `_exportDiveProfiles` / `_exportTankPressureProfiles`; add `packLegacySamples`) +- Modify: `lib/core/services/sync/sync_service.dart` (`entityHasUpdatedAt`; post-merge packing) +- Test: `test/core/services/sync/legacy_sample_entities_inbound_test.dart` (new) + +**Interfaces:** +- Consumes: `packLegacyProfileRows(DatabaseConnectionUser db, {int? nowMs})` from `lib/core/database/profile_series_pack.dart` (packs every dive that has legacy rows and NO series row, deterministic migrated ids, one series per identity group); the legacy `upsertRecord` / `upsertRecords` cases for `diveProfiles` / `tankPressureProfiles` (kept). +- Produces: `Future SyncDataSerializer.packLegacySamples()`; `SyncData.diveProfiles` / `tankPressureProfiles` documented as inbound-only (parsed by `fromJson`, absent from `toJson`). + +- [ ] **Step 1: Write the failing test** + +`test/core/services/sync/legacy_sample_entities_inbound_test.dart`, on the fixture of `cross_version_roundtrip_test.dart` (`seedPeerBaseFromPayload(cloud, 'peer-181', payload)` with a hand-built `SyncPayload`, then `performSync()`): + +```dart + test('exportChangeset carries no legacy sample entities', () async { + await DiveRepository().createDive(dive('d1', twoPoints)); + final data = await SyncDataSerializer().exportChangeset( + deviceId: 'me', + hlcWatermark: null, + deletions: const [], + ); + expect(data.toJson().containsKey('diveProfiles'), isFalse); + expect(data.toJson().containsKey('tankPressureProfiles'), isFalse); + expect(data.diveProfileSeries, hasLength(1)); + }); + + test('a v181 peer payload with legacy rows for a new dive lands as a series', () async { + // build a dives row (fetchRecord('dives', ...) style, see cross_version_roundtrip_test) + // for 'd-old' plus two legacy diveProfiles maps: + final profiles = [ + {'id': 'p1', 'diveId': 'd-old', 'timestamp': 0, 'depth': 0.0, 'isPrimary': true}, + {'id': 'p2', 'diveId': 'd-old', 'timestamp': 60, 'depth': 12.0, 'isPrimary': true}, + ]; + final data = SyncData(dives: [diveRow], diveProfiles: profiles); + // SyncData.fromJson(data.toJson()) would DROP diveProfiles (inbound only), + // so build the payload JSON by hand: jsonEncode({...data.toJson(), 'diveProfiles': profiles}) + await pullPeerPayload(payloadJson); + final series = await ProfileSeriesRepository().getSeriesForDive('d-old'); + expect(series, hasLength(1)); + expect(series.single.samples.map((s) => s.depth), [0.0, 12.0]); + expect(series.single.id, profileSeriesMigratedId(diveId: 'd-old', computerId: null, sourceId: null, isPrimary: true)); + }); + + test('legacy rows for a dive that already has series are ignored', () async { + await DiveRepository().createDive(dive('d1', twoPoints)); + final before = (await ProfileSeriesRepository().getSeriesForDive('d1')).single; + // payload with legacy diveProfiles for d1 carrying depth 99 + await pullPeerPayload(payloadJson); + final after = (await ProfileSeriesRepository().getSeriesForDive('d1')).single; + expect(after.id, before.id); + expect(after.samples.map((s) => s.depth), before.samples.map((s) => s.depth)); + }); + + test('SyncData.fromJson still parses the legacy keys', () { + final data = SyncData.fromJson({'diveProfiles': [{'id': 'x'}], 'tankPressureProfiles': [{'id': 'y'}]}); + expect(data.diveProfiles.single['id'], 'x'); + expect(data.tankPressureProfiles.single['id'], 'y'); + }); +``` + +Check `profileSeriesMigratedId`'s exact named parameters in `lib/features/dive_log/domain/entities/profile_series_identity.dart`. Write every body in full. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/services/sync/legacy_sample_entities_inbound_test.dart` +Expected: the export test fails on `containsKey('diveProfiles')`; the inbound tests fail on an empty series list. + +- [ ] **Step 3: Implement** + +`sync_data_serializer.dart`: +- Remove the `diveProfiles` and `tankPressureProfiles` entries from `_baseTables` and from `SyncData.toJson`; keep the two fields, their constructor defaults and their `fromJson` lines, and add above the fields: `/// Inbound only since v182: older peers still send row-per-sample arrays; they apply into the legacy tables and are packed into series by [SyncDataSerializer.packLegacySamples]. Never exported, absent from [toJson].` +- Remove the two `_safeExport('diveProfiles', ...)` / `_safeExport('tankPressureProfiles', ...)` lines from `exportChangeset`; delete `_exportDiveProfiles` and `_exportTankPressureProfiles`. +- Add: + +```dart + /// Packs legacy row-per-sample rows that an older peer's changeset left in + /// `dive_profiles` / `tank_pressure_profiles` into series rows. Dives that + /// already have a series are left alone: the peer is held below the floor + /// and will migrate its own rows when it upgrades. + Future packLegacySamples() => packLegacyProfileRows(_db); +``` + +(import `package:submersion/core/database/profile_series_pack.dart`; `ProfilePackReport` is its typedef.) + +`sync_service.dart`: +- Remove `'diveProfiles'` and `'tankPressureProfiles'` from `entityHasUpdatedAt` (the structural test compares its keys to `SyncData.toJson`). Keep their `mergeOrder` entries (they read `data.diveProfiles` / `data.tankPressureProfiles`, still parsed inbound), their `parentRefs` entries, and every legacy switch case in the serializer. +- In the merge method, after the `for (final entry in mergeOrder)` loop and BEFORE `await _serializer.repairDanglingForeignKeys();`, add: + +```dart + if (data.diveProfiles.isNotEmpty || data.tankPressureProfiles.isNotEmpty) { + // v182 receive-side tolerance: an older peer's row-per-sample arrays + // become series for dives that have none yet. + final packed = await _serializer.packLegacySamples(); + _log.info( + 'Packed legacy sample rows from a peer: ' + '${packed.profileSeries} profile series, ' + '${packed.tankSeries} tank series', + ); + } +``` + +(`data` is the `SyncData` the merge method receives; check the parameter name in `sync_service.dart` and use it.) + +- [ ] **Step 4: Run the tests** + +Run the new file, `profile_series_sync_test.dart`, `base_publish_streaming_parity_test.dart`, the `entityHasUpdatedAt` structural test, `cross_version_roundtrip_test.dart`, `consolidation_sync_roundtrip_test.dart`, and every test under `test/core/services/sync/` that mentions `diveProfiles` or `tankPressureProfiles` (`grep -rl "diveProfiles\|tankPressureProfiles" test/core/services/sync`); expectations that asserted the legacy keys in an EXPORT change to the series keys; expectations about INBOUND legacy apply stay. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/sync/sync_data_serializer.dart lib/core/services/sync/sync_service.dart test/core/services/sync/legacy_sample_entities_inbound_test.dart +git commit -m "feat(sync): legacy sample entities are inbound-only and packed into series on arrival" +``` + +--- + +### Task 3: Raise the schema floor to 182 + +**Files:** +- Modify: `lib/core/database/database.dart` (`minimumCompatibleSchemaVersion` and its doc comment, near line 3460) +- Modify: `test/core/services/sync/cross_version_roundtrip_test.dart` (one new group for the v182 boundary) +- Verify: whatever test pins the floor value today (`grep -rln "minimumCompatibleSchemaVersion" test`); update its expected number. + +**Interfaces:** +- Consumes: Task 2's inbound packing (`SyncDataSerializer.packLegacySamples`, the kept legacy apply cases). +- Produces: `AppDatabase.minimumCompatibleSchemaVersion == 182`. + +- [ ] **Step 1: Write the failing test** + +Append to `cross_version_roundtrip_test.dart` a group `'v181 peer round-trip across the series boundary (plan 2d)'` with the same `setUp` / `buildService` / `pullPeerDive`-style helpers as the v137 group (copy them; the payload helper takes a `SyncData` plus a raw legacy map because `SyncData.toJson` no longer carries the legacy keys): + +```dart + test('the floor is 182', () { + expect(AppDatabase.minimumCompatibleSchemaVersion, 182); + }); + + test('an old peer that still sends dive_profiles rows produces a series here', () async { + // seedModernDive('dive-old') as the v137 group does, then delete its + // series locally so the dive looks like one the peer created: + await ProfileSeriesRepository().deleteForDive('dive-old'); + await SyncRepository().resetSyncState(); + final legacyRows = [ + {'id': 'r1', 'diveId': 'dive-old', 'timestamp': 0, 'depth': 0.0, 'isPrimary': true}, + {'id': 'r2', 'diveId': 'dive-old', 'timestamp': 30, 'depth': 9.0, 'isPrimary': true}, + ]; + await pullPeerPayloadWithLegacy(diveRow, legacyRows); + final series = await ProfileSeriesRepository().getSeriesForDive('dive-old'); + expect(series.single.samples.map((s) => s.depth), [0.0, 9.0]); + }); + + test('a series row pushed by a modern peer applies with LWW by hlc', () async { + // seedModernDive('dive-new'); fetchRecord('diveProfileSeries', id) of its + // series; bump hlc and updatedAt by 60000 like the v137 group does for + // dives; replace samples with a re-encoded two-sample blob built through + // ProfileSeriesCodec().encode (base64 via _syncBlobSerializer semantics: + // base64Encode(bytes)); pull; assert getDiveProfile shows the peer's samples. + }); +``` + +Write the bodies in full; `pullPeerPayloadWithLegacy` builds the payload JSON as `{...data.toJson(), 'diveProfiles': legacyRows}` and signs it the way the v137 helper does (`sha256` over the encoded `data` map that INCLUDES the legacy key; check `SyncPayload` / `seedPeerBaseFromPayload` in `test/helpers/changeset_test_helpers.dart` for how the checksum is computed and mirror it). + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/services/sync/cross_version_roundtrip_test.dart` +Expected: 'the floor is 182' fails (170). + +- [ ] **Step 3: Raise the floor** + +In `database.dart`, above `static const int minimumCompatibleSchemaVersion = 170;` add the paragraph in the existing format and change the value: + +```dart + /// Raised 170 -> 182 by the packed profile series: v182 replaces the synced + /// entities diveProfiles and tankPressureProfiles with diveProfileSeries and + /// tankPressureSeries, which the first rule above classifies as breaking. + /// Peers below 182 are held until they update. Their payloads still arrive + /// here; SyncData keeps the two legacy keys inbound-only and + /// SyncDataSerializer.packLegacySamples packs them into series on apply. + static const int minimumCompatibleSchemaVersion = 182; +``` + +Update the floor-pinning test found in Step 0 to 182. + +- [ ] **Step 4: Run the tests** + +Run `cross_version_roundtrip_test.dart`, the floor-pinning test, `test/core/services/sync/sync_service_test.dart` (if it exists), and `grep -rl "held\|schema_version\|minimumCompatible" test/core/services/sync | head` files. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/database/database.dart test/core/services/sync/cross_version_roundtrip_test.dart +git commit -m "feat(sync): raise the schema floor to 182 for the series entities" +``` + +--- + +### Task 4: Deco signals over series flags, watchers, summary equivalence + +**Files:** +- Modify: `lib/features/statistics/data/dive_filter_sql.dart` (`decoSignalCondition`) +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`had_deco` near line 4802 and its `readsFrom`; the `readsFrom` sets of every query that calls `decoSignalCondition`, near lines 2395 and 2508; the two watchers' doc comments already name the series tables) +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart` (`scanRecordedDecoSignals` near line 2419; `watchStatisticsChanges` near line 119) +- Test: `test/features/dive_log/domain/codecs/summary_flags_sql_equivalence_test.dart` (new; spec section 10's summary-scalar equivalence) +- Verify (red on entry, must pass): `test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart` (3), `test/features/dive_log/presentation/providers/deco_filter_providers_test.dart` (3); plus `grep -rl "scanRecordedDecoSignals\|decoSignalCondition\|had_deco\|hadDeco" test` files. + +**Interfaces:** +- Consumes: the series flag columns `has_deco_type`, `has_deco_stop`, `has_positive_ceiling` (booleans stored as 0/1). +- Produces: `decoSignalCondition({required bool wantDeco, required String diveIdRef})` with the same signature and truth table, over series. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_log/domain/codecs/summary_flags_sql_equivalence_test.dart`: for a dive seeded through `ProfileSeriesRepository().insertSeries` with samples covering each case (no deco fields at all; `decoType: 1` only; `decoType: 2`; `ceiling: 3.0` with no deco type; `ceiling: 0.0`), assert that the row's `has_deco_type`, `has_deco_stop`, `has_positive_ceiling` equal what the legacy SQL would compute over the decoded samples, i.e. `samples.any((s) => s.decoType != null)`, `samples.any((s) => s.decoType == 2)`, `samples.any((s) => (s.ceiling ?? 0) > 0)`, and that `max_depth`, `first_depth`, `last_depth`, `start_timestamp`, `end_timestamp`, `sample_count` equal the obvious folds over the decoded samples. One test per case, reading the raw row with `getRowsForDives`. + +Then run the two red files to see the failing assertions (they build dives with `createDive` carrying `decoType` / `ceiling` points and expect the deco classification). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run the new file (may pass already if the codec summary is correct; that is fine, it is the pin) and the two red files (fail on classification sets). + +- [ ] **Step 3: Move the SQL** + +`dive_filter_sql.dart`: + +```dart + final hasDecoStop = + 'EXISTS (SELECT 1 FROM dive_profile_series s ' + 'WHERE s.dive_id = $diveIdRef AND s.has_deco_stop = 1) ' + "OR EXISTS (SELECT 1 FROM dive_profile_events e " + "WHERE e.dive_id = $diveIdRef AND e.event_type = 'decoStopStart')"; + final hasDecoType = + 'EXISTS (SELECT 1 FROM dive_profile_series s ' + 'WHERE s.dive_id = $diveIdRef AND s.has_deco_type = 1)'; + final hasPositiveCeiling = + 'EXISTS (SELECT 1 FROM dive_profile_series s ' + 'WHERE s.dive_id = $diveIdRef AND s.has_positive_ceiling = 1)'; +``` + +(the combination logic below is unchanged; update the doc comment's "profile" wording to name the series flags). + +`dive_repository_impl.dart` `had_deco`: + +```dart + 'EXISTS(SELECT 1 FROM dive_profile_series s WHERE s.dive_id = d.id ' + 'AND (s.has_deco_stop = 1 OR s.has_positive_ceiling = 1)) AS had_deco ' +``` + +and `readsFrom: {_db.dives, _db.diveProfileSeries}` there and on every query whose WHERE embeds `decoSignalCondition` (they also read `_db.diveProfileEvents`; keep that). + +`statistics_repository.dart` `scanRecordedDecoSignals`: + +```dart + signals AS ( + SELECT + s.dive_id AS dive_id, + MAX(CASE WHEN ps.id IS NOT NULL THEN 1 ELSE 0 END) AS has_profile, + MAX(CASE WHEN ps.has_deco_type = 1 THEN 1 ELSE 0 END) + AS has_deco_type, + MAX(CASE WHEN ps.has_deco_stop = 1 THEN 1 ELSE 0 END) AS deco_stop, + MAX(CASE WHEN ps.has_positive_ceiling = 1 THEN 1 ELSE 0 END) + AS positive_ceiling + FROM scoped s + LEFT JOIN dive_profile_series ps ON ps.dive_id = s.dive_id + GROUP BY s.dive_id + ), +``` + +(the rest of the query unchanged). `watchStatisticsChanges`: add `TableUpdateQuery.onTable(_db.diveProfileSeries)` and `TableUpdateQuery.onTable(_db.tankPressureSeries)` after the two legacy entries (legacy entries stay until 2e). + +- [ ] **Step 4: Run the tests** + +Run the new file, the two formerly-red files, and every file from `grep -rl "scanRecordedDecoSignals\|decoSignalCondition\|had_deco\|hadDeco\|watchStatisticsChanges" test`. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/statistics/data/dive_filter_sql.dart lib/features/dive_log/data/repositories/dive_repository_impl.dart lib/features/statistics/data/repositories/statistics_repository.dart test/features/dive_log/domain/codecs/summary_flags_sql_equivalence_test.dart +git commit -m "feat(series): deco signals read the series summary flags; statistics watch the series tables" +``` + +--- + +### Task 5: Profile span, quality context and prefilters over series + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`_effectiveRuntimeSql` near line 3388; `_diveTimesSelect` near line 4920; the `readsFrom` sets of every query using them, near lines 3432, 3456, 4955, 4981, 5010) +- Modify: `lib/features/data_quality/data/services/quality_context_builder.dart` (`_build` sample and pressure loads; `_neighbors` first/last depth) +- Modify: `lib/features/data_quality/data/services/quality_prefilters.dart` (`withProfiles`, `withPressures`) +- Test: `test/features/dive_log/data/repositories/profile_span_series_test.dart` (new) +- Verify (red on entry, must pass): `test/features/data_quality/data/quality_context_builder_test.dart` (2), `quality_prefilters_test.dart` (1), `test/features/dive_log/data/repositories/dashboard_queries_test.dart` (1), `dive_times_accessors_test.dart` (2). + +**Interfaces:** +- Consumes: `ProfileSeriesRepository.getSeriesForDive(diveId, {primaryOnly})`, `TankPressureSeriesRepository.getSeriesForDive(diveId)`, `mergeSeriesPoints`, `mergeTankSeriesPoints` (plan 2b, `lib/features/dive_log/domain/services/profile_series_merge.dart`), series columns `start_timestamp`, `end_timestamp`, `first_depth`, `last_depth`, `is_primary`. +- Produces: nothing new for later tasks. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_log/data/repositories/profile_span_series_test.dart`: seed a dive with no `runtime`, no `exit_time`, `bottom_time` 40 minutes, and two series (a primary one t=0..600 and a demoted one t=0..900, both through `ProfileSeriesRepository().insertSeries`); assert `DiveRepository().getDiveTimes(diveId)` reports `effectiveRuntime` of 900 seconds (the span over ALL series, as the legacy span was over all rows), and that a dive with a single-sample series (zero span) falls through to `bottom_time` (2400 seconds). Use the accessor the red `dive_times_accessors_test.dart` uses (`getDiveTimes` and its `DiveTimes.effectiveRuntime`). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run the new file and the four red files. +Expected: spans null / samples empty. + +- [ ] **Step 3: Move the SQL and the loads** + +`dive_repository_impl.dart`: + +```dart + 'NULLIF((SELECT MAX(s.end_timestamp) - MIN(s.start_timestamp) ' + 'FROM dive_profile_series s WHERE s.dive_id = d.id), 0), ' +``` + +in `_effectiveRuntimeSql` (replacing the `dive_profiles` subquery, keeping the surrounding COALESCE), and + +```dart + '(SELECT MAX(s.end_timestamp) - MIN(s.start_timestamp) ' + 'FROM dive_profile_series s WHERE s.dive_id = d.id) AS profile_span ' +``` + +in `_diveTimesSelect`. Every `readsFrom: {_db.dives, _db.diveProfiles}` on a query that embeds either constant becomes `readsFrom: {_db.dives, _db.diveProfileSeries}`; grep `_effectiveRuntimeSql\|_diveTimesSelect` to find them all (lines near 3432, 3456, 4955, 4981, 5010). + +`quality_context_builder.dart` `_build`: replace the `dive_profiles` select with + +```dart + final primarySeries = await _profileSeries.getSeriesForDive( + dive.id, + primaryOnly: true, + ); + final samples = [ + for (final p in mergeSeriesPoints(primarySeries)) + if (p.depth.isFinite && (p.temperature == null || p.temperature!.isFinite)) + QualitySample(t: p.timestamp, depth: p.depth, temp: p.temperature), + ]; +``` + +and the `tank_pressure_profiles` select with + +```dart + final pressures = >{}; + final tankSeries = await _tankSeries.getSeriesForDive(dive.id); + final byTank = >{}; + for (final s in tankSeries) { + byTank.putIfAbsent(s.tankId, () => []).add(s); + } + for (final entry in byTank.entries) { + for (final p in mergeTankSeriesPoints(entry.value)) { + if (!p.pressure.isFinite) continue; + pressures + .putIfAbsent(entry.key, () => []) + .add(QualityPressureSample(t: p.timestamp, bar: p.pressure)); + } + } +``` + +with fields `final _profileSeries = ProfileSeriesRepository(); final _tankSeries = TankPressureSeriesRepository();` and imports of the two repositories, `profile_series_merge.dart`, and the entity file `as series`. `_neighbors`: replace the two scalar subqueries with + +```dart + '(SELECT s.first_depth FROM dive_profile_series s ' + 'WHERE s.dive_id = dives.id AND s.is_primary = 1 ' + 'ORDER BY s.start_timestamp ASC, s.id ASC LIMIT 1) AS first_depth, ' + '(SELECT s.last_depth FROM dive_profile_series s ' + 'WHERE s.dive_id = dives.id AND s.is_primary = 1 ' + 'ORDER BY s.end_timestamp DESC, s.id DESC LIMIT 1) AS last_depth ' +``` + +and `readsFrom: {_db.dives, _db.diveProfileSeries}`. + +`quality_prefilters.dart`: + +```dart + final withProfiles = await ids( + 'SELECT d.id AS id FROM dives d WHERE EXISTS ' + '(SELECT 1 FROM dive_profile_series s WHERE s.dive_id = d.id ' + 'AND s.is_primary = 1)', + ); + final withPressures = await ids( + 'SELECT d.id AS id FROM dives d WHERE EXISTS ' + '(SELECT 1 FROM tank_pressure_series t WHERE t.dive_id = d.id)', + ); +``` + +- [ ] **Step 4: Run the tests** + +Run the new file, the four formerly-red files, `test/features/data_quality/` (`find test/features/data_quality -name '*_test.dart'`, each), `dashboard_queries_test.dart`, `dive_times_accessors_test.dart`, `dive_repository_test.dart`. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart lib/features/data_quality/data/services/quality_context_builder.dart lib/features/data_quality/data/services/quality_prefilters.dart test/features/dive_log/data/repositories/profile_span_series_test.dart +git commit -m "feat(series): profile span, quality context and prefilters read the series tables" +``` + +--- + +### Task 6: Sustained rates and time-at-depth in Dart over decoded series + +**Files:** +- Create: `lib/features/statistics/data/series_profile_aggregates.dart` +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart` (`getAscentDescentRates` near line 2200; `getTimeAtDepthRanges` near line 2297) +- Test: `test/features/statistics/data/series_profile_aggregates_test.dart` (new) +- Verify: `grep -rl "getAscentDescentRates\|getTimeAtDepthRanges" test` files (the statistics repository tests seed dives through `createDive`, which writes series since plan 2c). + +**Interfaces:** +- Consumes: `ProfileSeriesRepository.getRowsForDives(List)` (raw rows: `diveId`, `computerId`, `isPrimary`, `samples`, `codecVersion`), `ProfileSeriesCodec().decode(Uint8List)`, the constants `_rateWindowSeconds` (15, from `AscentRateCalculator.defaultSmoothingWindowSeconds`), `_sustainedTransitThreshold` (3.0), `_maxSampleGapFactor` (4) which move to the new file as public constants. +- Produces: + - `class SeriesBlob { final String diveId; final String? computerId; final Uint8List samples; }` (a plain record for the isolate boundary) + - `({double? avgAscent, double? avgDescent}) ascentDescentRatesFromBlobs(List blobs)` and `List<({int lowerDepth, int? upperDepth, int minutes})> timeAtDepthRangesFromBlobs(List blobs)` (top-level, isolate-safe, decode then aggregate) + - `({double? avgAscent, double? avgDescent}) ascentDescentRates(Map<(String, String?), List> samplesByStream)` and `List<({int lowerDepth, int? upperDepth, int minutes})> timeAtDepthRanges(Map<(String, String?), List> samplesByStream)` (pure, over decoded samples grouped by `(diveId, computerId)`, each list already in `(timestamp, stored order)`) + - `const int rateWindowSeconds = 15; const double sustainedTransitThreshold = 3.0; const int maxSampleGapFactor = 4;` + +- [ ] **Step 1: Write the failing test** + +`test/features/statistics/data/series_profile_aggregates_test.dart` (pure functions, no database): + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/statistics/data/series_profile_aggregates.dart'; + +void main() { + List ramp({required int step, required int count, required double perStep, double start = 0}) => [ + for (var i = 0; i < count; i++) ProfileSample(timestamp: i * step, depth: start + i * perStep), + ]; + + group('ascentDescentRates', () { + test('a steady descent then ascent averages the two transit rates', () { + // 10 s samples: descend 1 m per 10 s for 120 s (6 m/min), hold, ascend + // 0.5 m per 10 s (3 m/min, exactly the threshold, included) + final samples = [ + ...ramp(step: 10, count: 13, perStep: 1.0), // t 0..120, depth 0..12 + for (var i = 1; i <= 6; i++) ProfileSample(timestamp: 120 + i * 10, depth: 12.0), // hold to 180 + for (var i = 1; i <= 24; i++) ProfileSample(timestamp: 180 + i * 10, depth: 12.0 - i * 0.5), // ascend to 0 at 420 + ]; + final r = ascentDescentRates({('d1', 'c1'): samples}); + expect(r.avgDescent, closeTo(6.0, 0.05)); + expect(r.avgAscent, closeTo(3.0, 0.05)); + }); + + test('a flat profile yields nulls', () { + final r = ascentDescentRates({('d1', null): [for (var t = 0; t <= 300; t += 10) ProfileSample(timestamp: t, depth: 20.0)]}); + expect(r.avgAscent, isNull); + expect(r.avgDescent, isNull); + }); + + test('windows are averaged per stream, not across dives', () { + final descent = ramp(step: 10, count: 13, perStep: 1.0); + final r1 = ascentDescentRates({('d1', 'c1'): descent}); + final r2 = ascentDescentRates({('d1', 'c1'): descent, ('d2', 'c1'): descent}); + expect(r2.avgDescent, closeTo(r1.avgDescent!, 1e-9)); + }); + + test('empty input yields nulls', () { + final r = ascentDescentRates(const {}); + expect(r.avgAscent, isNull); + expect(r.avgDescent, isNull); + }); + }); + + group('timeAtDepthRanges', () { + test('buckets whole intervals by the interval start depth and caps gaps', () { + // 60 s at 5 m, 60 s at 15 m, then a 600 s gap (capped by cadence), then 60 s at 25 m + final samples = [ + const ProfileSample(timestamp: 0, depth: 5.0), + const ProfileSample(timestamp: 60, depth: 15.0), + const ProfileSample(timestamp: 120, depth: 25.0), + const ProfileSample(timestamp: 720, depth: 25.0), + const ProfileSample(timestamp: 780, depth: 25.0), + ]; + // cadence cap = (780 - 0) * 4 / (5 - 1) = 780 s, so the 600 s gap is kept whole + final r = timeAtDepthRanges({('d1', null): samples}); + expect(r, [ + (lowerDepth: 0, upperDepth: 10, minutes: 1), + (lowerDepth: 10, upperDepth: 20, minutes: 1), + (lowerDepth: 20, upperDepth: 30, minutes: 11), + ]); + }); + + test('a gap longer than the cadence cap is clipped to the cap', () { + // 10 s cadence for 5 samples then a 3600 s gap: cap = (3640 * 4) / 5 = 2912 + final samples = [ + for (var i = 0; i < 5; i++) ProfileSample(timestamp: i * 10, depth: 45.0), + const ProfileSample(timestamp: 3640, depth: 45.0), + ]; + final r = timeAtDepthRanges({('d1', null): samples}); + expect(r.single.lowerDepth, 40); + expect(r.single.upperDepth, isNull); + expect(r.single.minutes, ((40 + 2912) / 60).round()); + }); + + test('a stream with a single sample contributes nothing', () { + expect(timeAtDepthRanges({('d1', null): const [ProfileSample(timestamp: 0, depth: 10.0)]}), isEmpty); + }); + + test('buckets come back ascending by lower depth', () { + final r = timeAtDepthRanges({('d1', null): [ + const ProfileSample(timestamp: 0, depth: 35.0), + const ProfileSample(timestamp: 60, depth: 5.0), + const ProfileSample(timestamp: 120, depth: 5.0), + ]}); + expect(r.map((b) => b.lowerDepth), [0, 30]); + }); + }); + + test('the blob entry points decode and agree with the pure functions', () { + // encode a stream with ProfileSeriesCodec().encode(samples).bytes (check the + // encode result's accessor in profile_series_codec.dart), wrap it in a + // SeriesBlob, and assert ascentDescentRatesFromBlobs / timeAtDepthRangesFromBlobs + // equal the pure results over the same samples. + }); +} +``` + +Adjust the arithmetic of the expected values only if the legacy SQL (reproduced verbatim in Step 3) gives a different number for the same input; the SQL is the oracle. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/statistics/data/series_profile_aggregates_test.dart` +Expected: compile error, the file does not exist. + +- [ ] **Step 3: Implement the aggregates** + +`lib/features/statistics/data/series_profile_aggregates.dart` (top-level functions; the file is imported by the statistics repository and by `compute` callbacks, so it must not import Flutter widgets): + +```dart +import 'dart:typed_data'; + +import 'package:submersion/core/deco/ascent_rate_calculator.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec.dart'; + +/// Samples are averaged into fixed windows of this many seconds before the +/// rate between consecutive windows is taken (see StatisticsRepository's +/// sustained-rate doc comment). +const int rateWindowSeconds = AscentRateCalculator.defaultSmoothingWindowSeconds; + +/// Windows slower than this (m/min) are neither ascent nor descent. +const double sustainedTransitThreshold = 3.0; + +/// A gap longer than the stream's mean interval times this factor counts as +/// the capped interval, not the gap. +const int maxSampleGapFactor = 4; + +typedef StreamKey = (String diveId, String? computerId); +typedef DepthBucket = ({int lowerDepth, int? upperDepth, int minutes}); + +/// One primary series as it crosses the isolate boundary: identity plus the +/// undecoded blob. Decoding happens on the worker. +class SeriesBlob { + const SeriesBlob({ + required this.diveId, + required this.computerId, + required this.samples, + }); + final String diveId; + final String? computerId; + final Uint8List samples; +} + +Map> _decodeStreams(List blobs) { + const codec = ProfileSeriesCodec(); + final streams = >{}; + for (final b in blobs) { + final decoded = codec.decode(b.samples).samples; + streams.update( + (b.diveId, b.computerId), + (existing) => _mergedByTimestamp(existing, decoded), + ifAbsent: () => decoded, + ); + } + return streams; +} + +/// Stable interleave of two timestamp-ordered lists (two series of one +/// stream, the legacy `ORDER BY at, sample_id` tiebreak in stored order). +List _mergedByTimestamp(List a, List b) { + final out = []; + var i = 0; + var j = 0; + while (i < a.length && j < b.length) { + if (b[j].timestamp < a[i].timestamp) { + out.add(b[j++]); + } else { + out.add(a[i++]); + } + } + out.addAll(a.sublist(i)); + out.addAll(b.sublist(j)); + return out; +} + +({double? avgAscent, double? avgDescent}) ascentDescentRatesFromBlobs(List blobs) => + ascentDescentRates(_decodeStreams(blobs)); + +List timeAtDepthRangesFromBlobs(List blobs) => + timeAtDepthRanges(_decodeStreams(blobs)); + +/// The legacy SQL, in Dart: per stream, samples fall into windows of +/// [rateWindowSeconds] by `timestamp ~/ rateWindowSeconds`; each window +/// contributes its mean depth and mean timestamp; consecutive windows (in +/// window order) give `rate = (prevDepth - depth) * 60 / (at - prevAt)` when +/// `at > prevAt`; ascents average the rates at or above the threshold, +/// descents the rates at or below its negative (negated). +({double? avgAscent, double? avgDescent}) ascentDescentRates( + Map> samplesByStream, +) { + final ascents = []; + final descents = []; + for (final samples in samplesByStream.values) { + final windows = {}; + for (final s in samples) { + final index = s.timestamp ~/ rateWindowSeconds; + final w = windows[index]; + windows[index] = w == null + ? (depthSum: s.depth, atSum: s.timestamp.toDouble(), n: 1) + : (depthSum: w.depthSum + s.depth, atSum: w.atSum + s.timestamp, n: w.n + 1); + } + final ordered = windows.keys.toList()..sort(); + double? prevDepth; + double? prevAt; + for (final index in ordered) { + final w = windows[index]!; + final depth = w.depthSum / w.n; + final at = w.atSum / w.n; + if (prevAt != null && at > prevAt) { + final rate = (prevDepth! - depth) * 60.0 / (at - prevAt); + if (rate >= sustainedTransitThreshold) ascents.add(rate); + if (rate <= -sustainedTransitThreshold) descents.add(-rate); + } + prevDepth = depth; + prevAt = at; + } + } + double? mean(List xs) => + xs.isEmpty ? null : xs.reduce((a, b) => a + b) / xs.length; + return (avgAscent: mean(ascents), avgDescent: mean(descents)); +} + +/// The legacy SQL, in Dart: per stream with more than one sample, the cadence +/// cap is `(maxAt - minAt) * maxSampleGapFactor / (n - 1)`; each interval to +/// the next sample (in stored order after the timestamp sort) is bucketed by +/// its start depth (`< 10` -> 0, `< 20` -> 10, `< 30` -> 20, `< 40` -> 30, +/// else 40) and contributes `min(seconds, cap)` when `seconds > 0`. Buckets +/// come back ascending; empty buckets are absent. +List timeAtDepthRanges( + Map> samplesByStream, +) { + final seconds = {}; + for (final samples in samplesByStream.values) { + if (samples.length < 2) continue; + final minAt = samples.first.timestamp; + final maxAt = samples.last.timestamp; + final cap = (maxAt - minAt) * maxSampleGapFactor / (samples.length - 1.0); + for (var i = 0; i + 1 < samples.length; i++) { + final gap = samples[i + 1].timestamp - samples[i].timestamp; + if (gap <= 0) continue; + final lo = _bucketLo(samples[i].depth); + seconds[lo] = (seconds[lo] ?? 0) + (gap < cap ? gap.toDouble() : cap); + } + } + final los = seconds.keys.toList()..sort(); + return [ + for (final lo in los) + ( + lowerDepth: lo, + upperDepth: lo >= 40 ? null : lo + 10, + minutes: (seconds[lo]! / 60).round(), + ), + ]; +} + +int _bucketLo(double depth) { + if (depth < 10) return 0; + if (depth < 20) return 10; + if (depth < 30) return 20; + if (depth < 40) return 30; + return 40; +} +``` + +Two parity notes for the implementer: (1) the legacy `cadence` CTE used `MIN(at)` / `MAX(at)` over the stream, which after the timestamp sort are the first and last sample; a stream whose samples are not sorted must be sorted first, so `_decodeStreams` relies on the codec's monotonic samples and the stable merge; the pure functions assume sorted input and say so in their doc comments. (2) the legacy `SUM(MIN(i.seconds * 1.0, c.max_interval))` is `min(gap, cap)` per interval, exactly as written. + +`statistics_repository.dart`: replace the two `customSelect` bodies. Both start by scoping dive ids exactly the way `scanRecordedDecoSignals` does: + +```dart + final diverFilter = diverId != null ? 'AND d.diver_id = ?' : ''; + final df = _diveFilter(filter, alias: 'd'); + final params = diverId != null ? [diverId, ...df.params] : [...df.params]; + final scoped = await _db + .customSelect( + 'SELECT d.id AS id FROM dives d WHERE 1=1 $diverFilter ${df.clause}', + variables: params.map((p) => Variable(p)).toList(), + readsFrom: {_db.dives}, + ) + .get(); + final diveIds = [for (final r in scoped) r.read('id')]; + if (diveIds.isEmpty) return (avgAscent: null, avgDescent: null); // or [] for buckets + final rows = await _profileSeries.getRowsForDives(diveIds); + final blobs = [ + for (final r in rows) + if (r.isPrimary) + SeriesBlob(diveId: r.diveId, computerId: r.computerId, samples: r.samples), + ]; + return await compute(ascentDescentRatesFromBlobs, blobs); +``` + +(`compute` from `package:flutter/foundation.dart`; the repository already imports Flutter foundation or add it; `_profileSeries` is a new `final _profileSeries = ProfileSeriesRepository();` field). `getTimeAtDepthRanges` mirrors it with `timeAtDepthRangesFromBlobs` and returns `[]` for an empty scope. Delete the three private constants from the repository (they moved) and update the two doc comments to say the aggregation runs in Dart on a worker isolate over decoded primary series, same windows and thresholds. + +- [ ] **Step 4: Run the tests** + +Run the new file and every file from `grep -rl "getAscentDescentRates\|getTimeAtDepthRanges\|ascent_descent\|time_at_depth\|timeAtDepth" test`. +Expected: all pass. If a repository test's expected number differs from the Dart result, compare against the legacy SQL over the same seeded samples (run it by hand through `db.customSelect` in a scratch test) before changing either side; the SQL is the oracle and the Dart must match it, not the other way round. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/statistics/data/series_profile_aggregates.dart lib/features/statistics/data/repositories/statistics_repository.dart test/features/statistics/data/series_profile_aggregates_test.dart +git commit -m "feat(series): sustained rates and time-at-depth aggregate decoded series on a worker isolate" +``` + +--- + +### Task 7: Series twins for the computer unlink and relink statements; getDiveIdsForComputer + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/profile_series_repository.dart` (`clearComputer`, `clearComputersOfDiverForForeignDives`, `relinkComputer`) +- Modify: `lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart` (`clearComputer`, `clearComputersOfDiverForForeignDives`) +- Modify: `lib/features/divers/data/repositories/diver_repository.dart` (near line 373) +- Modify: `lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart` (`deleteComputer` near line 379; the relink near line 529; `getDiveIdsForComputer` near line 1012) +- Test: `test/features/dive_log/data/repositories/series_computer_link_test.dart` (new) +- Modify tests: the legacy unlink/relink tests in `dive_computer_repository_impl_test.dart` (near lines 394, 560, 680) and `test/features/divers/data/repositories/diver_repository_test.dart` gain series assertions beside their legacy ones (legacy rows and statements stay until 2e). + +**Interfaces:** +- Consumes: the repositories' `_ids`, `_markPending`, `_db`; Drift `isInQuery` / `isNotInQuery` on columns. +- Produces: + - `Future ProfileSeriesRepository.clearComputer(String computerId, {int? now})` and the tank twin: `SET computer_id = NULL, updated_at = now WHERE computer_id = ?`, mark every touched series pending, return the count. + - `Future ProfileSeriesRepository.clearComputersOfDiverForForeignDives(String diverId, {int? now})` and the tank twin: `SET computer_id = NULL WHERE computer_id IN (SELECT id FROM dive_computers WHERE diver_id = ?) AND dive_id NOT IN (SELECT id FROM dives WHERE diver_id = ?)`, marked pending. + - `Future ProfileSeriesRepository.relinkComputer(String computerId, List diveIds, {int? now})` (profile only, legacy parity): `SET computer_id = ? WHERE computer_id IS NULL AND dive_id IN (...) AND (SELECT COUNT(*) FROM dive_data_sources s WHERE s.dive_id = dive_profile_series.dive_id AND s.source_format = 'dive_computer') = 1`, marked pending. + - `DiveComputerRepository.getDiveIdsForComputer` reads `dive_profile_series`. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_log/data/repositories/series_computer_link_test.dart` (fixture: dives `d-mine` (diver `diver-a`) and `d-theirs` (diver `diver-b`), computers `comp-a` (diver `diver-a`) and `comp-x`, data sources as each case needs; seed through the Drift companions used in `dive_computer_repository_impl_test.dart`): + +```dart + test('clearComputer nulls the computer on every series of that computer and restamps hlc', () async { + final id = await profileSeries.insertSeries(diveId: 'd-mine', computerId: 'comp-a', samples: one, now: 1000); + final keep = await profileSeries.insertSeries(diveId: 'd-mine', computerId: 'comp-x', samples: one, now: 1000); + final before = (await profileSeries.getRowsForDives(['d-mine'])).firstWhere((r) => r.id == id).hlc; + expect(await profileSeries.clearComputer('comp-a', now: 2000), 1); + final rows = await profileSeries.getRowsForDives(['d-mine']); + expect(rows.firstWhere((r) => r.id == id).computerId, isNull); + expect(rows.firstWhere((r) => r.id == id).hlc, isNot(before)); + expect(rows.firstWhere((r) => r.id == keep).computerId, 'comp-x'); + }); + + test('clearComputersOfDiverForForeignDives touches only dives the diver does not own', () async { + final mine = await profileSeries.insertSeries(diveId: 'd-mine', computerId: 'comp-a', samples: one, now: 1000); + final theirs = await profileSeries.insertSeries(diveId: 'd-theirs', computerId: 'comp-a', samples: one, now: 1000); + expect(await profileSeries.clearComputersOfDiverForForeignDives('diver-a', now: 2000), 1); + final rows = await profileSeries.getRowsForDives(['d-mine', 'd-theirs']); + expect(rows.firstWhere((r) => r.id == mine).computerId, 'comp-a'); + expect(rows.firstWhere((r) => r.id == theirs).computerId, isNull); + }); + + test('relinkComputer stamps null-computer series of dives with exactly one computer source', () async { + // d-mine has ONE dive_data_sources row with source_format 'dive_computer'; + // d-theirs has TWO. Both carry a null-computer series. + final a = await profileSeries.insertSeries(diveId: 'd-mine', samples: one, now: 1000); + final b = await profileSeries.insertSeries(diveId: 'd-theirs', samples: one, now: 1000); + expect(await profileSeries.relinkComputer('comp-a', ['d-mine', 'd-theirs'], now: 2000), 1); + final rows = await profileSeries.getRowsForDives(['d-mine', 'd-theirs']); + expect(rows.firstWhere((r) => r.id == a).computerId, 'comp-a'); + expect(rows.firstWhere((r) => r.id == b).computerId, isNull); + }); + + test('deleteComputer clears the series before the FK cascade would', () async { + // seed a computer-owned profile series and tank series on d-mine, call + // DiveComputerRepository().deleteComputer('comp-a') (check the method's + // name and signature), assert both series survive with computerId null + // AND an hlc different from before (the FK SET NULL alone would not restamp). + }); + + test('getDiveIdsForComputer lists dives by their series', () async { + await profileSeries.insertSeries(diveId: 'd-mine', computerId: 'comp-a', samples: one, now: 1000); + expect(await DiveComputerRepository().getDiveIdsForComputer('comp-a'), ['d-mine']); + expect(await DiveComputerRepository().getDiveIdsForComputer('comp-x'), isEmpty); + }); +``` + +plus the tank twins of the first two tests. Write every body in full. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/series_computer_link_test.dart` +Expected: compile errors on the new methods; `getDiveIdsForComputer` returns empty. + +- [ ] **Step 3: Implement** + +`profile_series_repository.dart`: + +```dart + /// Nulls `computer_id` on every series of [computerId] and restamps each + /// (the FK's ON DELETE SET NULL would change the rows without an hlc bump, + /// so peers would never learn). Returns the number of series touched. + Future clearComputer(String computerId, {int? now}) => + _setComputer(null, (t) => t.computerId.equals(computerId), now: now); + + /// Diver reassignment: a computer that now belongs to [diverId] must not + /// stay attributed on dives the diver does not own. + Future clearComputersOfDiverForForeignDives(String diverId, {int? now}) => + _setComputer( + null, + (t) => + t.computerId.isInQuery( + _db.selectOnly(_db.diveComputers) + ..addColumns([_db.diveComputers.id]) + ..where(_db.diveComputers.diverId.equals(diverId)), + ) & + t.diveId.isNotInQuery( + _db.selectOnly(_db.dives) + ..addColumns([_db.dives.id]) + ..where(_db.dives.diverId.equals(diverId)), + ), + now: now, + ); + + /// A recreated computer takes back the null-computer series of [diveIds] + /// whose only computer source is the one being relinked (the legacy + /// `dive_profiles` relink, series twin). + Future relinkComputer(String computerId, List diveIds, {int? now}) { + if (diveIds.isEmpty) return Future.value(0); + return _setComputer( + computerId, + (t) => + t.computerId.isNull() & + t.diveId.isIn(diveIds) & + t.diveId.isInQuery( + _db.selectOnly(_db.diveDataSources) + ..addColumns([_db.diveDataSources.diveId]) + ..where(_db.diveDataSources.sourceFormat.equals('dive_computer')) + ..groupBy( + [_db.diveDataSources.diveId], + having: _db.diveDataSources.id.count().equals(1), + ), + ), + now: now, + ); + } + + Future _setComputer( + String? computerId, + Expression Function($DiveProfileSeriesTable t) where, { + int? now, + }) async { + final nowMs = now ?? DateTime.now().millisecondsSinceEpoch; + final ids = await _ids(where); + if (ids.isEmpty) return 0; + await _db.transaction(() async { + await (_db.update(_db.diveProfileSeries)..where((t) => t.id.isIn(ids))) + .write( + DiveProfileSeriesCompanion( + computerId: Value(computerId), + updatedAt: Value(nowMs), + ), + ); + for (final id in ids) { + await _markPending(id, nowMs); + } + }); + return ids.length; + } +``` + +(check the `dive_data_sources.source_format` column's Drift name, `sourceFormat`, and Drift's `groupBy(..., having:)` on `selectOnly`; if `isInQuery` with a grouped subquery does not compile, express the relink as `customStatement` with the legacy SQL text against `dive_profile_series` plus a preceding `customSelect` of the matching ids to mark pending). `tank_pressure_series_repository.dart`: `clearComputer` and `clearComputersOfDiverForForeignDives` with the same shape over `_db.tankPressureSeries` (no relink). + +`diver_repository.dart` near line 373: after the legacy `UPDATE dive_profiles SET computer_id = NULL ...` statement add `await ProfileSeriesRepository().clearComputersOfDiverForForeignDives(id); await TankPressureSeriesRepository().clearComputersOfDiverForForeignDives(id);` (fields if the class keeps repositories as fields; otherwise construct inline; `id` is the diver id the legacy statement binds). + +`dive_computer_repository_impl.dart`: in `deleteComputer` BEFORE the legacy `UPDATE dive_profiles SET computer_id = NULL WHERE computer_id = ?` add `await _profileSeries.clearComputer(id); await _tankSeries.clearComputer(id);`; in the relink block after the legacy `UPDATE dive_profiles SET computer_id = ? ...` add `await _profileSeries.relinkComputer(computerId, matchedDiveIds);`. `getDiveIdsForComputer`: `INNER JOIN dive_profile_series dp ON d.id = dp.dive_id WHERE dp.computer_id = ?` (the rest unchanged). + +- [ ] **Step 4: Run the tests** + +Run the new file, `dive_computer_repository_impl_test.dart`, `diver_repository_test.dart` (add one series assertion beside each legacy unlink/relink assertion: seed a series with the same computer next to the legacy row and assert its `computerId` after the call), `dive_computer_series_reads_test.dart`, `profile_series_repository_writers_test.dart`, `tank_pressure_series_repository_writers_test.dart`. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/data/repositories/profile_series_repository.dart lib/features/dive_log/data/repositories/tank_pressure_series_repository.dart lib/features/divers/data/repositories/diver_repository.dart lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart test/features/dive_log/data/repositories/series_computer_link_test.dart test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart test/features/divers/data/repositories/diver_repository_test.dart +git commit -m "feat(series): computer unlink and relink stamp the series; getDiveIdsForComputer reads series" +``` + +--- + +### Task 8: Verification + +**Files:** none new. + +- [ ] **Step 1: No legacy reads outside the allowed set** + +```bash +grep -rn "dive_profiles\|tank_pressure_profiles\|_db.diveProfiles\|_db.tankPressureProfiles\|db.diveProfiles\|db.tankPressureProfiles" lib --include='*.dart' | grep -v "database.dart\|database.g.dart\|profile_series_pack\|performance_indexes.dart" | grep -v "^\s*//\|///" | grep -v "Legacy\|delete(\|DELETE FROM\|deleteWhere\|UPDATE dive_profiles SET computer_id\|profileRows\|tankPressureRows\|case 'diveProfiles'\|case 'tankPressureProfiles'\|_db.diveProfiles,$\|_db.tankPressureProfiles,$\|onTable(_db.diveProfiles)\|onTable(_db.tankPressureProfiles)" +``` + +Expected: nothing, or only lines inside the 2b `_...Legacy` method bodies (open each hit and confirm it sits inside a method whose name ends in `Legacy`, inside `undo`'s verbatim re-inserts, inside `DiveMergeSnapshot.capture`, or inside the serializer's inbound legacy cases). Anything else is a missed consumer: fix it in this task and say so in the report. + +- [ ] **Step 2: The thirteen formerly-red tests** + +Run each: `quality_context_builder_test.dart`, `quality_prefilters_test.dart`, `dashboard_queries_test.dart`, `dive_repository_deco_filter_test.dart`, `dive_times_accessors_test.dart`, `deco_filter_providers_test.dart`, `consolidation_sync_roundtrip_test.dart`. +Expected: every file passes. + +- [ ] **Step 3: Every test file this plan created or touched, individually, plus the sync suite** + +The seven new files, every modified test file named in Tasks 1 to 7, every file under `test/core/services/sync/`, `test/features/statistics/` and `test/features/data_quality/`. +Expected: every file passes. + +- [ ] **Step 4: Format, analyze, layering** + +```bash +dart format --output=none --set-exit-if-changed . +flutter analyze +flutter test test/core/database/database_import_graph_test.dart +``` + +Expected: exit 0, "No issues found!", pass. + +- [ ] **Step 5: Full suite once** + +Background, output to the session scratchpad `full-suite-2d.log` with `exit=$?` appended. Known machine flakes: `local_file_resolver_test.dart` (passes with `TMPDIR=/private/tmp/claude-501/sysvol-tmp`), `changeset_writer_test.dart`, `backup_encryption_service_test.dart`, `database_security_service_test.dart`. Any other failure: rerun the file alone once; a repeat is real. There is NO known-red list after this plan. + +- [ ] **Step 6: Report** + +The commit list from `git log --oneline 30234a3973e..HEAD`, the grep results, the per-file results, the full-suite summary and exit, flake reruns. + +--- + +## Self-review + +**Spec coverage.** Section 7 (sync): registration with `blob: true`, export by `hlc > since` (Task 1's `_exportDiveProfileSeries` mirrors `_exportGpsTracks`), `updated_at` / hlc-bearing (`hasUpdatedAt: true`, `entityHasUpdatedAt`), apply by `insertOnConflictUpdate` on the series id with LWW through `_mergeEntity`, `parentRefs` incl. `dive_tanks` for tank series (Task 1); the legacy entities leave `_baseTables`, `SyncData.toJson`, `entityHasUpdatedAt` and the export path but keep inbound apply, delete, `recordIdsFor`, `tableFor` (Task 2; the spec's "leave the switches" is deferred to 2e so older peers' payloads and tombstones still apply, which the spec's own next paragraph requires); one tombstone per series (the repositories, plan 2a); floor to 182 and the round-trip projection (Task 3); receive-side packing with the migration packer (Task 2). Section 9: rows 1 to 6 (Task 4: deco classification, `decoSignalCondition`, `had_deco`; Task 5: profile span, quality first/last depth, prefilters), row 7 (source ownership) was finished in plan 2c (`ownsAny`, `promoteWinnerOwnedBy`), rows 8 and 9 (Task 6, Dart on a worker isolate). Section 6 "Decoding one series is on the order of a millisecond ... Whole-library aggregations decode on a worker isolate via `compute`" (Task 6). The three `UPDATE dive_profiles SET computer_id` statements the 2c review found (Task 7). The apply-time validation carried from PR 1's review ("bounded inflate for peer blobs") is the soundness check in Task 1. + +Not in this plan by design: dropping the tables, the `_...Legacy` fallbacks, the legacy deletes and undo re-inserts, the snapshot's legacy lists, `TankPressurePoint.id`, `performance_indexes.dart`'s legacy index heal, the legacy sync switch cases and `parentRefs` entries, VACUUM, the benchmark fixture and gates (all plan 2e). + +**Placeholder scan.** Every task carries the code it changes and the tests with their assertions. Task 1's round-trip tests, Task 2's inbound tests and Task 3's boundary tests reuse the fixtures of `consolidation_sync_roundtrip_test.dart` and `cross_version_roundtrip_test.dart` by name (copy, not import) and spell out each assertion. Task 6's expected numbers are stated with their arithmetic and the legacy SQL is named as the oracle. Task 7's `relinkComputer` names a `customStatement` fallback if Drift's grouped `isInQuery` does not compile. No "add error handling", no "similar to Task N". + +**Type consistency.** `SyncData.diveProfileSeries` / `tankPressureSeries` (Task 1) are read by Task 2's export test and Task 3's payload builder; `packLegacySamples()` returns `ProfilePackReport` (the packer's typedef, fields `profileSeries`, `tankSeries`, `droppedSamples`, `skippedOrphans`, `skippedRows`) and Task 2's log reads `profileSeries` / `tankSeries`; `decoSignalCondition({required bool wantDeco, required String diveIdRef})` keeps its signature (Task 4) so its two callers in `dive_repository_impl.dart` compile unchanged; `SeriesBlob(diveId, computerId, samples)`, `ascentDescentRatesFromBlobs`, `timeAtDepthRangesFromBlobs`, `ascentDescentRates`, `timeAtDepthRanges`, `StreamKey`, `DepthBucket` (Task 6) match the repository call sites and the test; `clearComputer(String computerId, {int? now})`, `clearComputersOfDiverForForeignDives(String diverId, {int? now})`, `relinkComputer(String computerId, List diveIds, {int? now})` (Task 7) match their three call sites and the test; `getRowsForDives` (plan 2c Task 1) returns rows with `diveId`, `computerId`, `isPrimary`, `samples` as Task 6 reads them. diff --git a/docs/superpowers/plans/2026-08-29-profile-series-codec.md b/docs/superpowers/plans/2026-08-29-profile-series-codec.md new file mode 100644 index 0000000000..51005e28f9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-profile-series-codec.md @@ -0,0 +1,2456 @@ +# Profile Series Codec Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship PR 1 of the packed-series program: a pure-Dart, lossless, versioned codec that packs a dive's profile samples (and a tank's pressure samples) into one zlib-compressed columnar blob and back, together with the summary scalars the series tables will store. + +**Architecture:** A small byte-level reader/writer (`byte_io.dart`) provides varints, zigzag signed varints, float64, and presence-bitmapped columns. `ProfileSeriesCodec` walks a fixed, versioned field table over a `ProfileSample` value type, writes one column block per field, and zlib-compresses the result; decoding reverses it strictly and throws `ProfileSeriesCodecException` on anything malformed. `TankPressureSeriesCodec` is the two-field sibling. Nothing in this PR touches the database, the repositories, or sync; the codec is consumed in PR 2. + +**Tech Stack:** Dart 3 (records, switch expressions), `dart:typed_data`, `dart:io` `ZLibCodec`, `package:equatable`, `flutter_test`, Drift's generated table metadata (test only). + +**Spec:** `docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md`, sections 3 (architecture), 5 (codec v1), 10 (codec tests), 11 (delivery: this is PR 1). + +## Global Constraints + +- Work only inside the worktree `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-sample-storage` on branch `worktree-profile-sample-storage`. Every command below assumes `cd` into that directory first; the shell's working directory does not persist between commands. +- Never use an em-dash (U+2014) or an en-dash as punctuation anywhere: code, comments, tests, commit messages, this plan. Rewrite with a colon, comma, or two sentences. +- No emojis in code, comments, or documentation. +- Lints in force: `package:flutter_lints/flutter.yaml` plus `prefer_const_constructors`, `prefer_const_declarations`, `prefer_final_fields`, `prefer_final_locals`, `avoid_print`, `require_trailing_commas`, `always_use_package_imports`. All imports of project files use the `package:submersion/...` form. +- Codec files under `lib/` import nothing from Drift or Flutter. Allowed: `dart:*`, `package:equatable`, `package:meta`, and the domain entity file `package:submersion/features/dive_log/domain/entities/dive.dart`. +- Codec v1 constants, verbatim from the spec: version byte `1`; real fields are float64 little-endian (lossless, never float32 or fixed point); integer fields are delta-encoded zigzag varints with the delta reset to 0 at the start of each block and taken from the last present value; presence modes are `0` absent, `1` all present, `2` bitmap (LSB-first within each byte); the whole body is zlib level 6. The 28-field table order is fixed in Task 4 and must not be reordered. +- Run tests per file (`flutter test `), never a whole directory; the Bash tool times out on directories. Never pipe `flutter test` through `grep` or `tail`: the exit code you see would be the pipe's, not the test run's. +- Before every commit run `dart format .` from the worktree root (the whole project, not a subdirectory) and `flutter analyze`. CI fails on a single unformatted file. +- Commit messages: conventional prefix (`feat:`, `test:`, `docs:`), no `Co-Authored-By` trailer, no session URL. +- Files stay small: none of the new files should exceed 400 lines. + +--- + +## File structure + +Create: + +| file | responsibility | +|---|---| +| `lib/features/dive_log/domain/codecs/profile_series_codec_exception.dart` | the one exception type every decoder throws | +| `lib/features/dive_log/domain/codecs/byte_io.dart` | `ByteWriter`, `ByteReader`, varint/zigzag/float64, presence-bitmapped column blocks | +| `lib/features/dive_log/domain/codecs/profile_sample.dart` | `ProfileSample`, the 28-field value type the codec packs, with conversions to and from `DiveProfilePoint` | +| `lib/features/dive_log/domain/codecs/profile_series_summary.dart` | `ProfileSeriesSummary` and its computation from a sample list | +| `lib/features/dive_log/domain/codecs/profile_series_codec.dart` | `ProfileField`, `ProfileFieldKind`, the v1 field table, `EncodedProfileSeries`, `ProfileSeriesCodec` | +| `lib/features/dive_log/domain/codecs/tank_pressure_series_codec.dart` | `TankPressureSample`, `TankPressureSeriesSummary`, `EncodedTankPressureSeries`, `TankPressureSeriesCodec` | +| `test/features/dive_log/domain/codecs/byte_io_test.dart` | | +| `test/features/dive_log/domain/codecs/profile_sample_test.dart` | | +| `test/features/dive_log/domain/codecs/profile_series_summary_test.dart` | | +| `test/features/dive_log/domain/codecs/profile_series_codec_test.dart` | round trips, malformed input, forward tolerance | +| `test/features/dive_log/domain/codecs/profile_series_field_table_test.dart` | the field table enumerated against Drift's `dive_profiles` columns | +| `test/features/dive_log/domain/codecs/tank_pressure_series_codec_test.dart` | | + +Modify: nothing. This PR adds files only. + +--- + +### Task 1: Exception type and byte reader/writer + +**Files:** +- Create: `lib/features/dive_log/domain/codecs/profile_series_codec_exception.dart` +- Create: `lib/features/dive_log/domain/codecs/byte_io.dart` +- Test: `test/features/dive_log/domain/codecs/byte_io_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `class ProfileSeriesCodecException implements Exception { const ProfileSeriesCodecException(String message); final String message; }` + - `class ByteWriter { int get length; void writeByte(int); void writeVarUint(int); void writeVarInt(int); void writeFloat64(double); void writeBytes(List); Uint8List takeBytes(); }` + - `class ByteReader { ByteReader(Uint8List bytes); int get offset; int get remaining; bool get isAtEnd; int readByte(); int readVarUint(); int readVarInt(); double readFloat64(); Uint8List readBytes(int count); }` + - `extension ColumnWriter on ByteWriter { bool writePresence(List values); void writeColumn(List values, void Function(T value) writeValue); }` + - `extension ColumnReader on ByteReader { List readPresence(int count); List readColumn(int count, T Function() readValue); }` + - Constants `kPresenceAbsent = 0`, `kPresenceAll = 1`, `kPresenceBitmap = 2`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/dive_log/domain/codecs/byte_io_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/byte_io.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec_exception.dart'; + +/// The raw IEEE-754 bits, so -0.0 and NaN payloads compare exactly. +int bitsOf(double value) => + (ByteData(8)..setFloat64(0, value, Endian.little)).getInt64( + 0, + Endian.little, + ); + +void main() { + group('varuint', () { + for (final value in [ + 0, + 1, + 127, + 128, + 255, + 300, + 16383, + 16384, + 1 << 31, + (1 << 62) - 1, + ]) { + test('round-trips $value', () { + final writer = ByteWriter()..writeVarUint(value); + expect(ByteReader(writer.takeBytes()).readVarUint(), value); + }); + } + + test('127 fits in one byte and 128 needs two', () { + expect((ByteWriter()..writeVarUint(127)).takeBytes(), hasLength(1)); + expect((ByteWriter()..writeVarUint(128)).takeBytes(), hasLength(2)); + }); + }); + + group('varint (zigzag)', () { + for (final value in [ + 0, + -1, + 1, + -2, + 2, + -64, + 63, + -65, + 64, + -1000000, + 1000000, + -(1 << 40), + 1 << 40, + ]) { + test('round-trips $value', () { + final writer = ByteWriter()..writeVarInt(value); + expect(ByteReader(writer.takeBytes()).readVarInt(), value); + }); + } + + test('small negatives stay one byte', () { + expect((ByteWriter()..writeVarInt(-1)).takeBytes(), hasLength(1)); + expect((ByteWriter()..writeVarInt(-64)).takeBytes(), hasLength(1)); + expect((ByteWriter()..writeVarInt(-65)).takeBytes(), hasLength(2)); + }); + }); + + group('float64', () { + for (final value in [ + 0.0, + -0.0, + 1.5, + 18.3, + -273.15, + double.maxFinite, + double.minPositive, + double.infinity, + double.negativeInfinity, + ]) { + test('round-trips $value bit-exactly', () { + final writer = ByteWriter()..writeFloat64(value); + final bytes = writer.takeBytes(); + expect(bytes, hasLength(8)); + expect(bitsOf(ByteReader(bytes).readFloat64()), bitsOf(value)); + }); + } + + test('NaN round-trips as NaN', () { + final writer = ByteWriter()..writeFloat64(double.nan); + expect(ByteReader(writer.takeBytes()).readFloat64().isNaN, isTrue); + }); + + test('two consecutive floats do not alias each other', () { + final writer = ByteWriter() + ..writeFloat64(1.0) + ..writeFloat64(2.0); + final reader = ByteReader(writer.takeBytes()); + expect(reader.readFloat64(), 1.0); + expect(reader.readFloat64(), 2.0); + }); + }); + + group('reader bounds', () { + test('reading past the end throws', () { + final reader = ByteReader(Uint8List.fromList([0x01])); + reader.readByte(); + expect(reader.isAtEnd, isTrue); + expect(reader.readByte, throwsA(isA())); + }); + + test('a float needs eight bytes', () { + final reader = ByteReader(Uint8List.fromList([0, 0, 0, 0])); + expect(reader.readFloat64, throwsA(isA())); + }); + + test('an unterminated varint throws', () { + final reader = ByteReader(Uint8List.fromList([0x80, 0x80])); + expect(reader.readVarUint, throwsA(isA())); + }); + + test('a varint longer than 64 bits throws', () { + final reader = ByteReader(Uint8List.fromList(List.filled(11, 0x80))); + expect(reader.readVarUint, throwsA(isA())); + }); + + test('readBytes past the end throws', () { + final reader = ByteReader(Uint8List.fromList([1, 2, 3])); + expect( + () => reader.readBytes(4), + throwsA(isA()), + ); + }); + + test('offset and remaining track consumption', () { + final reader = ByteReader(Uint8List.fromList([1, 2, 3])); + reader.readByte(); + expect(reader.offset, 1); + expect(reader.remaining, 2); + }); + }); + + group('columns', () { + // Note: `writer` is declared on its own line in every case below. Dart + // rejects a closure that names the variable being initialised + // ("referenced before declaration"), so the cascade form cannot be used. + + test('an all-null column costs exactly one byte', () { + final writer = ByteWriter(); + writer.writeColumn([null, null, null], writer.writeVarInt); + final bytes = writer.takeBytes(); + expect(bytes, [kPresenceAbsent]); + final reader = ByteReader(bytes); + expect(reader.readColumn(3, reader.readVarInt), [null, null, null]); + expect(reader.isAtEnd, isTrue); + }); + + test('a fully present column carries no bitmap', () { + final writer = ByteWriter(); + writer.writeColumn([5, 6, 7], writer.writeVarInt); + final bytes = writer.takeBytes(); + expect(bytes.first, kPresenceAll); + expect(bytes, hasLength(4)); + final reader = ByteReader(bytes); + expect(reader.readColumn(3, reader.readVarInt), [5, 6, 7]); + }); + + test('a mixed column uses a bitmap and round-trips across a byte edge', () { + // Nine values force a two-byte bitmap. + final values = [1, null, 3, null, null, 6, 7, null, 9]; + final writer = ByteWriter(); + writer.writeColumn(values, writer.writeVarInt); + final bytes = writer.takeBytes(); + expect(bytes.first, kPresenceBitmap); + // mode + 2 bitmap bytes + 5 one-byte values + expect(bytes, hasLength(8)); + final reader = ByteReader(bytes); + expect(reader.readColumn(9, reader.readVarInt), values); + expect(reader.isAtEnd, isTrue); + }); + + test('float columns round-trip through the same presence logic', () { + final values = [1.5, null, -2.25]; + final writer = ByteWriter(); + writer.writeColumn(values, writer.writeFloat64); + final reader = ByteReader(writer.takeBytes()); + expect(reader.readColumn(3, reader.readFloat64), values); + }); + + test('an unknown presence mode throws', () { + final reader = ByteReader(Uint8List.fromList([7])); + expect( + () => reader.readColumn(1, reader.readVarInt), + throwsA(isA()), + ); + }); + + test('an empty column writes only the absent marker', () { + final writer = ByteWriter(); + writer.writeColumn(const [], writer.writeVarInt); + expect(writer.takeBytes(), [kPresenceAbsent]); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/domain/codecs/byte_io_test.dart` +Expected: compilation failure, `Error: Couldn't resolve the package 'submersion/features/dive_log/domain/codecs/byte_io.dart'` (or "Target of URI doesn't exist"). + +- [ ] **Step 3: Create the exception type** + +Create `lib/features/dive_log/domain/codecs/profile_series_codec_exception.dart`: + +```dart +/// Thrown when a packed series blob cannot be decoded. +/// +/// Decoders never guess. An unknown version byte, a truncated payload, +/// trailing bytes, or a block that disagrees with its field table all end +/// here rather than in a partial sample list. +class ProfileSeriesCodecException implements Exception { + const ProfileSeriesCodecException(this.message); + + final String message; + + @override + String toString() => 'ProfileSeriesCodecException: $message'; +} +``` + +- [ ] **Step 4: Create the byte reader and writer** + +Create `lib/features/dive_log/domain/codecs/byte_io.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec_exception.dart'; + +/// Presence mode for a column block: every value is null, no payload. +const int kPresenceAbsent = 0; + +/// Presence mode for a column block: every value is present, no bitmap. +const int kPresenceAll = 1; + +/// Presence mode for a column block: a bitmap of `ceil(n / 8)` bytes +/// (LSB-first within each byte) precedes the present values. +const int kPresenceBitmap = 2; + +/// Append-only little-endian byte sink for the series codecs. +class ByteWriter { + // copy: true (the default) matters: writeFloat64 hands the builder a view + // of a reused scratch buffer, and a non-copying builder would alias every + // float written to the same eight bytes. + final BytesBuilder _builder = BytesBuilder(); + final ByteData _scratch = ByteData(8); + + int get length => _builder.length; + + void writeByte(int value) { + assert(value >= 0 && value <= 0xFF, 'not a byte: $value'); + _builder.addByte(value); + } + + /// Unsigned LEB128 varint: seven bits per byte, high bit set on all but + /// the last byte. + void writeVarUint(int value) { + assert(value >= 0, 'varuint cannot encode $value'); + var remaining = value; + while (remaining >= 0x80) { + _builder.addByte((remaining & 0x7F) | 0x80); + remaining >>= 7; + } + _builder.addByte(remaining); + } + + /// Zigzag-mapped signed varint, so small negatives stay small: 0, -1, 1, + /// -2, 2 map to 0, 1, 2, 3, 4. + void writeVarInt(int value) => writeVarUint((value << 1) ^ (value >> 63)); + + /// IEEE-754 binary64, little-endian, bit-exact. + void writeFloat64(double value) { + _scratch.setFloat64(0, value, Endian.little); + _builder.add(_scratch.buffer.asUint8List(0, 8)); + } + + void writeBytes(List bytes) => _builder.add(bytes); + + /// Returns everything written and resets the writer. + Uint8List takeBytes() => _builder.takeBytes(); +} + +/// Strict forward-only reader over a byte buffer. Every read that would run +/// past the end throws [ProfileSeriesCodecException]. +class ByteReader { + ByteReader(Uint8List bytes) + : _bytes = bytes, + _data = ByteData.sublistView(bytes); + + final Uint8List _bytes; + final ByteData _data; + int _offset = 0; + + int get offset => _offset; + int get remaining => _bytes.length - _offset; + bool get isAtEnd => _offset >= _bytes.length; + + int readByte() { + _ensure(1); + return _bytes[_offset++]; + } + + int readVarUint() { + var result = 0; + var shift = 0; + while (true) { + final byte = readByte(); + result |= (byte & 0x7F) << shift; + if ((byte & 0x80) == 0) return result; + shift += 7; + if (shift > 63) { + throw const ProfileSeriesCodecException('varint longer than 64 bits'); + } + } + } + + int readVarInt() { + final zigzag = readVarUint(); + return (zigzag >>> 1) ^ -(zigzag & 1); + } + + double readFloat64() { + _ensure(8); + final value = _data.getFloat64(_offset, Endian.little); + _offset += 8; + return value; + } + + Uint8List readBytes(int count) { + _ensure(count); + final view = Uint8List.sublistView(_bytes, _offset, _offset + count); + _offset += count; + return view; + } + + void _ensure(int count) { + if (count < 0 || _offset + count > _bytes.length) { + throw ProfileSeriesCodecException( + 'unexpected end of data: needed $count byte(s) at offset $_offset ' + 'of ${_bytes.length}', + ); + } + } +} + +/// Column blocks: a presence mode byte, an optional bitmap, then only the +/// present values in order. +extension ColumnWriter on ByteWriter { + /// Writes the presence mode and, when needed, the bitmap. Returns whether + /// any value is present, so the caller knows whether to write payload. + bool writePresence(List values) { + var presentCount = 0; + for (final value in values) { + if (value != null) presentCount++; + } + if (presentCount == 0) { + writeByte(kPresenceAbsent); + return false; + } + if (presentCount == values.length) { + writeByte(kPresenceAll); + return true; + } + writeByte(kPresenceBitmap); + final bitmap = Uint8List((values.length + 7) >> 3); + for (var i = 0; i < values.length; i++) { + if (values[i] != null) bitmap[i >> 3] |= 1 << (i & 7); + } + writeBytes(bitmap); + return true; + } + + /// Writes one column: presence, then [writeValue] for each present value + /// in order. [writeValue] may keep state between calls (delta encoding). + void writeColumn( + List values, + void Function(T value) writeValue, + ) { + if (!writePresence(values)) return; + for (final value in values) { + if (value != null) writeValue(value); + } + } +} + +extension ColumnReader on ByteReader { + /// Reads a presence block for [count] values. + List readPresence(int count) { + final mode = readByte(); + switch (mode) { + case kPresenceAbsent: + return List.filled(count, false); + case kPresenceAll: + return List.filled(count, true); + case kPresenceBitmap: + final bitmap = readBytes((count + 7) >> 3); + return [ + for (var i = 0; i < count; i++) + ((bitmap[i >> 3] >> (i & 7)) & 1) == 1, + ]; + default: + throw ProfileSeriesCodecException('unknown presence mode $mode'); + } + } + + /// Reads one column of [count] values, calling [readValue] once per + /// present value in order. [readValue] may keep state between calls. + List readColumn(int count, T Function() readValue) { + final present = readPresence(count); + return [for (final isPresent in present) isPresent ? readValue() : null]; + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `flutter test test/features/dive_log/domain/codecs/byte_io_test.dart` +Expected: all tests pass (around 45 cases). + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/domain/codecs/profile_series_codec_exception.dart lib/features/dive_log/domain/codecs/byte_io.dart test/features/dive_log/domain/codecs/byte_io_test.dart +git commit -m "feat: byte reader and writer for the profile series codec" +``` + +--- + +### Task 2: ProfileSample value type + +**Files:** +- Create: `lib/features/dive_log/domain/codecs/profile_sample.dart` +- Test: `test/features/dive_log/domain/codecs/profile_sample_test.dart` + +**Interfaces:** +- Consumes: `DiveProfilePoint` from `package:submersion/features/dive_log/domain/entities/dive.dart` (27 fields, no `pressure`). +- Produces: `class ProfileSample extends Equatable` with 28 fields matching the `dive_profiles` sample columns: `int timestamp`, `double depth`, `double? pressure`, `double? temperature`, `int? heartRate`, `double? ascentRate`, `double? ceiling`, `int? ndl`, `double? setpoint`, `double? ppO2`, `double? o2Sensor1` to `o2Sensor6`, `double? cns`, `int? tts`, `int? rbt`, `int? decoType`, `String? heartRateSource`, `double? heading`, `int? o2SensorMv1` to `o2SensorMv6`. Plus `factory ProfileSample.fromPoint(DiveProfilePoint point, {double? pressure})` and `DiveProfilePoint toPoint()`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/domain/codecs/profile_sample_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +void main() { + const point = DiveProfilePoint( + timestamp: 120, + depth: 18.3, + temperature: 21.5, + heartRate: 88, + heading: 270.0, + setpoint: 1.2, + ppO2: 1.19, + o2Sensor1: 1.18, + o2Sensor2: 1.2, + o2Sensor3: 1.21, + o2Sensor4: 1.17, + o2Sensor5: 1.22, + o2Sensor6: 1.19, + o2SensorMv1: 51, + o2SensorMv2: 52, + o2SensorMv3: 53, + o2SensorMv4: 50, + o2SensorMv5: 54, + o2SensorMv6: 52, + heartRateSource: 'appleWatch', + cns: 12.5, + ndl: 1800, + ceiling: 3.0, + ascentRate: -9.0, + rbt: 1500, + decoType: 2, + tts: 900, + ); + + test('fromPoint then toPoint is the identity on every point field', () { + final sample = ProfileSample.fromPoint(point); + expect(sample.toPoint(), point); + }); + + test('fromPoint carries the legacy per-sample pressure separately', () { + final sample = ProfileSample.fromPoint(point, pressure: 180.5); + expect(sample.pressure, 180.5); + // DiveProfilePoint has no pressure field, so it cannot survive toPoint. + expect(sample.toPoint(), point); + }); + + test('a minimal point maps with every optional field null', () { + const minimal = DiveProfilePoint(timestamp: 0, depth: 0.0); + final sample = ProfileSample.fromPoint(minimal); + expect(sample.timestamp, 0); + expect(sample.depth, 0.0); + expect(sample.pressure, isNull); + expect(sample.temperature, isNull); + expect(sample.heartRateSource, isNull); + expect(sample.o2SensorMv6, isNull); + expect(sample.toPoint(), minimal); + }); + + test('value equality covers every field', () { + final a = ProfileSample.fromPoint(point, pressure: 1.0); + final b = ProfileSample.fromPoint(point, pressure: 1.0); + final c = ProfileSample.fromPoint(point, pressure: 2.0); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(c)); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/domain/codecs/profile_sample_test.dart` +Expected: compilation failure, `profile_sample.dart` not found. + +- [ ] **Step 3: Create the value type** + +Create `lib/features/dive_log/domain/codecs/profile_sample.dart`: + +```dart +import 'package:equatable/equatable.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +/// One profile sample exactly as the `dive_profiles` table stores it, minus +/// the identity columns (`id`, `dive_id`, `computer_id`, `source_id`, +/// `is_primary`) that live on the series row. +/// +/// This is the codec's input and output type. It differs from +/// [DiveProfilePoint] in one field: the legacy per-sample [pressure] +/// column, which the v59 migration moved to tank pressure profiles but +/// which older rows still populate. The codec is lossless over the stored +/// row, so the field rides along; [toPoint] drops it, as every read path +/// does today. +class ProfileSample extends Equatable { + const ProfileSample({ + required this.timestamp, + required this.depth, + this.pressure, + this.temperature, + this.heartRate, + this.ascentRate, + this.ceiling, + this.ndl, + this.setpoint, + this.ppO2, + this.o2Sensor1, + this.o2Sensor2, + this.o2Sensor3, + this.o2Sensor4, + this.o2Sensor5, + this.o2Sensor6, + this.cns, + this.tts, + this.rbt, + this.decoType, + this.heartRateSource, + this.heading, + this.o2SensorMv1, + this.o2SensorMv2, + this.o2SensorMv3, + this.o2SensorMv4, + this.o2SensorMv5, + this.o2SensorMv6, + }); + + factory ProfileSample.fromPoint(DiveProfilePoint point, {double? pressure}) { + return ProfileSample( + timestamp: point.timestamp, + depth: point.depth, + pressure: pressure, + temperature: point.temperature, + heartRate: point.heartRate, + ascentRate: point.ascentRate, + ceiling: point.ceiling, + ndl: point.ndl, + setpoint: point.setpoint, + ppO2: point.ppO2, + o2Sensor1: point.o2Sensor1, + o2Sensor2: point.o2Sensor2, + o2Sensor3: point.o2Sensor3, + o2Sensor4: point.o2Sensor4, + o2Sensor5: point.o2Sensor5, + o2Sensor6: point.o2Sensor6, + cns: point.cns, + tts: point.tts, + rbt: point.rbt, + decoType: point.decoType, + heartRateSource: point.heartRateSource, + heading: point.heading, + o2SensorMv1: point.o2SensorMv1, + o2SensorMv2: point.o2SensorMv2, + o2SensorMv3: point.o2SensorMv3, + o2SensorMv4: point.o2SensorMv4, + o2SensorMv5: point.o2SensorMv5, + o2SensorMv6: point.o2SensorMv6, + ); + } + + /// Seconds from dive start. + final int timestamp; + + /// Metres. + final double depth; + + /// Legacy per-sample pressure in bar; null on every row written after the + /// v59 migration. + final double? pressure; + final double? temperature; + final int? heartRate; + final double? ascentRate; + final double? ceiling; + final int? ndl; + final double? setpoint; + final double? ppO2; + final double? o2Sensor1; + final double? o2Sensor2; + final double? o2Sensor3; + final double? o2Sensor4; + final double? o2Sensor5; + final double? o2Sensor6; + final double? cns; + final int? tts; + final int? rbt; + final int? decoType; + final String? heartRateSource; + final double? heading; + final int? o2SensorMv1; + final int? o2SensorMv2; + final int? o2SensorMv3; + final int? o2SensorMv4; + final int? o2SensorMv5; + final int? o2SensorMv6; + + /// The domain point. Drops [pressure], which [DiveProfilePoint] does not + /// carry. + DiveProfilePoint toPoint() { + return DiveProfilePoint( + timestamp: timestamp, + depth: depth, + temperature: temperature, + heartRate: heartRate, + heading: heading, + setpoint: setpoint, + ppO2: ppO2, + o2Sensor1: o2Sensor1, + o2Sensor2: o2Sensor2, + o2Sensor3: o2Sensor3, + o2Sensor4: o2Sensor4, + o2Sensor5: o2Sensor5, + o2Sensor6: o2Sensor6, + o2SensorMv1: o2SensorMv1, + o2SensorMv2: o2SensorMv2, + o2SensorMv3: o2SensorMv3, + o2SensorMv4: o2SensorMv4, + o2SensorMv5: o2SensorMv5, + o2SensorMv6: o2SensorMv6, + heartRateSource: heartRateSource, + cns: cns, + ndl: ndl, + ceiling: ceiling, + ascentRate: ascentRate, + rbt: rbt, + decoType: decoType, + tts: tts, + ); + } + + @override + List get props => [ + timestamp, + depth, + pressure, + temperature, + heartRate, + ascentRate, + ceiling, + ndl, + setpoint, + ppO2, + o2Sensor1, + o2Sensor2, + o2Sensor3, + o2Sensor4, + o2Sensor5, + o2Sensor6, + cns, + tts, + rbt, + decoType, + heartRateSource, + heading, + o2SensorMv1, + o2SensorMv2, + o2SensorMv3, + o2SensorMv4, + o2SensorMv5, + o2SensorMv6, + ]; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/dive_log/domain/codecs/profile_sample_test.dart` +Expected: 4 tests pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/domain/codecs/profile_sample.dart test/features/dive_log/domain/codecs/profile_sample_test.dart +git commit -m "feat: ProfileSample value type for the series codec" +``` + +--- + +### Task 3: ProfileSeriesSummary + +**Files:** +- Create: `lib/features/dive_log/domain/codecs/profile_series_summary.dart` +- Test: `test/features/dive_log/domain/codecs/profile_series_summary_test.dart` + +**Interfaces:** +- Consumes: `ProfileSample` (Task 2). +- Produces: `class ProfileSeriesSummary extends Equatable` with `int sampleCount`, `int startTimestamp`, `int endTimestamp`, `double maxDepth`, `double firstDepth`, `double lastDepth`, `bool hasDecoType`, `bool hasDecoStop`, `bool hasPositiveCeiling`, and `factory ProfileSeriesSummary.of(List samples)` which throws `ArgumentError` on an empty list. Constant `kDecoTypeDecoStop = 2`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/domain/codecs/profile_series_summary_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_summary.dart'; + +void main() { + test('summarises a plain no-deco series', () { + const samples = [ + ProfileSample(timestamp: 0, depth: 0.0), + ProfileSample(timestamp: 10, depth: 5.2), + ProfileSample(timestamp: 20, depth: 18.7), + ProfileSample(timestamp: 30, depth: 12.1), + ProfileSample(timestamp: 40, depth: 0.3), + ]; + final summary = ProfileSeriesSummary.of(samples); + expect( + summary, + const ProfileSeriesSummary( + sampleCount: 5, + startTimestamp: 0, + endTimestamp: 40, + maxDepth: 18.7, + firstDepth: 0.0, + lastDepth: 0.3, + hasDecoType: false, + hasDecoStop: false, + hasPositiveCeiling: false, + ), + ); + }); + + test('a recorded deco type without a stop sets only hasDecoType', () { + const samples = [ + ProfileSample(timestamp: 0, depth: 0.0, decoType: 0), + ProfileSample(timestamp: 10, depth: 20.0, decoType: 1), + ]; + final summary = ProfileSeriesSummary.of(samples); + expect(summary.hasDecoType, isTrue); + expect(summary.hasDecoStop, isFalse); + }); + + test('deco type 2 on any sample sets hasDecoStop', () { + const samples = [ + ProfileSample(timestamp: 0, depth: 0.0, decoType: 0), + ProfileSample(timestamp: 10, depth: 6.0, decoType: kDecoTypeDecoStop), + ]; + expect(ProfileSeriesSummary.of(samples).hasDecoStop, isTrue); + }); + + test('a positive ceiling on any sample sets hasPositiveCeiling', () { + const samples = [ + ProfileSample(timestamp: 0, depth: 0.0, ceiling: 0.0), + ProfileSample(timestamp: 10, depth: 30.0, ceiling: 3.0), + ]; + expect(ProfileSeriesSummary.of(samples).hasPositiveCeiling, isTrue); + }); + + test('a zero or null ceiling does not count as positive', () { + const samples = [ + ProfileSample(timestamp: 0, depth: 0.0, ceiling: 0.0), + ProfileSample(timestamp: 10, depth: 30.0), + ]; + expect(ProfileSeriesSummary.of(samples).hasPositiveCeiling, isFalse); + }); + + test('a single sample is its own start, end, first, last and max', () { + const samples = [ProfileSample(timestamp: 7, depth: 4.4)]; + final summary = ProfileSeriesSummary.of(samples); + expect(summary.sampleCount, 1); + expect(summary.startTimestamp, 7); + expect(summary.endTimestamp, 7); + expect(summary.maxDepth, 4.4); + expect(summary.firstDepth, 4.4); + expect(summary.lastDepth, 4.4); + }); + + test('an empty series is a caller error', () { + expect(() => ProfileSeriesSummary.of(const []), throwsArgumentError); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/domain/codecs/profile_series_summary_test.dart` +Expected: compilation failure, `profile_series_summary.dart` not found. + +- [ ] **Step 3: Create the summary** + +Create `lib/features/dive_log/domain/codecs/profile_series_summary.dart`: + +```dart +import 'package:equatable/equatable.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; + +/// The `deco_type` value that marks a mandatory decompression stop +/// (0 = NDL, 1 = safety stop, 2 = deco stop, 3 = deep stop). +const int kDecoTypeDecoStop = 2; + +/// The scalars a `dive_profile_series` row stores next to its blob. +/// +/// These exist so the SQL consumers that only need a predicate or a span +/// (deco classification, runtime fallback, quality neighbours) never decode +/// a blob. They are computed from the same sample list the codec packs, so +/// a scalar can never disagree with its blob. +class ProfileSeriesSummary extends Equatable { + const ProfileSeriesSummary({ + required this.sampleCount, + required this.startTimestamp, + required this.endTimestamp, + required this.maxDepth, + required this.firstDepth, + required this.lastDepth, + required this.hasDecoType, + required this.hasDecoStop, + required this.hasPositiveCeiling, + }); + + /// Computes the summary of a non-empty, timestamp-ordered series. + factory ProfileSeriesSummary.of(List samples) { + if (samples.isEmpty) { + throw ArgumentError.value( + samples, + 'samples', + 'a series needs at least one sample', + ); + } + var maxDepth = samples.first.depth; + var hasDecoType = false; + var hasDecoStop = false; + var hasPositiveCeiling = false; + for (final sample in samples) { + if (sample.depth > maxDepth) maxDepth = sample.depth; + final decoType = sample.decoType; + if (decoType != null) { + hasDecoType = true; + if (decoType == kDecoTypeDecoStop) hasDecoStop = true; + } + final ceiling = sample.ceiling; + if (ceiling != null && ceiling > 0) hasPositiveCeiling = true; + } + return ProfileSeriesSummary( + sampleCount: samples.length, + startTimestamp: samples.first.timestamp, + endTimestamp: samples.last.timestamp, + maxDepth: maxDepth, + firstDepth: samples.first.depth, + lastDepth: samples.last.depth, + hasDecoType: hasDecoType, + hasDecoStop: hasDecoStop, + hasPositiveCeiling: hasPositiveCeiling, + ); + } + + final int sampleCount; + + /// Seconds from dive start of the first sample. + final int startTimestamp; + + /// Seconds from dive start of the last sample. + final int endTimestamp; + + /// Metres. + final double maxDepth; + final double firstDepth; + final double lastDepth; + + /// Any sample carries a `deco_type`. + final bool hasDecoType; + + /// Any sample carries `deco_type == 2`. + final bool hasDecoStop; + + /// Any sample carries `ceiling > 0`. + final bool hasPositiveCeiling; + + @override + List get props => [ + sampleCount, + startTimestamp, + endTimestamp, + maxDepth, + firstDepth, + lastDepth, + hasDecoType, + hasDecoStop, + hasPositiveCeiling, + ]; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/dive_log/domain/codecs/profile_series_summary_test.dart` +Expected: 7 tests pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/domain/codecs/profile_series_summary.dart test/features/dive_log/domain/codecs/profile_series_summary_test.dart +git commit -m "feat: ProfileSeriesSummary scalars for series rows" +``` + +--- + +### Task 4: ProfileSeriesCodec + +**Files:** +- Create: `lib/features/dive_log/domain/codecs/profile_series_codec.dart` +- Test: `test/features/dive_log/domain/codecs/profile_series_codec_test.dart` + +**Interfaces:** +- Consumes: `ByteWriter`, `ByteReader`, `ColumnWriter`, `ColumnReader` (Task 1); `ProfileSample` (Task 2); `ProfileSeriesSummary` (Task 3); `ProfileSeriesCodecException` (Task 1). +- Produces: + - `enum ProfileFieldKind { deltaInt, float64, runLengthString }` + - `class ProfileField { const ProfileField(String name, ProfileFieldKind kind); final String name; final ProfileFieldKind kind; }` + - `class EncodedProfileSeries { final Uint8List bytes; final int codecVersion; final ProfileSeriesSummary summary; }` + - `class ProfileSeriesCodec { const ProfileSeriesCodec({Map> fieldTables}); static const int version = 1; static const List fieldTableV1; EncodedProfileSeries encode(List samples, {int version = 1}); List decode(Uint8List bytes); }` + - PR 2 calls `const ProfileSeriesCodec().encode(samples)` and `.decode(bytes)`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/dive_log/domain/codecs/profile_series_codec_test.dart`: + +```dart +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec_exception.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_summary.dart'; + +/// A fully populated sample: every one of the 28 fields present. +ProfileSample fullSample(int i) => ProfileSample( + timestamp: i * 10, + depth: 10.0 + i * 0.5, + pressure: 200.0 - i, + temperature: 20.0 - i * 0.01, + heartRate: 80 + (i % 7), + ascentRate: -3.0 + i * 0.1, + ceiling: i > 5 ? 3.0 : 0.0, + ndl: 1800 - i * 15, + setpoint: 1.2, + ppO2: 1.19 + i * 0.001, + o2Sensor1: 1.18, + o2Sensor2: 1.20, + o2Sensor3: 1.21, + o2Sensor4: 1.17, + o2Sensor5: 1.22, + o2Sensor6: 1.19, + cns: 12.5 + i, + tts: 900 + i * 3, + rbt: 1500 - i * 2, + decoType: i > 5 ? 2 : 0, + heartRateSource: i < 4 ? 'diveComputer' : 'appleWatch', + heading: (i * 37) % 360 * 1.0, + o2SensorMv1: 51 + i, + o2SensorMv2: 52 - i, + o2SensorMv3: 53, + o2SensorMv4: 50, + o2SensorMv5: 54, + o2SensorMv6: 52, +); + +/// Depth and timestamp only, like a manually entered profile. +ProfileSample minimalSample(int i) => + ProfileSample(timestamp: i * 30, depth: 5.0 * (i % 4)); + +void main() { + const codec = ProfileSeriesCodec(); + + group('round trips', () { + test('every field present', () { + final samples = [for (var i = 0; i < 12; i++) fullSample(i)]; + final encoded = codec.encode(samples); + expect(encoded.codecVersion, 1); + expect(codec.decode(encoded.bytes), samples); + }); + + test('every optional field null', () { + final samples = [for (var i = 0; i < 12; i++) minimalSample(i)]; + expect(codec.decode(codec.encode(samples).bytes), samples); + }); + + test('mixed presence within one column', () { + final samples = [ + for (var i = 0; i < 20; i++) + ProfileSample( + timestamp: i, + depth: 1.0 * i, + temperature: i.isEven ? 20.0 : null, + ndl: i % 3 == 0 ? 600 - i : null, + heartRateSource: i % 5 == 0 ? 'garmin' : null, + ), + ]; + expect(codec.decode(codec.encode(samples).bytes), samples); + }); + + test('a single sample', () { + final samples = [fullSample(0)]; + expect(codec.decode(codec.encode(samples).bytes), samples); + }); + + test('duplicate timestamps survive in insertion order', () { + const samples = [ + ProfileSample(timestamp: 10, depth: 5.0), + ProfileSample(timestamp: 10, depth: 5.5), + ProfileSample(timestamp: 10, depth: 5.0, temperature: 19.0), + ProfileSample(timestamp: 20, depth: 6.0), + ]; + expect(codec.decode(codec.encode(samples).bytes), samples); + }); + + test('integer fields that decrease encode negative deltas correctly', () { + final samples = [ + for (var i = 0; i < 50; i++) + ProfileSample( + timestamp: i, + depth: 1.0, + ndl: 3000 - i * 60, + rbt: 2000 - i * 45, + heartRate: 100 - i, + o2SensorMv1: -5 + i, + ), + ]; + expect(codec.decode(codec.encode(samples).bytes), samples); + }); + + test('float64 fields are bit-exact', () { + const samples = [ + ProfileSample(timestamp: 0, depth: 0.1 + 0.2, temperature: -0.0), + ProfileSample(timestamp: 1, depth: double.maxFinite, cns: 1e-300), + ]; + final decoded = codec.decode(codec.encode(samples).bytes); + expect(decoded[0].depth, samples[0].depth); + expect(decoded[0].temperature!.isNegative, isTrue); + expect(decoded[1].depth, double.maxFinite); + expect(decoded[1].cns, 1e-300); + }); + + test('the largest realistic series: 20,000 samples at one second', () { + final random = Random(42); + var depth = 0.0; + final samples = []; + for (var i = 0; i < 20000; i++) { + depth = max(0.0, depth + (random.nextDouble() - 0.5) * 0.3); + samples.add( + ProfileSample( + timestamp: i, + depth: depth, + temperature: 8.0 + (i ~/ 600) * 0.1, + ndl: max(0, 3600 - i ~/ 2), + cns: i / 400.0, + decoType: i > 15000 ? 2 : 0, + ), + ); + } + final encoded = codec.encode(samples); + expect(codec.decode(encoded.bytes), samples); + // 20,000 samples of six fields. Row storage today costs roughly 300 + // bytes per sample; the packed blob must land far below even the raw + // columnar size (8 bytes per float64 field per sample). + expect(encoded.bytes.length, lessThan(20000 * 3 * 8)); + }); + }); + + group('summary', () { + test('encode returns the summary of the packed samples', () { + final samples = [for (var i = 0; i < 12; i++) fullSample(i)]; + final encoded = codec.encode(samples); + expect(encoded.summary, ProfileSeriesSummary.of(samples)); + expect(encoded.summary.sampleCount, 12); + expect(encoded.summary.hasDecoStop, isTrue); + expect(encoded.summary.hasPositiveCeiling, isTrue); + }); + }); + + group('caller errors', () { + test('an empty series cannot be encoded', () { + expect(() => codec.encode(const []), throwsArgumentError); + }); + + test('timestamps must be non-decreasing', () { + const samples = [ + ProfileSample(timestamp: 10, depth: 1.0), + ProfileSample(timestamp: 9, depth: 1.0), + ]; + expect(() => codec.encode(samples), throwsArgumentError); + }); + + test('an unregistered version cannot be encoded', () { + expect( + () => codec.encode([fullSample(0)], version: 9), + throwsArgumentError, + ); + }); + }); + + group('malformed input', () { + Uint8List validBytes() => + codec.encode([for (var i = 0; i < 8; i++) fullSample(i)]).bytes; + + /// Re-compresses a tampered body so the failure is in the codec, not + /// in zlib. + Uint8List recompress(List body) => + Uint8List.fromList(ZLibCodec(level: 6).encode(body)); + + Uint8List inflate(Uint8List bytes) => Uint8List.fromList(zlib.decode(bytes)); + + test('bytes that are not a zlib stream', () { + expect( + () => codec.decode(Uint8List.fromList([1, 2, 3, 4, 5])), + throwsA(isA()), + ); + }); + + test('an empty blob', () { + expect( + () => codec.decode(Uint8List(0)), + throwsA(isA()), + ); + }); + + test('an unknown version byte', () { + final body = inflate(validBytes()); + body[0] = 42; + expect( + () => codec.decode(recompress(body)), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('version'), + ), + ), + ); + }); + + test('a truncated body', () { + final body = inflate(validBytes()); + final truncated = body.sublist(0, body.length ~/ 2); + expect( + () => codec.decode(recompress(truncated)), + throwsA(isA()), + ); + }); + + test('trailing bytes after the last block', () { + final body = inflate(validBytes()); + final padded = Uint8List.fromList([...body, 0, 0, 0]); + expect( + () => codec.decode(recompress(padded)), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('trailing'), + ), + ), + ); + }); + + test('a blob whose timestamp column is marked absent', () { + // Header is version (1 byte) then count varint (8 fits in 1 byte); + // byte 2 is the timestamp block's presence mode. + final body = inflate(validBytes()); + expect(body[2], isNot(0)); + body[2] = 0; + expect( + () => codec.decode(recompress(body)), + throwsA(isA()), + ); + }); + + test('a sample count larger than the payload', () { + final body = inflate(validBytes()); + // Replace the one-byte count (8) with a five-byte varint for 2^32. + const huge = [0x80, 0x80, 0x80, 0x80, 0x10]; + final tampered = Uint8List.fromList([ + body[0], + ...huge, + ...body.sublist(2), + ]); + expect( + () => codec.decode(recompress(tampered)), + throwsA(isA()), + ); + }); + + test('a string run longer than the present values', () { + // A three-field table keeps the body small enough to address by hand: + // [version][count 3][timestamp: mode, 3 deltas][depth: mode, 3 floats] + // [heart_rate_source: mode, run count, run length, byte length, 'a']. + const table = [ + ProfileField('timestamp', ProfileFieldKind.deltaInt), + ProfileField('depth', ProfileFieldKind.float64), + ProfileField('heart_rate_source', ProfileFieldKind.runLengthString), + ]; + const small = ProfileSeriesCodec(fieldTables: {9: table}); + const samples = [ + ProfileSample(timestamp: 0, depth: 1.0, heartRateSource: 'a'), + ProfileSample(timestamp: 1, depth: 2.0, heartRateSource: 'a'), + ProfileSample(timestamp: 2, depth: 3.0, heartRateSource: 'a'), + ]; + final body = inflate(small.encode(samples, version: 9).bytes); + const runLengthOffset = 1 + 1 + (1 + 3) + (1 + 24) + 1 + 1; + expect(body[runLengthOffset], 3); + body[runLengthOffset] = 5; + expect( + () => small.decode(recompress(body)), + throwsA(isA()), + ); + }); + }); + + group('forward tolerance', () { + // A hypothetical older format that never recorded heading. + final withoutHeading = [ + for (final field in ProfileSeriesCodec.fieldTableV1) + if (field.name != 'heading') field, + ]; + + test('a newer decoder reads an older version with missing fields null', () { + final legacy = ProfileSeriesCodec(fieldTables: {7: withoutHeading}); + final modern = ProfileSeriesCodec( + fieldTables: { + 7: withoutHeading, + ProfileSeriesCodec.version: ProfileSeriesCodec.fieldTableV1, + }, + ); + final samples = [for (var i = 0; i < 6; i++) fullSample(i)]; + final bytes = legacy.encode(samples, version: 7).bytes; + final decoded = modern.decode(bytes); + expect(decoded, hasLength(6)); + for (var i = 0; i < 6; i++) { + expect(decoded[i].heading, isNull); + expect(decoded[i], samples[i].copyWithoutHeading()); + } + }); + + test('a decoder that does not know the version refuses it', () { + final legacy = ProfileSeriesCodec(fieldTables: {7: withoutHeading}); + final bytes = legacy.encode([fullSample(0)], version: 7).bytes; + expect( + () => codec.decode(bytes), + throwsA(isA()), + ); + }); + + test('every field table must carry timestamp and depth', () { + final noDepth = [ + for (final field in ProfileSeriesCodec.fieldTableV1) + if (field.name != 'depth') field, + ]; + expect( + () => ProfileSeriesCodec(fieldTables: {1: noDepth}).encode([ + fullSample(0), + ]), + throwsArgumentError, + ); + }); + }); + + group('field table', () { + test('v1 has 28 entries with unique names', () { + final names = ProfileSeriesCodec.fieldTableV1.map((f) => f.name); + expect(names, hasLength(28)); + expect(names.toSet(), hasLength(28)); + }); + + test('v1 begins with timestamp and depth', () { + expect(ProfileSeriesCodec.fieldTableV1[0].name, 'timestamp'); + expect(ProfileSeriesCodec.fieldTableV1[1].name, 'depth'); + }); + }); +} + +extension on ProfileSample { + ProfileSample copyWithoutHeading() => ProfileSample( + timestamp: timestamp, + depth: depth, + pressure: pressure, + temperature: temperature, + heartRate: heartRate, + ascentRate: ascentRate, + ceiling: ceiling, + ndl: ndl, + setpoint: setpoint, + ppO2: ppO2, + o2Sensor1: o2Sensor1, + o2Sensor2: o2Sensor2, + o2Sensor3: o2Sensor3, + o2Sensor4: o2Sensor4, + o2Sensor5: o2Sensor5, + o2Sensor6: o2Sensor6, + cns: cns, + tts: tts, + rbt: rbt, + decoType: decoType, + heartRateSource: heartRateSource, + o2SensorMv1: o2SensorMv1, + o2SensorMv2: o2SensorMv2, + o2SensorMv3: o2SensorMv3, + o2SensorMv4: o2SensorMv4, + o2SensorMv5: o2SensorMv5, + o2SensorMv6: o2SensorMv6, + ); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/domain/codecs/profile_series_codec_test.dart` +Expected: compilation failure, `profile_series_codec.dart` not found. + +- [ ] **Step 3: Create the codec** + +Create `lib/features/dive_log/domain/codecs/profile_series_codec.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:submersion/features/dive_log/domain/codecs/byte_io.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec_exception.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_summary.dart'; + +/// How a field's column block is written. +enum ProfileFieldKind { + /// Zigzag varint of the difference from the previous present value; the + /// previous value starts at 0 for each block. + deltaInt, + + /// IEEE-754 binary64, little-endian. + float64, + + /// Runs of identical strings: run count, then (length, UTF-8 length, + /// UTF-8 bytes) per run, covering the present values only. + runLengthString, +} + +/// One entry of a field table. [name] is the `dive_profiles` column name. +class ProfileField { + const ProfileField(this.name, this.kind); + + final String name; + final ProfileFieldKind kind; +} + +/// The bytes and scalars a `dive_profile_series` row stores. +class EncodedProfileSeries { + const EncodedProfileSeries({ + required this.bytes, + required this.codecVersion, + required this.summary, + }); + + final Uint8List bytes; + final int codecVersion; + final ProfileSeriesSummary summary; +} + +/// Packs a series of [ProfileSample]s into one zlib-compressed columnar blob +/// and back. Lossless. +/// +/// Layout of the uncompressed body: a version byte, the sample count as a +/// varint, then one column block per entry of that version's field table in +/// table order. See `byte_io.dart` for the block format. +/// +/// Versioning: a later codec appends fields under a new version byte. The +/// decoder selects the field table by the blob's version byte, so an older +/// blob decodes under a newer codec with its missing fields null. A version +/// this codec does not know is refused. +class ProfileSeriesCodec { + const ProfileSeriesCodec({ + this.fieldTables = const {version: fieldTableV1}, + }); + + /// The version new blobs are written with. + static const int version = 1; + + /// Codec v1: every `dive_profiles` sample column, in this order. Never + /// reorder or remove an entry; append under a new version instead. + static const List fieldTableV1 = [ + ProfileField('timestamp', ProfileFieldKind.deltaInt), + ProfileField('depth', ProfileFieldKind.float64), + ProfileField('pressure', ProfileFieldKind.float64), + ProfileField('temperature', ProfileFieldKind.float64), + ProfileField('heart_rate', ProfileFieldKind.deltaInt), + ProfileField('ascent_rate', ProfileFieldKind.float64), + ProfileField('ceiling', ProfileFieldKind.float64), + ProfileField('ndl', ProfileFieldKind.deltaInt), + ProfileField('setpoint', ProfileFieldKind.float64), + ProfileField('pp_o2', ProfileFieldKind.float64), + ProfileField('o2_sensor1', ProfileFieldKind.float64), + ProfileField('o2_sensor2', ProfileFieldKind.float64), + ProfileField('o2_sensor3', ProfileFieldKind.float64), + ProfileField('o2_sensor4', ProfileFieldKind.float64), + ProfileField('o2_sensor5', ProfileFieldKind.float64), + ProfileField('o2_sensor6', ProfileFieldKind.float64), + ProfileField('cns', ProfileFieldKind.float64), + ProfileField('tts', ProfileFieldKind.deltaInt), + ProfileField('rbt', ProfileFieldKind.deltaInt), + ProfileField('deco_type', ProfileFieldKind.deltaInt), + ProfileField('heart_rate_source', ProfileFieldKind.runLengthString), + ProfileField('heading', ProfileFieldKind.float64), + ProfileField('o2_sensor_mv1', ProfileFieldKind.deltaInt), + ProfileField('o2_sensor_mv2', ProfileFieldKind.deltaInt), + ProfileField('o2_sensor_mv3', ProfileFieldKind.deltaInt), + ProfileField('o2_sensor_mv4', ProfileFieldKind.deltaInt), + ProfileField('o2_sensor_mv5', ProfileFieldKind.deltaInt), + ProfileField('o2_sensor_mv6', ProfileFieldKind.deltaInt), + ]; + + /// Field table per version byte this codec can read. + final Map> fieldTables; + + // ZLibCodec has no const constructor, so this is `final`, not `const`. + static final ZLibCodec _zlib = ZLibCodec(level: 6); + + /// Encodes a non-empty, timestamp-ordered series. + /// + /// Throws [ArgumentError] on an empty list, on decreasing timestamps, on + /// an unregistered [version], or on a field table without `timestamp` and + /// `depth`. These are caller bugs, not data faults. + EncodedProfileSeries encode( + List samples, { + int version = ProfileSeriesCodec.version, + }) { + final table = fieldTables[version]; + if (table == null) { + throw ArgumentError.value(version, 'version', 'no field table'); + } + _requireTimestampAndDepth(table); + final summary = ProfileSeriesSummary.of(samples); + for (var i = 1; i < samples.length; i++) { + if (samples[i].timestamp < samples[i - 1].timestamp) { + throw ArgumentError.value( + samples, + 'samples', + 'timestamps must be non-decreasing (sample $i)', + ); + } + } + + final writer = ByteWriter() + ..writeByte(version) + ..writeVarUint(samples.length); + for (final field in table) { + final column = [for (final sample in samples) _fieldOf(sample, field.name)]; + _writeColumn(writer, field.kind, column); + } + return EncodedProfileSeries( + bytes: Uint8List.fromList(_zlib.encode(writer.takeBytes())), + codecVersion: version, + summary: summary, + ); + } + + /// Decodes a blob written by [encode] under any registered version. + /// + /// Throws [ProfileSeriesCodecException] on anything malformed: not a zlib + /// stream, an unknown version, a truncated block, trailing bytes, or a + /// sample without timestamp or depth. + List decode(Uint8List bytes) { + final Uint8List body; + try { + body = Uint8List.fromList(_zlib.decode(bytes)); + } catch (e) { + throw ProfileSeriesCodecException('not a zlib stream: $e'); + } + if (body.isEmpty) { + throw const ProfileSeriesCodecException('empty body'); + } + final reader = ByteReader(body); + final blobVersion = reader.readByte(); + final table = fieldTables[blobVersion]; + if (table == null) { + throw ProfileSeriesCodecException('unknown codec version $blobVersion'); + } + final count = reader.readVarUint(); + // Every sample carries at least a one-byte timestamp delta, so a count + // the remaining payload cannot hold is corruption, not a large series. + // Guarding here keeps a bogus count from sizing 28 column lists. + if (count > reader.remaining) { + throw ProfileSeriesCodecException( + 'sample count $count exceeds the ${reader.remaining} remaining ' + 'byte(s)', + ); + } + final columns = >{}; + for (final field in table) { + columns[field.name] = _readColumn(reader, field.kind, count); + } + if (!reader.isAtEnd) { + throw ProfileSeriesCodecException( + '${reader.remaining} trailing byte(s) after the last block', + ); + } + return _samplesFrom(columns, count); + } + + static void _requireTimestampAndDepth(List table) { + final names = {for (final field in table) field.name}; + if (!names.contains('timestamp') || !names.contains('depth')) { + throw ArgumentError.value( + table, + 'fieldTables', + 'every field table must carry timestamp and depth', + ); + } + } + + static Object? _fieldOf(ProfileSample s, String name) => switch (name) { + 'timestamp' => s.timestamp, + 'depth' => s.depth, + 'pressure' => s.pressure, + 'temperature' => s.temperature, + 'heart_rate' => s.heartRate, + 'ascent_rate' => s.ascentRate, + 'ceiling' => s.ceiling, + 'ndl' => s.ndl, + 'setpoint' => s.setpoint, + 'pp_o2' => s.ppO2, + 'o2_sensor1' => s.o2Sensor1, + 'o2_sensor2' => s.o2Sensor2, + 'o2_sensor3' => s.o2Sensor3, + 'o2_sensor4' => s.o2Sensor4, + 'o2_sensor5' => s.o2Sensor5, + 'o2_sensor6' => s.o2Sensor6, + 'cns' => s.cns, + 'tts' => s.tts, + 'rbt' => s.rbt, + 'deco_type' => s.decoType, + 'heart_rate_source' => s.heartRateSource, + 'heading' => s.heading, + 'o2_sensor_mv1' => s.o2SensorMv1, + 'o2_sensor_mv2' => s.o2SensorMv2, + 'o2_sensor_mv3' => s.o2SensorMv3, + 'o2_sensor_mv4' => s.o2SensorMv4, + 'o2_sensor_mv5' => s.o2SensorMv5, + 'o2_sensor_mv6' => s.o2SensorMv6, + _ => throw ArgumentError.value(name, 'name', 'not a profile sample field'), + }; + + static void _writeColumn( + ByteWriter writer, + ProfileFieldKind kind, + List column, + ) { + switch (kind) { + case ProfileFieldKind.deltaInt: + var previous = 0; + writer.writeColumn(column.cast(), (value) { + writer.writeVarInt(value - previous); + previous = value; + }); + case ProfileFieldKind.float64: + writer.writeColumn(column.cast(), writer.writeFloat64); + case ProfileFieldKind.runLengthString: + _writeStringColumn(writer, column.cast()); + } + } + + static List _readColumn( + ByteReader reader, + ProfileFieldKind kind, + int count, + ) { + switch (kind) { + case ProfileFieldKind.deltaInt: + var previous = 0; + return reader.readColumn(count, () { + previous += reader.readVarInt(); + return previous; + }); + case ProfileFieldKind.float64: + return reader.readColumn(count, reader.readFloat64); + case ProfileFieldKind.runLengthString: + return _readStringColumn(reader, count); + } + } + + static void _writeStringColumn(ByteWriter writer, List values) { + if (!writer.writePresence(values)) return; + final runs = <(String, int)>[]; + for (final value in values) { + if (value == null) continue; + if (runs.isNotEmpty && runs.last.$1 == value) { + runs[runs.length - 1] = (value, runs.last.$2 + 1); + } else { + runs.add((value, 1)); + } + } + writer.writeVarUint(runs.length); + for (final (value, length) in runs) { + final encoded = utf8.encode(value); + writer + ..writeVarUint(length) + ..writeVarUint(encoded.length) + ..writeBytes(encoded); + } + } + + static List _readStringColumn(ByteReader reader, int count) { + final present = reader.readPresence(count); + var presentCount = 0; + for (final isPresent in present) { + if (isPresent) presentCount++; + } + if (presentCount == 0) return List.filled(count, null); + final runCount = reader.readVarUint(); + final values = []; + for (var run = 0; run < runCount; run++) { + final length = reader.readVarUint(); + if (values.length + length > presentCount) { + throw ProfileSeriesCodecException( + 'string run $run of length $length overruns the $presentCount ' + 'present values', + ); + } + final byteLength = reader.readVarUint(); + final String value; + try { + value = utf8.decode(reader.readBytes(byteLength)); + } on FormatException catch (e) { + throw ProfileSeriesCodecException( + 'invalid UTF-8 in string run $run: ${e.message}', + ); + } + for (var i = 0; i < length; i++) { + values.add(value); + } + } + if (values.length != presentCount) { + throw ProfileSeriesCodecException( + 'string runs cover ${values.length} of $presentCount present values', + ); + } + var next = 0; + return [for (final isPresent in present) isPresent ? values[next++] : null]; + } + + static List _samplesFrom( + Map> columns, + int count, + ) { + List column(String name) { + final values = columns[name]; + if (values == null) return List.filled(count, null); + return values.cast(); + } + + final timestamps = column('timestamp'); + final depths = column('depth'); + final pressures = column('pressure'); + final temperatures = column('temperature'); + final heartRates = column('heart_rate'); + final ascentRates = column('ascent_rate'); + final ceilings = column('ceiling'); + final ndls = column('ndl'); + final setpoints = column('setpoint'); + final ppO2s = column('pp_o2'); + final o2Sensor1s = column('o2_sensor1'); + final o2Sensor2s = column('o2_sensor2'); + final o2Sensor3s = column('o2_sensor3'); + final o2Sensor4s = column('o2_sensor4'); + final o2Sensor5s = column('o2_sensor5'); + final o2Sensor6s = column('o2_sensor6'); + final cnss = column('cns'); + final ttss = column('tts'); + final rbts = column('rbt'); + final decoTypes = column('deco_type'); + final heartRateSources = column('heart_rate_source'); + final headings = column('heading'); + final mv1s = column('o2_sensor_mv1'); + final mv2s = column('o2_sensor_mv2'); + final mv3s = column('o2_sensor_mv3'); + final mv4s = column('o2_sensor_mv4'); + final mv5s = column('o2_sensor_mv5'); + final mv6s = column('o2_sensor_mv6'); + + return [ + for (var i = 0; i < count; i++) + ProfileSample( + timestamp: + timestamps[i] ?? + (throw ProfileSeriesCodecException( + 'sample $i has no timestamp', + )), + depth: + depths[i] ?? + (throw ProfileSeriesCodecException('sample $i has no depth')), + pressure: pressures[i], + temperature: temperatures[i], + heartRate: heartRates[i], + ascentRate: ascentRates[i], + ceiling: ceilings[i], + ndl: ndls[i], + setpoint: setpoints[i], + ppO2: ppO2s[i], + o2Sensor1: o2Sensor1s[i], + o2Sensor2: o2Sensor2s[i], + o2Sensor3: o2Sensor3s[i], + o2Sensor4: o2Sensor4s[i], + o2Sensor5: o2Sensor5s[i], + o2Sensor6: o2Sensor6s[i], + cns: cnss[i], + tts: ttss[i], + rbt: rbts[i], + decoType: decoTypes[i], + heartRateSource: heartRateSources[i], + heading: headings[i], + o2SensorMv1: mv1s[i], + o2SensorMv2: mv2s[i], + o2SensorMv3: mv3s[i], + o2SensorMv4: mv4s[i], + o2SensorMv5: mv5s[i], + o2SensorMv6: mv6s[i], + ), + ]; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/dive_log/domain/codecs/profile_series_codec_test.dart` +Expected: all tests pass, including the 20,000-sample case. If the size assertion in "the largest realistic series" fails, the encoder is writing a bitmap or payload it should not; do not loosen the bound, find the block that grew. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/domain/codecs/profile_series_codec.dart test/features/dive_log/domain/codecs/profile_series_codec_test.dart +git commit -m "feat: ProfileSeriesCodec, lossless columnar packing of profile samples" +``` + +--- + +### Task 5: Field table enumerated against the Drift table + +**Files:** +- Test: `test/features/dive_log/domain/codecs/profile_series_field_table_test.dart` + +**Interfaces:** +- Consumes: `ProfileSeriesCodec.fieldTableV1` (Task 4); `AppDatabase` and its `diveProfiles` table from `package:submersion/core/database/database.dart`; `NativeDatabase.memory()` from `package:drift/native.dart`; `DriftSqlType` from `package:drift/drift.dart`. +- Produces: nothing; this task is the guard the spec's risk list names ("a sample field the field table forgot fails the test, not the user"). + +- [ ] **Step 1: Write the test** + +Create `test/features/dive_log/domain/codecs/profile_series_field_table_test.dart`: + +```dart +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec.dart'; + +/// Columns of `dive_profiles` that identify the series, not the sample. +/// They live on the series row and are never packed. +const identityColumns = { + 'id', + 'dive_id', + 'computer_id', + 'source_id', + 'is_primary', +}; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + }); + + tearDown(() => db.close()); + + test('codec v1 covers every dive_profiles sample column, and only those', () { + final tableColumns = { + for (final column in db.diveProfiles.$columns) column.$name, + }; + final expected = tableColumns.difference(identityColumns); + final actual = { + for (final field in ProfileSeriesCodec.fieldTableV1) field.name, + }; + expect( + actual, + expected, + reason: + 'A dive_profiles column was added or removed without a codec ' + 'version. Append the field under a new version in ' + 'ProfileSeriesCodec; never edit fieldTableV1.', + ); + }); + + test('each field kind matches its column type', () { + final typeByName = { + for (final column in db.diveProfiles.$columns) column.$name: column.type, + }; + for (final field in ProfileSeriesCodec.fieldTableV1) { + final type = typeByName[field.name]; + final expectedKind = switch (type) { + DriftSqlType.int => ProfileFieldKind.deltaInt, + DriftSqlType.double => ProfileFieldKind.float64, + DriftSqlType.string => ProfileFieldKind.runLengthString, + _ => fail('unexpected column type $type for ${field.name}'), + }; + expect(field.kind, expectedKind, reason: field.name); + } + }); +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `flutter test test/features/dive_log/domain/codecs/profile_series_field_table_test.dart` +Expected: 2 tests pass. If the first fails, the set difference in the failure output names the column the field table is missing or has extra; that is a real finding, resolve it in the field table only if the column genuinely is a sample column, otherwise add it to `identityColumns` with a comment. + +- [ ] **Step 3: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add test/features/dive_log/domain/codecs/profile_series_field_table_test.dart +git commit -m "test: pin the codec field table to the dive_profiles columns" +``` + +--- + +### Task 6: TankPressureSeriesCodec + +**Files:** +- Create: `lib/features/dive_log/domain/codecs/tank_pressure_series_codec.dart` +- Test: `test/features/dive_log/domain/codecs/tank_pressure_series_codec_test.dart` + +**Interfaces:** +- Consumes: `ByteWriter`, `ByteReader`, `ColumnWriter`, `ColumnReader`, `ProfileSeriesCodecException` (Task 1). +- Produces: + - `class TankPressureSample extends Equatable { const TankPressureSample({required int timestamp, required double pressure}); }` + - `class TankPressureSeriesSummary extends Equatable { int sampleCount; int startTimestamp; int endTimestamp; factory TankPressureSeriesSummary.of(List); }` + - `class EncodedTankPressureSeries { Uint8List bytes; int codecVersion; TankPressureSeriesSummary summary; }` + - `class TankPressureSeriesCodec { const TankPressureSeriesCodec(); static const int version = 1; EncodedTankPressureSeries encode(List samples); List decode(Uint8List bytes); }` + +- [ ] **Step 1: Write the failing tests** + +Create `test/features/dive_log/domain/codecs/tank_pressure_series_codec_test.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec_exception.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; + +void main() { + const codec = TankPressureSeriesCodec(); + + List descending(int count) => [ + for (var i = 0; i < count; i++) + TankPressureSample(timestamp: i * 10, pressure: 210.0 - i * 0.3), + ]; + + group('round trips', () { + test('a typical series', () { + final samples = descending(200); + final encoded = codec.encode(samples); + expect(encoded.codecVersion, 1); + expect(codec.decode(encoded.bytes), samples); + }); + + test('a single sample', () { + const samples = [TankPressureSample(timestamp: 0, pressure: 200.0)]; + expect(codec.decode(codec.encode(samples).bytes), samples); + }); + + test('duplicate timestamps survive in insertion order', () { + const samples = [ + TankPressureSample(timestamp: 5, pressure: 200.0), + TankPressureSample(timestamp: 5, pressure: 199.0), + TankPressureSample(timestamp: 6, pressure: 198.5), + ]; + expect(codec.decode(codec.encode(samples).bytes), samples); + }); + + test('pressures are bit-exact', () { + const samples = [ + TankPressureSample(timestamp: 0, pressure: 0.1 + 0.2), + TankPressureSample(timestamp: 1, pressure: 1e-300), + ]; + final decoded = codec.decode(codec.encode(samples).bytes); + expect(decoded[0].pressure, 0.1 + 0.2); + expect(decoded[1].pressure, 1e-300); + }); + + test('20,000 samples pack below raw columnar size', () { + final samples = descending(20000); + final encoded = codec.encode(samples); + expect(codec.decode(encoded.bytes), samples); + // Raw columnar is one varint byte plus eight float bytes per sample. + // Any compression at all lands below this; the bound is deterministic. + expect(encoded.bytes.length, lessThan(20000 * 9)); + }); + }); + + group('summary', () { + test('encode returns the summary of the packed samples', () { + final samples = descending(10); + final encoded = codec.encode(samples); + expect(encoded.summary, TankPressureSeriesSummary.of(samples)); + expect(encoded.summary.sampleCount, 10); + expect(encoded.summary.startTimestamp, 0); + expect(encoded.summary.endTimestamp, 90); + }); + }); + + group('caller errors', () { + test('an empty series cannot be encoded', () { + expect(() => codec.encode(const []), throwsArgumentError); + }); + + test('an empty summary is a caller error', () { + expect(() => TankPressureSeriesSummary.of(const []), throwsArgumentError); + }); + + test('timestamps must be non-decreasing', () { + const samples = [ + TankPressureSample(timestamp: 10, pressure: 1.0), + TankPressureSample(timestamp: 9, pressure: 1.0), + ]; + expect(() => codec.encode(samples), throwsArgumentError); + }); + }); + + group('malformed input', () { + Uint8List validBytes() => codec.encode(descending(8)).bytes; + Uint8List recompress(List body) => + Uint8List.fromList(ZLibCodec(level: 6).encode(body)); + Uint8List inflate(Uint8List bytes) => Uint8List.fromList(zlib.decode(bytes)); + + test('bytes that are not a zlib stream', () { + expect( + () => codec.decode(Uint8List.fromList([9, 9, 9])), + throwsA(isA()), + ); + }); + + test('an unknown version byte', () { + final body = inflate(validBytes()); + body[0] = 2; + expect( + () => codec.decode(recompress(body)), + throwsA(isA()), + ); + }); + + test('a truncated body', () { + final body = inflate(validBytes()); + expect( + () => codec.decode(recompress(body.sublist(0, body.length - 3))), + throwsA(isA()), + ); + }); + + test('trailing bytes', () { + final body = inflate(validBytes()); + expect( + () => codec.decode(recompress([...body, 0])), + throwsA(isA()), + ); + }); + + test('a sample count larger than the payload', () { + final body = inflate(validBytes()); + // Replace the one-byte count (8) with a five-byte varint for 2^32. + const huge = [0x80, 0x80, 0x80, 0x80, 0x10]; + final tampered = Uint8List.fromList([ + body[0], + ...huge, + ...body.sublist(2), + ]); + expect( + () => codec.decode(recompress(tampered)), + throwsA(isA()), + ); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/domain/codecs/tank_pressure_series_codec_test.dart` +Expected: compilation failure, `tank_pressure_series_codec.dart` not found. + +- [ ] **Step 3: Create the codec** + +Create `lib/features/dive_log/domain/codecs/tank_pressure_series_codec.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; +import 'package:submersion/features/dive_log/domain/codecs/byte_io.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec_exception.dart'; + +/// One tank pressure reading as `tank_pressure_profiles` stores it, minus +/// the identity columns (`id`, `dive_id`, `tank_id`, `computer_id`) that +/// live on the series row. +class TankPressureSample extends Equatable { + const TankPressureSample({required this.timestamp, required this.pressure}); + + /// Seconds from dive start. + final int timestamp; + + /// Bar. + final double pressure; + + @override + List get props => [timestamp, pressure]; +} + +/// The scalars a `tank_pressure_series` row stores next to its blob. +class TankPressureSeriesSummary extends Equatable { + const TankPressureSeriesSummary({ + required this.sampleCount, + required this.startTimestamp, + required this.endTimestamp, + }); + + /// Computes the summary of a non-empty, timestamp-ordered series. + factory TankPressureSeriesSummary.of(List samples) { + if (samples.isEmpty) { + throw ArgumentError.value( + samples, + 'samples', + 'a series needs at least one sample', + ); + } + return TankPressureSeriesSummary( + sampleCount: samples.length, + startTimestamp: samples.first.timestamp, + endTimestamp: samples.last.timestamp, + ); + } + + final int sampleCount; + final int startTimestamp; + final int endTimestamp; + + @override + List get props => [sampleCount, startTimestamp, endTimestamp]; +} + +/// The bytes and scalars a `tank_pressure_series` row stores. +class EncodedTankPressureSeries { + const EncodedTankPressureSeries({ + required this.bytes, + required this.codecVersion, + required this.summary, + }); + + final Uint8List bytes; + final int codecVersion; + final TankPressureSeriesSummary summary; +} + +/// Packs a tank's pressure readings into one zlib-compressed columnar blob +/// and back. Lossless. +/// +/// Body: version byte, sample count varint, a delta-zigzag-varint timestamp +/// column, a float64 pressure column. Both columns are always fully present +/// (the fields are non-nullable), so each costs one presence byte. +class TankPressureSeriesCodec { + const TankPressureSeriesCodec(); + + static const int version = 1; + + // ZLibCodec has no const constructor, so this is `final`, not `const`. + static final ZLibCodec _zlib = ZLibCodec(level: 6); + + /// Encodes a non-empty, timestamp-ordered series. Throws [ArgumentError] + /// on an empty list or on decreasing timestamps. + EncodedTankPressureSeries encode(List samples) { + final summary = TankPressureSeriesSummary.of(samples); + for (var i = 1; i < samples.length; i++) { + if (samples[i].timestamp < samples[i - 1].timestamp) { + throw ArgumentError.value( + samples, + 'samples', + 'timestamps must be non-decreasing (sample $i)', + ); + } + } + final writer = ByteWriter() + ..writeByte(version) + ..writeVarUint(samples.length); + var previous = 0; + writer.writeColumn([for (final s in samples) s.timestamp], (value) { + writer.writeVarInt(value - previous); + previous = value; + }); + writer.writeColumn( + [for (final s in samples) s.pressure], + writer.writeFloat64, + ); + return EncodedTankPressureSeries( + bytes: Uint8List.fromList(_zlib.encode(writer.takeBytes())), + codecVersion: version, + summary: summary, + ); + } + + /// Decodes a blob written by [encode]. Throws + /// [ProfileSeriesCodecException] on anything malformed. + List decode(Uint8List bytes) { + final Uint8List body; + try { + body = Uint8List.fromList(_zlib.decode(bytes)); + } catch (e) { + throw ProfileSeriesCodecException('not a zlib stream: $e'); + } + if (body.isEmpty) { + throw const ProfileSeriesCodecException('empty body'); + } + final reader = ByteReader(body); + final blobVersion = reader.readByte(); + if (blobVersion != version) { + throw ProfileSeriesCodecException('unknown codec version $blobVersion'); + } + final count = reader.readVarUint(); + // Every sample carries at least a one-byte timestamp delta, so a count + // the remaining payload cannot hold is corruption, not a large series. + if (count > reader.remaining) { + throw ProfileSeriesCodecException( + 'sample count $count exceeds the ${reader.remaining} remaining ' + 'byte(s)', + ); + } + var previous = 0; + final timestamps = reader.readColumn(count, () { + previous += reader.readVarInt(); + return previous; + }); + final pressures = reader.readColumn(count, reader.readFloat64); + if (!reader.isAtEnd) { + throw ProfileSeriesCodecException( + '${reader.remaining} trailing byte(s) after the last block', + ); + } + return [ + for (var i = 0; i < count; i++) + TankPressureSample( + timestamp: + timestamps[i] ?? + (throw ProfileSeriesCodecException( + 'sample $i has no timestamp', + )), + pressure: + pressures[i] ?? + (throw ProfileSeriesCodecException( + 'sample $i has no pressure', + )), + ), + ]; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/dive_log/domain/codecs/tank_pressure_series_codec_test.dart` +Expected: all tests pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/domain/codecs/tank_pressure_series_codec.dart test/features/dive_log/domain/codecs/tank_pressure_series_codec_test.dart +git commit -m "feat: TankPressureSeriesCodec" +``` + +--- + +### Task 7: Verification and PR + +**Files:** +- No new files. This task proves the branch is green and opens the PR. + +**Interfaces:** +- Consumes: everything above. +- Produces: an open PR against `main` titled `feat: profile series codec (packed sample storage, part 1)`. + +- [ ] **Step 1: Run every codec test file individually** + +Run each of these; every one must report `All tests passed!`: + +```bash +flutter test test/features/dive_log/domain/codecs/byte_io_test.dart +flutter test test/features/dive_log/domain/codecs/profile_sample_test.dart +flutter test test/features/dive_log/domain/codecs/profile_series_summary_test.dart +flutter test test/features/dive_log/domain/codecs/profile_series_codec_test.dart +flutter test test/features/dive_log/domain/codecs/profile_series_field_table_test.dart +flutter test test/features/dive_log/domain/codecs/tank_pressure_series_codec_test.dart +``` + +- [ ] **Step 2: Confirm format and analyze are clean project-wide** + +```bash +dart format --output=none --set-exit-if-changed . +flutter analyze +``` + +Expected: both exit 0. `flutter analyze` must report `No issues found!`; an "info" level finding still fails CI. + +- [ ] **Step 3: Confirm the codec files import nothing from Drift or Flutter** + +```bash +grep -l "package:drift\|package:flutter" lib/features/dive_log/domain/codecs/*.dart +``` + +Expected: no output (exit 1 from grep is the success case here). + +- [ ] **Step 4: Run the full suite once in the background** + +```bash +flutter test --reporter compact > /private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/5b0068b6-136c-4277-89c4-30a25ed89d1c/scratchpad/full-suite-pr1.log 2>&1; echo "exit=$?" >> /private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/5b0068b6-136c-4277-89c4-30a25ed89d1c/scratchpad/full-suite-pr1.log +``` + +Run with `run_in_background: true` and a 600000 ms timeout. When it finishes, read the last 30 lines of the log. Expected: `exit=0` and a summary line with zero failures. Do not start a second suite run while this one is going; overlapping runs fake lone failures. If a single unrelated test fails, rerun that one file alone before concluding anything. + +- [ ] **Step 5: Push and open the PR** + +```bash +git push -u origin worktree-profile-sample-storage +gh pr create --title "feat: profile series codec (packed sample storage, part 1)" --body-file /private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/5b0068b6-136c-4277-89c4-30a25ed89d1c/scratchpad/pr1-body.md +``` + +Write the body file first with exactly this content (no attribution line, no session URL): + +```markdown +## Summary + +Part 1 of the packed profile sample storage program +(`docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md`). +Adds a pure-Dart, lossless, versioned codec that packs a series of profile +samples (or a tank's pressure readings) into one zlib-compressed columnar +blob, plus the summary scalars the series tables will store. Nothing in +this PR touches the database, repositories, or sync; part 2 adopts it. + +## Why + +On a 40-dive database the two row-per-sample tables and their indexes are +25.4 of 28.1 MB (90%). Measured on that data, this codec packs +`dive_profiles` 74x and `tank_pressure_profiles` 129x, lossless. + +## What is in it + +- `byte_io.dart`: varint, zigzag varint, float64, presence-bitmapped + column blocks. +- `ProfileSample`: the 28 sample columns of `dive_profiles` as a value + type, with `DiveProfilePoint` conversions. +- `ProfileSeriesSummary`: the scalars that keep the deco, runtime, and + quality predicates in SQL. +- `ProfileSeriesCodec`: versioned field table, encode/decode, strict + failure on anything malformed, forward tolerance for older versions. +- `TankPressureSeriesCodec`: the two-field sibling. +- A test that pins the field table to the live `dive_profiles` column + list, so a column added without a codec version fails CI. + +## Test plan + +- Round trips: every field present, every optional field null, mixed + presence, duplicate timestamps, decreasing integers, float64 + bit-exactness, 20,000 samples. +- Malformed input: non-zlib bytes, empty, unknown version, truncated, + trailing bytes, absent required column. +- Forward tolerance: a newer decoder reads an older version with the + missing field null; an unknown version is refused. +- Full suite green locally. +``` + +- [ ] **Step 6: Report** + +Reply with the PR URL, the measured blob size from the 20,000-sample test (print `encoded.bytes.length` once while verifying, then remove the print), and any test that needed a rerun and why. + +--- + +## Self-review + +**Spec coverage.** Section 5's layout (version byte, count varint, per-field blocks, three presence modes, delta zigzag ints with reset per block and delta from last present value, float64 reals, run-length strings, zlib level 6, 28-field table in the stated order): Tasks 1 and 4. Section 5's versioning and strict failure: Task 4 (`fieldTables` keyed by version byte, `ProfileSeriesCodecException` on unknown version, truncation, trailing bytes, missing required column). Section 5's "summary scalars computed by the encoder and returned alongside the bytes": Task 3 and `EncodedProfileSeries` in Task 4. Section 4's scalar set for `dive_profile_series` (sample_count, start_timestamp, end_timestamp, max_depth, first_depth, last_depth, has_deco_type, has_deco_stop, has_positive_ceiling): Task 3, one for one. Section 4's scalar set for `tank_pressure_series`: Task 6. Section 10's codec tests (property round trips, largest series, bit-exactness, forward tolerance, the three malformed cases, the field-table-versus-Drift guard): Tasks 4, 5, 6. Section 11's PR 1 scope (codec, exception, summary, tests; no schema or behaviour change): all tasks; Task 7 verifies no Drift or Flutter import leaked into `lib/`. Section 3's layering ("pure Dart, same layering as `track_point_codec.dart`"): files live under `lib/features/dive_log/domain/codecs/`. + +Deliberately not in this plan, by spec: the repository, migration packer, sync shim, and benchmark fixture are PR 2. + +**Placeholder scan.** Every code step carries the full file or test. No "similar to", no "add handling". The one open-ended instruction, Task 5 step 2's "resolve it in the field table only if the column genuinely is a sample column", is a decision rule, not a placeholder. + +## Post-review amendments (2026-08-29) + +The whole-branch review after Task 7 added the following on top of the tasks +above. They are recorded here so the plan matches the branch. + +- `byte_io.dart`: `readVarUint` rejects a tenth byte whose payload reaches + bit 63 (the value would wrap negative and escape the count guard as a + `RangeError`); `ByteWriter.length` removed as dead API. +- `profile_series_codec.dart`: `_validateTable` also rejects duplicate field + names and runs on decode as well as encode; `decode` rejects a zero sample + count. `ProfileFieldKind`, `ProfileField`, and the v1 table moved to + `profile_field_table.dart` (top-level `kProfileFieldTableV1`; + `ProfileSeriesCodec.fieldTableV1` forwards to it) to keep every file under + 400 lines. +- `tank_pressure_series_codec.dart`: `decode` rejects a zero sample count. +- Tests: absent-timestamp and absent-depth (and absent-pressure) bodies + built on a two-field table so the tampering stays aligned and reaches the + `no timestamp` / `no depth` / `no pressure` guards; malformed UTF-8 in a + string run; duplicate field name refused on both sides; zero count; the + tank table column pin; `props` length 28; and + `profile_series_golden_test.dart`, which freezes the v1 wire format as + hard-coded bytes for both codecs. +- Deferred to PR 2 or later (recorded in the review): narrow the `catch (e)` + around zlib decode to `on FormatException`; a decode-side monotonic + timestamp re-check; `ProfileSample.copyWith`; a version-dispatch note on + the tank codec; a bounded inflate for peer-received blobs; the spec section + 10 summary-scalar SQL equivalence test belongs to PR 2. + +**Type consistency.** `ProfileSeriesCodecException(String message)` with a `message` field is used by Tasks 1, 4, 6 and matched by `.having((e) => e.message, ...)` in tests. `ByteWriter.writeColumn(List, void Function(T))` and `ByteReader.readColumn(int, T Function())` are called with `int` and `double` in Tasks 1, 4, 6. `writePresence` returns `bool` and `readPresence` returns `List`, used by the string column in Task 4. `ProfileSeriesSummary.of(List)` is used in Task 4's encode and asserted in its test. `EncodedProfileSeries.bytes`, `.codecVersion`, `.summary` and the tank equivalents match between implementation and tests. `ProfileSeriesCodec.fieldTableV1` and `.version` are referenced by Tasks 4 and 5 with those exact names. `kDecoTypeDecoStop` is defined in Task 3 and used in its test. diff --git a/docs/superpowers/plans/2026-08-29-storage-usage-slice-a.md b/docs/superpowers/plans/2026-08-29-storage-usage-slice-a.md new file mode 100644 index 0000000000..264087ace1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-storage-usage-slice-a.md @@ -0,0 +1,1650 @@ +# Storage Usage (Slice A) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the user a page that shows, by category, how many bytes the app is holding on this device. + +**Architecture:** A `StorageCategory` descriptor carries an id, a group, and a `measure()` closure that each category implements in its cheapest available form (an indexed `SUM` for the media cache, an FMTC-native call for tiles, a directory walk for the rest). `StorageInventory` builds the descriptor list from injected directory resolvers so it is testable without `path_provider`. A `FutureProvider.family` keyed by category id gives every row independent loading, so the page never blocks on its slowest measurement and one failing category cannot blank the page. + +**Tech Stack:** Flutter, Riverpod 3 (manual providers, no codegen), Drift, `go_router`, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-08-29-storage-reclamation-design.md` + +## Global Constraints + +- **Worktree:** all work happens in `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/issue-1375-storage-usage` on branch `worktree-issue-1375-storage-usage`. Never edit the main checkout. +- **Slice A is read-only.** No file is deleted that is not already deletable through existing UI. No cap is changed. No sweep is scheduled. `startup_page.dart` is not touched. +- **No em-dashes** (U+2014) in any output: code, comments, docs, commit messages. En-dashes as sentence punctuation and " - " as prose punctuation are equally forbidden. Hyphens inside compound words and CLI flags are fine. +- **No emojis** in code, comments, or documentation. +- **All 11 locales.** Every new ARB key goes into all ten non-English files, not just `app_en.arb`. `test/l10n/arb_parity_test.dart` fails otherwise. +- **File size:** 200 to 400 lines typical, 800 maximum. +- **Immutability:** never mutate objects or arrays. +- **`dart format .`** is run over the whole project before the final commit. +- **`measure()` returns `Future`.** `null` means genuinely unmeasurable and is not the same as zero. + +--- + +## File Structure + +**Create:** +- `lib/core/utils/byte_format.dart` - shared `formatBytes(int)`, extracted from the private copy in `sync_devices_page.dart` +- `lib/core/services/storage/directory_size.dart` - `measureDirectoryBytes`, `measureFileGroupBytes`, `measureLooseFilesBytes` +- `lib/core/services/storage/storage_category.dart` - `StorageGroup`, `StorageCategory`, `StorageCategoryId` +- `lib/core/services/storage/storage_inventory.dart` - builds the 14 descriptors from injected resolvers +- `lib/features/settings/presentation/providers/storage_usage_providers.dart` - `storageInventoryProvider`, `storageCategoriesProvider`, `storageCategorySizeProvider` +- `lib/features/settings/presentation/pages/storage_usage_page.dart` - the page + +**Modify:** +- `lib/features/settings/presentation/pages/sync_devices_page.dart:342` - drop the private `_formatBytes`, import the shared one +- `lib/features/settings/presentation/pages/storage_settings_page.dart` - add a tile linking to the new page +- `lib/core/router/app_router.dart` - add the `storage-usage` route +- `lib/l10n/arb/app_*.arb` (11 files) - new keys + +**Test:** +- `test/core/utils/byte_format_test.dart` +- `test/core/services/storage/directory_size_test.dart` +- `test/core/services/storage/storage_inventory_test.dart` +- `test/features/settings/presentation/pages/storage_usage_page_test.dart` + +--- + +## Error semantics (read before Task 2) + +Each `measure()` owns its own error policy. The split is deliberate: + +- **A missing directory returns 0.** That is a true measurement: the directory holds no bytes. +- **A real I/O failure throws.** The provider surfaces it as an error in that one row. Swallowing it would report a falsely low total, and a falsely low total is worse than a visible error because the user acts on it. +- **`null` is reserved for structurally unmeasurable.** Two cases exist today: the tile cache when `TileCacheService` never initialized (startup swallows its failure at `startup_page.dart:666`), and an Android SAF backup location, which is a `content://` tree URI with no `Directory` to enumerate. + +--- + +## Task 1: Shared byte formatter + +**Files:** +- Create: `lib/core/utils/byte_format.dart` +- Test: `test/core/utils/byte_format_test.dart` +- Modify: `lib/features/settings/presentation/pages/sync_devices_page.dart` (remove private `_formatBytes` at :342, import shared) + +**Interfaces:** +- Consumes: nothing +- Produces: `String formatBytes(int bytes)` + +- [ ] **Step 1: Write the failing test** + +Create `test/core/utils/byte_format_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/utils/byte_format.dart'; + +void main() { + group('formatBytes', () { + test('reports raw bytes below one kibibyte', () { + expect(formatBytes(0), '0 B'); + expect(formatBytes(1023), '1023 B'); + }); + + test('switches to KB at one kibibyte', () { + expect(formatBytes(1024), '1.0 KB'); + expect(formatBytes(1536), '1.5 KB'); + }); + + test('drops the decimal at ten units and above', () { + expect(formatBytes(10 * 1024), '10 KB'); + }); + + test('climbs through MB, GB and TB', () { + expect(formatBytes(5 * 1024 * 1024), '5.0 MB'); + expect(formatBytes(3 * 1024 * 1024 * 1024), '3.0 GB'); + expect(formatBytes(2 * 1024 * 1024 * 1024 * 1024), '2.0 TB'); + }); + + test('a negative count is clamped to zero rather than formatted', () { + expect(formatBytes(-1), '0 B'); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/core/utils/byte_format_test.dart` +Expected: FAIL, "Target of URI doesn't exist: package:submersion/core/utils/byte_format.dart". + +- [ ] **Step 3: Write minimal implementation** + +Create `lib/core/utils/byte_format.dart`: + +```dart +/// Formats a byte count for display. +/// +/// Bytes are not a diver-facing unit, so this deliberately ignores the active +/// diver's unit settings: a megabyte is a megabyte in metric and imperial +/// alike. Sizes are a floor, since a source that reports no size counts zero. +String formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + if (bytes < 1024) return '$bytes B'; + const units = ['KB', 'MB', 'GB', 'TB']; + var value = bytes / 1024; + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return '${value.toStringAsFixed(value >= 10 ? 0 : 1)} ${units[unit]}'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/core/utils/byte_format_test.dart` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Migrate the existing caller** + +In `lib/features/settings/presentation/pages/sync_devices_page.dart`, delete the private `_formatBytes` function (the whole function including its doc comment, at roughly line 341 to 351), add the import + +```dart +import 'package:submersion/core/utils/byte_format.dart'; +``` + +in the correct group (local package imports, alphabetical), and rename every `_formatBytes(` call site in that file to `formatBytes(`. + +Find the call sites with: `grep -n "_formatBytes" lib/features/settings/presentation/pages/sync_devices_page.dart` + +- [ ] **Step 6: Verify the migration compiles and its tests still pass** + +Run: `flutter analyze lib/features/settings/presentation/pages/sync_devices_page.dart lib/core/utils/byte_format.dart` +Expected: "No issues found." + +Run: `flutter test test/features/settings/ -r compact` +Expected: PASS, no new failures. + +- [ ] **Step 7: Commit** + +```bash +git add lib/core/utils/byte_format.dart test/core/utils/byte_format_test.dart lib/features/settings/presentation/pages/sync_devices_page.dart +git commit -m "refactor(settings): extract the byte formatter for reuse" +``` + +--- + +## Task 2: Directory measurement helpers + +**Files:** +- Create: `lib/core/services/storage/directory_size.dart` +- Test: `test/core/services/storage/directory_size_test.dart` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `Future measureDirectoryBytes(Directory dir)` + - `Future measureFileGroupBytes(Iterable files)` + - `Future measureLooseFilesBytes(Directory dir, {required bool Function(String name) exclude})` + +These are plain `test()` cases against real temporary directories, deliberately not `testWidgets`. Awaiting `dart:io` inside `testWidgets` parks forever with no output and no timeout firing. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/storage/directory_size_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:submersion/core/services/storage/directory_size.dart'; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('storage_size_test'); + }); + + tearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + + Future writeFile(String relative, int bytes) async { + final file = File(p.join(root.path, relative)); + await file.parent.create(recursive: true); + await file.writeAsBytes(List.filled(bytes, 0)); + return file; + } + + group('measureDirectoryBytes', () { + test('sums every file in the tree, including nested ones', () async { + await writeFile('a.bin', 100); + await writeFile('nested/b.bin', 250); + await writeFile('nested/deeper/c.bin', 50); + + expect(await measureDirectoryBytes(root), 400); + }); + + test('an empty directory measures zero', () async { + expect(await measureDirectoryBytes(root), 0); + }); + + test('a directory that does not exist measures zero, not null', () async { + final absent = Directory(p.join(root.path, 'never_created')); + expect(await measureDirectoryBytes(absent), 0); + }); + }); + + group('measureFileGroupBytes', () { + test('sums the files that exist and skips those that do not', () async { + final present = await writeFile('db.sqlite', 300); + final absent = File(p.join(root.path, 'db.sqlite-wal')); + + expect(await measureFileGroupBytes([present, absent]), 300); + }); + }); + + group('measureLooseFilesBytes', () { + test('counts files in the top level only, never subdirectories', () async { + await writeFile('export.csv', 100); + await writeFile('Submersion/database.db', 9999); + + final total = await measureLooseFilesBytes( + root, + exclude: (name) => false, + ); + + expect(total, 100); + }); + + test('honours the exclusion predicate', () async { + await writeFile('export.csv', 100); + await writeFile('database.db', 500); + + final total = await measureLooseFilesBytes( + root, + exclude: (name) => name.startsWith('database.db'), + ); + + expect(total, 100); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/core/services/storage/directory_size_test.dart` +Expected: FAIL, "Target of URI doesn't exist". + +- [ ] **Step 3: Write minimal implementation** + +Create `lib/core/services/storage/directory_size.dart`: + +```dart +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Recursively sums the bytes held by every file under [dir]. +/// +/// A directory that does not exist measures zero: that is a true measurement, +/// not a failure. A real I/O error (a permission denial, say) is allowed to +/// throw, because reporting a falsely low total is worse than showing the user +/// an error on the row that failed. +Future measureDirectoryBytes(Directory dir) async { + if (!await dir.exists()) return 0; + var total = 0; + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is File) { + total += await entity.length(); + } + } + return total; +} + +/// Sums a known set of files, skipping any that are absent. +/// +/// Used for a database and its sidecars, where the `-wal` and `-shm` files +/// exist only while a connection is open. +Future measureFileGroupBytes(Iterable files) async { + var total = 0; + for (final file in files) { + if (await file.exists()) { + total += await file.length(); + } + } + return total; +} + +/// Sums the files directly inside [dir], never descending into subdirectories, +/// skipping any whose basename [exclude] returns true for. +/// +/// The Documents root needs this shape: exported files land loose at the top +/// level, while the database and the `Submersion/` subtree live alongside them +/// and must not be counted as exports. +Future measureLooseFilesBytes( + Directory dir, { + required bool Function(String name) exclude, +}) async { + if (!await dir.exists()) return 0; + var total = 0; + await for (final entity in dir.list(recursive: false, followLinks: false)) { + if (entity is! File) continue; + if (exclude(p.basename(entity.path))) continue; + total += await entity.length(); + } + return total; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/core/services/storage/directory_size_test.dart` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add lib/core/services/storage/directory_size.dart test/core/services/storage/directory_size_test.dart +git commit -m "feat(storage): add directory and file measurement helpers" +``` + +--- + +## Task 3: The `StorageCategory` descriptor + +**Files:** +- Create: `lib/core/services/storage/storage_category.dart` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `enum StorageGroup { appData, mediaCache, caches, backups, temporary, exports }` + - `class StorageCategory { final String id; final StorageGroup group; final Future Function() measure; }` + - `abstract final class StorageCategoryId` with the 14 id constants + +This task has no test of its own: it is a data holder with no behaviour, and Task 4 exercises it. Adding a test that asserts a constructor assigns its fields would be testing the language, not the code. + +- [ ] **Step 1: Write the implementation** + +Create `lib/core/services/storage/storage_category.dart`: + +```dart +/// Where a storage category is shown on the usage page. +enum StorageGroup { appData, mediaCache, caches, backups, temporary, exports } + +/// Stable category ids. +/// +/// These key the l10n label, the size provider, and the reclaim policy that a +/// later slice adds. They are deliberately not derived from directory names: +/// a directory can be renamed without breaking a saved layout or a test, and +/// an id cannot. +abstract final class StorageCategoryId { + static const database = 'database'; + static const localCache = 'localCache'; + static const mediaCacheOriginals = 'mediaCacheOriginals'; + static const mediaCacheThumbs = 'mediaCacheThumbs'; + static const mediaCacheRenditions = 'mediaCacheRenditions'; + static const mediaCacheStaging = 'mediaCacheStaging'; + static const mediaCacheTranscode = 'mediaCacheTranscode'; + static const mapTiles = 'mapTiles'; + static const networkImages = 'networkImages'; + static const videoThumbnails = 'videoThumbnails'; + static const pdfThumbnails = 'pdfThumbnails'; + static const backups = 'backups'; + static const temporary = 'temporary'; + static const exports = 'exports'; +} + +/// One place on disk where the app accumulates bytes. +/// +/// Measurement cost is uneven by an order of magnitude across categories: the +/// media cache pools are a SUM over an index, the tile cache has a native size +/// call, and the rest need a recursive walk. Each category therefore brings its +/// own strategy rather than everything sharing one directory walk, and the page +/// renders each result as it arrives instead of being paced by the slowest. +class StorageCategory { + const StorageCategory({ + required this.id, + required this.group, + required this.measure, + }); + + /// One of [StorageCategoryId]. + final String id; + + final StorageGroup group; + + /// Returns the bytes held, or null when the category is structurally + /// unmeasurable on this platform or in this configuration. Null is not zero: + /// an Android SAF backup location has no directory to enumerate, and + /// reporting it as empty would tell the user their backups had vanished. + final Future Function() measure; +} +``` + +- [ ] **Step 2: Verify it analyzes clean** + +Run: `flutter analyze lib/core/services/storage/storage_category.dart` +Expected: "No issues found." + +- [ ] **Step 3: Commit** + +```bash +git add lib/core/services/storage/storage_category.dart +git commit -m "feat(storage): add the StorageCategory descriptor" +``` + +--- + +## Task 4: `StorageInventory` + +**Files:** +- Create: `lib/core/services/storage/storage_inventory.dart` +- Test: `test/core/services/storage/storage_inventory_test.dart` + +**Interfaces:** +- Consumes: `StorageCategory`, `StorageGroup`, `StorageCategoryId` (Task 3); `measureDirectoryBytes`, `measureFileGroupBytes`, `measureLooseFilesBytes` (Task 2) +- Produces: `class StorageInventory` with `List get categories` + +Every external dependency is injected as a closure so the whole class is testable without `path_provider`, a real database, or an initialized FMTC store. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/storage/storage_inventory_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:submersion/core/services/storage/storage_category.dart'; +import 'package:submersion/core/services/storage/storage_inventory.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; + +void main() { + late Directory root; + late Directory support; + late Directory documents; + late Directory temporary; + + setUp(() async { + root = await Directory.systemTemp.createTemp('storage_inventory_test'); + support = Directory(p.join(root.path, 'support')); + documents = Directory(p.join(root.path, 'documents')); + temporary = Directory(p.join(root.path, 'temporary')); + for (final dir in [support, documents, temporary]) { + await dir.create(recursive: true); + } + }); + + tearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + + Future writeFile(String absolute, int bytes) async { + final file = File(absolute); + await file.parent.create(recursive: true); + await file.writeAsBytes(List.filled(bytes, 0)); + } + + StorageInventory build({ + Future Function(MediaCacheKind)? mediaCacheBytes, + Future Function()? mapTileKibibytes, + Future Function()? networkImageBytes, + Future Function()? backupsDirectoryPath, + Future Function()? databasePath, + }) { + return StorageInventory( + supportDirectory: () async => support, + documentsDirectory: () async => documents, + temporaryDirectory: () async => temporary, + databasePath: databasePath ?? + () async => p.join(documents.path, 'submersion.db'), + backupsDirectoryPath: backupsDirectoryPath ?? () async => null, + mediaCacheBytes: mediaCacheBytes ?? (_) async => 0, + mapTileKibibytes: mapTileKibibytes ?? () async => null, + networkImageBytes: networkImageBytes ?? () async => 0, + ); + } + + StorageCategory categoryFor(StorageInventory inventory, String id) => + inventory.categories.firstWhere((c) => c.id == id); + + test('exposes exactly the fourteen documented categories', () { + final ids = build().categories.map((c) => c.id).toList(); + + expect(ids, hasLength(14)); + expect(ids.toSet(), hasLength(14), reason: 'ids must be unique'); + expect(ids, contains(StorageCategoryId.database)); + expect(ids, contains(StorageCategoryId.exports)); + }); + + test('the database category counts the file and its sidecars', () async { + final dbPath = p.join(documents.path, 'submersion.db'); + await writeFile(dbPath, 100); + await writeFile('$dbPath-wal', 40); + await writeFile('$dbPath-shm', 10); + + final inventory = build(databasePath: () async => dbPath); + + expect( + await categoryFor(inventory, StorageCategoryId.database).measure(), + 150, + ); + }); + + test('the local cache category measures submersion_local.db', () async { + await writeFile( + p.join(support.path, 'Submersion', 'submersion_local.db'), + 321, + ); + + expect( + await categoryFor(build(), StorageCategoryId.localCache).measure(), + 321, + ); + }); + + test('each media cache pool reads its own indexed total', () async { + final inventory = build( + mediaCacheBytes: (kind) async => switch (kind) { + MediaCacheKind.original => 1000, + MediaCacheKind.thumb => 200, + MediaCacheKind.rendition => 30, + }, + ); + + expect( + await categoryFor( + inventory, + StorageCategoryId.mediaCacheOriginals, + ).measure(), + 1000, + ); + expect( + await categoryFor( + inventory, + StorageCategoryId.mediaCacheThumbs, + ).measure(), + 200, + ); + expect( + await categoryFor( + inventory, + StorageCategoryId.mediaCacheRenditions, + ).measure(), + 30, + ); + }); + + test('staging and transcode are walked, not read from the index', () async { + final cache = p.join(support.path, 'Submersion', 'media_cache'); + await writeFile(p.join(cache, 'staging', 'stage_1'), 70); + await writeFile(p.join(cache, 'transcode', 'abc_high.mp4'), 900); + + final inventory = build(); + + expect( + await categoryFor( + inventory, + StorageCategoryId.mediaCacheStaging, + ).measure(), + 70, + ); + expect( + await categoryFor( + inventory, + StorageCategoryId.mediaCacheTranscode, + ).measure(), + 900, + ); + }); + + test('map tiles convert the FMTC kibibyte reading into bytes', () async { + final inventory = build(mapTileKibibytes: () async => 4.0); + + expect( + await categoryFor(inventory, StorageCategoryId.mapTiles).measure(), + 4096, + ); + }); + + test('map tiles report null when the store never initialized', () async { + final inventory = build(mapTileKibibytes: () async => null); + + expect( + await categoryFor(inventory, StorageCategoryId.mapTiles).measure(), + isNull, + ); + }); + + test('the thumbnail categories walk their own directories', () async { + await writeFile( + p.join(support.path, 'Submersion', 'video_thumbnails', 'a.img'), + 12, + ); + await writeFile( + p.join(support.path, 'Submersion', 'pdf_thumbnails', 'b.jpg'), + 34, + ); + + final inventory = build(); + + expect( + await categoryFor( + inventory, + StorageCategoryId.videoThumbnails, + ).measure(), + 12, + ); + expect( + await categoryFor(inventory, StorageCategoryId.pdfThumbnails).measure(), + 34, + ); + }); + + test('backups measure the resolved directory when there is one', () async { + final backups = p.join(documents.path, 'Submersion', 'Backups'); + await writeFile(p.join(backups, 'backup.db'), 500); + + final inventory = build(backupsDirectoryPath: () async => backups); + + expect( + await categoryFor(inventory, StorageCategoryId.backups).measure(), + 500, + ); + }); + + test('backups report null when the location cannot be enumerated', () async { + final inventory = build(backupsDirectoryPath: () async => null); + + expect( + await categoryFor(inventory, StorageCategoryId.backups).measure(), + isNull, + ); + }); + + test('temporary counts picked files and loose share files', () async { + await writeFile(p.join(temporary.path, 'picked', '0', 'dives.zip'), 800); + await writeFile(p.join(temporary.path, 'shared_photo.jpg'), 60); + + expect( + await categoryFor(build(), StorageCategoryId.temporary).measure(), + 860, + ); + }); + + test('exports exclude the database, its sidecars and subdirectories', + () async { + final dbPath = p.join(documents.path, 'submersion.db'); + await writeFile(dbPath, 5000); + await writeFile('$dbPath-wal', 400); + await writeFile(p.join(documents.path, 'Submersion', 'Backups', 'b.db'), 9); + await writeFile(p.join(documents.path, 'dives_export.csv'), 77); + + final inventory = build(databasePath: () async => dbPath); + + expect( + await categoryFor(inventory, StorageCategoryId.exports).measure(), + 77, + ); + }); + + test('a database stored outside Documents leaves exports unaffected', + () async { + final elsewhere = Directory(p.join(root.path, 'elsewhere')); + await elsewhere.create(recursive: true); + final dbPath = p.join(elsewhere.path, 'submersion.db'); + await writeFile(dbPath, 5000); + await writeFile(p.join(documents.path, 'dives_export.csv'), 77); + + final inventory = build(databasePath: () async => dbPath); + + expect( + await categoryFor(inventory, StorageCategoryId.exports).measure(), + 77, + ); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/core/services/storage/storage_inventory_test.dart` +Expected: FAIL, "Target of URI doesn't exist: .../storage_inventory.dart". + +- [ ] **Step 3: Write minimal implementation** + +Create `lib/core/services/storage/storage_inventory.dart`: + +```dart +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'package:submersion/core/services/storage/directory_size.dart'; +import 'package:submersion/core/services/storage/storage_category.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; + +/// Builds the list of places this app accumulates bytes on disk. +/// +/// Every external dependency arrives as a closure rather than being looked up +/// internally, so the whole inventory is testable without path_provider, a real +/// database, or an initialized FMTC store. +class StorageInventory { + StorageInventory({ + required Future Function() supportDirectory, + required Future Function() documentsDirectory, + required Future Function() temporaryDirectory, + required Future Function() databasePath, + required Future Function() backupsDirectoryPath, + required Future Function(MediaCacheKind kind) mediaCacheBytes, + required Future Function() mapTileKibibytes, + required Future Function() networkImageBytes, + }) : _supportDirectory = supportDirectory, + _documentsDirectory = documentsDirectory, + _temporaryDirectory = temporaryDirectory, + _databasePath = databasePath, + _backupsDirectoryPath = backupsDirectoryPath, + _mediaCacheBytes = mediaCacheBytes, + _mapTileKibibytes = mapTileKibibytes, + _networkImageBytes = networkImageBytes; + + final Future Function() _supportDirectory; + final Future Function() _documentsDirectory; + final Future Function() _temporaryDirectory; + final Future Function() _databasePath; + final Future Function() _backupsDirectoryPath; + final Future Function(MediaCacheKind kind) _mediaCacheBytes; + final Future Function() _mapTileKibibytes; + final Future Function() _networkImageBytes; + + /// Order is display order on the usage page. + List get categories => [ + StorageCategory( + id: StorageCategoryId.database, + group: StorageGroup.appData, + measure: _measureDatabase, + ), + StorageCategory( + id: StorageCategoryId.localCache, + group: StorageGroup.appData, + measure: () async => measureFileGroupBytes([ + File(p.join((await _supportDirectory()).path, _appDir, _localCacheDb)), + ]), + ), + StorageCategory( + id: StorageCategoryId.mediaCacheOriginals, + group: StorageGroup.mediaCache, + measure: () => _mediaCacheBytes(MediaCacheKind.original), + ), + StorageCategory( + id: StorageCategoryId.mediaCacheThumbs, + group: StorageGroup.mediaCache, + measure: () => _mediaCacheBytes(MediaCacheKind.thumb), + ), + StorageCategory( + id: StorageCategoryId.mediaCacheRenditions, + group: StorageGroup.mediaCache, + measure: () => _mediaCacheBytes(MediaCacheKind.rendition), + ), + // Staging and transcode are walked rather than read from the index: the + // LRU caps in MediaCacheStore are enforced only over rows in + // media_cache_entries, and neither subdirectory is indexed. Their bytes are + // invisible to the 3.25 GiB budget, which is precisely why they get rows. + StorageCategory( + id: StorageCategoryId.mediaCacheStaging, + group: StorageGroup.mediaCache, + measure: () => _measureSupportSubdirectory(['media_cache', 'staging']), + ), + StorageCategory( + id: StorageCategoryId.mediaCacheTranscode, + group: StorageGroup.mediaCache, + measure: () => _measureSupportSubdirectory(['media_cache', 'transcode']), + ), + StorageCategory( + id: StorageCategoryId.mapTiles, + group: StorageGroup.caches, + measure: _measureMapTiles, + ), + StorageCategory( + id: StorageCategoryId.networkImages, + group: StorageGroup.caches, + measure: _networkImageBytes, + ), + StorageCategory( + id: StorageCategoryId.videoThumbnails, + group: StorageGroup.caches, + measure: () => _measureSupportSubdirectory(['video_thumbnails']), + ), + StorageCategory( + id: StorageCategoryId.pdfThumbnails, + group: StorageGroup.caches, + measure: () => _measureSupportSubdirectory(['pdf_thumbnails']), + ), + StorageCategory( + id: StorageCategoryId.backups, + group: StorageGroup.backups, + measure: _measureBackups, + ), + StorageCategory( + id: StorageCategoryId.temporary, + group: StorageGroup.temporary, + measure: _measureTemporary, + ), + StorageCategory( + id: StorageCategoryId.exports, + group: StorageGroup.exports, + measure: _measureExports, + ), + ]; + + static const _appDir = 'Submersion'; + static const _localCacheDb = 'submersion_local.db'; + + Future _measureDatabase() async { + final path = await _databasePath(); + return measureFileGroupBytes([ + File(path), + File('$path-wal'), + File('$path-shm'), + ]); + } + + Future _measureSupportSubdirectory(List segments) async { + final support = await _supportDirectory(); + return measureDirectoryBytes( + Directory(p.joinAll([support.path, _appDir, ...segments])), + ); + } + + /// FMTC reports kibibytes as a double. Converting here rather than at the + /// call site keeps every category's contract in bytes. + Future _measureMapTiles() async { + final kibibytes = await _mapTileKibibytes(); + if (kibibytes == null) return null; + return (kibibytes * 1024).round(); + } + + Future _measureBackups() async { + final path = await _backupsDirectoryPath(); + if (path == null) return null; + return measureDirectoryBytes(Directory(path)); + } + + Future _measureTemporary() async { + final temp = await _temporaryDirectory(); + return measureDirectoryBytes(temp); + } + + /// Loose files in the Documents root, which is where saveAndShareFile leaves + /// every export permanently. The database may or may not live here depending + /// on the configured location, so it is excluded by name either way, as are + /// its sidecars and every subdirectory. + Future _measureExports() async { + final documents = await _documentsDirectory(); + final dbName = p.basename(await _databasePath()); + return measureLooseFilesBytes( + documents, + exclude: (name) => name.startsWith(dbName), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/core/services/storage/storage_inventory_test.dart` +Expected: PASS, 13 tests. + +- [ ] **Step 5: Commit** + +```bash +git add lib/core/services/storage/storage_inventory.dart test/core/services/storage/storage_inventory_test.dart +git commit -m "feat(storage): build the storage category inventory" +``` + +--- + +## Task 5: Providers + +**Files:** +- Create: `lib/features/settings/presentation/providers/storage_usage_providers.dart` + +**Interfaces:** +- Consumes: `StorageInventory` (Task 4), `StorageCategory` (Task 3) +- Produces: + - `final storageInventoryProvider = Provider(...)` + - `final storageCategoriesProvider = Provider>(...)` + - `final storageCategorySizeProvider = FutureProvider.family(...)` + +The family keyed by id, rather than one future over the whole list, is what gives each row independent loading and stops one throwing category from blanking the page. + +This task has no dedicated test: the providers are wiring over code Task 4 already covers, and Task 7's widget tests override `storageCategorySizeProvider` directly. Constructing the real inventory in a unit test would require `path_provider`, which is exactly the dependency Task 4's injection exists to avoid. + +- [ ] **Step 1: Write the implementation** + +Create `lib/features/settings/presentation/providers/storage_usage_providers.dart`: + +```dart +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/local_cache_database_service.dart'; +import 'package:submersion/core/services/storage/storage_category.dart'; +import 'package:submersion/core/services/storage/storage_inventory.dart'; +import 'package:submersion/features/backup/data/repositories/backup_preferences.dart'; +import 'package:submersion/features/backup/data/services/backup_service.dart'; +import 'package:submersion/features/maps/data/services/tile_cache_service.dart'; +import 'package:submersion/features/media/data/services/cached_network_image_diagnostics.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; +import 'package:submersion/features/settings/presentation/providers/storage_providers.dart'; + +/// The real inventory, wired to path_provider and the live services. +final storageInventoryProvider = Provider((ref) { + return StorageInventory( + supportDirectory: getApplicationSupportDirectory, + documentsDirectory: getApplicationDocumentsDirectory, + temporaryDirectory: getTemporaryDirectory, + databasePath: () => + ref.read(databaseLocationServiceProvider).getDatabasePath(), + backupsDirectoryPath: _resolveBackupsDirectoryPath, + mediaCacheBytes: (kind) async { + final support = await getApplicationSupportDirectory(); + final store = MediaCacheStore( + database: LocalCacheDatabaseService.instance.database, + root: Directory(p.join(support.path, 'Submersion', 'media_cache')), + ); + return store.totalBytes(kind); + }, + mapTileKibibytes: _resolveMapTileKibibytes, + networkImageBytes: () => CachedNetworkImageDiagnostics().cacheSize(), + ); +}); + +/// The descriptor list. Pure construction, so a plain Provider. +final storageCategoriesProvider = Provider>( + (ref) => ref.watch(storageInventoryProvider).categories, +); + +/// One future per category, keyed by [StorageCategory.id]. +/// +/// Keyed rather than a single future over the whole list so every row loads +/// independently: the media cache pools resolve instantly off an index while +/// the network image walk can take seconds, and a category that throws shows an +/// error on its own row instead of blanking the page. +final storageCategorySizeProvider = FutureProvider.family(( + ref, + id, +) { + final category = ref + .watch(storageCategoriesProvider) + .firstWhere((c) => c.id == id); + return category.measure(); +}); + +/// Returns null when the backup location cannot be enumerated as a directory. +/// +/// An Android SAF location is a content:// tree URI with no Directory behind +/// it. Reporting it as zero bytes would tell the user their backups had +/// vanished, so it reports unavailable instead. +Future _resolveBackupsDirectoryPath() async { + final prefs = await SharedPreferences.getInstance(); + final location = BackupPreferences(prefs).getSettings().backupLocation; + if (location != null && location.startsWith('content://')) return null; + if (location != null && location.isNotEmpty) return location; + return BackupService.resolveDefaultBackupsDirectory(); +} + +/// Returns null when the tile store never initialized. +/// +/// Startup swallows a tile cache initialization failure (startup_page.dart +/// around line 666), so an uninitialized store is a normal state rather than a +/// bug, and getTotalCacheSize would throw a StateError on it. +Future _resolveMapTileKibibytes() async { + try { + return await TileCacheService.instance.getTotalCacheSize(); + } on StateError { + return null; + } +} +``` + +- [ ] **Step 2: Verify the imports and provider names resolve** + +Run: `flutter analyze lib/features/settings/presentation/providers/storage_usage_providers.dart` +Expected: "No issues found." + +`databaseLocationServiceProvider` is confirmed to live at +`lib/features/settings/presentation/providers/storage_providers.dart:69`, which +is why that file is imported above. Reuse it rather than adding a new provider. + +- [ ] **Step 3: Commit** + +```bash +git add lib/features/settings/presentation/providers/storage_usage_providers.dart +git commit -m "feat(storage): add storage usage providers" +``` + +--- + +## Task 6: Localisation keys + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb` and the ten other locale files + +**Interfaces:** +- Consumes: nothing +- Produces: the l10n getters Task 7 calls, all of the form `settings_storageUsage_*` + +All eleven files, not just English. `test/l10n/arb_parity_test.dart` asserts every English key exists in all ten non-English locales, and it reports the failure against every locale at once, which makes a partial fanout look like a much larger problem than it is. + +- [ ] **Step 1: Add the English keys** + +Insert these into `lib/l10n/arb/app_en.arb`, keeping the file's existing alphabetical-by-key ordering within its section. None of them take placeholders, so no `@key` metadata blocks are needed. + +```json +"settings_storageUsage_appBar_title": "Storage Usage", +"settings_storageUsage_tile_title": "Storage Usage", +"settings_storageUsage_tile_subtitle": "See what is using space on this device", +"settings_storageUsage_total": "Total", +"settings_storageUsage_totalPartial": "Total so far", +"settings_storageUsage_refresh_tooltip": "Recalculate", +"settings_storageUsage_unavailable": "Not available", +"settings_storageUsage_measureFailed": "Could not measure", +"settings_storageUsage_group_appData": "App Data", +"settings_storageUsage_group_mediaCache": "Media Cache", +"settings_storageUsage_group_caches": "Caches", +"settings_storageUsage_group_backups": "Backups", +"settings_storageUsage_group_temporary": "Temporary Files", +"settings_storageUsage_group_exports": "Exported Files", +"settings_storageUsage_category_database": "Dive log database", +"settings_storageUsage_category_localCache": "Local cache database", +"settings_storageUsage_category_mediaCacheOriginals": "Original photos and videos", +"settings_storageUsage_category_mediaCacheThumbs": "Thumbnails", +"settings_storageUsage_category_mediaCacheRenditions": "Video renditions", +"settings_storageUsage_category_mediaCacheStaging": "Staged transfers", +"settings_storageUsage_category_mediaCacheTranscode": "Transcoded video", +"settings_storageUsage_category_mapTiles": "Map tiles", +"settings_storageUsage_category_networkImages": "Network images", +"settings_storageUsage_category_videoThumbnails": "Video thumbnails", +"settings_storageUsage_category_pdfThumbnails": "Document thumbnails", +"settings_storageUsage_category_backups": "Backup files", +"settings_storageUsage_category_temporary": "Temporary files", +"settings_storageUsage_category_exports": "Exported files", +``` + +- [ ] **Step 2: Translate into all ten other locales** + +Add the same 28 keys, translated, to each of: `app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, `app_zh.arb`. + +Translate the values properly for each language. Do not copy the English strings across. Arabic and Hebrew are right-to-left; Flutter handles the direction, so translate the text normally. + +- [ ] **Step 3: Regenerate the localizations** + +Run: `flutter gen-l10n` +Expected: completes with no error; `lib/l10n/arb/app_localizations.dart` and the eleven per-locale files are rewritten. + +- [ ] **Step 4: Verify parity** + +Run: `flutter test test/l10n/arb_parity_test.dart` +Expected: PASS. A failure naming missing keys against every locale means the fanout is incomplete, not that English has too many. + +- [ ] **Step 5: Commit** + +```bash +git add lib/l10n/arb/ +git commit -m "feat(l10n): add storage usage strings in all locales" +``` + +--- + +## Task 7: The Storage usage page + +**Files:** +- Create: `lib/features/settings/presentation/pages/storage_usage_page.dart` +- Test: `test/features/settings/presentation/pages/storage_usage_page_test.dart` + +**Interfaces:** +- Consumes: `storageCategoriesProvider`, `storageCategorySizeProvider` (Task 5); `StorageGroup`, `StorageCategoryId` (Task 3); `formatBytes` (Task 1); the l10n keys (Task 6) +- Produces: `class StorageUsagePage extends ConsumerWidget` + +Two traps are designed against rather than discovered: + +- The test font renders one em per glyph, so a `Row` of label plus size overflows a 360px surface unless the label is `Flexible`. Every row is built that way from the start. +- Widget tests override `storageCategorySizeProvider` so that no real `dart:io` runs. Awaiting real file I/O inside `testWidgets` parks forever with no output and no timeout firing. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/settings/presentation/pages/storage_usage_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/storage/storage_category.dart'; +import 'package:submersion/features/settings/presentation/pages/storage_usage_page.dart'; +import 'package:submersion/features/settings/presentation/providers/storage_usage_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + Widget harness({required Map> sizes}) { + return ProviderScope( + overrides: [ + storageCategorySizeProvider.overrideWith( + (ref, id) => sizes[id] ?? Future.value(0), + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: StorageUsagePage(), + ), + ); + } + + testWidgets('renders a row for every category', (tester) async { + await tester.pumpWidget(harness(sizes: {})); + await tester.pumpAndSettle(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(StorageUsagePage)), + ); + final expected = container.read(storageCategoriesProvider).length; + + expect(find.byType(StorageUsageRow), findsNWidgets(expected)); + }); + + testWidgets('shows a formatted size once a category resolves', ( + tester, + ) async { + await tester.pumpWidget( + harness( + sizes: { + StorageCategoryId.database: Future.value(1536), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('1.5 KB'), findsOneWidget); + }); + + testWidgets('a null measurement reads as unavailable, never as zero', ( + tester, + ) async { + await tester.pumpWidget( + harness( + sizes: { + StorageCategoryId.backups: Future.value(null), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Not available'), findsOneWidget); + }); + + testWidgets('a failing category errors on its own row and its siblings ' + 'still resolve', (tester) async { + await tester.pumpWidget( + harness( + sizes: { + StorageCategoryId.networkImages: Future.error( + const FileSystemException('denied'), + ), + StorageCategoryId.database: Future.value(2048), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Could not measure'), findsOneWidget); + expect(find.text('2.0 KB'), findsOneWidget); + }); + + testWidgets('the total sums only the categories that resolved', ( + tester, + ) async { + await tester.pumpWidget( + harness( + sizes: { + StorageCategoryId.database: Future.value(1024), + StorageCategoryId.localCache: Future.value(1024), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('2.0 KB'), findsOneWidget); + }); + + testWidgets('a row does not overflow at a narrow width', (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + harness( + sizes: { + StorageCategoryId.mediaCacheOriginals: Future.value( + 5 * 1024 * 1024 * 1024, + ), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); +} +``` + +Note on the total test: every unlisted category defaults to `0`, so a total of exactly 2048 bytes proves the sum, and `'2.0 KB'` appearing once rather than three times proves the two 1024-byte rows render as `'1.0 KB'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/settings/presentation/pages/storage_usage_page_test.dart` +Expected: FAIL, "Target of URI doesn't exist: .../storage_usage_page.dart". + +- [ ] **Step 3: Write minimal implementation** + +Create `lib/features/settings/presentation/pages/storage_usage_page.dart`: + +```dart +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/storage/storage_category.dart'; +import 'package:submersion/core/utils/byte_format.dart'; +import 'package:submersion/features/settings/presentation/providers/storage_usage_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Reports how many bytes the app holds on this device, by category. +/// +/// Read-only by design. Nothing on this page deletes anything: the categories +/// that can be reclaimed today are reclaimed from the pages that already own +/// that action, and the ones that cannot be reclaimed safely at all are shown +/// here precisely so the user can decide for themselves. +class StorageUsagePage extends ConsumerWidget { + const StorageUsagePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final categories = ref.watch(storageCategoriesProvider); + final grouped = >{}; + for (final category in categories) { + grouped.putIfAbsent(category.group, () => []).add(category); + } + + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settings_storageUsage_appBar_title), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: context.l10n.settings_storageUsage_refresh_tooltip, + onPressed: () { + for (final category in categories) { + ref.invalidate(storageCategorySizeProvider(category.id)); + } + }, + ), + ], + ), + body: ListView( + children: [ + _TotalHeader(categories: categories), + for (final group in StorageGroup.values) + if (grouped[group] != null) ...[ + _GroupHeader(group: group), + for (final category in grouped[group]!) + StorageUsageRow(category: category), + ], + const SizedBox(height: 32), + ], + ), + ); + } +} + +/// The running total across every category that has resolved so far. +/// +/// Labelled as partial while any category is still measuring, because a total +/// that silently grows as rows land would read as a wrong number rather than an +/// incomplete one. +class _TotalHeader extends ConsumerWidget { + const _TotalHeader({required this.categories}); + + final List categories; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + var total = 0; + var pending = false; + for (final category in categories) { + final size = ref.watch(storageCategorySizeProvider(category.id)); + switch (size) { + case AsyncData(:final value?): + total += value; + case AsyncData(): + break; + case AsyncError(): + break; + default: + pending = true; + } + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pending + ? context.l10n.settings_storageUsage_totalPartial + : context.l10n.settings_storageUsage_total, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text(formatBytes(total), style: theme.textTheme.headlineMedium), + ], + ), + ); + } +} + +class _GroupHeader extends StatelessWidget { + const _GroupHeader({required this.group}); + + final StorageGroup group; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + _labelFor(context, group), + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + static String _labelFor(BuildContext context, StorageGroup group) { + final l10n = context.l10n; + return switch (group) { + StorageGroup.appData => l10n.settings_storageUsage_group_appData, + StorageGroup.mediaCache => l10n.settings_storageUsage_group_mediaCache, + StorageGroup.caches => l10n.settings_storageUsage_group_caches, + StorageGroup.backups => l10n.settings_storageUsage_group_backups, + StorageGroup.temporary => l10n.settings_storageUsage_group_temporary, + StorageGroup.exports => l10n.settings_storageUsage_group_exports, + }; + } +} + +/// One category's row. +/// +/// The label is Flexible on purpose: the test font renders one em per glyph, so +/// a fixed label beside a size overflows a 360px surface. +class StorageUsageRow extends ConsumerWidget { + const StorageUsageRow({required this.category, super.key}); + + final StorageCategory category; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final size = ref.watch(storageCategorySizeProvider(category.id)); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Flexible( + child: Text( + _labelFor(context, category.id), + style: theme.textTheme.bodyMedium, + ), + ), + const SizedBox(width: 12), + _SizeLabel(size: size), + ], + ), + ); + } + + static String _labelFor(BuildContext context, String id) { + final l10n = context.l10n; + return switch (id) { + StorageCategoryId.database => + l10n.settings_storageUsage_category_database, + StorageCategoryId.localCache => + l10n.settings_storageUsage_category_localCache, + StorageCategoryId.mediaCacheOriginals => + l10n.settings_storageUsage_category_mediaCacheOriginals, + StorageCategoryId.mediaCacheThumbs => + l10n.settings_storageUsage_category_mediaCacheThumbs, + StorageCategoryId.mediaCacheRenditions => + l10n.settings_storageUsage_category_mediaCacheRenditions, + StorageCategoryId.mediaCacheStaging => + l10n.settings_storageUsage_category_mediaCacheStaging, + StorageCategoryId.mediaCacheTranscode => + l10n.settings_storageUsage_category_mediaCacheTranscode, + StorageCategoryId.mapTiles => + l10n.settings_storageUsage_category_mapTiles, + StorageCategoryId.networkImages => + l10n.settings_storageUsage_category_networkImages, + StorageCategoryId.videoThumbnails => + l10n.settings_storageUsage_category_videoThumbnails, + StorageCategoryId.pdfThumbnails => + l10n.settings_storageUsage_category_pdfThumbnails, + StorageCategoryId.backups => l10n.settings_storageUsage_category_backups, + StorageCategoryId.temporary => + l10n.settings_storageUsage_category_temporary, + StorageCategoryId.exports => l10n.settings_storageUsage_category_exports, + _ => id, + }; + } +} + +class _SizeLabel extends StatelessWidget { + const _SizeLabel({required this.size}); + + final AsyncValue size; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final muted = theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + + return switch (size) { + AsyncData(:final value?) => Text( + formatBytes(value), + style: theme.textTheme.bodyMedium, + ), + AsyncData() => Text( + context.l10n.settings_storageUsage_unavailable, + style: muted, + ), + AsyncError() => Text( + context.l10n.settings_storageUsage_measureFailed, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + _ => const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/features/settings/presentation/pages/storage_usage_page_test.dart` +Expected: PASS, 6 tests. + +If the loading spinner keeps a `pumpAndSettle` from ever settling, the override is returning a future that never completes; check the harness returns `Future.value(0)` for unlisted ids. + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/settings/presentation/pages/storage_usage_page.dart test/features/settings/presentation/pages/storage_usage_page_test.dart +git commit -m "feat(settings): add the storage usage page" +``` + +--- + +## Task 8: Route and entry point + +**Files:** +- Modify: `lib/core/router/app_router.dart` (near the existing `storage` route, around line 982) +- Modify: `lib/features/settings/presentation/pages/storage_settings_page.dart` + +**Interfaces:** +- Consumes: `StorageUsagePage` (Task 7), the l10n tile keys (Task 6) +- Produces: the named route `storageUsage` at path `storage-usage` + +- [ ] **Step 1: Add the route** + +In `lib/core/router/app_router.dart`, add the import + +```dart +import 'package:submersion/features/settings/presentation/pages/storage_usage_page.dart'; +``` + +alongside the existing `storage_settings_page.dart` import, then add this `GoRoute` immediately after the existing `storage` route: + +```dart +GoRoute( + path: 'storage-usage', + name: 'storageUsage', + builder: (context, state) => const StorageUsagePage(), +), +``` + +- [ ] **Step 2: Add the entry tile** + +In `lib/features/settings/presentation/pages/storage_settings_page.dart`, add a tile just before the Danger Zone block in `build`. The surrounding code is: + +```dart + // Danger Zone + const SizedBox(height: 16), +``` + +Insert immediately above that comment: + +```dart + const Divider(), + ListTile( + leading: const Icon(Icons.pie_chart_outline), + title: Text(context.l10n.settings_storageUsage_tile_title), + subtitle: Text( + context.l10n.settings_storageUsage_tile_subtitle, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.pushNamed('storageUsage'), + ), +``` + +If `context.pushNamed` is not already available in that file, add + +```dart +import 'package:go_router/go_router.dart'; +``` + +to the package import group. + +- [ ] **Step 3: Verify it analyzes and the existing page tests still pass** + +Run: `flutter analyze lib/core/router/app_router.dart lib/features/settings/presentation/pages/storage_settings_page.dart` +Expected: "No issues found." + +Run: `flutter test test/features/settings/presentation/pages/ -r compact` +Expected: PASS, no new failures. `storage_settings_reset_test.dart`, `storage_settings_pick_failure_test.dart` and `storage_settings_custom_folder_subtitle_test.dart` all build this page, so a missing l10n key or a bad import shows up here. + +- [ ] **Step 4: Check the file size ceiling** + +Run: `wc -l lib/features/settings/presentation/pages/storage_settings_page.dart` +Expected: under 800. It was 754 before this task, and the tile adds about 11 lines. + +- [ ] **Step 5: Commit** + +```bash +git add lib/core/router/app_router.dart lib/features/settings/presentation/pages/storage_settings_page.dart +git commit -m "feat(settings): link the storage usage page from storage settings" +``` + +--- + +## Task 9: Whole-project verification + +**Files:** none created; this task proves the branch is releasable. + +- [ ] **Step 1: Format the whole project** + +Run: `dart format .` +Expected: reports the files it changed. Formatting the whole project, not just the touched files, is the project rule. + +- [ ] **Step 2: Analyze the whole project** + +Run: `flutter analyze` +Expected: "No issues found." + +Do not pipe this through `grep` or `head`. A pipe returns the exit status of the last command in the pipeline, which masks an analyzer failure. Infos count as failures in CI, so a clean run means zero output of any severity. + +- [ ] **Step 3: Run the full test suite once** + +Run: `flutter test -r compact` +Expected: all tests pass. + +One full run is sufficient before a PR. Do not overlap it with another local test run: concurrent runs fake a lone failure. If a test fails, rerun that file alone before believing it. + +- [ ] **Step 4: Commit any formatting churn** + +```bash +git add -u +git commit -m "style: dart format" +``` + +Skip this step if `git status` is clean. Stage with `git add -u` rather than `git add -A`, and only after confirming `git status` shows nothing from a sibling worktree. + +- [ ] **Step 5: Push and open the PR** + +```bash +git push -u origin worktree-issue-1375-storage-usage +``` + +PR title: `feat(storage): show on-device storage usage by category` + +PR body: summarise the slice, link the spec, state that it is read-only, and reference `#1375` without closing it, since slices B through E remain. No Claude Code attribution line and no session URL. + +--- + +## Self-Review + +**Spec coverage.** Every Slice A requirement in the spec maps to a task: the `StorageCategory` contract to Task 3, all 14 categories with their per-category strategies to Task 4, the provider family to Task 5, the page and its four render states to Task 7, the shared byte formatter to Task 1, the eleven-locale fanout to Task 6, and the new-page-rather-than-append constraint to Task 8 Step 4. The spec's two named test traps (the Ahem-font row overflow and real I/O under `testWidgets`) are designed into Tasks 7 and 2 respectively rather than left to be rediscovered. + +**Slices B through E** are documented in the spec and deliberately have no tasks here. + +**Type consistency.** `measure` is `Future Function()` in Task 3, Task 4 and the Task 5 family. `formatBytes(int)` is defined in Task 1 and called in Task 7. `StorageCategoryId` constants are defined in Task 3 and consumed by name in Tasks 4 and 7. `mapTileKibibytes` returns `Future` in both the Task 4 constructor and the Task 5 wiring, and the kibibyte-to-byte conversion happens once, inside `_measureMapTiles`. + +**Identifiers verified.** Every API this plan calls was read from the tree before being written down: `MediaCacheStore.totalBytes` and `MediaCacheKind` (`media_cache_store.dart:240`), `TileCacheService.getTotalCacheSize` (`tile_cache_service.dart:387`, which returns kibibytes as a `double`, hence the conversion in `_measureMapTiles`), `CachedNetworkImageDiagnostics.cacheSize` (`cached_network_image_diagnostics.dart:41`), `BackupService.resolveDefaultBackupsDirectory` (`backup_service.dart:1147`), `DatabaseLocationService.getDatabasePath` (`database_location_service.dart:120`) reached through `databaseLocationServiceProvider` (`storage_providers.dart:69`), and the `context.l10n` extension (`lib/l10n/l10n_extension.dart`). diff --git a/docs/superpowers/plans/2026-08-29-usb-hid-transport.md b/docs/superpowers/plans/2026-08-29-usb-hid-transport.md new file mode 100644 index 0000000000..f8fb0bafde --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-usb-hid-transport.md @@ -0,0 +1,229 @@ +# USB HID Transport Implementation Plan + +**Goal:** Let Submersion download from USB HID dive computers (Scubapro G2 +family and Aladin Square, Suunto EON Steel family) under the USB transfer mode +on macOS, Windows and Linux (issue #1271). + +**Spec:** `docs/superpowers/specs/2026-08-29-usb-hid-transport-design.md` + +**Architecture:** libdivecomputer is opened through `dc_custom_open` with a +plugin-owned eleven-slot callback table, so a new byte pipe needs no change to +libdivecomputer, its build, or the Pigeon API. Each desktop platform gains a HID +implementation of that table plus a device enumerator, and the HID device joins +the existing ordered candidate probe that already tries serial ports and raw +FTDI cables. + +## Global Constraints + +- No em-dashes anywhere, including code comments and commit messages. +- No emojis in code, comments or documentation. +- `dart format .` must produce no changes before any commit touching Dart. +- `flutter analyze` must be clean across the whole project; infos are fatal. +- Swift files that must stay unit-testable live in + `packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/` and are + symlinked into `macos/Classes/` and `ios/Classes/`. A new shared darwin Swift + file needs both symlinks or the CocoaPods build will not see it. +- Status codes are the `LIBDC_STATUS_*` values from + `packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h`, numerically + identical to `dc_status_t`. +- `libdc_wrapper.c` and `libdc_download.c` under `macos/Classes/` are shared: + the Windows, Linux and Android builds all compile them. A change there is a + change on every platform. +- Android and iOS keep USBHID suppressed. `DiveComputerHostApiImpl.swift` is + shared between macOS and iOS, so its mapping change is `#if os(macOS)`. + +## Contract for the byte pipe + +From `third_party/libdivecomputer/src/usbhid.c:707-790`: + +- **write:** `data[0]` is the HID report id. If it is zero, strip it and send + `size - 1` bytes; report `size` as written either way. +- **read:** return one input report, at most `size` bytes, within the timeout + set by `set_timeout`. A timeout returns zero bytes and success, not an error. +- `configure`, `set_dtr`, `set_rts` and `purge` are meaningless for HID. + +Report sizes come from the device (max input/output report length), falling +back to 64 bytes. + +## File Structure + +Create under `packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/`: + +| File | Responsibility | +| --- | --- | +| `UsbHidReportFraming.swift` | Pure report-id framing and buffer sizing. No I/O. | +| `DescriptorTransportMapping.swift` | Pure bitmask-to-transfer-mode mapping. Extracted from the host API because it is where the bug lived and it had no test at any layer. | +| `UsbHidDeviceEnumerator.swift` | IOHIDManager enumeration of HID devices with their VID/PID. | +| `UsbHidIoStream.swift` | Glue: owns the IOHIDDevice, queues input reports, produces `libdc_io_callbacks_t`. | + +Create under `packages/libdivecomputer_plugin/darwin/Tests/`: +`UsbHidReportFramingTests/main.swift`, +`DescriptorTransportMappingTests/main.swift`. + +Create under `packages/libdivecomputer_plugin/windows/`: +`usbhid_io_stream.{h,cc}`, `usbhid_enumerator.{h,cc}`. + +Create under `packages/libdivecomputer_plugin/linux/`: +`usbhid_io_stream.{h,c}`, `usbhid_enumerator.{h,c}`. + +Create under `packages/libdivecomputer_plugin/test/native/`: +`test_usbhid_descriptor_match.c`. + +Modify: `macos/Classes/libdc_wrapper.{h,c}`, +`darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift`, +`darwin/run_native_tests.sh`, +`windows/dive_computer_host_api_impl.cc`, `windows/CMakeLists.txt`, +`linux/dive_computer_host_api_impl.cc`, `linux/CMakeLists.txt`, +`test/native/CMakeLists.txt`. + +--- + +## Task 1: Descriptor queries in the shared C wrapper + +Ask libdivecomputer whether a model speaks USB HID, and whether a plugged-in +VID/PID belongs to it. Keeps the VID/PID allowlist upstream instead of copying +it into plugin code. + +**Files:** +- Modify: `packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.{h,c}` +- Test: `packages/libdivecomputer_plugin/test/native/test_usbhid_descriptor_match.c` +- Modify: `packages/libdivecomputer_plugin/test/native/CMakeLists.txt` + +**Produces:** +- `unsigned int libdc_descriptor_transports(const char *vendor, const char *product, unsigned int model)` +- `int libdc_usbhid_match(const char *vendor, const char *product, unsigned int model, unsigned short vid, unsigned short pid)` + +- [x] **Step 1: Write the failing test.** Assert the Uwatec pairs + (`0x2e6c:0x3201` G2 and G2 TEK, `0x2e6c:0x3211` G2 Console, `0x2e6c:0x4201` + G2 HUD, `0xc251:0x2006` Aladin Square) and the Suunto pairs (`0x1493:0x0030`, + `0x0033`, `0x0035`, `0x0036`) match their own descriptors; that a G2 TEK + rejects the Suunto D5 pair; that a Shearwater Perdix (BLE and serial, no + USBHID) matches nothing; and that + `libdc_descriptor_transports("Scubapro", "G2 TEK", 0x31)` has the USBHID bit + and not the SERIAL bit. Vectors come from `descriptor.c`, not from the new + code. +- [x] **Step 2: Implement** over `dc_descriptor_iterator` plus + `dc_descriptor_filter(descriptor, DC_TRANSPORT_USBHID, &desc)`. +- [x] **Step 3: Verify** via `test/native/` CMake build. + +## Task 2: Per-candidate transport on darwin + +Refactor only. The probe currently runs every candidate with +`LIBDC_TRANSPORT_SERIAL`; a HID candidate needs `LIBDC_TRANSPORT_USBHID`. + +**Files:** `darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift` + +- [x] Give `DownloadCandidate` a `transportValue: UInt32` (`SERIAL` for both + existing cases) and use it in both the single-candidate and multi-candidate + paths instead of the function-level constant. +- [x] Verify no behaviour change: `flutter build macos --debug`. + +## Task 3: HID report framing (pure, tested) + +**Files:** +- Create: `darwin/Sources/LibDCDarwin/UsbHidReportFraming.swift` +- Test: `darwin/Tests/UsbHidReportFramingTests/main.swift` +- Modify: `darwin/run_native_tests.sh` + +**Produces:** `enum UsbHidReportFraming` with +`outgoingReport(from: [UInt8]) -> (reportId: UInt8, payload: [UInt8])?` and +`reportSize(deviceMaximum: Int?, fallback: Int) -> Int`. + +- [x] **Step 1: Failing test.** A 33-byte buffer whose first byte is zero + yields report id 0 and a 32-byte payload; a non-zero first byte yields that + id and keeps all remaining bytes; an empty buffer yields nil. Vectors from + `usbhid.c:745-790`. +- [x] **Step 2: Implement.** +- [x] **Step 3:** `packages/libdivecomputer_plugin/darwin/run_native_tests.sh`. + +## Task 4: macOS HID enumeration and byte pipe + +**Files:** +- Create: `darwin/Sources/LibDCDarwin/UsbHidDeviceEnumerator.swift`, + `darwin/Sources/LibDCDarwin/UsbHidIoStream.swift` +- Symlink both into `macos/Classes/` and `ios/Classes/` + +- [x] `UsbHidDevice` value type (vid, pid, product name, `IOHIDDevice`) and an + `enumerate(log:)` that reports every HID device it sees, matched or not, so a + user's debug log distinguishes "not enumerating" from "rejected". +- [x] `UsbHidIoStream`: open with `IOHIDDeviceOpen`, register an input report + callback on a dedicated run loop, queue reports, `IOHIDDeviceSetReport` for + write, and `makeCallbacks()` returning `libdc_io_callbacks_t`. +- [x] Guard the whole file body with `#if os(macOS)` so the iOS build compiles + it to nothing. + +## Task 5: Wire HID into the macOS download + +**Files:** `darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift` + +- [x] Add `case usbHid(UsbHidDevice)` to `DownloadCandidate` with transport + `LIBDC_TRANSPORT_USBHID` and log category `"HID"`. +- [x] In `performSerialDownload`, when + `libdc_descriptor_transports(...) & LIBDC_TRANSPORT_USBHID`, enumerate HID + devices, keep those `libdc_usbhid_match` accepts, and prepend them. +- [x] Replace the bare "No USB serial ports found" when the model is HID-capable + and nothing matched. +- [x] Surface USBHID as `.usb` in `mapTransports`, `#if os(macOS)` only, and + rewrite the #143 comment to say what is now true. + +## Task 6: Windows HID + +**Files:** `windows/usbhid_io_stream.{h,cc}`, `windows/usbhid_enumerator.{h,cc}`, +`windows/dive_computer_host_api_impl.cc`, `windows/CMakeLists.txt` + +- [x] Enumerate with `SetupDiGetClassDevs(GUID_DEVINTERFACE_HID)` + + `HidD_GetAttributes` + `HidD_GetProductString`; buffer sizes from + `HidP_GetCaps`. +- [x] Overlapped `ReadFile` with `WaitForSingleObject` for the timeout, and + `WriteFile` for output reports. +- [x] Same candidate ordering and mapping changes as Task 5. +- [x] Link `hid.lib` (SetupAPI.lib is already linked). + +## Task 7: Linux HID + +**Files:** `linux/usbhid_io_stream.{h,c}`, `linux/usbhid_enumerator.{h,c}`, +`linux/dive_computer_host_api_impl.cc`, `linux/CMakeLists.txt` + +- [x] Enumerate `/dev/hidraw*`, VID/PID from `HIDIOCGRAWINFO`, name from + `HIDIOCGRAWNAME`. No libudev, no libusb. +- [x] `poll()` + `read()` for the timeout contract, `write()` for output + reports. +- [x] Same candidate ordering and mapping changes as Task 5. Note the Linux + backend currently forces USB to SERIAL at + `linux/dive_computer_host_api_impl.cc:372`; that becomes per-candidate. + +## Task 8: Dart coverage and l10n + +- [x] Test that `usbDeviceModelsProvider` keeps a descriptor whose only + transport is `usb` (the shape a HID device now arrives in). +- [x] Any new user-facing string goes through `lib/l10n/arb/` for every locale. + +## Findings during implementation + +- **The two HID families frame reports differently, and both directions + matter.** The Uwatec family writes report id 0 (`uwatec_smart.c:287`), which + is stripped before it goes on the wire. The Suunto EON Steel family writes + report type `0x3f` (`suunto_eonsteel.c:242`) and rejects any reply whose + first byte is not `0x3f` (`:156`), so its id byte must stay. Treating "strip + the first byte" as unconditional would have worked for the G2 and failed + every EON Steel packet. Both cases are pinned by tests. +- **A HID-only model must not fall through to the serial probe.** It has no + serial port to find, and probing writes dive-computer handshake bytes at + whatever else is plugged in. The candidate list stops at HID when the + descriptor declares no serial or bulk-USB transport. +- **No Pigeon change was needed.** Surfacing USB HID as the existing `usb` + transport and dispatching from the descriptor inside the download path also + keeps a saved computer working: `dive_computers.connection_type` stores the + string `"usb"` either way, so a G2 registered today comes back as a plain USB + device tomorrow. A new enum value would only have handled a fresh selection. + +## Task 9: Verification + +- [x] `packages/libdivecomputer_plugin/darwin/run_native_tests.sh` +- [x] `test/native/` CMake suite +- [x] `flutter analyze` clean, `dart format .` clean +- [x] Full `flutter test` run +- [x] `flutter build macos --debug` and a smoke run of the USB tab, confirming + Scubapro and Suunto models are listed +- [x] CI must build Windows and Linux green +- [x] Reply on issue #1271 offering a build to test diff --git a/docs/superpowers/plans/2026-08-30-profile-series-2e-retire.md b/docs/superpowers/plans/2026-08-30-profile-series-2e-retire.md new file mode 100644 index 0000000000..574e128656 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-profile-series-2e-retire.md @@ -0,0 +1,819 @@ +# Profile Series Plan 2e: Retire the Legacy Tables Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The row-per-sample tables are gone: a v183 rung drops `dive_profiles` / `tank_pressure_profiles` and purges their sync bookkeeping, the upgrade path VACUUMs once, every legacy fallback, delete, re-insert, snapshot list, watcher entry, index heal and `TankPressurePoint.id` is deleted, the receive-side shim for older peers stages inbound legacy rows in temp tables instead, the forty-five legacy-touching test files are ported, and a synthesized 1,000-dive fixture backs the benchmark gates the spec requires, so that PR 2 can open. + +**Architecture:** Plans 2a to 2d (done) built the series tables, the series-first reads with a legacy fallback, the series writers, the SQL consumers and the sync registration, and left the legacy tables in place as a safety net. This plan removes the net in a fixed order: capture the legacy benchmark baseline while the legacy read path still exists (Task 1), add the rung that drops the tables and the VACUUM that reclaims the pages (Task 2), move the inbound shim off the legacy tables and onto temp staging tables so older peers' payloads still pack (Task 3), then delete the fallbacks and legacy statements in three slices (Tasks 4 to 6), port the tests (Task 7), verify with the benchmark gates (Task 8), and stop at the PR gate (Task 9). One deviation from spec section 8: the drop is its own rung (183) rather than part of the pack rung, because v182 already exists on this branch and every developer database that ran it must still get the drop. + +**Tech Stack:** Flutter, Drift 2.34.3 (with `build_runner` for the regenerated `database.g.dart`), SQLite via `package:sqlite3`; `flutter test` (`performance` tag for the gates); `dart format`; `flutter analyze`. + +**Spec:** `docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md` (sections 4 "Schema", 7 "Sync", 8 "Migration and one-time compaction", 10 "Testing and benchmark gates", 11 "Delivery", 12 "Risks"). The 2b/2c/2d hand-off notes (session scratchpad `2b-handoff-checklist.md`) are folded into the constraints and the task file lists. + +## Global Constraints + +- Schema version rises from 182 to 183 (`currentSchemaVersion`, `migrationVersions`); origin/main is at 180 and PR #1390 holds 181, so 182 and 183 are this branch's; re-check against `origin/main` and every open PR head immediately before the PR opens (sweep with `git fetch origin` and `git show "origin/${h}:lib/core/database/database.dart"` per branch; the zsh `$h:...` modifier trap is documented in memory). `minimumCompatibleSchemaVersion` becomes 183 (the final review wave raised it from 182): no released build was ever stamped 182, since main is at 180, the 181 rung is an open PR, and 182 and 183 ship together, so 183 holds nothing in the fleet that 182 did not already hold. What it records is that v183, not v182, is the rung that purges the `diveProfiles` / `tankPressureProfiles` `deletion_log` rows, which were also the inbound resurrection guard for those entity types, so a reader below 183 must not be one this build expects to apply its payloads. +- Order matters: Task 1 (baseline) runs before any deletion; Task 2 (drop) before Task 3 (staging shim) leaves a window where the shim writes into dropped tables, so Tasks 2 and 3 are one commit each but Task 3's tests are the gate for both (the inbound tests must pass with the tables gone). +- `packLegacyProfileRows` and its migration-ladder call (the v182 rung) SURVIVE: an upgrade from a pre-182 database runs the v182 rung (pack) before the v183 rung (drop). The `beforeOpen` backstop's pack call also survives (it no-ops when the tables are gone: `_columnNames` of a missing table is empty). The packer gains table-name parameters so the same code packs the staging tables. +- The receive-side tolerance (spec section 7) stays until every peer that can publish is at 182 or above: `SyncData.diveProfiles` / `tankPressureProfiles` (inbound-only fields), the `mergeOrder` legacy entries, `SyncService.inboundOnlyLegacyEntities`, `_baseApplyEntityFlags` and the pack hooks all stay. What changes is where the rows land: temp staging tables, packed and emptied immediately. +- After this plan, `grep -rn "dive_profiles\|tank_pressure_profiles" lib` matches only: the migration rungs below 183 in `database.dart` (history), the packer's parameter defaults, the staging DDL, and doc comments that describe history. `DiveProfile`, `DiveProfilesCompanion`, `TankPressureProfile`, `TankPressureProfilesCompanion` no longer exist. +- Benchmarks (spec section 10): on the synthesized 1,000-dive fixture, per-dive detail hydrate, `getBatchProfileSummaries` for 50 dives, both statistics aggregations, migration wall time, post-VACUUM file size and VACUUM time; gate "nothing slower than today", numbers reported in the PR description. "Today" is the legacy SQL shape measured on the pre-migration fixture by the same test run. +- No em-dashes (U+2014) or en-dashes used as punctuation anywhere (code, comments, tests, commit messages, the PR body). No emojis. Immutability: never mutate a list handed in. +- TDD per task; `dart format .` from the worktree root; `flutter analyze` zero issues (infos included); tests run per file with `flutter test `, never piped; stage explicit paths; one local commit per task with the message given; no push and no PR until Task 9's gate is explicitly authorized by the user; no `Co-Authored-By` trailer; never `git stash`. +- Worktree: `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/profile-sample-storage-2`, branch `worktree-profile-sample-storage-2`, absolute paths only. `build_runner` output (`database.g.dart`) is committed in this repository (check `git ls-files lib/core/database/database.g.dart`; if tracked, commit the regenerated file with Task 2; if ignored, do not). +- Known machine flakes: `local_file_resolver_test.dart` (mounted `$TMPDIR`, passes with `TMPDIR=/private/tmp/claude-501/sysvol-tmp`), `changeset_writer_test.dart`, `backup_encryption_service_test.dart`, `database_security_service_test.dart`. + +## File structure + +| File | Responsibility after this plan | +|---|---| +| `tools/synth_fixture.dart` (new) | copies a plaintext Submersion database and replicates every dive (with its child rows) N times under fresh ids; prints row counts | +| `test/performance/profile_series_benchmark_test.dart` (new, `performance` tag) | the spec's six numbers on the synthesized fixture: legacy SQL shapes on the pre-migration copy, series path on the migrated copy, migration and VACUUM timings, file sizes; asserts the gate and prints the table for the PR | +| `lib/core/database/database.dart` | `currentSchemaVersion = 183`; the v183 rung (`_dropLegacySampleTables`, bookkeeping purge); `DiveProfiles` / `TankPressureProfiles` table classes removed from the schema; the backstop comment updated | +| `lib/core/database/database.g.dart` | regenerated | +| `lib/core/services/database_service.dart` | `_runUpgradeLadder` reads the stored version before the ladder and VACUUMs once when it was below 183 | +| `lib/core/database/performance_indexes.dart` | the two legacy index heals removed | +| `lib/core/database/profile_series_pack.dart` | `packLegacyProfileRows(db, {nowMs, profileTable, tankTable})` | +| `lib/core/database/legacy_sample_staging.dart` (new) | temp staging DDL from the codec field table, JSON-row staging, `packStagedLegacyRows` | +| `lib/core/services/sync/sync_data_serializer.dart` | legacy `upsertRecord` / `upsertRecords` cases stage rows; legacy `fetchRecord`, `fetchRecords`, `recordIdsFor`, `_syncTableFor`, `deleteRecord` cases removed; `packLegacySamples` packs the staging tables | +| `lib/core/services/sync/sync_service.dart` | `parentRefs` legacy entries removed; the adopt clear loop skips inbound-only entities | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart`, `tank_pressure_repository.dart`, `dive_computer_repository_impl.dart`, `lib/features/divers/data/repositories/diver_repository.dart` | fallbacks, legacy deletes, legacy UPDATEs and legacy watcher entries gone; `_minProfileTemp` over series points | +| `lib/features/dive_log/data/services/dive_split_service.dart`, `dive_merge_service.dart`, `dive_consolidation_service.dart`, `dive_merge_snapshot.dart`, `lib/features/dive_computer/data/services/reparse_service.dart` | legacy deletes, undo `deleteWhere`s and verbatim re-inserts, snapshot legacy lists gone | +| `lib/features/dive_log/domain/entities/dive.dart`, `lib/features/dive_log/domain/services/profile_series_merge.dart`, `lib/features/dive_log/domain/services/estimated_tank_pressure_synthesizer.dart` | `TankPressurePoint.id` removed | +| `lib/features/statistics/data/repositories/statistics_repository.dart` | legacy watcher entries gone | +| 45 test files | ported (see Task 7's table) | + +--- + +### Task 1: Synthesized fixture and the legacy benchmark baseline + +**Files:** +- Create: `tools/synth_fixture.dart` +- Create: `test/performance/profile_series_benchmark_test.dart` +- Create: `test/performance/README.md` (how to build the fixture and run the gate; five lines) + +**Interfaces:** +- Consumes: `package:sqlite3` (raw), `AppDatabase(NativeDatabase(file))` (runs the ladder on first `SELECT 1`), `DiveRepository().getDiveById`, `getBatchProfileSummaries(ids, 200)`, `StatisticsRepository().getAscentDescentRates()`, `getTimeAtDepthRanges()`, `DatabaseService.getStoredSchemaVersion(path)`, the legacy SQL shapes reproduced verbatim from git history (`git show 30234a3973e:lib/features/statistics/data/repositories/statistics_repository.dart` for the two aggregation CTEs; `git show 879a9cf1624:lib/features/dive_log/data/repositories/dive_repository_impl.dart` for the per-dive profile select and the batch summary query). +- Produces: `dart run tools/synth_fixture.dart --replicas 25` (40 dives x 25 = 1,000); the benchmark test reads `SUBMERSION_BENCH_FIXTURE` (a path to a pre-migration fixture at schema 180 or later, never a live database) and skips with a message when unset. + +- [ ] **Step 1: The fixture tool** + +`tools/synth_fixture.dart` (top-level script, `package:sqlite3` only; no Flutter): + +```dart +import 'dart:io'; + +import 'package:sqlite3/sqlite3.dart'; + +/// Replicates every dive of a plaintext Submersion database N times under +/// fresh ids so a small development library becomes a large benchmark +/// fixture. Usage: +/// +/// dart run tools/synth_fixture.dart --replicas 25 +/// +/// Never points at a live database: it copies the source file first. Child +/// rows are found by column name: every table with a `dive_id` column is +/// replicated; `id` (when text) gets a `-r` suffix, `dive_id`, `tank_id` +/// and `source_id` are remapped to the replica's ids, `computer_id`, +/// `site_id` and every other column are copied as they are. Media rows are +/// skipped (blob stores are not part of the profile benchmark). +void main(List args) { + if (args.length < 2) { + stderr.writeln('Usage: dart run tools/synth_fixture.dart [--replicas N]'); + exit(64); + } + final source = args[0]; + final out = args[1]; + final replicas = args.contains('--replicas') ? int.parse(args[args.indexOf('--replicas') + 1]) : 25; + if (!File(source).existsSync()) { + stderr.writeln('No such file: $source'); + exit(66); + } + File(source).copySync(out); + final db = sqlite3.open(out); + try { + db.execute('PRAGMA foreign_keys = OFF'); + final tables = _tablesWithDiveId(db)..remove('media'); + final diveIds = db.select('SELECT id FROM dives').map((r) => r['id'] as String).toList(); + db.execute('BEGIN'); + for (var k = 1; k <= replicas; k++) { + final suffix = '-r$k'; + for (final table in ['dives', ...tables.where((t) => t != 'dives')]) { + _replicate(db, table, suffix, diveIds); + } + } + db.execute('COMMIT'); + for (final table in ['dives', ...tables]) { + final n = db.select('SELECT COUNT(*) AS n FROM $table').first['n']; + stdout.writeln('$table: $n'); + } + } finally { + db.close(); + } +} + +Set _tablesWithDiveId(Database db) { + final out = {}; + for (final row in db.select("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")) { + final name = row['name'] as String; + final cols = db.select('SELECT name FROM pragma_table_info(?)', [name]).map((c) => c['name'] as String); + if (cols.contains('dive_id')) out.add(name); + } + return out; +} + +void _replicate(Database db, String table, String suffix, List diveIds) { + final cols = db.select('SELECT name, pk FROM pragma_table_info(?)', [table]).map((c) => (name: c['name'] as String, pk: (c['pk'] as int) > 0)).toList(); + final names = cols.map((c) => c.name).toList(); + final where = table == 'dives' ? 'WHERE id IN (${List.filled(diveIds.length, '?').join(',')})' : 'WHERE dive_id IN (${List.filled(diveIds.length, '?').join(',')})'; + final rows = db.select('SELECT ${names.join(', ')} FROM $table $where', diveIds); + final insert = db.prepare('INSERT OR IGNORE INTO $table (${names.join(', ')}) VALUES (${List.filled(names.length, '?').join(', ')})'); + try { + for (final row in rows) { + final values = []; + for (final c in cols) { + final v = row[c.name]; + if (v is String && (c.name == 'id' || c.name == 'dive_id' || c.name == 'tank_id' || c.name == 'source_id')) { + values.add('$v$suffix'); + } else { + values.add(v); + } + } + insert.execute(values); + } + } finally { + insert.dispose(); + } +} +``` + +`_replicate` remaps `id`, `dive_id`, `tank_id`, `source_id` by suffix; because every replicated table's referenced ids (tanks, sources) are themselves replicated with the same suffix, the remap is consistent. Composite-key junction tables without an `id` column get their `dive_id` remapped and their other key copied, which is correct (the other key is a shared parent: equipment, tag, buddy, species). Run it once on a copy of the development database (`/Users/ericgriffin/Library/Containers/app.submersion/Data/Documents/Submersion/submersion.db`, plaintext, 40 dives, schema 180) into the session scratchpad (`/private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/5b0068b6-136c-4277-89c4-30a25ed89d1c/scratchpad/bench-1000.db`) and record the printed counts in the report: `dives: 1040` (40 originals plus 1,000 replicas), `dive_profiles` about 26x the original count. + +- [ ] **Step 2: The benchmark test (fails until the fixture path is set; skips without it)** + +`test/performance/profile_series_benchmark_test.dart`: + +```dart +@Tags(['performance']) +library; + +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/statistics/data/repositories/statistics_repository.dart'; + +/// Spec section 10 gates on the synthesized 1,000-dive fixture. Legacy +/// numbers come from the legacy SQL shapes run raw against the +/// pre-migration copy; series numbers from the app's own methods on the +/// migrated copy. Set SUBMERSION_BENCH_FIXTURE to the fixture path +/// (see test/performance/README.md); the test skips without it. +void main() { + final fixture = Platform.environment['SUBMERSION_BENCH_FIXTURE']; + + test('profile series benchmarks: nothing slower than the legacy shapes', () async { + if (fixture == null) { + markTestSkipped('SUBMERSION_BENCH_FIXTURE not set'); + return; + } + final work = Directory.systemTemp.createTempSync('series-bench'); + final legacyCopy = File('${work.path}/legacy.db')..writeAsBytesSync(File(fixture).readAsBytesSync()); + final migratedCopy = File('${work.path}/migrated.db')..writeAsBytesSync(File(fixture).readAsBytesSync()); + final results = {}; + + // Legacy shapes, raw SQL, pre-migration copy. + final raw = sqlite.sqlite3.open(legacyCopy.path); + final diveIds = raw.select('SELECT id FROM dives ORDER BY dive_date_time DESC LIMIT 50').map((r) => r['id'] as String).toList(); + final legacyHydrate = _time(() { + for (final id in diveIds) { + raw.select('SELECT * FROM dive_profiles WHERE dive_id = ? AND is_primary = 1 ORDER BY timestamp', [id]); + raw.select('SELECT * FROM tank_pressure_profiles WHERE dive_id = ? ORDER BY timestamp', [id]); + } + }); + final legacySummaries = _time(() { + raw.select(_legacyBatchSummarySql(diveIds.length), diveIds); + }); + final legacyAscent = _time(() => raw.select(_legacyAscentDescentSql)); + final legacyBuckets = _time(() => raw.select(_legacyTimeAtDepthSql)); + final sizeBefore = legacyCopy.lengthSync(); + raw.close(); + + // Migration (the ladder from the fixture's version to 183) plus VACUUM. + final storedBefore = DatabaseService.getStoredSchemaVersion(migratedCopy.path)!; + final migration = Stopwatch()..start(); + final migrator = AppDatabase(NativeDatabase(migratedCopy.file)); + await migrator.customSelect('SELECT 1').get(); + migration.stop(); + final vacuum = Stopwatch()..start(); + await migrator.customStatement('VACUUM'); + vacuum.stop(); + await migrator.close(); + final sizeAfter = migratedCopy.lengthSync(); + + // Series path through the app, migrated copy. + final db = AppDatabase(NativeDatabase(migratedCopy.file)); + DatabaseService.instance.setTestDatabase(db); + final dives = DiveRepository(); + final stats = StatisticsRepository(); + final seriesHydrate = await _timeAsync(() async { + for (final id in diveIds) { + await dives.getDiveById(id); + } + }); + final seriesSummaries = await _timeAsync(() => dives.getBatchProfileSummaries(diveIds, 200)); + final seriesAscent = await _timeAsync(() => stats.getAscentDescentRates()); + final seriesBuckets = await _timeAsync(() => stats.getTimeAtDepthRanges()); + await db.close(); + DatabaseService.instance.resetForTesting(); + + results['per-dive hydrate (50 dives)'] = (legacy: legacyHydrate, series: seriesHydrate); + results['batch summaries (50 dives)'] = (legacy: legacySummaries, series: seriesSummaries); + results['ascent/descent rates'] = (legacy: legacyAscent, series: seriesAscent); + results['time at depth'] = (legacy: legacyBuckets, series: seriesBuckets); + + final table = StringBuffer() + ..writeln('| metric | legacy | series |') + ..writeln('|---|---|---|'); + for (final e in results.entries) { + table.writeln('| ${e.key} | ${e.value.legacy.inMilliseconds} ms | ${e.value.series.inMilliseconds} ms |'); + } + table + ..writeln('| migration $storedBefore -> ${AppDatabase.currentSchemaVersion} | | ${migration.elapsed.inMilliseconds} ms |') + ..writeln('| VACUUM | | ${vacuum.elapsed.inMilliseconds} ms |') + ..writeln('| file size | ${sizeBefore ~/ 1024} KB | ${sizeAfter ~/ 1024} KB |'); + // ignore: avoid_print + print(table); + + for (final e in results.entries) { + expect( + e.value.series.inMicroseconds, + lessThanOrEqualTo((e.value.legacy.inMicroseconds * 1.25).round()), + reason: '${e.key}: series ${e.value.series} vs legacy ${e.value.legacy} (25% tolerance for timer noise)', + ); + } + expect(sizeAfter, lessThan(sizeBefore ~/ 2), reason: 'the drop plus VACUUM must return most of the file'); + expect(storedBefore, lessThan(183)); + expect(DatabaseService.getStoredSchemaVersion(migratedCopy.path), AppDatabase.currentSchemaVersion); + }); +} + +Duration _time(void Function() body) { + final sw = Stopwatch()..start(); + body(); + return sw.elapsed; +} + +Future _timeAsync(Future Function() body) async { + final sw = Stopwatch()..start(); + await body(); + return sw.elapsed; +} + +String _legacyBatchSummarySql(int n) => + 'SELECT dive_id, timestamp, depth FROM dive_profiles WHERE is_primary = 1 AND dive_id IN (${List.filled(n, '?').join(',')}) ORDER BY dive_id, timestamp'; + +// The two aggregation queries as they stood before plan 2d, unfiltered scope +// (diver filter and dive filter clauses empty). Copy them verbatim from +// `git show 30234a3973e:lib/features/statistics/data/repositories/statistics_repository.dart` +// (getAscentDescentRates at about line 2200, getTimeAtDepthRanges at about +// line 2297), substituting 15, 3.0 and 4 for the constants. +const _legacyAscentDescentSql = ''' +... +'''; +const _legacyTimeAtDepthSql = ''' +... +'''; +``` + +Fill the two `const` strings with the verbatim legacy SQL (the plan does not repeat 60 lines of SQL; the commit named above is the source, and the implementer pastes them). `getBatchProfileSummaries` signature: check the second parameter's name and meaning in `dive_repository_impl.dart` (it is `maxSamples`). `NativeDatabase(File)` takes a `File`; `migratedCopy` is one. If `AppDatabase` needs the `NativeDatabase` opened with the app's `setup:` callback for pragmas, use `DatabaseService`'s public helpers instead of constructing the connection by hand; read `database_service.dart` lines 30-60 for `_connectionSetup` and whether a public equivalent exists (if not, `NativeDatabase(file)` is acceptable for a benchmark). + +The gate deliberately runs BEFORE any deletion: on this commit the migrated copy's ladder stops at 182 (Task 2 adds 183), so the size assertion and the `storedBefore < 183` expectation are written now and go green in Task 8. Mark the two of them with a comment "green after Task 2" and run the test once to record the legacy versus series timings in the report (they are the baseline the PR cites). + +- [ ] **Step 3: Run** + +```bash +dart run tools/synth_fixture.dart /private/tmp/claude-501/-Users-ericgriffin-repos-submersion-app-submersion/5b0068b6-136c-4277-89c4-30a25ed89d1c/scratchpad/bench-1000.db --replicas 25 +SUBMERSION_BENCH_FIXTURE= flutter test --run-skipped --tags performance test/performance/profile_series_benchmark_test.dart +``` + +Expected: the tool prints counts; the test prints the table; the timing assertions pass (or, if a series metric is slower than legacy by more than 25%, STOP and report the numbers: that is the gate the spec set, and the fix belongs to whoever owns the slow read, not to this plan's deletions); the two "green after Task 2" assertions fail for now. + +- [ ] **Step 4: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add tools/synth_fixture.dart test/performance/profile_series_benchmark_test.dart test/performance/README.md +git commit -m "test(perf): synthesized 1,000-dive fixture tool and the profile series benchmark gate" +``` + +--- + +### Task 2: The v183 rung (drop and purge) and the one-time VACUUM + +**Files:** +- Modify: `lib/core/database/database.dart` (`currentSchemaVersion`, `migrationVersions`, the rung after v182, the `@DriftDatabase(tables: [...])` list, delete the `DiveProfiles` and `TankPressureProfiles` table classes near lines 849 and 2802, the v182 backstop comment) +- Regenerate: `lib/core/database/database.g.dart` (`dart run build_runner build --delete-conflicting-outputs`) +- Modify: `lib/core/database/performance_indexes.dart` (delete the `idx_dive_profiles_dive_id` and `idx_tank_pressure_dive_tank` entries near lines 85-95) +- Modify: `lib/core/services/database_service.dart` (`_runUpgradeLadder` near line 309) +- Modify: `test/core/database/query_plan_test.dart` (delete the two legacy-table plan tests near lines 30-45), `test/core/database/performance_indexes_test.dart` (the dropped-index heal test near line 40 uses `idx_dive_profile_series_dive_primary` instead) +- Test: `test/core/database/migration_v183_drop_legacy_tables_test.dart` (new) +- Test: `test/core/services/database_service_vacuum_test.dart` (new) + +**Interfaces:** +- Consumes: `legacyDdlAt180(raw, {userVersion})` and `seedParents` / `seedProfiles` / `seedPressures` from `test/helpers/legacy_profile_fixtures.dart` (plan 2a) to build a pre-183 fixture; `DatabaseService.getStoredSchemaVersion(path, {keyHex})`; the packer (v182 rung) unchanged. +- Produces: `AppDatabase.currentSchemaVersion == 183`; a private `Future _dropLegacySampleTables()` on `AppDatabase`; `_runUpgradeLadder` VACUUMs when the pre-ladder `user_version` was below 183. After this task nothing in `lib/` can name `_db.diveProfiles` or `_db.tankPressureProfiles` and compile; Tasks 3 to 6 remove every such reference and this task's `flutter analyze` step is where they are enumerated, so Task 2's commit includes the MECHANICAL removals needed to compile (each listed in Step 3) and leaves the behavioral deletions to their tasks. + +- [ ] **Step 1: Write the failing tests** + +`test/core/database/migration_v183_drop_legacy_tables_test.dart` (model: `test/core/database/migration_v182_profile_series_test.dart`, which opens `AppDatabase(NativeDatabase.opened(raw, closeUnderlyingOnClose: false))` over a `sqlite3.openInMemory()` handle stamped with `PRAGMA user_version`): + +```dart + test('the v183 rung drops the legacy tables and purges their bookkeeping', () async { + final raw = sqlite3.openInMemory(); + legacyDdlAt180(raw, userVersion: 182); + seedParents(raw); + seedProfiles(raw); // legacy rows the v182 rung would have packed already + raw.execute("CREATE TABLE IF NOT EXISTS sync_records (id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, record_id TEXT NOT NULL, local_updated_at INTEGER, synced_at INTEGER, sync_status TEXT, conflict_data TEXT, created_at INTEGER, updated_at INTEGER)"); + raw.execute("CREATE TABLE IF NOT EXISTS deletion_log (id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, record_id TEXT NOT NULL, deleted_at INTEGER NOT NULL, hlc TEXT, peer_device_id TEXT, provider TEXT, base_seq_applied INTEGER, last_seq_applied INTEGER)"); + raw.execute("INSERT INTO sync_records (id, entity_type, record_id) VALUES ('s1', 'diveProfiles', 'p1'), ('s2', 'tankPressureProfiles', 't1'), ('s3', 'dives', 'd1')"); + raw.execute("INSERT INTO deletion_log (id, entity_type, record_id, deleted_at) VALUES ('l1', 'diveProfiles', 'p9', 1), ('l2', 'dives', 'd9', 1)"); + // A series row must survive the drop: pack first the way v182 did. + final db = AppDatabase(NativeDatabase.opened(raw, closeUnderlyingOnClose: false)); + await db.customSelect('SELECT 1').get(); + expect(raw.select("SELECT name FROM sqlite_master WHERE name IN ('dive_profiles', 'tank_pressure_profiles')"), isEmpty); + expect(raw.select("SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('idx_dive_profiles_dive_id', 'idx_tank_pressure_dive_tank')"), isEmpty); + expect(raw.select("SELECT entity_type FROM sync_records").map((r) => r['entity_type']), ['dives']); + expect(raw.select("SELECT entity_type FROM deletion_log").map((r) => r['entity_type']), ['dives']); + expect(raw.select('SELECT COUNT(*) AS n FROM dive_profile_series').first['n'], greaterThan(0)); + expect(raw.select('PRAGMA user_version').first.values.first, 183); + await db.close(); + }); + + test('re-running the ladder at 183 is a no-op', () async { + // open a 183 database (the previous test's end state rebuilt), open again, no throw, counts unchanged + }); + + test('a database that skipped the v182 rung still packs before the drop', () async { + // legacyDdlAt180(raw, userVersion: 181) with seedProfiles: after the + // ladder the series exist, the legacy tables are gone, and the packed + // sample counts equal the seeded rows minus exact duplicates. + }); +``` + +(If the fixture helper's `sync_records` / `deletion_log` DDL differs from the real schema, copy the real CREATE TABLE text from `database.g.dart` for those two tables; the rung's `DELETE` needs only `entity_type`.) Write the three bodies in full. + +`test/core/services/database_service_vacuum_test.dart`: find the existing test that opens a file through `DatabaseService` with a pending migration (`grep -rln "migrationThenBackground\|_runUpgradeLadder\|openDatabaseFile\|lastOpenMode" test/core/services`) and copy its fixture: write a plaintext database file at `user_version` 182 containing a big `dive_profiles` table (insert 20,000 rows through the raw handle) plus the series tables; open it through the service; assert `lastOpenMode == DatabaseOpenMode.migrationThenBackground`, the stored version is 183, and `PRAGMA freelist_count` on the file (raw, after the service closes it or on a fresh raw handle) is 0, which only VACUUM achieves after a drop. A second test opens a file already at 183 and asserts no VACUUM happened (seed a freelist by deleting rows from a scratch table before opening; `freelist_count` stays greater than 0). + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: the rung does not exist (tables still present, version 182); the VACUUM test finds `freelist_count > 0`. + +- [ ] **Step 3: Implement** + +`database.dart`: +- `static const int currentSchemaVersion = 183;` and `183,` appended to `migrationVersions`. +- After the v182 block in `onUpgrade`: + +```dart + // v183: the legacy row-per-sample tables are gone. The v182 rung + // above packed every row into series (and the beforeOpen backstop + // re-packs any dive that missed it), so nothing reads these tables + // any more. Their sync bookkeeping goes with them: pending records + // and tombstones for entity types no peer exports. Idempotent + // (IF EXISTS / DELETE), so a retried ladder is safe. The pages come + // back at the one VACUUM in DatabaseService._runUpgradeLadder. + if (from < 183) { + await _dropLegacySampleTables(); + } + if (from < 183) await reportProgress(); +``` + +with + +```dart + Future _dropLegacySampleTables() async { + await customStatement('DROP INDEX IF EXISTS idx_dive_profiles_dive_id'); + await customStatement('DROP INDEX IF EXISTS idx_tank_pressure_dive_tank'); + await customStatement('DROP TABLE IF EXISTS dive_profiles'); + await customStatement('DROP TABLE IF EXISTS tank_pressure_profiles'); + await customStatement( + "DELETE FROM sync_records WHERE entity_type IN ('diveProfiles', 'tankPressureProfiles')", + ); + await customStatement( + "DELETE FROM deletion_log WHERE entity_type IN ('diveProfiles', 'tankPressureProfiles')", + ); + } +``` + +- Remove `DiveProfiles` and `TankPressureProfiles` from the `@DriftDatabase(tables: [...])` list and delete the two table classes. Update the v182 backstop comment's last sentence ("The legacy tables stay until plan 2e retires them" becomes "v183 drops the legacy tables; the packer no-ops once they are gone"). +- Regenerate `database.g.dart`. + +`database_service.dart` `_runUpgradeLadder`: + +```dart + Future _runUpgradeLadder( + File file, + String? keyHex, + void Function(int currentStep, int totalSteps)? onMigrationProgress, + ) async { + // Read before the ladder: the one-time VACUUM below keys off the version + // the file had on disk, not the version the ladder leaves behind. + final storedBefore = getStoredSchemaVersion(file.path, keyHex: keyHex); + final migrator = AppDatabase( + NativeDatabase(file, setup: _connectionSetup(keyHex)), + onMigrationProgress: onMigrationProgress, + ); + try { + await migrator.customSelect('SELECT 1').get(); + if (storedBefore != null && storedBefore < 183) { + // v183 dropped the row-per-sample tables (about 90% of the pages of + // an older file). VACUUM here: outside any migration transaction, + // on the one exclusive main-isolate connection, before the + // background executor opens the file. Non-fatal: a busy lock leaves + // a correct but large file. + try { + await migrator.customStatement('VACUUM'); + } catch (e, stackTrace) { + _log.warning('Post-migration VACUUM skipped', error: e, stackTrace: stackTrace); + } + } + } catch (_) { + await migrator.close().timeout(const Duration(seconds: 5), onTimeout: () {}).catchError((_) {}); + rethrow; + } + await migrator.close().timeout(const Duration(seconds: 5)); + } +``` + +(use the file's real logger; if `_log` does not exist, `developer.log` as `database.dart` does.) + +`performance_indexes.dart`: delete the two legacy entries. `query_plan_test.dart`: delete the two legacy-table tests. `performance_indexes_test.dart`: the heal test drops and re-heals `idx_dive_profile_series_dive_primary`. + +Mechanical compile fixes this task MUST include (everything that names the deleted Drift classes; the behavioral cleanup of each site is Tasks 4 to 6, so here only remove the reference in the smallest way that keeps the file's current behavior for series and stops it for legacy): run `flutter analyze` after the regeneration and fix every error it lists. Expected error sites (from `grep -rn "_db.diveProfiles\|_db.tankPressureProfiles\|db.diveProfiles\|db.tankPressureProfiles\|DiveProfile\b\|TankPressureProfile\b\|DiveProfilesCompanion\|TankPressureProfilesCompanion" lib`): `dive_repository_impl.dart` (the fallback bodies, `_mapRowToDive`'s legacy branches, `_dropSupersededOriginals`, `_dropDuplicateSamples`, `_profilePointFromRow`, `_minProfileTemp`, `restoreOriginalProfile`'s legacy delete, the watchers), `tank_pressure_repository.dart` (three fallbacks, one legacy delete), `dive_computer_repository_impl.dart` (two fallbacks, two legacy deletes in `clearSourceAndProfiles`, the two `UPDATE dive_profiles` raw statements do not name the class but the table is gone, so they go too), `diver_repository.dart` (raw UPDATE), `dive_split_service.dart` (the two restored legacy deletes), `dive_merge_snapshot.dart` (the two legacy lists), `dive_merge_service.dart` and `dive_consolidation_service.dart` (undo `deleteWhere`s and re-inserts, snapshot id sets), `reparse_service.dart` (two legacy deletes), `sync_data_serializer.dart` (every legacy case), `statistics_repository.dart` (watcher entries). For THIS task delete exactly those references (Tasks 4 to 6 then do the semantic cleanup: removing the `if (series.isEmpty)` gates, the `_...Legacy` method shells, `TankPressurePoint.id`, comments) and delete the tests that only exercised the deleted references if they cannot compile (list each in the report; Task 7 owns the ports). + +The compile-fix scope is deliberately mechanical because `database.g.dart` regeneration makes the branch uncompilable until every reference is gone; a reviewer checks that no series behavior changed in this commit. + +- [ ] **Step 4: Run the tests** + +Run the two new files, `migration_v182_profile_series_test.dart`, `profile_series_pack_test.dart`, `backstop_resilience_test.dart`, `query_plan_test.dart`, `performance_indexes_test.dart`, `database_import_graph_test.dart`, then `flutter analyze` (zero issues) and, because the compile fixes touch many files, the whole `test/features/dive_log/data/repositories/` and `test/core/services/sync/` directories (per file, or one `flutter test ` run per directory, unpiped). Files that fail ONLY because their fixture seeds the dropped tables are listed for Task 7 in the report; anything else is a regression to fix here. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/database/database.dart lib/core/database/performance_indexes.dart lib/core/services/database_service.dart test/core/database/migration_v183_drop_legacy_tables_test.dart test/core/services/database_service_vacuum_test.dart test/core/database/query_plan_test.dart test/core/database/performance_indexes_test.dart +git commit -m "feat(db): v183 drops the legacy sample tables, purges their bookkeeping, VACUUMs once on upgrade" +``` + +--- + +### Task 3: The inbound shim stages legacy rows in temp tables + +**Files:** +- Create: `lib/core/database/legacy_sample_staging.dart` +- Modify: `lib/core/database/profile_series_pack.dart` (`profileTable` / `tankTable` parameters; five hardcoded table names near lines 67, 73, 83, 90, 126, 210) +- Modify: `lib/core/services/sync/sync_data_serializer.dart` (legacy `upsertRecord` / `upsertRecords` cases; `packLegacySamples`; the legacy `fetchRecord`, `fetchRecords`, `recordIdsFor`, `_syncTableFor`, `deleteRecord` cases are gone since Task 2) +- Modify: `lib/core/services/sync/sync_service.dart` (`parentRefs` legacy entries removed; the adopt clear loop skips `inboundOnlyLegacyEntities`) +- Test: `test/core/database/legacy_sample_staging_test.dart` (new) +- Verify (all six scenarios must pass with the tables gone): `test/core/services/sync/legacy_sample_entities_inbound_test.dart`, `cross_version_roundtrip_test.dart`, `profile_series_sync_test.dart`, `base_publish_streaming_parity_test.dart`, `sync_base_streaming_parity_test.dart`, the adopt tests. + +**Interfaces:** +- Consumes: `kProfileFieldTableV1` (`lib/features/dive_log/domain/codecs/profile_field_table.dart`: `ProfileField(name, kind)`, kinds `deltaInt` / `float64` / `runLengthString`), `packLegacyProfileRows`, `_columnNames`, `_unpackedDiveIds(db, legacyTable: ...)` (existing packer internals). +- Produces: + - `const String kLegacyProfileStagingTable = 'dive_profiles_inbound'; const String kLegacyTankStagingTable = 'tank_pressure_profiles_inbound';` + - `Future ensureLegacyStagingTables(DatabaseConnectionUser db)` (CREATE TEMP TABLE IF NOT EXISTS for both) + - `Future stageLegacyProfileRows(DatabaseConnectionUser db, List> jsonRows)` and `stageLegacyTankRows(...)` (camelCase wire keys to snake_case columns, unknown keys ignored, `INSERT OR REPLACE`, returns the count) + - `Future packStagedLegacyRows(DatabaseConnectionUser db)` (packs the staging tables, then empties them) + - `packLegacyProfileRows(db, {int? nowMs, String profileTable = 'dive_profiles', String tankTable = 'tank_pressure_profiles'})` + +- [ ] **Step 1: Write the failing test** + +`test/core/database/legacy_sample_staging_test.dart` (an in-memory `AppDatabase` from `setUpTestDatabase()`, FK parents seeded as in `profile_series_repository_writers_test.dart`): + +```dart + test('wire rows land in the staging table with snake_case columns', () async { + await ensureLegacyStagingTables(db); + final n = await stageLegacyProfileRows(db, [ + {'id': 'p1', 'diveId': 'dive-1', 'computerId': null, 'sourceId': null, 'isPrimary': true, 'timestamp': 0, 'depth': 0.0, 'ppO2': 1.2, 'o2SensorMv1': 55, 'heartRateSource': 'chest', 'unknownKey': 42}, + {'id': 'p2', 'diveId': 'dive-1', 'isPrimary': true, 'timestamp': 30, 'depth': 12.0}, + ]); + expect(n, 2); + final rows = await db.customSelect('SELECT id, dive_id, is_primary, pp_o2, o2_sensor_mv1, heart_rate_source FROM dive_profiles_inbound ORDER BY timestamp').get(); + expect(rows.first.data['pp_o2'], 1.2); + expect(rows.first.data['o2_sensor_mv1'], 55); + expect(rows.first.data['heart_rate_source'], 'chest'); + expect(rows.first.data['is_primary'], 1); + }); + + test('packStagedLegacyRows packs into series and empties the staging tables', () async { + await ensureLegacyStagingTables(db); + await stageLegacyProfileRows(db, [/* two rows for dive-1 as above */]); + await stageLegacyTankRows(db, [{'id': 't1', 'diveId': 'dive-1', 'tankId': 'tank-a', 'timestamp': 0, 'pressure': 200.0}]); + final report = await packStagedLegacyRows(db); + expect(report.profileSeries, 1); + expect(report.tankSeries, 1); + final series = await ProfileSeriesRepository().getSeriesForDive('dive-1'); + expect(series.single.samples.map((s) => s.depth), [0.0, 12.0]); + expect(series.single.id, profileSeriesMigratedId(/* the identity of the rows */)); + expect((await db.customSelect('SELECT COUNT(*) AS n FROM dive_profiles_inbound').getSingle()).data['n'], 0); + expect((await db.customSelect('SELECT COUNT(*) AS n FROM tank_pressure_profiles_inbound').getSingle()).data['n'], 0); + }); + + test('a dive that already has a series ignores staged rows, and the staging is still emptied', () async { + // insert a series for dive-1 first; stage rows for dive-1; pack; the + // series is unchanged and the staging table is empty. + }); + + test('ensureLegacyStagingTables is idempotent and survives a missing legacy table', () async { + await ensureLegacyStagingTables(db); + await ensureLegacyStagingTables(db); + expect(await packStagedLegacyRows(db), isA()); + }); +``` + +Write every body in full (`profileSeriesMigratedId`'s named parameters are in `lib/features/dive_log/domain/entities/profile_series_identity.dart`). + +- [ ] **Step 2: Run the test to verify it fails** + +Expected: compile error, the file does not exist. + +- [ ] **Step 3: Implement** + +`lib/core/database/legacy_sample_staging.dart`: + +```dart +import 'package:drift/drift.dart'; + +import 'package:submersion/core/database/profile_series_pack.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_field_table.dart'; + +/// Where an older peer's row-per-sample arrays land now that the legacy +/// tables are gone (v183). TEMP tables live for the connection; the packer +/// reads them like it read `dive_profiles`, and they are emptied after each +/// pack. Retire with the receive-side shim (plan 2d's +/// SyncService.inboundOnlyLegacyEntities) once no peer below 182 can publish. +const String kLegacyProfileStagingTable = 'dive_profiles_inbound'; +const String kLegacyTankStagingTable = 'tank_pressure_profiles_inbound'; + +String _sqlType(ProfileFieldKind kind) => switch (kind) { + ProfileFieldKind.deltaInt => 'INTEGER', + ProfileFieldKind.float64 => 'REAL', + ProfileFieldKind.runLengthString => 'TEXT', +}; + +/// The legacy `dive_profiles` columns: identity plus every codec field. +final List kLegacyProfileColumns = [ + 'id', 'dive_id', 'computer_id', 'source_id', 'is_primary', + for (final f in kProfileFieldTableV1) f.name, +]; + +const List kLegacyTankColumns = ['id', 'dive_id', 'tank_id', 'computer_id', 'timestamp', 'pressure']; + +String _profileDdl() => 'CREATE TEMP TABLE IF NOT EXISTS $kLegacyProfileStagingTable (' + 'id TEXT NOT NULL PRIMARY KEY, dive_id TEXT NOT NULL, computer_id TEXT, source_id TEXT, ' + 'is_primary INTEGER NOT NULL DEFAULT 1, ' + '${[for (final f in kProfileFieldTableV1) '${f.name} ${_sqlType(f.kind)}'].join(', ')})'; + +const String _tankDdl = 'CREATE TEMP TABLE IF NOT EXISTS $kLegacyTankStagingTable (' + 'id TEXT NOT NULL PRIMARY KEY, dive_id TEXT NOT NULL, tank_id TEXT NOT NULL, ' + 'computer_id TEXT, timestamp INTEGER NOT NULL, pressure REAL NOT NULL)'; + +Future ensureLegacyStagingTables(DatabaseConnectionUser db) async { + await db.customStatement(_profileDdl()); + await db.customStatement(_tankDdl); +} + +/// `diveId` -> `dive_id`, `ppO2` -> `pp_o2`, `o2SensorMv1` -> `o2_sensor_mv1`: +/// Drift's default column naming, which is what the wire keys were made from. +String legacyColumnFor(String wireKey) => + wireKey.replaceAllMapped(RegExp('[A-Z]'), (m) => '_${m[0]!.toLowerCase()}'); + +Future stageLegacyProfileRows(DatabaseConnectionUser db, List> jsonRows) => + _stage(db, kLegacyProfileStagingTable, kLegacyProfileColumns, jsonRows); + +Future stageLegacyTankRows(DatabaseConnectionUser db, List> jsonRows) => + _stage(db, kLegacyTankStagingTable, kLegacyTankColumns, jsonRows); + +Future _stage(DatabaseConnectionUser db, String table, List columns, List> jsonRows) async { + var staged = 0; + for (final row in jsonRows) { + final values = {}; + for (final entry in row.entries) { + final column = legacyColumnFor(entry.key); + if (!columns.contains(column)) continue; + final v = entry.value; + values[column] = v is bool ? (v ? 1 : 0) : v; + } + if (values['id'] == null || values['dive_id'] == null) continue; + final names = values.keys.toList(); + await db.customStatement( + 'INSERT OR REPLACE INTO $table (${names.join(', ')}) VALUES (${List.filled(names.length, '?').join(', ')})', + [for (final n in names) values[n]], + ); + staged++; + } + return staged; +} + +/// Packs whatever is staged into series (dives that already have a series +/// are left alone, exactly as the migration packer does) and empties both +/// staging tables. +Future packStagedLegacyRows(DatabaseConnectionUser db) async { + await ensureLegacyStagingTables(db); + try { + return await packLegacyProfileRows( + db, + profileTable: kLegacyProfileStagingTable, + tankTable: kLegacyTankStagingTable, + ); + } finally { + await db.customStatement('DELETE FROM $kLegacyProfileStagingTable'); + await db.customStatement('DELETE FROM $kLegacyTankStagingTable'); + } +} +``` + +Check `customStatement`'s argument form on `DatabaseConnectionUser` (positional `List` args) and adjust; keep `database.dart`'s import graph Flutter-free (this file imports only drift, the packer and the codec field table; `database_import_graph_test` covers it if `database.dart` imports it, which it does not need to). + +`profile_series_pack.dart`: add the two named parameters and use them at every hardcoded site (the `_columnNames` calls, the two `_unpackedDiveIds(... legacyTable: ...)` calls, the two `SELECT * FROM ... WHERE dive_id = ?` reads). The doc comment names the defaults and the staging use. + +`sync_data_serializer.dart`: +- `upsertRecord` cases: `case 'diveProfiles': await ensureLegacyStagingTables(_db); await stageLegacyProfileRows(_db, [data]); return;` and the tank twin; `upsertRecords` cases: `ensureLegacyStagingTables(_db)` then `stageLegacy...Rows(_db, records)`. +- `packLegacySamples()` returns `packStagedLegacyRows(_db)`. +- Confirm the legacy `fetchRecord`, `fetchRecords`, `recordIdsFor`, `_syncTableFor`, `deleteRecord` cases are gone (Task 2); a legacy tombstone from an older peer now falls through `deleteRecord`'s switch, which has no default, so it is a no-op: add a comment above the switch saying so. `recordIdsFor`'s default throws, which is right: nothing passes a legacy type any more (its callers iterate `entityHasUpdatedAt` keys). + +`sync_service.dart`: +- Remove `parentRefs['diveProfiles']` and `['tankPressureProfiles']` (`_mergeEntity` reads `parentRefs[type] ?? const []`). +- In `_adoptApplyStreaming`, the clear loop over the union: skip keys in `inboundOnlyLegacyEntities` (nothing local to clear; the temp tables are per-connection scratch). Keep the fold-back of `changeset.data.diveProfiles` / `tankPressureProfiles` and the `wantRows` predicates (they feed the staging cases). + +- [ ] **Step 4: Run the tests** + +Run the new file, then every file in the Verify list plus `test/core/services/sync/` as a directory. The six inbound scenarios must pass with the legacy tables gone. +Expected: all pass. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/database/legacy_sample_staging.dart lib/core/database/profile_series_pack.dart lib/core/services/sync/sync_data_serializer.dart lib/core/services/sync/sync_service.dart test/core/database/legacy_sample_staging_test.dart +git commit -m "feat(sync): older peers' sample rows stage in temp tables and pack into series" +``` + +--- + +### Task 4: Repositories without fallbacks + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`getDiveProfile`, `getMergedProfile`, `getProfilesByDataSource`, `getBatchProfileSummaries`, `_mapRowToDive`, `_mergedSeriesPoints`, `_minProfileTemp`, `restoreOriginalProfile`, the two watchers, `_dropSupersededOriginals`, `_dropDuplicateSamples`, `_profilePointFromRow`) +- Modify: `lib/features/dive_log/data/repositories/tank_pressure_repository.dart` (`getTankPressuresForDive`, `getPressuresForTank`, `hasTankPressures`, `deleteTankPressuresForDive`) +- Modify: `lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart` (`getComputerIdsForDive`, `getPrimaryComputerId`, `clearSourceAndProfiles`, `deleteComputer`, the relink block, `importProfile`'s remaining doc lines) +- Modify: `lib/features/divers/data/repositories/diver_repository.dart` (the legacy UPDATE near line 375 is gone since Task 2; the series twins stay) +- Modify: `lib/features/statistics/data/repositories/statistics_repository.dart` (`watchStatisticsChanges` legacy entries) +- Test: extend `test/features/dive_log/data/repositories/dive_repository_series_reads_test.dart` (a dive with NO series reads as an empty profile, null merged profile behavior as the method documents, empty tank map) and `tank_pressure_series_reads_test.dart` (no fallback: a dive with no tank series has no pressures). + +**Interfaces:** +- Consumes: Task 2 left these files compiling with the legacy references cut out; the `if (series.isEmpty) return _xLegacy(...)` gates may still call now-empty or deleted shells. +- Produces: each read is series-only: `getDiveProfile` returns `mergeSeriesPoints(primary series)` (empty list when none); `_mergedSeriesPoints` returns a non-nullable `List`; `getMergedProfile` / `_mapRowToDive` use it directly; `getProfilesByDataSource` is the former `_profilesBySourceFromSeries` body (the empty-series case yields `{}` for a dive with no sources, or one empty entry per source, whichever the former series path did); `getBatchProfileSummaries` is the former series branch with no legacy leg; `_mapRowToDive`'s tank start/end come from tank series only; `_minProfileTemp(List points)` is the minimum finite `temperature` over the merged points (the legacy helper's rule over rows); `TankPressureRepository`'s three reads are series-only; `DiveComputerRepository`'s two reads are series-only; every `_...Legacy` method, `_dropSupersededOriginals`, `_dropDuplicateSamples`, `_profilePointFromRow` deleted; the watchers list only the series tables (with `dives`, `diveTanks`, events, etc. as before). + +- [ ] **Step 1: Write the failing tests** (the "no series" cases: `getDiveProfile` returns `[]`, `getMergedProfile` returns `[]`, `getDiveById` has an empty profile and tanks with null start/end pressures, `getProfilesByDataSource` returns what the series path returns for a dive with sources but no series, `getTankPressuresForDive` returns `{}`, `hasTankPressures` false, `getComputerIdsForDive` `[]`, `getPrimaryComputerId` null). Most already exist as fallback tests seeded with legacy rows; rewrite those to seed nothing and assert the empty results. +- [ ] **Step 2: Run them** (they fail on the Task 2 shells or on now-unreachable legacy seeds). +- [ ] **Step 3: Delete the gates and shells** as the Interfaces block describes. In `_mapRowToDive`, `_minProfileTemp` takes the merged points. Doc comments: remove every "falls back to the legacy table" sentence; the watcher docs name only the series tables. +- [ ] **Step 4: Run** the two extended files, `dive_repository_test.dart`, `dive_repository_series_writes_test.dart`, `profiles_by_data_source_series_test.dart`, `batch_summaries_series_test.dart`, `dive_computer_series_reads_test.dart`, `dive_computer_repository_impl_test.dart`, `dive_computer_repository_series_writes_test.dart`, `diver_repository_test.dart`, `series_computer_link_test.dart`, `tank_pressure_repository_series_writes_test.dart`, `test/features/statistics/data/repositories/statistics_repository_deco_test.dart`. Legacy-seeded fallback tests that now fail are listed for Task 7 (they are deleted or re-seeded there). +- [ ] **Step 5: Format, analyze, commit** + +```bash +git commit -m "refactor(series): repositories read series only; the 2b fallbacks and legacy statements are gone" +``` + +(stage the five lib files and the two test files by path). + +--- + +### Task 5: Services without legacy rows + +**Files:** +- Modify: `lib/features/dive_log/data/services/dive_merge_snapshot.dart` (delete `profileRows`, `tankPressureRows` and their capture; keep `profileSeriesRows`, `tankSeriesRows`) +- Modify: `lib/features/dive_log/data/services/dive_merge_service.dart` (undo: the legacy `deleteWhere`s and verbatim re-inserts; comments) +- Modify: `lib/features/dive_log/data/services/dive_consolidation_service.dart` (undo: `snapshotIds` / `currentChildIds` legacy entries, legacy `deleteWhere`s, verbatim re-inserts; comments) +- Modify: `lib/features/dive_log/data/services/dive_split_service.dart` (the two legacy deletes restored by the 2c fix wave; the comment that explains them) +- Modify: `lib/features/dive_computer/data/services/reparse_service.dart` (two legacy deletes) +- Modify tests: the six hand-built `DiveMergeSnapshot(...)` constructions drop the two fields (`test/features/dive_log/presentation/widgets/combine_dives_dialog_test.dart`, `dive_list_selection_test.dart`, `merge_dive_dialog_test.dart`, `test/features/import_wizard/data/adapters/dive_computer_adapter_test.dart` (two), `universal_adapter_test.dart`, `test/features/universal_import/presentation/providers/import_consolidation_service_test.dart`); `dive_split_service_test.dart`'s "legacy rows of the moved identities" test (from the 2c fix wave) is deleted (its subject no longer exists). + +- [ ] **Step 1: Failing test**: in `dive_merge_service_series_test.dart` and `dive_consolidation_service_series_test.dart` assert the snapshot has no legacy fields (a compile-level change: constructing `DiveMergeSnapshot` without them); run the six snapshot tests to see the compile failures. +- [ ] **Step 2: Delete** the fields, the capture queries, the undo legacy statements and re-inserts, the split and reparse legacy deletes; rewrite the comments that explained the legacy deletes ("a migrated database still holds dive_profiles rows" is no longer true). +- [ ] **Step 3: Run** the six snapshot tests, `dive_merge_service_test.dart`, `dive_merge_service_series_test.dart`, `dive_consolidation_service_test.dart`, `dive_consolidation_service_series_test.dart`, `dive_split_service_test.dart`, `dive_split_service_series_test.dart`, `dive_consolidation_test.dart`, `reparse_service_test.dart`, `reparse_service_series_test.dart`, `raw_data_persistence_test.dart`, `multi_computer_integration_test.dart`. +- [ ] **Step 4: Format, analyze, commit** + +```bash +git commit -m "refactor(series): services carry series only; snapshot legacy lists and legacy deletes are gone" +``` + +--- + +### Task 6: TankPressurePoint.id and the last legacy names + +**Files:** +- Modify: `lib/features/dive_log/domain/entities/dive.dart` (`TankPressurePoint`: delete `id`, its constructor parameter and its `props` entry) +- Modify: `lib/features/dive_log/domain/services/profile_series_merge.dart` (`mergeTankSeriesPoints` no longer synthesizes ids; doc comment) +- Modify: `lib/features/dive_log/domain/services/estimated_tank_pressure_synthesizer.dart` (drops the fabricated ids) +- Modify: every `TankPressurePoint(` construction in `lib/` and `test/` (104 carry `id:`; `grep -rn "TankPressurePoint(" lib test --include='*.dart' -A 1 | grep "id:"` lists them; the `TankPressurePoint` record typedef in `lib/features/dive_log/domain/services/tank_pressure_series.dart` is a different type and is untouched) +- Modify: `lib/core/database/database.dart` doc comments that still say "until plan 2e" on the v182 rung and backstop; `lib/features/dive_log/domain/codecs/profile_sample.dart` / `profile_field_table.dart` / `tank_pressure_series_codec.dart` doc comments that say "as the dive_profiles table stores it" become "as the v181 dive_profiles table stored it (the codec's column order)". +- Test: `test/features/dive_log/domain/entities/tank_pressure_point_test.dart` (new, small): equality ignores nothing that no longer exists; two points with the same tank, timestamp and pressure are equal. + +- [ ] **Step 1: Failing test** (the equality test compiles only once `id` is gone; run to see the compile error). +- [ ] **Step 2: Remove the field**; `dart fix` will not do the constructor sweep, so run the grep and edit each site (a `sed` over the exact `id: '...',` / `id: ...,` argument lines is acceptable when followed by `dart format .` and `flutter analyze`; review the diff for any `id:` that belonged to another type). +- [ ] **Step 3: Run** `flutter analyze` (zero), then every test file the sweep touched (list them from `git diff --name-only`), per file. +- [ ] **Step 4: Format, analyze, commit** + +```bash +git commit -m "refactor(series): TankPressurePoint has no row id; last legacy-table names in docs" +``` + +--- + +### Task 7: Port the legacy-touching tests + +**Files:** the test files below (one commit; group edits by directory in the report). + +**Interfaces:** +- Consumes: `ProfileSeriesRepository` / `TankPressureSeriesRepository` (`insertSeries`, `getSeriesForDive`, `getRowsForDives`), bound with `database: db, syncRepository: SyncRepository(database: db)` in tests that build their own `AppDatabase`; `ProfileSeriesCodec().decode(row.samples)` for raw-row assertions in migration tests; `legacyDdlAt180` / `seedProfiles` / `seedPressures` (`test/helpers/legacy_profile_fixtures.dart`) which STAY (they build pre-183 fixtures). +- Produces: no test seeds, reads or asserts on `dive_profiles` / `tank_pressure_profiles` except (a) migration fixtures at a version below 183 (they seed legacy rows for the ladder to pack and drop) and (b) the packer tests that build the legacy tables by hand. `DiveProfilesCompanion` / `TankPressureProfilesCompanion` / `DiveProfile` / `TankPressureProfile` appear nowhere under `test/`. + +Port rules (from plan 2c, extended for the drop): +- P1 seeds: a helper that inserted a legacy row inserts one single-sample series through the repository instead (the legacy helper is deleted, not kept beside the twin). +- P2 raw-row assertions after the ladder (old migration tests): read `dive_profile_series` / `tank_pressure_series` rows for the dive (raw `SELECT` on the `sqlite3` handle, or the repository bound to the test's `AppDatabase`), decode with the codec, and assert the same values on the decoded samples (a v89 test that asserted `setpoint` on a row asserts it on the decoded sample at the same timestamp). +- P3 "legacy table is empty" assertions (R5 from plan 2c): delete; the table does not exist. +- P4 `performance_data_generator.dart`: `_buildProfilePoints` builds a `List` per dive and the generator inserts one primary series per dive through `ProfileSeriesRepository.insertSeries` (batching is not available for series; the generator's perf test budget may need its dive count halved; say so in the report if it does). +- P5 tests whose subject was the fallback itself (`dive_profile_duplicate_rows_test.dart`'s legacy cases, `profiles_by_data_source_test.dart`'s legacy-seeded cases, the `_...Legacy` twins in the 2b series tests): the assertion survives only if it is about the series path; otherwise delete the test and say which in the report. + +| file | rule | +|---|---| +| `test/core/database/migration_v59_test.dart`, `migration_v89_o2_cells_test.dart`, `migration_v102_tank_pressure_relink_test.dart`, `migration_v105_profile_heading_test.dart`, `migration_v132_bottom_time_backfill_test.dart`, `migration_v153_o2_cell_mv_test.dart` | fixtures stay; P2 for every post-ladder read | +| `test/core/database/backfill_missing_data_sources_test.dart` (fixture at `currentSchemaVersion`: it must create the SERIES tables, not `dive_profiles`; seed series rows with the identity the backfill reads) | P1 + P2 | +| `test/core/services/sync/sync_dive_profile_mv_test.dart` | P1 (series with `o2SensorMv` samples), P2 | +| `test/features/media/data/lightroom_scan_service_test.dart` | P1 | +| `test/features/dive_log/profile_analysis_tick_reactivity_test.dart` | P1 | +| `test/helpers/performance_data_generator.dart` | P4 | +| `test/features/dive_computer/data/services/reparse_service_test.dart`, `raw_data_persistence_test.dart` | delete the legacy helpers and the R5 assertions (P1, P3) | +| `test/features/dive_log/data/services/dive_split_service_test.dart`, `dive_merge_service_test.dart`, `dive_consolidation_service_test.dart` | P1, P3 | +| `test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart`, `dive_consolidation_test.dart`, `dive_repository_test.dart`, `edited_profile_supersedes_originals_test.dart`, `profiles_by_data_source_test.dart`, `dive_profile_duplicate_rows_test.dart`, `diver_repository_test.dart` (`test/features/divers/...`) | P1, P3, P5 | +| the 2b/2c/2d series test files that carry `expect(await db.select(db.diveProfiles).get(), isEmpty)` style assertions (`dive_repository_series_reads_test.dart`, `dive_computer_repository_series_writes_test.dart`, `batch_summaries_series_test.dart`, `tank_pressure_series_reads_test.dart`, `dive_repository_series_writes_test.dart`, `dive_computer_series_reads_test.dart`, `tank_pressure_repository_series_writes_test.dart`, `dive_split_service_series_test.dart`, `dive_merge_service_series_test.dart`, `dive_consolidation_service_series_test.dart`, `reparse_service_series_test.dart`, `profile_analysis_loading_race_test.dart`, `profile_analysis_deco_stop_wiring_test.dart`, `dive_providers_test.dart`) | P3 | +| `test/core/database/profile_series_pack_test.dart`, `profile_series_pack_orphans_test.dart`, `migration_v182_profile_series_test.dart`, `backstop_resilience_test.dart` | fixtures build the legacy tables by hand at a version below 183: unchanged, but each opens the ladder to `currentSchemaVersion`, so any post-ladder assertion that the legacy tables still exist becomes an assertion that they are gone | + +- [ ] **Step 1: Enumerate**: `grep -rln "DiveProfilesCompanion\|TankPressureProfilesCompanion\|DiveProfile\b\|TankPressureProfile\b\|db.diveProfiles\b\|db.tankPressureProfiles\b\|INSERT INTO dive_profiles\|INSERT INTO tank_pressure_profiles\|FROM dive_profiles\|FROM tank_pressure_profiles" test` and reconcile against the table above (any file not in the table is reported and ported by the same rules). +- [ ] **Step 2: Port**, file by file, running each file after its edit. +- [ ] **Step 3: Run** every file in the table, then the directories `test/core/database/`, `test/core/services/sync/`, `test/features/dive_log/`, `test/features/dive_computer/`, `test/features/statistics/`, `test/features/data_quality/`. +- [ ] **Step 4: Format, analyze, commit** + +```bash +git commit -m "test(series): port the legacy-table fixtures and assertions to series" +``` + +--- + +### Task 8: Verification and the benchmark gate + +- [ ] **Step 1: Greps** + +```bash +grep -rn "dive_profiles\|tank_pressure_profiles" lib --include='*.dart' | grep -v "database.dart\|profile_series_pack.dart\|legacy_sample_staging.dart" | grep -v "^\S*:\s*//\|///" +grep -rn "DiveProfile\b\|TankPressureProfile\b\|DiveProfilesCompanion\|TankPressureProfilesCompanion\|_db.diveProfiles\|_db.tankPressureProfiles" lib test --include='*.dart' +grep -rn "Legacy(" lib/features/dive_log lib/features/dive_computer --include='*.dart' +grep -rn "TankPressurePoint(" lib test --include='*.dart' -A 1 | grep "id:" +``` + +Expected: nothing from the last three; the first shows only history comments (older rungs' text in `database.dart` is excluded by the filter; anything left is a comment describing history). + +- [ ] **Step 2: Every test file this plan created or touched, individually**, then the six directories from Task 7 Step 3, then `database_import_graph_test.dart`. +- [ ] **Step 3: Format, analyze**: `dart format --output=none --set-exit-if-changed .` exit 0; `flutter analyze` "No issues found!". +- [ ] **Step 4: Full suite once** (background, `full-suite-2e.log`, known flakes as listed in the constraints; any other failure reruns alone once; a repeat is real). +- [ ] **Step 5: The benchmark gate**: rebuild the fixture from a fresh copy of the development database with the tool (Task 1), then `SUBMERSION_BENCH_FIXTURE= flutter test --run-skipped --tags performance test/performance/profile_series_benchmark_test.dart`. Expected: every assertion green, including the two marked "green after Task 2"; paste the printed table into the report. If a series metric exceeds its legacy number by more than 25%, STOP: the gate failed; report the numbers and the method; do not weaken the assertion. +- [ ] **Step 6: Report**: commit list from `git log --oneline efdd779365b..HEAD`, the greps, per-file results, the full-suite summary and flake reruns, the benchmark table, and the schema-ladder sweep result (`git fetch origin` then, for every `origin/*` branch, `git show "origin/${h}:lib/core/database/database.dart" | grep -oE 'currentSchemaVersion = [0-9]+'`; any branch at 182 or 183 other than this one is a collision to report). + +--- + +### Task 9: PR gate (STOP for authorization) + +This task performs NO push and opens NO pull request. It prepares and stops. + +- [ ] **Step 1: Merge `origin/main`** into the branch (`git fetch origin && git merge origin/main`), resolve conflicts if any (a conflicting `database.dart` rung number is the one to watch: if `origin/main` moved past 180, re-check that 182 and 183 are still free; if not, renumber this branch's two rungs and their tests to the next free numbers, update the floor to match the pack rung, and rerun Task 8), run `git submodule update --init --recursive`, `flutter pub get`, `dart run build_runner build --delete-conflicting-outputs`, then `flutter analyze` and the Task 8 directory runs again. +- [ ] **Step 2: Write the PR body** to the session scratchpad as `pr2-body.md`: title "Packed profile series: 90% smaller dive logs, one series row per source"; sections: Why (the spike numbers: 25.4 of 28.07 MB were sample rows, 719 KB per dive; 74x / 129x lossless), What (the five plans in one paragraph each: tables and packer; series-first reads; writers; consumers and sync with the floor at 182; retirement at 183 with VACUUM), Migration and sync notes (deterministic migrated ids, the floor, the inbound shim and when it may retire, the one VACUUM), Behavior notes (the whole-series primary swap from plan 2c's review; the deterministic neighbour tie-break; dedupe at encode time), Benchmarks (the table from Task 8), Testing (the suite totals; the benchmark command), Follow-ups (retire the shim; `_applyAdoptInMemory`; the two files over 800 lines). No attribution line, no session link (repository rule). +- [ ] **Step 3: Release notes**: add the user-facing entry where this repository keeps them (memory: `feedback_release_notes_location.md` and `project_release_notes_source_of_truth.md` name the file and the rule; read them before writing) in one commit `docs: release notes for packed profile series`. +- [ ] **Step 4: STOP.** Report to the user: the branch state, the PR body path, the ladder sweep, and the exact push command (`git push --no-verify origin HEAD:worktree-profile-sample-storage-2`, the pre-push hook dies silently on new test files from a worktree) with the PR base (`main`; PR #1387 is the codec PR this branch stacks on, so note that PR 2 should target `main` after PR 1 merges or be opened as a stacked PR against PR 1's branch, the user's call). Do not push; do not open the PR; wait for explicit authorization. + +--- + +## Self-review + +**Spec coverage.** Section 8: step 4 (drop) and step 5 (purge) in the v183 rung (Task 2; the deviation from "one rung" is stated in the header and the constraints); the VACUUM at the `_runUpgradeLadder` seam keyed off the pre-ladder `user_version`, non-fatal (Task 2); the pre-migration backup is untouched. Section 7: the legacy entities' switch cases leave the serializer (Task 2 removes the read/delete cases, Task 3 turns the apply cases into staging); the receive-side tolerance survives on temp tables (Task 3) with its retirement condition named in the constraints. Section 4 "the legacy index heals go with the table" (Task 2). Section 6 "`TankPressurePoint.id` is removed" (Task 6). Section 10: migration test asserts "the old tables are gone; the retired tombstones and pending records are purged; two independently migrated copies produce identical series ids" (Task 2's new test plus the v182 determinism test that stays); `query_plan_test` and `performance_indexes_test` updated (Task 2); the benchmark fixture tool and the six numbers with the "nothing slower than today" gate (Tasks 1 and 8); "of the 53 test files that reference the old tables, those asserting on raw rows are ported" (Task 7; the count is 45 today after plans 2c and 2d ported the rest). Section 11: the PR body and release notes (Task 9), the push withheld for authorization. + +**Placeholder scan.** Task 1's two legacy SQL constants are marked as verbatim copies from a named commit (60 lines the plan does not repeat; the commit and line numbers are given). Task 2's compile-fix list names every file and construct. Task 7 is a table of files and rules with the rule semantics spelled out; each rule names the replacement construct. No "add error handling", no "similar to Task N". + +**Type consistency.** `packLegacyProfileRows(db, {nowMs, profileTable, tankTable})` (Task 3) matches `packStagedLegacyRows` and the v182 rung's unchanged call; `ensureLegacyStagingTables` / `stageLegacyProfileRows` / `stageLegacyTankRows` / `packStagedLegacyRows` (Task 3) match the serializer edits; `_dropLegacySampleTables()` (Task 2) is called from the rung only; `DatabaseService.getStoredSchemaVersion(path, {keyHex})` (existing) is used by Task 2's ladder and Task 1's benchmark; `_minProfileTemp(List)` (Task 4) is called from `_mapRowToDive` only; `TankPressurePoint(tankId, timestamp, pressure)` (Task 6) matches `mergeTankSeriesPoints` and the synthesizer after the sweep. diff --git a/docs/superpowers/plans/2026-09-03-linux-packaging-phase1-packages.md b/docs/superpowers/plans/2026-09-03-linux-packaging-phase1-packages.md new file mode 100644 index 0000000000..c54bf4fad4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-linux-packaging-phase1-packages.md @@ -0,0 +1,2440 @@ +# Linux Packaging Phase 1: Installable Packages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** ship `.deb` and `.rpm` packages that install Submersion, declare their +own dependencies, register desktop integration, and grant dive computer device +access, plus a tarball that diagnoses what it needs. + +**Architecture:** one Flutter build inside an `ubuntu:22.04` container produces +a bundle; a staging script turns that bundle into a canonical filesystem tree; a +dependency script derives the runtime dependency list from the built binary's +`DT_NEEDED` entries; `fpm` emits both package formats from that one tree. Clean +containers install the results in CI as a smoke test. + +**Tech Stack:** GitHub Actions, Docker containers, Python 3 (stdlib only), fpm +(Ruby), dpkg, rpm, systemd udev, Flutter/Dart, C++ (GTK runner). + +**Spec:** `docs/superpowers/specs/2026-09-03-linux-packaging-design.md` + +**Phase 2** (the APT/DNF repositories on GitHub Pages, self-enrollment, and the +in-app update-channel wiring that depends on them) is a separate plan, written +after this one lands. Phase 1 stands alone: it gives Linux users installable, +dependency-resolving packages from GitHub Releases. + +## Global Constraints + +- **Worktree:** all work happens in `.claude/worktrees/linux-packaging` on + branch `feat/linux-packaging`. Never check this branch out in the main tree. +- **No em-dashes** (U+2014) in any file, comment, commit message, or PR body. + En-dashes as prose punctuation and spaced hyphens are equally forbidden. +- **No emojis** in code, comments, or documentation. +- **Python scripts are stdlib only**, matching `scripts/check_bundled_native_assets.py`. + Each gets a sibling `*_test.py` runnable as `python3 scripts/_test.py`, + using `unittest`. +- **Package identity:** name `submersion`, binary `submersion`, application ID + `app.submersion`, license `GPL-3.0`, homepage `https://submersion.app`, + maintainer `Submersion `, description + `An open-source dive logging application for scuba divers.` +- **Version string:** the release tag with the leading `v` stripped, for example + tag `v1.7.7.7180` gives package version `1.7.7.7180`. +- **Asset names:** `Submersion-${TAG_NAME}-Linux-amd64.deb` and + `Submersion-${TAG_NAME}-Linux-x86_64.rpm`. Capital S, matching the existing + six assets, so the `Submersion-*` checksum globs pick them up. +- **Build floor:** glibc 2.35 via `container: ubuntu:22.04`. +- **udev rules** use `TAG+="uaccess"`, never `GROUP="plugdev"` or + `GROUP="dialout"`. +- **Immutability:** never mutate objects or arrays in Dart code. +- **After any Dart change:** run `dart format .` over the whole project. +- **File size:** 200-400 lines typical, 800 maximum. + +--- + +## File Structure + +**Created:** + +| File | Responsibility | +| --- | --- | +| `scripts/gen_udev_rules.py` | Parse libdivecomputer's VID/PID tables, emit udev rules | +| `scripts/gen_udev_rules_test.py` | Fixture tests for the generator | +| `scripts/data/linux_soname_map.json` | soname to package name, for four package managers | +| `scripts/linux_package_deps.py` | Read `DT_NEEDED` from a bundle, map to dependency tokens | +| `scripts/linux_package_deps_test.py` | Tests for the mapping, including the unmapped-soname failure | +| `scripts/release/stage_linux_package.py` | Build the canonical install tree from a bundle | +| `scripts/release/stage_linux_package_test.py` | Tests for tree layout and generated file contents | +| `scripts/release/linux_tarball_extras/install.sh` | Tarball installer with dependency preflight | +| `scripts/release/linux_tarball_extras/uninstall.sh` | Reverses install.sh | +| `scripts/release/install_sh_test.sh` | Shell tests for the installer | +| `lib/features/auto_update/domain/entities/linux_install_method.dart` | `LinuxInstallMethod` enum | +| `lib/features/auto_update/data/services/linux_install_method_reader.dart` | Reads the `INSTALL_METHOD` marker | +| `test/features/auto_update/data/linux_install_method_reader_test.dart` | Reader tests | +| `lib/features/auto_update/presentation/widgets/update_banner_actions.dart` | Banner action row, testable without providers | +| `test/features/auto_update/presentation/update_banner_actions_test.dart` | Banner action behavior per install method | + +**Modified:** + +| File | Change | +| --- | --- | +| `.github/workflows/build-all.yml:549-631` | Container build, packaging steps, new artifacts | +| `.github/workflows/release.yml:434` | `EXPECTED` gains the two package assets | +| `linux/runner/main.cc` | `--version` short-circuit | +| `lib/features/auto_update/presentation/providers/update_providers.dart` | `linuxInstallMethodProvider` | +| `lib/features/auto_update/presentation/widgets/update_banner.dart:47-52` | Package-manager hint instead of Download | +| `lib/l10n/arb/app_*.arb` (11 files) | New `autoUpdate_banner_packageManagerHint` key | +| `README.md`, `docs/guide/installation.md` | Linux install instructions | + +--- + +### Task 1: Verify the Ubuntu 22.04 dependency floor + +The spec makes this task one because the answer can invalidate the floor +decision. `.github/workflows/native-plugin-tests.yml:177-179` carries this +comment: + +> libwebkit2gtk-4.1-dev: required since flutter_web_auth_2 pulled in +> desktop_webview_window, whose Linux plugin links webkit2gtk (4.1 on +> Ubuntu 24.04; its CMake falls back to the 4.0 name only on older distros). + +If `libwebkit2gtk-4.1-dev` is absent on 22.04, `desktop_webview_window` +silently links `webkit2gtk-4.0` and `libsoup-2.4` instead +(`desktop_webview_window-0.3.0/linux/CMakeLists.txt:10-17`), which changes the +runtime contract without failing the build. + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-03-linux-packaging-design.md` (Risks table) + +**Interfaces:** +- Produces: a recorded yes/no that Tasks 2, 5, and 7 depend on. + +- [ ] **Step 1: Check package availability in a clean 22.04 image** + +```bash +docker run --rm ubuntu:22.04 bash -c ' + apt-get update -qq + apt-cache policy libwebkit2gtk-4.1-dev libsoup-3.0-dev libsecret-1-dev libgtk-3-dev +' +``` + +Expected: a `Candidate:` line with a real version for each, not `(none)`. + +- [ ] **Step 2: Confirm the pkg-config module name resolves** + +```bash +docker run --rm ubuntu:22.04 bash -c ' + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq pkg-config libwebkit2gtk-4.1-dev > /dev/null + pkg-config --exists webkit2gtk-4.1 && echo "webkit2gtk-4.1 OK" + pkg-config --exists libsoup-3.0 && echo "libsoup-3.0 OK" +' +``` + +Expected: both `OK` lines. This is the exact check +`desktop_webview_window`'s CMake performs, so a pass here means no fallback. + +- [ ] **Step 3: Record the result in the spec** + +Replace the first row of the spec's Risks table with the finding. If both +checks passed, write: + +```markdown +| ~~`libwebkit2gtk-4.1` may be unavailable on Ubuntu 22.04~~ | Verified 2026-09-03: `ubuntu:22.04` provides `libwebkit2gtk-4.1-dev` and `libsoup-3.0-dev`, and `pkg-config --exists webkit2gtk-4.1` succeeds, so no CMake fallback occurs at the chosen floor. | +``` + +If either check failed, **stop and escalate**: the floor decision needs +revisiting, and the remaining tasks assume it holds. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/specs/2026-09-03-linux-packaging-design.md +git commit -m "docs: verify webkit2gtk-4.1 availability at the Ubuntu 22.04 floor" +``` + +--- + +### Task 2: Move the Linux build into an ubuntu:22.04 container + +No packaging yet. This task changes only where the build runs, so a regression +here is unambiguous: the tarball it produces must be equivalent to today's. + +**Files:** +- Modify: `.github/workflows/build-all.yml:549-570` + +**Interfaces:** +- Produces: a `build/linux/x64/release/bundle/` built against glibc 2.35, which + every later task consumes. + +- [ ] **Step 1: Add the container and its prerequisites** + +Replace the job header and dependency step in `.github/workflows/build-all.yml`: + +```yaml + build-linux: + name: Build Linux + runs-on: ubuntu-latest + # The glibc floor is pinned by the container image, not the runner label: + # GitHub retires runner images on its own schedule, and a label-pinned + # floor would rise silently the day 22.04 is removed. glibc 2.35 covers + # Ubuntu 22.04+, Debian 12+, and every current Fedora and openSUSE. + container: ubuntu:22.04 + timeout-minutes: 40 + + steps: + - name: Install Linux dependencies + run: | + apt-get update -y + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + clang cmake ninja-build pkg-config \ + libgtk-3-dev liblzma-dev libstdc++-12-dev \ + libsqlite3-dev libsecret-1-dev libwebkit2gtk-4.1-dev \ + libssl-dev \ + curl git unzip xz-utils ca-certificates sudo file rpm + + # actions/checkout inside a container trips git's dubious-ownership + # check, which makes every later git command fail. + - name: Trust the workspace + run: git config --global --add safe.directory '*' + + - name: Checkout repository + uses: actions/checkout@v7 + with: + submodules: recursive + ref: ${{ inputs.ref }} +``` + +Note the removed `sudo` prefixes: the container runs as root. `timeout-minutes` +rises from 30 to 40 because the container installs its toolchain from scratch +on every run. + +- [ ] **Step 2: Push the branch and watch the job** + +```bash +git add .github/workflows/build-all.yml +git commit -m "ci: build Linux inside an ubuntu:22.04 container" +git push -u origin feat/linux-packaging +gh run watch "$(gh run list --branch feat/linux-packaging --limit 1 --json databaseId -q '.[0].databaseId')" +``` + +Expected: `Build Linux` succeeds and uploads `linux-tar`. + +- [ ] **Step 3: Verify the glibc floor actually moved** + +Download the artifact and read the binary's required glibc versions: + +```bash +gh run download "$(gh run list --branch feat/linux-packaging --limit 1 --json databaseId -q '.[0].databaseId')" -n linux-tar -D /tmp/lintar +mkdir -p /tmp/linbundle && tar xzf /tmp/lintar/Submersion-*-Linux.tar.gz -C /tmp/linbundle +docker run --rm -v /tmp/linbundle:/b ubuntu:22.04 bash -c \ + 'objdump -T /b/submersion 2>/dev/null | grep -o "GLIBC_[0-9.]*" | sort -Vu | tail -3' +``` + +Expected: the highest version is `GLIBC_2.35` or lower. A `GLIBC_2.38` or +similar means the container did not take effect. + +- [ ] **Step 4: Commit** + +Already committed in Step 2. If Step 3 required a fix, commit it now: + +```bash +git add .github/workflows/build-all.yml +git commit -m "ci: correct the Linux container build floor" +``` + +--- + +### Task 3: Add a `--version` flag to the Linux runner + +`linux/runner/main.cc` is the stock six-line Flutter runner. Without this, the +container smoke test in Task 9 cannot assert anything about the installed +binary without standing up a display server. + +There is deliberately no unit test for this: the repo's native test harness +(`packages/libdivecomputer_plugin/test/native/`) belongs to the plugin, not the +runner, and standing up a second CMake test target to cover an argv comparison +is not worth it. Task 9's smoke test is this change's test, in a real installed +package, which is a stronger assertion than a unit test would be. + +**Files:** +- Modify: `linux/runner/main.cc` + +**Interfaces:** +- Produces: `submersion --version` prints `submersion ` to stdout and + exits 0 without initializing GTK. Task 9 asserts on this exact format. + +- [ ] **Step 1: Write the implementation** + +Replace the whole of `linux/runner/main.cc`: + +```cpp +#include +#include + +#include "my_application.h" + +// Kept in sync with pubspec.yaml by the build; see linux/CMakeLists.txt. +#ifndef SUBMERSION_VERSION +#define SUBMERSION_VERSION "unknown" +#endif + +int main(int argc, char** argv) { + // Answered before GTK initializes, so packaging smoke tests can assert the + // installed version in a container with no display server. + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--version") == 0) { + printf("submersion %s\n", SUBMERSION_VERSION); + return 0; + } + } + + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} +``` + +- [ ] **Step 2: Feed the version in from the build** + +Append to `linux/CMakeLists.txt`, immediately after the +`add_dependencies(${BINARY_NAME} flutter_assemble)` line: + +```cmake +# Surface the marketing version to the runner's --version flag. FLUTTER_VERSION +# is set by Flutter's generated config from pubspec.yaml. +target_compile_definitions(${BINARY_NAME} + PRIVATE "SUBMERSION_VERSION=\"${FLUTTER_VERSION}\"") +``` + +- [ ] **Step 3: Build locally to verify it compiles** + +```bash +docker run --rm -v "$PWD":/src -w /src ubuntu:22.04 bash -c ' + apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ + clang cmake ninja-build pkg-config libgtk-3-dev > /dev/null + echo "toolchain ready" +' +``` + +Expected: `toolchain ready`. The full Flutter build is exercised in CI rather +than locally; the compile itself is verified by the next CI run. + +- [ ] **Step 4: Commit** + +```bash +git add linux/runner/main.cc linux/CMakeLists.txt +git commit -m "feat(linux): add a --version flag to the runner" +``` + +--- + +### Task 4: Generate udev rules from libdivecomputer's device tables + +Upstream's +`packages/libdivecomputer_plugin/third_party/libdivecomputer/contrib/udev/libdivecomputer.rules` +is 35 lines covering nine devices, every rule using `GROUP="plugdev"`, a group +Fedora does not create. Generating from the descriptor tables means a submodule +bump widens hardware support automatically. + +The tables live in +`packages/libdivecomputer_plugin/third_party/libdivecomputer/src/descriptor.c` +in exactly two shapes (lines 667, 699, 862): + +```c + static const dc_usbhid_desc_t usbhid[] = { + {0x2e6c, 0x3201}, // G2, G2 TEK + {0xc251, 0x2006}, // Aladin Square + }; + static const dc_usb_desc_t usb[] = { + {0x0471, 0x0888}, // Atomic Aquatics Cobalt + }; +``` + +**Files:** +- Create: `scripts/gen_udev_rules.py` +- Create: `scripts/gen_udev_rules_test.py` + +**Interfaces:** +- Produces: `python3 scripts/gen_udev_rules.py > `. + Task 6 calls it to place the file in the staging tree. + +- [ ] **Step 1: Write the failing test** + +Create `scripts/gen_udev_rules_test.py`: + +```python +#!/usr/bin/env python3 +"""Unit tests for gen_udev_rules.py. + +Run: python3 scripts/gen_udev_rules_test.py + +The regression this guards: libdivecomputer's descriptor tables are C source, +not a data file, so a submodule bump that reshapes them would make a naive +parser emit an empty rules file. An empty file installs cleanly and silently +leaves every USB dive computer unreachable, which is exactly the class of +failure check_bundled_native_assets.py exists to prevent for native libraries. +""" + +import importlib.util +import os +import tempfile +import unittest + +SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gen_udev_rules.py") +spec = importlib.util.spec_from_file_location("gen_udev_rules", SCRIPT) +gen = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gen) + +SAMPLE = """ +dc_filter_uwatec (const dc_descriptor_t *descriptor) +{ +\tstatic const dc_usbhid_desc_t usbhid[] = { +\t\t{0x2e6c, 0x3201}, // G2, G2 TEK +\t\t{0xc251, 0x2006}, // Aladin Square +\t}; +} + +dc_filter_atomic (const dc_descriptor_t *descriptor) +{ +\tstatic const dc_usb_desc_t usb[] = { +\t\t{0x0471, 0x0888}, // Atomic Aquatics Cobalt +\t}; +} +""" + + +class ParseDevicesTest(unittest.TestCase): + def test_extracts_usbhid_devices_with_comments(self): + devices = gen.parse_devices(SAMPLE) + self.assertIn(gen.Device("2e6c", "3201", "G2, G2 TEK", True), devices) + self.assertIn(gen.Device("c251", "2006", "Aladin Square", True), devices) + + def test_extracts_plain_usb_devices(self): + devices = gen.parse_devices(SAMPLE) + self.assertIn( + gen.Device("0471", "0888", "Atomic Aquatics Cobalt", False), devices + ) + + def test_finds_every_device_in_the_sample(self): + self.assertEqual(len(gen.parse_devices(SAMPLE)), 3) + + def test_empty_source_raises_rather_than_emitting_nothing(self): + with self.assertRaises(SystemExit): + gen.parse_devices("int main(void) { return 0; }") + + +class RenderTest(unittest.TestCase): + def test_hidraw_device_gets_hidraw_and_usb_rules(self): + text = gen.render([gen.Device("2e6c", "3201", "G2, G2 TEK", True)]) + self.assertIn( + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="2e6c", ' + 'ATTRS{idProduct}=="3201", TAG+="uaccess"', + text, + ) + self.assertIn( + 'SUBSYSTEM=="usb", ATTR{idVendor}=="2e6c", ' + 'ATTR{idProduct}=="3201", TAG+="uaccess"', + text, + ) + + def test_plain_usb_device_gets_no_hidraw_rule(self): + text = gen.render([gen.Device("0471", "0888", "Cobalt", False)]) + self.assertNotIn("hidraw", text) + + def test_never_uses_group_based_access(self): + text = gen.render([gen.Device("2e6c", "3201", "G2", True)]) + self.assertNotIn("plugdev", text) + self.assertNotIn("dialout", text) + + def test_includes_serial_bridge_rules(self): + text = gen.render([gen.Device("2e6c", "3201", "G2", True)]) + # FTDI, Prolific, and Silicon Labs bridges are how the serial dive + # computers reach /dev/ttyUSB*. + for vid in ("0403", "067b", "10c4"): + self.assertIn( + 'SUBSYSTEM=="tty", ATTRS{idVendor}=="%s", TAG+="uaccess"' % vid, + text, + ) + + def test_device_comment_is_preserved(self): + text = gen.render([gen.Device("2e6c", "3201", "G2, G2 TEK", True)]) + self.assertIn("# G2, G2 TEK", text) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 scripts/gen_udev_rules_test.py` +Expected: FAIL, `FileNotFoundError` or `ModuleNotFoundError` for +`gen_udev_rules.py`. + +- [ ] **Step 3: Write the implementation** + +Create `scripts/gen_udev_rules.py`: + +```python +#!/usr/bin/env python3 +"""Generate udev rules for the dive computers libdivecomputer supports. + +Upstream ships contrib/udev/libdivecomputer.rules, but it covers only a handful +of devices and every rule reads GROUP="plugdev", a group Fedora does not create. +This generator reads the VID/PID tables out of descriptor.c instead, so a +submodule bump widens hardware support without anyone editing a list, and emits +TAG+="uaccess" so systemd-logind grants access to the user on the active seat. +uaccess needs no group membership, no usermod, and no log out and back in, and +it behaves identically on Debian and Fedora. + +Usage: + gen_udev_rules.py > 60-submersion-divecomputers.rules +""" + +import collections +import re +import sys + +Device = collections.namedtuple("Device", "vid pid comment hid") + +# The two table shapes in descriptor.c. dc_usbhid_desc_t devices are opened +# through /dev/hidraw*; dc_usb_desc_t devices through the raw USB node. +_TABLE_RE = re.compile( + r"static\s+const\s+(dc_usbhid_desc_t|dc_usb_desc_t)\s+\w+\s*\[\]\s*=\s*\{(.*?)\};", + re.DOTALL, +) +_ENTRY_RE = re.compile( + r"\{\s*0x([0-9a-fA-F]{4})\s*,\s*0x([0-9a-fA-F]{4})\s*\}\s*,?\s*(?://\s*(.*))?" +) + +# USB-to-serial bridges used by the serial dive computers. These are not in +# descriptor.c because libdivecomputer addresses them by tty path, not by ID. +# Vendor-wide by necessity: the bridge chip carries the vendor's ID, and the +# dive computer behind it is invisible to udev. +SERIAL_BRIDGES = ( + ("0403", "FTDI (Oceanic, Aeris, Sherwood, Hollis)"), + ("067b", "Prolific PL2303 (Mares, Cressi)"), + ("10c4", "Silicon Labs CP210x (Suunto, Uwatec)"), +) + +HEADER = """# Submersion dive computer access rules. +# +# Generated by scripts/gen_udev_rules.py from libdivecomputer's descriptor.c. +# Do not edit by hand; edit the generator or bump the submodule. +# +# TAG+="uaccess" hands access to the user on the active seat via +# systemd-logind. No group membership is required, and access follows the +# seat, so it is revoked on fast user switching. +""" + + +def parse_devices(source): + """Extract every VID/PID pair from descriptor.c's device tables.""" + devices = [] + for kind, body in _TABLE_RE.findall(source): + hid = kind == "dc_usbhid_desc_t" + for vid, pid, comment in _ENTRY_RE.findall(body): + devices.append( + Device(vid.lower(), pid.lower(), (comment or "").strip(), hid) + ) + if not devices: + sys.exit( + "gen_udev_rules: no device tables found. descriptor.c's shape " + "changed; update _TABLE_RE and _ENTRY_RE rather than shipping an " + "empty rules file." + ) + return devices + + +def render(devices): + """Render the rules file for the given devices.""" + lines = [HEADER] + for device in devices: + if device.comment: + lines.append("# %s" % device.comment) + if device.hid: + lines.append( + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="%s", ' + 'ATTRS{idProduct}=="%s", TAG+="uaccess"' % (device.vid, device.pid) + ) + lines.append( + 'SUBSYSTEM=="usb", ATTR{idVendor}=="%s", ' + 'ATTR{idProduct}=="%s", TAG+="uaccess"' % (device.vid, device.pid) + ) + lines.append("") + + lines.append("# USB-to-serial bridges (/dev/ttyUSB*)") + for vid, comment in SERIAL_BRIDGES: + lines.append("# %s" % comment) + lines.append( + 'SUBSYSTEM=="tty", ATTRS{idVendor}=="%s", TAG+="uaccess"' % vid + ) + lines.append("") + return "\n".join(lines) + + +def main(argv): + if len(argv) != 2: + sys.exit("usage: gen_udev_rules.py ") + with open(argv[1], "r", encoding="utf-8") as handle: + source = handle.read() + sys.stdout.write(render(parse_devices(source))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 scripts/gen_udev_rules_test.py` +Expected: `OK`, 10 tests. + +- [ ] **Step 5: Run it against the real descriptor.c** + +```bash +python3 scripts/gen_udev_rules.py \ + packages/libdivecomputer_plugin/third_party/libdivecomputer/src/descriptor.c +``` + +Expected: rules for 9 devices (4 Uwatec/Scubapro hidraw, 4 Suunto hidraw, 1 +Atomic Aquatics usb) plus the 3 serial bridge rules. Confirm no `plugdev` +appears anywhere in the output. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/gen_udev_rules.py scripts/gen_udev_rules_test.py +git commit -m "feat(linux): generate udev rules from libdivecomputer's device tables" +``` + +--- + +### Task 5: Derive package dependencies from the built binary + +Hand-writing the dependency list would let a CMake fallback ship a package that +declares `webkit2gtk-4.1` while linking `4.0`. Reading `DT_NEEDED` from the +artifact makes that impossible. + +The script separates the two concerns so the mapping is testable without an ELF +fixture: `read_needed()` shells out to `readelf`, and `map_sonames()` is pure. + +**Files:** +- Create: `scripts/data/linux_soname_map.json` +- Create: `scripts/linux_package_deps.py` +- Create: `scripts/linux_package_deps_test.py` + +**Interfaces:** +- Consumes: a bundle directory from Task 2. +- Produces: `python3 scripts/linux_package_deps.py --format deb|rpm|json`. + `deb` and `rpm` print one dependency token per line; `json` prints the full + mapping. Task 7 consumes `deb` and `rpm`; Task 10 ships the JSON map. + +- [ ] **Step 1: Write the soname map** + +Create `scripts/data/linux_soname_map.json`: + +```json +{ + "_comment": "soname to package name, per package manager. rpm entries are soname provides, which every RPM distro exposes identically: Fedora names the package gtk3 and openSUSE names it libgtk-3-0, but both provide libgtk-3.so.0()(64bit). The pacman and zypper columns are used only by the tarball's install.sh preflight.", + "libgtk-3.so.0": {"apt": "libgtk-3-0", "rpm": "libgtk-3.so.0()(64bit)", "pacman": "gtk3", "zypper": "gtk3"}, + "libgdk-3.so.0": {"apt": "libgtk-3-0", "rpm": "libgdk-3.so.0()(64bit)", "pacman": "gtk3", "zypper": "gtk3"}, + "libwebkit2gtk-4.1.so.0": {"apt": "libwebkit2gtk-4.1-0", "rpm": "libwebkit2gtk-4.1.so.0()(64bit)", "pacman": "webkit2gtk-4.1", "zypper": "webkit2gtk-4_1"}, + "libjavascriptcoregtk-4.1.so.0": {"apt": "libjavascriptcoregtk-4.1-0", "rpm": "libjavascriptcoregtk-4.1.so.0()(64bit)", "pacman": "webkit2gtk-4.1", "zypper": "webkit2gtk-4_1"}, + "libsoup-3.0.so.0": {"apt": "libsoup-3.0-0", "rpm": "libsoup-3.0.so.0()(64bit)", "pacman": "libsoup3", "zypper": "libsoup-3_0-0"}, + "libsecret-1.so.0": {"apt": "libsecret-1-0", "rpm": "libsecret-1.so.0()(64bit)", "pacman": "libsecret", "zypper": "libsecret-1-0"}, + "libglib-2.0.so.0": {"apt": "libglib2.0-0", "rpm": "libglib-2.0.so.0()(64bit)", "pacman": "glib2", "zypper": "libglib-2_0-0"}, + "libgobject-2.0.so.0": {"apt": "libglib2.0-0", "rpm": "libgobject-2.0.so.0()(64bit)", "pacman": "glib2", "zypper": "libgobject-2_0-0"}, + "libgio-2.0.so.0": {"apt": "libglib2.0-0", "rpm": "libgio-2.0.so.0()(64bit)", "pacman": "glib2", "zypper": "libgio-2_0-0"}, + "libcairo.so.2": {"apt": "libcairo2", "rpm": "libcairo.so.2()(64bit)", "pacman": "cairo", "zypper": "libcairo2"}, + "libpango-1.0.so.0": {"apt": "libpango-1.0-0", "rpm": "libpango-1.0.so.0()(64bit)", "pacman": "pango", "zypper": "libpango-1_0-0"}, + "liblzma.so.5": {"apt": "liblzma5", "rpm": "liblzma.so.5()(64bit)", "pacman": "xz", "zypper": "liblzma5"}, + "libstdc++.so.6": {"apt": "libstdc++6", "rpm": "libstdc++.so.6()(64bit)", "pacman": "gcc-libs", "zypper": "libstdc++6"}, + "libgcc_s.so.1": {"apt": "libgcc-s1", "rpm": "libgcc_s.so.1()(64bit)", "pacman": "gcc-libs", "zypper": "libgcc_s1"}, + "libm.so.6": {"apt": "libc6", "rpm": "libm.so.6()(64bit)", "pacman": "glibc", "zypper": "glibc"}, + "libc.so.6": {"apt": "libc6", "rpm": "libc.so.6()(64bit)", "pacman": "glibc", "zypper": "glibc"}, + "libpthread.so.0": {"apt": "libc6", "rpm": "libpthread.so.0()(64bit)", "pacman": "glibc", "zypper": "glibc"}, + "libdl.so.2": {"apt": "libc6", "rpm": "libdl.so.2()(64bit)", "pacman": "glibc", "zypper": "glibc"} +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `scripts/linux_package_deps_test.py`: + +```python +#!/usr/bin/env python3 +"""Unit tests for linux_package_deps.py. + +Run: python3 scripts/linux_package_deps_test.py + +The regression this guards: desktop_webview_window's CMake silently falls back +from webkit2gtk-4.1 to 4.0 when 4.1 is absent at build time, which changes what +the shipped binary needs without failing the build. A hand-written dependency +list would then be wrong in a way nothing detects until a user's package +manager installs the wrong library. Deriving the list from DT_NEEDED and +failing on any unmapped soname makes that impossible to ship. +""" + +import importlib.util +import json +import os +import tempfile +import unittest + +SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "linux_package_deps.py") +spec = importlib.util.spec_from_file_location("linux_package_deps", SCRIPT) +deps = importlib.util.module_from_spec(spec) +spec.loader.exec_module(deps) + +SONAME_MAP = { + "libgtk-3.so.0": {"apt": "libgtk-3-0", "rpm": "libgtk-3.so.0()(64bit)", + "pacman": "gtk3", "zypper": "gtk3"}, + "libc.so.6": {"apt": "libc6", "rpm": "libc.so.6()(64bit)", + "pacman": "glibc", "zypper": "glibc"}, + "libglib-2.0.so.0": {"apt": "libglib2.0-0", "rpm": "libglib-2.0.so.0()(64bit)", + "pacman": "glib2", "zypper": "libglib-2_0-0"}, +} + + +class MapSonamesTest(unittest.TestCase): + def test_maps_to_apt_names(self): + result = deps.map_sonames({"libgtk-3.so.0", "libc.so.6"}, SONAME_MAP, "apt") + self.assertEqual(result, ["libc6", "libgtk-3-0"]) + + def test_maps_to_rpm_soname_provides(self): + result = deps.map_sonames({"libgtk-3.so.0"}, SONAME_MAP, "rpm") + self.assertEqual(result, ["libgtk-3.so.0()(64bit)"]) + + def test_deduplicates_shared_packages(self): + # Several glib sonames map to one apt package; the list must not repeat it. + result = deps.map_sonames( + {"libglib-2.0.so.0", "libc.so.6"}, SONAME_MAP, "apt" + ) + self.assertEqual(result, ["libc6", "libglib2.0-0"]) + + def test_output_is_sorted_for_reproducibility(self): + result = deps.map_sonames( + {"libgtk-3.so.0", "libc.so.6", "libglib-2.0.so.0"}, SONAME_MAP, "apt" + ) + self.assertEqual(result, sorted(result)) + + def test_unmapped_soname_is_a_hard_failure(self): + with self.assertRaises(SystemExit) as caught: + deps.map_sonames({"libwebkit2gtk-4.0.so.37"}, SONAME_MAP, "apt") + self.assertIn("libwebkit2gtk-4.0.so.37", str(caught.exception)) + + +class BundledFilteringTest(unittest.TestCase): + def test_bundled_libraries_are_not_dependencies(self): + with tempfile.TemporaryDirectory() as root: + os.makedirs(os.path.join(root, "lib")) + for name in ("libsqlcipher.so", "libdivecomputer.so.0"): + open(os.path.join(root, "lib", name), "w").close() + bundled = deps.bundled_sonames(root) + self.assertEqual(bundled, {"libsqlcipher.so", "libdivecomputer.so.0"}) + + def test_missing_lib_dir_yields_no_bundled_names(self): + with tempfile.TemporaryDirectory() as root: + self.assertEqual(deps.bundled_sonames(root), set()) + + +class LoadMapTest(unittest.TestCase): + def test_comment_key_is_ignored(self): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: + json.dump({"_comment": "notes", "libc.so.6": SONAME_MAP["libc.so.6"]}, handle) + path = handle.name + try: + loaded = deps.load_map(path) + self.assertNotIn("_comment", loaded) + self.assertIn("libc.so.6", loaded) + finally: + os.unlink(path) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `python3 scripts/linux_package_deps_test.py` +Expected: FAIL, module not found. + +- [ ] **Step 4: Write the implementation** + +Create `scripts/linux_package_deps.py`: + +```python +#!/usr/bin/env python3 +"""Derive a Linux package's runtime dependencies from a built bundle. + +Hand-written dependency lists go stale silently. desktop_webview_window's CMake +falls back from webkit2gtk-4.1 to 4.0 when 4.1 is missing at build time +(desktop_webview_window/linux/CMakeLists.txt), which changes what the binary +needs without failing the build, so a declared list can be wrong in a way +nothing catches until a user's package manager installs the wrong library. + +This reads DT_NEEDED out of the built artifacts instead, subtracts the +libraries shipped inside the package, and maps what remains through +scripts/data/linux_soname_map.json. An unmapped soname is a hard failure: the +alternative is a package that installs cleanly and then fails to launch, which +is the same shape as issue #1129 (see check_bundled_native_assets.py). + +RPM dependencies are soname provides rather than package names, because Fedora +calls the package gtk3 while openSUSE calls it libgtk-3-0, and both provide +libgtk-3.so.0()(64bit). + +Usage: + linux_package_deps.py --format {deb,rpm,json} +""" + +import argparse +import json +import os +import re +import subprocess +import sys + +DEFAULT_MAP = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "linux_soname_map.json" +) + +_NEEDED_RE = re.compile(r"\(NEEDED\).*\[(.+?)\]") + +_FORMAT_COLUMN = {"deb": "apt", "rpm": "rpm"} + + +def load_map(path): + """Load the soname map, dropping documentation keys.""" + with open(path, "r", encoding="utf-8") as handle: + raw = json.load(handle) + return {k: v for k, v in raw.items() if not k.startswith("_")} + + +def bundled_sonames(bundle_root): + """Names of the shared libraries shipped inside the bundle's lib/.""" + lib_dir = os.path.join(bundle_root, "lib") + if not os.path.isdir(lib_dir): + return set() + return {name for name in os.listdir(lib_dir) if ".so" in name} + + +def read_needed(path): + """DT_NEEDED entries of one ELF file, via readelf.""" + try: + output = subprocess.run( + ["readelf", "-d", path], + check=True, + capture_output=True, + text=True, + ).stdout + except FileNotFoundError: + sys.exit( + "linux_package_deps: readelf not found. Install binutils in the " + "build container." + ) + except subprocess.CalledProcessError as error: + sys.exit("linux_package_deps: readelf failed on %s: %s" % (path, error)) + return set(_NEEDED_RE.findall(output)) + + +def collect_sonames(bundle_root): + """Every DT_NEEDED entry across the bundle, minus what the bundle ships.""" + targets = [os.path.join(bundle_root, "submersion")] + lib_dir = os.path.join(bundle_root, "lib") + if os.path.isdir(lib_dir): + targets.extend( + os.path.join(lib_dir, name) + for name in sorted(os.listdir(lib_dir)) + if ".so" in name + ) + + needed = set() + for target in targets: + if os.path.isfile(target): + needed |= read_needed(target) + return needed - bundled_sonames(bundle_root) + + +def map_sonames(sonames, soname_map, column): + """Map sonames to dependency tokens, sorted and deduplicated.""" + unmapped = sorted(name for name in sonames if name not in soname_map) + if unmapped: + sys.exit( + "linux_package_deps: unmapped soname(s): %s\n" + "Add them to scripts/data/linux_soname_map.json. An unmapped " + "library means the package would install without declaring " + "something it needs." % ", ".join(unmapped) + ) + return sorted({soname_map[name][column] for name in sonames}) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bundle_root") + parser.add_argument("--format", choices=("deb", "rpm", "json"), required=True) + parser.add_argument("--map", default=DEFAULT_MAP) + args = parser.parse_args(argv) + + soname_map = load_map(args.map) + sonames = collect_sonames(args.bundle_root) + + if args.format == "json": + json.dump( + {name: soname_map[name] for name in map_sonames_keys(sonames, soname_map)}, + sys.stdout, + indent=2, + sort_keys=True, + ) + sys.stdout.write("\n") + return 0 + + for token in map_sonames(sonames, soname_map, _FORMAT_COLUMN[args.format]): + print(token) + return 0 + + +def map_sonames_keys(sonames, soname_map): + """Validated soname list, for the json format.""" + map_sonames(sonames, soname_map, "apt") + return sorted(sonames) + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `python3 scripts/linux_package_deps_test.py` +Expected: `OK`, 9 tests. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/linux_package_deps.py scripts/linux_package_deps_test.py scripts/data/linux_soname_map.json +git commit -m "feat(linux): derive package dependencies from DT_NEEDED" +``` + +--- + +### Task 6: Stage the canonical install tree + +One tree, two package formats, so the `.deb` and `.rpm` are provably the same +binary. + +**Files:** +- Create: `scripts/release/stage_linux_package.py` +- Create: `scripts/release/stage_linux_package_test.py` + +**Interfaces:** +- Consumes: `gen_udev_rules.py` from Task 4. +- Produces: + `python3 scripts/release/stage_linux_package.py --version --install-method ` + creates the tree Task 7 hands to fpm. + +- [ ] **Step 1: Write the failing test** + +Create `scripts/release/stage_linux_package_test.py`: + +```python +#!/usr/bin/env python3 +"""Unit tests for stage_linux_package.py. + +Run: python3 scripts/release/stage_linux_package_test.py + +The tree this builds is the only thing standing between a working install and +a 40 MB archive of loose files: it is where the .desktop entry, the icons, the +AppStream metadata, the udev rules, and the install-method marker come from. +Every assertion here is something a user would otherwise discover as a missing +menu entry or an unreachable dive computer. +""" + +import importlib.util +import os +import tempfile +import unittest + +SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stage_linux_package.py") +spec = importlib.util.spec_from_file_location("stage_linux_package", SCRIPT) +stage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(stage) + + +def make_bundle(root): + """A minimal stand-in for build/linux/x64/release/bundle.""" + os.makedirs(os.path.join(root, "lib")) + os.makedirs(os.path.join(root, "data", "flutter_assets")) + with open(os.path.join(root, "submersion"), "w") as handle: + handle.write("binary") + with open(os.path.join(root, "lib", "libsqlcipher.so"), "w") as handle: + handle.write("lib") + return root + + +class TreeLayoutTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.bundle = make_bundle(os.path.join(self._tmp.name, "bundle")) + self.staging = os.path.join(self._tmp.name, "staging") + stage.build_tree( + self.bundle, self.staging, version="1.7.7.7180", install_method="deb" + ) + + def tearDown(self): + self._tmp.cleanup() + + def _path(self, *parts): + return os.path.join(self.staging, *parts) + + def test_bundle_is_copied_verbatim(self): + self.assertTrue(os.path.isfile(self._path("usr/lib/submersion/submersion"))) + self.assertTrue( + os.path.isfile(self._path("usr/lib/submersion/lib/libsqlcipher.so")) + ) + + def test_bundle_relative_layout_is_preserved_for_the_rpath(self): + # The runner finds its native assets through $ORIGIN/lib + # (linux/CMakeLists.txt:17); flattening the tree would break every + # bundled library at launch. + binary = self._path("usr/lib/submersion/submersion") + sibling_lib = self._path("usr/lib/submersion/lib") + self.assertEqual(os.path.dirname(binary), os.path.dirname(sibling_lib)) + + def test_wrapper_is_executable_and_execs_the_real_binary(self): + wrapper = self._path("usr/bin/submersion") + self.assertTrue(os.access(wrapper, os.X_OK)) + with open(wrapper) as handle: + self.assertIn('exec /usr/lib/submersion/submersion "$@"', handle.read()) + + def test_desktop_file_binds_the_window_to_its_icon(self): + with open(self._path("usr/share/applications/app.submersion.desktop")) as h: + text = h.read() + self.assertIn("StartupWMClass=submersion", text) + self.assertIn("Exec=submersion %U", text) + self.assertIn("Icon=app.submersion", text) + self.assertIn("Categories=Science;Education;Utility;", text) + + def test_icons_are_installed_at_every_hicolor_size(self): + for size in (16, 32, 48, 64, 128, 256, 512): + self.assertTrue( + os.path.isfile( + self._path( + "usr/share/icons/hicolor/%dx%d/apps/app.submersion.png" + % (size, size) + ) + ), + "missing %dx%d icon" % (size, size), + ) + + def test_appstream_metadata_carries_id_name_and_version(self): + with open(self._path("usr/share/metainfo/app.submersion.metainfo.xml")) as h: + text = h.read() + self.assertIn("app.submersion", text) + self.assertIn("Submersion", text) + self.assertIn('version="1.7.7.7180"', text) + + def test_udev_rules_are_installed_and_use_uaccess(self): + path = self._path("usr/lib/udev/rules.d/60-submersion-divecomputers.rules") + self.assertTrue(os.path.isfile(path)) + with open(path) as handle: + text = handle.read() + self.assertIn('TAG+="uaccess"', text) + self.assertNotIn("plugdev", text) + + def test_install_method_marker_records_the_format(self): + with open(self._path("usr/lib/submersion/INSTALL_METHOD")) as handle: + self.assertEqual(handle.read().strip(), "deb") + + +class InstallMethodTest(unittest.TestCase): + def test_rpm_marker_says_rpm(self): + with tempfile.TemporaryDirectory() as tmp: + bundle = make_bundle(os.path.join(tmp, "bundle")) + staging = os.path.join(tmp, "staging") + stage.build_tree(bundle, staging, version="1.0.0.1", install_method="rpm") + with open(os.path.join(staging, "usr/lib/submersion/INSTALL_METHOD")) as h: + self.assertEqual(h.read().strip(), "rpm") + + def test_unknown_install_method_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + bundle = make_bundle(os.path.join(tmp, "bundle")) + with self.assertRaises(SystemExit): + stage.build_tree( + bundle, os.path.join(tmp, "s"), version="1.0.0.1", + install_method="snap", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 scripts/release/stage_linux_package_test.py` +Expected: FAIL, module not found. + +- [ ] **Step 3: Write the implementation** + +Create `scripts/release/stage_linux_package.py`. Icon downscaling uses Pillow, +which `scripts/requirements.txt` already provides for `generate_icon.py`; when +Pillow is absent the script copies the master icon to every size rather than +failing, so the tree is always complete. + +```python +#!/usr/bin/env python3 +"""Build the canonical Linux install tree from a Flutter bundle. + +Both the .deb and the .rpm are emitted from this one tree, so the two packages +are provably the same binary rather than two builds that happen to agree. + +The bundle is copied verbatim into /usr/lib/submersion because the runner +resolves its native assets through an $ORIGIN/lib rpath (linux/CMakeLists.txt); +preserving the relative layout is what lets the packaged copy launch at all. + +Usage: + stage_linux_package.py \\ + --version 1.7.7.7180 --install-method deb +""" + +import argparse +import os +import shutil +import subprocess +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +MASTER_ICON = os.path.join(REPO_ROOT, "assets", "icon", "icon.png") +DESCRIPTOR_C = os.path.join( + REPO_ROOT, "packages", "libdivecomputer_plugin", "third_party", + "libdivecomputer", "src", "descriptor.c", +) +GEN_UDEV = os.path.join(REPO_ROOT, "scripts", "gen_udev_rules.py") + +ICON_SIZES = (16, 32, 48, 64, 128, 256, 512) +INSTALL_METHODS = ("deb", "rpm") + +DESKTOP_ENTRY = """[Desktop Entry] +Type=Application +Name=Submersion +GenericName=Dive Log +Comment=An open-source dive logging application for scuba divers. +Exec=submersion %U +Icon=app.submersion +Terminal=false +Categories=Science;Education;Utility; +Keywords=dive;diving;scuba;divelog;logbook; +StartupWMClass=submersion +""" + +WRAPPER = """#!/bin/sh +# Submersion launcher. The real binary lives beside its data/ and lib/ +# directories because it resolves native assets through an $ORIGIN/lib rpath. +exec /usr/lib/submersion/submersion "$@" +""" + +METAINFO = """ + + app.submersion + Submersion + Dive logging for scuba divers + CC0-1.0 + GPL-3.0 + +

+ Submersion is an open-source dive logging application. It downloads dives + from dive computers, tracks gear and dive sites, and shows decompression + and gas analytics. +

+
+ app.submersion.desktop + https://submersion.app + https://github.com/submersion-app/submersion/issues + + + +
+""" + + +def _write(path, text, mode=0o644): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + os.chmod(path, mode) + + +def _stage_icons(staging): + try: + from PIL import Image + except ImportError: + Image = None + + for size in ICON_SIZES: + target = os.path.join( + staging, "usr/share/icons/hicolor/%dx%d/apps/app.submersion.png" + % (size, size) + ) + os.makedirs(os.path.dirname(target), exist_ok=True) + if Image is None: + shutil.copyfile(MASTER_ICON, target) + continue + with Image.open(MASTER_ICON) as image: + image.convert("RGBA").resize((size, size), Image.LANCZOS).save(target) + + +def _stage_udev_rules(staging): + target = os.path.join( + staging, "usr/lib/udev/rules.d/60-submersion-divecomputers.rules" + ) + os.makedirs(os.path.dirname(target), exist_ok=True) + rules = subprocess.run( + [sys.executable, GEN_UDEV, DESCRIPTOR_C], + check=True, capture_output=True, text=True, + ).stdout + _write(target, rules) + + +def build_tree(bundle_root, staging, version, install_method, date="2026-09-03"): + """Create the full install tree under `staging`.""" + if install_method not in INSTALL_METHODS: + sys.exit( + "stage_linux_package: unknown install method %r (expected one of %s)" + % (install_method, ", ".join(INSTALL_METHODS)) + ) + + app_dir = os.path.join(staging, "usr/lib/submersion") + if os.path.exists(staging): + shutil.rmtree(staging) + shutil.copytree(bundle_root, app_dir) + + _write(os.path.join(staging, "usr/bin/submersion"), WRAPPER, mode=0o755) + _write( + os.path.join(staging, "usr/share/applications/app.submersion.desktop"), + DESKTOP_ENTRY, + ) + _write( + os.path.join(staging, "usr/share/metainfo/app.submersion.metainfo.xml"), + METAINFO.format(version=version, date=date), + ) + _write(os.path.join(app_dir, "INSTALL_METHOD"), install_method + "\n") + _stage_icons(staging) + _stage_udev_rules(staging) + return staging + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bundle_root") + parser.add_argument("staging") + parser.add_argument("--version", required=True) + parser.add_argument("--install-method", required=True, choices=INSTALL_METHODS) + args = parser.parse_args(argv) + build_tree(args.bundle_root, args.staging, args.version, args.install_method) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 scripts/release/stage_linux_package_test.py` +Expected: `OK`, 10 tests. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/release/stage_linux_package.py scripts/release/stage_linux_package_test.py +git commit -m "feat(linux): stage the canonical package install tree" +``` + +--- + +### Task 7: Emit the .deb and .rpm in CI + +**Files:** +- Modify: `.github/workflows/build-all.yml` (after the existing + `Check bundled native assets` step) + +**Interfaces:** +- Consumes: Tasks 4, 5, and 6. +- Produces: artifact `linux-packages` containing the tarball, the `.deb`, and + the `.rpm`. + +- [ ] **Step 1: Add the packaging steps** + +Insert after the `Check bundled native assets` step and before `Create tarball`: + +```yaml + - name: Install packaging tools + run: | + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ruby ruby-dev build-essential binutils rpm python3-pil + gem install --no-document fpm + + - name: Stage package trees + env: + TAG_NAME: ${{ inputs.version-tag }} + run: | + set -euo pipefail + VERSION="${TAG_NAME#v}" + echo "PKG_VERSION=$VERSION" >> "$GITHUB_ENV" + for method in deb rpm; do + python3 scripts/release/stage_linux_package.py \ + build/linux/x64/release/bundle \ + "staging-$method" \ + --version "$VERSION" \ + --install-method "$method" + done + + - name: Derive runtime dependencies + run: | + set -euo pipefail + python3 scripts/linux_package_deps.py \ + build/linux/x64/release/bundle --format deb > deps-deb.txt + python3 scripts/linux_package_deps.py \ + build/linux/x64/release/bundle --format rpm > deps-rpm.txt + echo "--- deb dependencies ---"; cat deps-deb.txt + echo "--- rpm dependencies ---"; cat deps-rpm.txt + + - name: Build .deb and .rpm + env: + TAG_NAME: ${{ inputs.version-tag }} + run: | + set -euo pipefail + common_args=( + --name submersion + --version "$PKG_VERSION" + --license GPL-3.0 + --maintainer "Submersion " + --url "https://submersion.app" + --description "An open-source dive logging application for scuba divers." + --input-type dir + ) + + # ffmpeg and gnome-keyring are weak dependencies on purpose. ffmpeg + # is absent from stock Fedora (it lives in RPM Fusion), so a hard + # requirement would make the package uninstallable there, and the app + # already degrades gracefully without it. gnome-keyring supplies the + # Secret Service that flutter_secure_storage_linux needs, which is + # present on GNOME and KDE but not on minimal window managers. + deb_depends=() + while read -r dep; do deb_depends+=(--depends "$dep"); done < deps-deb.txt + fpm "${common_args[@]}" \ + --output-type deb \ + --architecture amd64 \ + "${deb_depends[@]}" \ + --deb-recommends ffmpeg \ + --deb-recommends gnome-keyring \ + --package "Submersion-${TAG_NAME}-Linux-amd64.deb" \ + -C staging-deb usr + + rpm_depends=() + while read -r dep; do rpm_depends+=(--depends "$dep"); done < deps-rpm.txt + fpm "${common_args[@]}" \ + --output-type rpm \ + --architecture x86_64 \ + "${rpm_depends[@]}" \ + --rpm-tag "Recommends: ffmpeg" \ + --rpm-tag "Recommends: gnome-keyring" \ + --package "Submersion-${TAG_NAME}-Linux-x86_64.rpm" \ + -C staging-rpm usr +``` + +- [ ] **Step 2: Widen the upload to carry all three artifacts** + +Replace the `Upload Linux artifact` step: + +```yaml + - name: Upload Linux artifacts + uses: actions/upload-artifact@v7 + with: + name: linux-packages + path: | + Submersion-*.tar.gz + Submersion-*.deb + Submersion-*.rpm + retention-days: 5 +``` + +- [ ] **Step 3: Push and verify the packages build** + +```bash +git add .github/workflows/build-all.yml +git commit -m "ci(linux): emit .deb and .rpm packages" +git push +gh run watch "$(gh run list --branch feat/linux-packaging --limit 1 --json databaseId -q '.[0].databaseId')" +``` + +Expected: the run succeeds, the `Derive runtime dependencies` step logs a +dependency list that includes `libgtk-3-0` and `libwebkit2gtk-4.1-0`, and the +`linux-packages` artifact holds three files. + +- [ ] **Step 4: Inspect the built .deb locally** + +```bash +gh run download "$(gh run list --branch feat/linux-packaging --limit 1 --json databaseId -q '.[0].databaseId')" -n linux-packages -D /tmp/linpkg +docker run --rm -v /tmp/linpkg:/p debian:12 bash -c 'dpkg -I /p/Submersion-*.deb' +``` + +Expected: the control block lists `Depends:` with the derived libraries, +`Recommends: ffmpeg, gnome-keyring`, and version without a leading `v`. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add .github/workflows/build-all.yml +git commit -m "ci(linux): correct package metadata" +``` + +--- + +### Task 8: Wire the new assets through the release workflows + +Without this, a packaging failure produces a release that is missing packages +and nothing notices. + +**Files:** +- Modify: `.github/workflows/release.yml:434` + +**Interfaces:** +- Consumes: the artifact names from Task 7. + +- [ ] **Step 1: Add the packages to the expected-asset list** + +In `.github/workflows/release.yml`, extend the `EXPECTED` array: + +```bash + EXPECTED=( + "Submersion-${TAG_NAME}-macOS.dmg" + "Submersion-${TAG_NAME}-Windows-Setup.exe" + "Submersion-${TAG_NAME}-Linux.tar.gz" + "Submersion-${TAG_NAME}-Linux-amd64.deb" + "Submersion-${TAG_NAME}-Linux-x86_64.rpm" + "Submersion-${TAG_NAME}-Android.apk" + "Submersion-${TAG_NAME}-Android.aab" + "checksums-sha256.txt" + ) +``` + +- [ ] **Step 2: Confirm the checksum globs already cover the new files** + +The `Submersion-*` globs at `release.yml:335` and `release.yml:388`, the +`softprops/action-gh-release` `files:` glob at `release.yml:404`, and +`beta.yml:230`'s `Submersion*` all match the new capital-S asset names. Verify +by inspection, then confirm with: + +```bash +grep -n "Submersion-\*\|Submersion\*" .github/workflows/release.yml .github/workflows/beta.yml +``` + +Expected: every match is a glob, not an explicit per-file list. No changes +needed. If any explicit list appears, add both new files to it. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: require the Linux packages in release validation" +``` + +--- + +### Task 9: Smoke-test the packages in clean containers + +An unsatisfiable dependency must be a test failure, not a user's bug report. + +**Files:** +- Modify: `.github/workflows/build-all.yml` (new job) + +**Interfaces:** +- Consumes: the `linux-packages` artifact from Task 7 and the `--version` flag + from Task 3. + +- [ ] **Step 1: Add the job** + +Append to `.github/workflows/build-all.yml`, after `build-linux`: + +```yaml + # ============================================================================ + # Verify Linux Packages + # ============================================================================ + verify-linux-packages: + name: Verify Linux Packages + needs: build-linux + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - image: debian:12 + format: deb + - image: fedora:latest + format: rpm + + steps: + - name: Download Linux packages + uses: actions/download-artifact@v8 + with: + name: linux-packages + path: packages + + # Installing through the real package manager is the point: it proves + # every declared dependency is resolvable on a stock system, which is + # exactly what the tar.gz could never demonstrate. + - name: Install and run in a clean ${{ matrix.image }} + run: | + set -euo pipefail + docker run --rm -v "$PWD/packages:/p" ${{ matrix.image }} bash -c ' + set -eux + if command -v apt-get > /dev/null; then + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y /p/Submersion-*.deb + else + dnf install -y /p/Submersion-*.rpm + fi + + # The binary answers --version before GTK initializes, so this + # needs no display server. + /usr/bin/submersion --version + + test -f /usr/share/applications/app.submersion.desktop + test -f /usr/share/metainfo/app.submersion.metainfo.xml + test -f /usr/lib/udev/rules.d/60-submersion-divecomputers.rules + test -f /usr/share/icons/hicolor/256x256/apps/app.submersion.png + + # A GROUP-based rule would silently fail on Fedora, which has no + # plugdev group. + grep -q "uaccess" /usr/lib/udev/rules.d/60-submersion-divecomputers.rules + ! grep -q "plugdev" /usr/lib/udev/rules.d/60-submersion-divecomputers.rules + + # No unresolved shared libraries after a real dependency install. + ldd /usr/lib/submersion/submersion | grep "not found" && exit 1 + echo "package verified" + ' +``` + +- [ ] **Step 2: Push and verify both matrix legs pass** + +```bash +git add .github/workflows/build-all.yml +git commit -m "ci(linux): smoke-test packages in clean debian and fedora containers" +git push +gh run watch "$(gh run list --branch feat/linux-packaging --limit 1 --json databaseId -q '.[0].databaseId')" +``` + +Expected: both legs print `package verified`. A failure in the `ldd` check +means the soname map is missing an entry; add it to +`scripts/data/linux_soname_map.json` and re-run. + +- [ ] **Step 3: Commit any soname map additions** + +```bash +git add scripts/data/linux_soname_map.json +git commit -m "fix(linux): map sonames surfaced by the container smoke test" +``` + +--- + +### Task 10: Give the tarball an installer and a dependency preflight + +The tarball stays the path for Arch, NixOS, and anyone who will not use a +package manager. Today it turns a missing library into a loader error; this +turns it into the exact command to fix it. + +**Files:** +- Create: `scripts/release/linux_tarball_extras/install.sh` +- Create: `scripts/release/linux_tarball_extras/uninstall.sh` +- Create: `scripts/release/install_sh_test.sh` +- Modify: `.github/workflows/build-all.yml` (`Create tarball` step) + +**Interfaces:** +- Consumes: `scripts/data/linux_soname_map.json` from Task 5, shipped in the + tarball root as `deps.json`. + +- [ ] **Step 1: Write the failing test** + +Create `scripts/release/install_sh_test.sh`: + +```bash +#!/usr/bin/env bash +# Tests for the tarball installer's pure functions. +# +# Run: bash scripts/release/install_sh_test.sh +# +# The installer's job is to turn "error while loading shared libraries: +# libwebkit2gtk-4.1.so.0" into "sudo apt install libwebkit2gtk-4.1-0". These +# tests cover the mapping and the package-manager detection, which is where +# that translation lives. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER="$SCRIPT_DIR/linux_tarball_extras/install.sh" +FAILURES=0 + +assert_eq() { + if [ "$1" != "$2" ]; then + echo "FAIL: $3" + echo " expected: $2" + echo " actual: $1" + FAILURES=$((FAILURES + 1)) + else + echo "ok: $3" + fi +} + +# Source the installer with SUBMERSION_INSTALL_SH_TEST set, which makes it +# define its functions and return instead of running. +SUBMERSION_INSTALL_SH_TEST=1 +export SUBMERSION_INSTALL_SH_TEST +# shellcheck source=/dev/null +. "$INSTALLER" + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT +cat > "$WORKDIR/deps.json" <<'JSON' +{ + "_comment": "test fixture", + "libgtk-3.so.0": {"apt": "libgtk-3-0", "rpm": "libgtk-3.so.0()(64bit)", "pacman": "gtk3", "zypper": "gtk3"}, + "libwebkit2gtk-4.1.so.0": {"apt": "libwebkit2gtk-4.1-0", "rpm": "x", "pacman": "webkit2gtk-4.1", "zypper": "webkit2gtk-4_1"} +} +JSON + +assert_eq "$(map_soname "$WORKDIR/deps.json" "libgtk-3.so.0" apt)" \ + "libgtk-3-0" "maps a soname to its apt package" + +assert_eq "$(map_soname "$WORKDIR/deps.json" "libgtk-3.so.0" pacman)" \ + "gtk3" "maps a soname to its pacman package" + +assert_eq "$(map_soname "$WORKDIR/deps.json" "libunknown.so.9" apt)" \ + "" "returns empty for an unmapped soname rather than guessing" + +assert_eq "$(install_command_for apt "libgtk-3-0 libwebkit2gtk-4.1-0")" \ + "sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0" "builds the apt command" + +assert_eq "$(install_command_for dnf "gtk3")" \ + "sudo dnf install gtk3" "builds the dnf command" + +assert_eq "$(install_command_for pacman "gtk3")" \ + "sudo pacman -S --needed gtk3" "builds the pacman command" + +assert_eq "$(install_command_for zypper "gtk3")" \ + "sudo zypper install gtk3" "builds the zypper command" + +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES test(s) failed" + exit 1 +fi +echo "all tests passed" +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bash scripts/release/install_sh_test.sh` +Expected: FAIL, `No such file or directory` for `install.sh`. + +- [ ] **Step 3: Write the installer** + +Create `scripts/release/linux_tarball_extras/install.sh`: + +```bash +#!/usr/bin/env bash +# Install Submersion from the tarball into the current user's home. +# +# The .deb and .rpm packages are the better path on Debian, Ubuntu, Mint, +# Fedora, RHEL, and openSUSE. This exists for everything else, and for anyone +# who prefers not to install system packages. +# +# Usage: ./install.sh [--prefix DIR] [--no-udev] +set -euo pipefail + +PREFIX="${HOME}/.local" +APP_DIR="${PREFIX}/opt/submersion" +INSTALL_UDEV=1 +UDEV_RULES="/usr/lib/udev/rules.d/60-submersion-divecomputers.rules" + +map_soname() { + # map_soname -> package name, or empty + python3 - "$1" "$2" "$3" <<'PY' +import json, sys +path, soname, column = sys.argv[1:4] +with open(path, encoding="utf-8") as handle: + data = json.load(handle) +entry = data.get(soname) +print(entry[column] if entry else "") +PY +} + +install_command_for() { + # install_command_for -> the command to run + case "$1" in + apt) echo "sudo apt install $2" ;; + dnf) echo "sudo dnf install $2" ;; + pacman) echo "sudo pacman -S --needed $2" ;; + zypper) echo "sudo zypper install $2" ;; + *) echo "" ;; + esac +} + +detect_manager() { + for manager in apt dnf pacman zypper; do + if command -v "$manager" > /dev/null 2>&1; then + echo "$manager" + return + fi + done + echo "" +} + +preflight() { + local here="$1" + local manager missing packages command + manager="$(detect_manager)" + missing="$(ldd "$here/submersion" "$here"/lib/*.so 2>/dev/null \ + | awk '/not found/ {print $1}' | sort -u)" + + if [ -z "$missing" ]; then + echo "All shared libraries resolve." + return 0 + fi + + echo "Missing shared libraries:" + packages="" + for soname in $missing; do + echo " $soname" + if [ -n "$manager" ]; then + local package + package="$(map_soname "$here/deps.json" "$soname" \ + "$([ "$manager" = "dnf" ] && echo rpm || echo "$manager")")" + [ -n "$package" ] && packages="$packages $package" + fi + done + + if [ -n "$packages" ]; then + command="$(install_command_for "$manager" "${packages# }")" + echo "" + echo "Install them with:" + echo " $command" + fi + return 1 +} + +main() { + local here + here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + while [ $# -gt 0 ]; do + case "$1" in + --prefix) PREFIX="$2"; APP_DIR="${PREFIX}/opt/submersion"; shift 2 ;; + --no-udev) INSTALL_UDEV=0; shift ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac + done + + preflight "$here" || echo "Continuing; the app may not launch until these are installed." + + mkdir -p "$APP_DIR" "${PREFIX}/bin" \ + "${PREFIX}/share/applications" "${PREFIX}/share/icons/hicolor/256x256/apps" + cp -a "$here/." "$APP_DIR/" + rm -f "$APP_DIR/install.sh" "$APP_DIR/uninstall.sh" + + ln -sf "$APP_DIR/submersion" "${PREFIX}/bin/submersion" + + cat > "${PREFIX}/share/applications/app.submersion.desktop" <&2; exit 2 ;; + esac +done + +rm -rf "${PREFIX}/opt/submersion" +rm -f "${PREFIX}/bin/submersion" +rm -f "${PREFIX}/share/applications/app.submersion.desktop" +rm -f "${PREFIX}/share/icons/hicolor/256x256/apps/app.submersion.png" + +echo "Removed Submersion from ${PREFIX}." +echo "Your dive log data was not touched." +echo "" +echo "If you installed the udev rules, remove them with:" +echo " sudo rm -f /usr/lib/udev/rules.d/60-submersion-divecomputers.rules" +echo " sudo udevadm control --reload-rules" +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `bash scripts/release/install_sh_test.sh` +Expected: `all tests passed`, 7 assertions. + +- [ ] **Step 6: Lint both scripts** + +Run: `shellcheck scripts/release/linux_tarball_extras/*.sh scripts/release/install_sh_test.sh` +Expected: no errors. Warnings about sourcing a dynamic path are acceptable and +already suppressed with a `# shellcheck source=/dev/null` directive. + +- [ ] **Step 7: Ship the extras inside the tarball** + +Replace the `Create tarball` step in `.github/workflows/build-all.yml`: + +```yaml + - name: Create tarball + env: + TAG_NAME: ${{ inputs.version-tag }} + run: | + set -euo pipefail + BUNDLE=build/linux/x64/release/bundle + cp scripts/release/linux_tarball_extras/install.sh "$BUNDLE/" + cp scripts/release/linux_tarball_extras/uninstall.sh "$BUNDLE/" + chmod +x "$BUNDLE/install.sh" "$BUNDLE/uninstall.sh" + cp scripts/data/linux_soname_map.json "$BUNDLE/deps.json" + python3 scripts/gen_udev_rules.py \ + packages/libdivecomputer_plugin/third_party/libdivecomputer/src/descriptor.c \ + > "$BUNDLE/60-submersion-divecomputers.rules" + cd "$BUNDLE" + tar czf "$GITHUB_WORKSPACE/Submersion-${TAG_NAME}-Linux.tar.gz" . +``` + +Note this step now runs **after** the staging steps, so the extras never reach +the `.deb` or `.rpm` trees. Confirm the ordering when editing. + +- [ ] **Step 8: Commit** + +```bash +git add scripts/release/linux_tarball_extras scripts/release/install_sh_test.sh .github/workflows/build-all.yml +git commit -m "feat(linux): give the tarball an installer with a dependency preflight" +``` + +--- + +### Task 11: Teach the in-app updater about packaged installs + +`update_providers.dart:43` points Linux at `Linux.tar.gz` and +`update_banner.dart:49` opens it in a browser. On a dpkg-managed install that +hands the user a tarball which would shadow the packaged copy. + +**Files:** +- Create: `lib/features/auto_update/domain/entities/linux_install_method.dart` +- Create: `lib/features/auto_update/data/services/linux_install_method_reader.dart` +- Create: `test/features/auto_update/data/linux_install_method_reader_test.dart` +- Create: `lib/features/auto_update/presentation/widgets/update_banner_actions.dart` +- Create: `test/features/auto_update/presentation/update_banner_actions_test.dart` +- Modify: `lib/features/auto_update/presentation/providers/update_providers.dart` +- Modify: `lib/features/auto_update/presentation/widgets/update_banner.dart:47-52` +- Modify: `lib/l10n/arb/app_*.arb` (11 files) + +**Interfaces:** +- Consumes: the `INSTALL_METHOD` marker written by Task 6. +- Produces: `linuxInstallMethodProvider` returning `LinuxInstallMethod`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/auto_update/data/linux_install_method_reader_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/auto_update/data/services/linux_install_method_reader.dart'; +import 'package:submersion/features/auto_update/domain/entities/linux_install_method.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('install_method_test'); + }); + + tearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + + test('reads deb from the marker beside the executable', () async { + File('${tempDir.path}/INSTALL_METHOD').writeAsStringSync('deb\n'); + final reader = LinuxInstallMethodReader( + executablePath: '${tempDir.path}/submersion', + ); + expect(reader.read(), LinuxInstallMethod.deb); + }); + + test('reads rpm from the marker', () async { + File('${tempDir.path}/INSTALL_METHOD').writeAsStringSync('rpm\n'); + final reader = LinuxInstallMethodReader( + executablePath: '${tempDir.path}/submersion', + ); + expect(reader.read(), LinuxInstallMethod.rpm); + }); + + test('falls back to tarball when the marker is absent', () { + final reader = LinuxInstallMethodReader( + executablePath: '${tempDir.path}/submersion', + ); + expect(reader.read(), LinuxInstallMethod.tarball); + }); + + test('falls back to tarball when the marker holds something unknown', () { + File('${tempDir.path}/INSTALL_METHOD').writeAsStringSync('snap\n'); + final reader = LinuxInstallMethodReader( + executablePath: '${tempDir.path}/submersion', + ); + expect(reader.read(), LinuxInstallMethod.tarball); + }); + + test('tolerates surrounding whitespace in the marker', () { + File('${tempDir.path}/INSTALL_METHOD').writeAsStringSync(' deb \n\n'); + final reader = LinuxInstallMethodReader( + executablePath: '${tempDir.path}/submersion', + ); + expect(reader.read(), LinuxInstallMethod.deb); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/auto_update/data/linux_install_method_reader_test.dart` +Expected: FAIL, `Target of URI doesn't exist`. + +- [ ] **Step 3: Write the entity** + +Create `lib/features/auto_update/domain/entities/linux_install_method.dart`: + +```dart +/// How this Linux copy of Submersion was installed. +/// +/// A packaged install is upgraded by the system package manager, so the +/// in-app updater must not offer a tarball that would shadow the packaged +/// copy. The marker file is written into the package by +/// scripts/release/stage_linux_package.py. +enum LinuxInstallMethod { + /// Installed from a .deb; upgraded with apt. + deb, + + /// Installed from an .rpm; upgraded with dnf. + rpm, + + /// Unpacked from the tarball; upgraded by downloading a new tarball. + tarball; + + /// Whether a system package manager owns this install. + bool get isPackaged => this != LinuxInstallMethod.tarball; +} +``` + +- [ ] **Step 4: Write the reader** + +Create `lib/features/auto_update/data/services/linux_install_method_reader.dart`: + +```dart +import 'dart:io'; + +import '../../domain/entities/linux_install_method.dart'; + +/// Reads the install-method marker that the Linux packages ship. +/// +/// The marker sits beside the executable at /usr/lib/submersion/INSTALL_METHOD. +/// The tarball ships no marker, so its absence is the tarball case rather than +/// an error. +class LinuxInstallMethodReader { + const LinuxInstallMethodReader({required this.executablePath}); + + final String executablePath; + + LinuxInstallMethod read() { + try { + final marker = File( + '${File(executablePath).parent.path}/INSTALL_METHOD', + ); + if (!marker.existsSync()) return LinuxInstallMethod.tarball; + return switch (marker.readAsStringSync().trim()) { + 'deb' => LinuxInstallMethod.deb, + 'rpm' => LinuxInstallMethod.rpm, + _ => LinuxInstallMethod.tarball, + }; + } on FileSystemException { + return LinuxInstallMethod.tarball; + } + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `flutter test test/features/auto_update/data/linux_install_method_reader_test.dart` +Expected: 5 tests pass. + +- [ ] **Step 6: Add the provider** + +In `lib/features/auto_update/presentation/providers/update_providers.dart`, add +the import and the provider next to the existing platform providers: + +```dart +/// How this Linux copy was installed. A provider rather than an inline +/// Platform check, matching isLinuxPlatformProvider in sync_providers.dart, +/// so widget tests can simulate each install method on any CI host. +final linuxInstallMethodProvider = Provider((ref) { + if (!Platform.isLinux) return LinuxInstallMethod.tarball; + return LinuxInstallMethodReader( + executablePath: Platform.resolvedExecutable, + ).read(); +}); +``` + +- [ ] **Step 7: Add the localized hint to all 11 ARB files** + +In `lib/l10n/arb/app_en.arb`, beside `autoUpdate_banner_download` (line 20078): + +```json + "autoUpdate_banner_packageManagerHint": "Update with: {command}", + "@autoUpdate_banner_packageManagerHint": { + "description": "Shown instead of a Download button when Submersion was installed from a .deb or .rpm, since the system package manager owns the upgrade.", + "placeholders": { + "command": { "type": "String", "example": "sudo apt upgrade submersion" } + } + }, +``` + +Add the same key with a translation to each of `app_ar.arb`, `app_de.arb`, +`app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, +`app_nl.arb`, `app_pt.arb`, `app_zh.arb`. Every locale must carry the key, or +the generated hub falls back inconsistently. + +- [ ] **Step 8: Regenerate localizations** + +Run: `flutter gen-l10n` +Expected: `lib/l10n/arb/app_localizations*.dart` regenerate with the new +getter, and `flutter analyze` reports no missing-key errors. + +- [ ] **Step 9: Design the actions widget (write the test first, in Step 10)** + +`UpdateStatusNotifier`'s constructor schedules a 5 second delayed check, so +stubbing it inside a widget test leaks a pending timer. Extracting the actions +row removes the need to stub it at all, and gives the install-method behavior a +unit to be tested against directly. + +This is the widget Step 11 creates, at +`lib/features/auto_update/presentation/widgets/update_banner_actions.dart`. +Read it now, then write its test before creating the file: + +```dart +import 'package:flutter/material.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../domain/entities/linux_install_method.dart'; + +/// The action area of [UpdateBanner]. +/// +/// A packaged Linux install is upgraded by the system package manager, so this +/// shows the command rather than a download button: the app knows about the +/// release before the package manager has told the user, but it is not the +/// thing that should install it. +class UpdateBannerActions extends StatelessWidget { + const UpdateBannerActions({ + super.key, + required this.installMethod, + required this.downloadUrl, + required this.onDownload, + required this.onDismiss, + }); + + final LinuxInstallMethod installMethod; + final String? downloadUrl; + final ValueChanged onDownload; + final VoidCallback onDismiss; + + /// The command that upgrades a packaged install. + static String upgradeCommand(LinuxInstallMethod method) => switch (method) { + LinuxInstallMethod.deb => 'sudo apt upgrade submersion', + LinuxInstallMethod.rpm => 'sudo dnf upgrade submersion', + LinuxInstallMethod.tarball => '', + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final url = downloadUrl; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (installMethod.isPackaged) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SelectableText( + context.l10n.autoUpdate_banner_packageManagerHint( + upgradeCommand(installMethod), + ), + style: theme.textTheme.bodySmall, + ), + ) + else if (url != null) + TextButton( + onPressed: () => onDownload(url), + child: Text(context.l10n.autoUpdate_banner_download), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + tooltip: context.l10n.common_action_dismiss, + onPressed: onDismiss, + ), + ], + ); + } +} +``` + +Confirm the l10n import path matches what `update_banner.dart` already uses; if +that file imports the localizations differently, copy its import verbatim. + +- [ ] **Step 10: Write the failing test** + +Create `test/features/auto_update/presentation/update_banner_actions_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/auto_update/domain/entities/linux_install_method.dart'; +import 'package:submersion/features/auto_update/presentation/widgets/update_banner_actions.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +Widget _host(LinuxInstallMethod method, {String? downloadUrl}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: UpdateBannerActions( + installMethod: method, + downloadUrl: downloadUrl, + onDownload: (_) {}, + onDismiss: () {}, + ), + ), +); + +void main() { + const url = 'https://example.invalid/Submersion-Linux.tar.gz'; + + testWidgets('tarball install offers a download button', (tester) async { + await tester.pumpWidget( + _host(LinuxInstallMethod.tarball, downloadUrl: url), + ); + expect(find.text('Download'), findsOneWidget); + }); + + testWidgets('deb install shows the apt command, not a download', ( + tester, + ) async { + await tester.pumpWidget(_host(LinuxInstallMethod.deb, downloadUrl: url)); + expect(find.text('Download'), findsNothing); + expect(find.textContaining('sudo apt upgrade submersion'), findsOneWidget); + }); + + testWidgets('rpm install shows the dnf command', (tester) async { + await tester.pumpWidget(_host(LinuxInstallMethod.rpm, downloadUrl: url)); + expect(find.text('Download'), findsNothing); + expect(find.textContaining('sudo dnf upgrade submersion'), findsOneWidget); + }); + + testWidgets('a packaged install shows no download even with a url', ( + tester, + ) async { + await tester.pumpWidget(_host(LinuxInstallMethod.deb, downloadUrl: url)); + expect(find.byType(TextButton), findsNothing); + }); + + testWidgets('dismiss is always available', (tester) async { + await tester.pumpWidget(_host(LinuxInstallMethod.deb)); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + test('upgradeCommand is empty for a tarball install', () { + expect( + UpdateBannerActions.upgradeCommand(LinuxInstallMethod.tarball), + isEmpty, + ); + }); +} +``` + +- [ ] **Step 11: Run the test to verify it fails, then wire the widget in** + +Run: `flutter test test/features/auto_update/presentation/update_banner_actions_test.dart` +Expected: FAIL, `Target of URI doesn't exist` for `update_banner_actions.dart`. + +Then create the widget from Step 9 and replace the `actions:` list in +`lib/features/auto_update/presentation/widgets/update_banner.dart` with a single +entry: + +```dart + actions: [ + UpdateBannerActions( + installMethod: ref.watch(linuxInstallMethodProvider), + downloadUrl: downloadUrl, + onDownload: _openDownload, + onDismiss: () => setState(() => _dismissed = true), + ), + ], +``` + +Add the import for `update_banner_actions.dart` at the top of the file. + +- [ ] **Step 12: Run the tests to verify they pass** + +Run: `flutter test test/features/auto_update/` +Expected: all tests pass, including the pre-existing ones. + +- [ ] **Step 13: Format, analyze, and commit** + +```bash +dart format . +flutter analyze +git add lib test +git commit -m "feat(linux): show the package-manager command on packaged installs" +``` + +`flutter analyze` must report zero issues, infos included, because CI treats +infos as fatal. + +--- + +### Task 12: Document the install paths + +**Files:** +- Modify: `README.md:335-372` (the Linux build-from-source section) +- Modify: `docs/guide/installation.md` + +**Interfaces:** +- Consumes: the asset names from Task 7. + +- [ ] **Step 1: Add an install section to the README** + +Insert a new collapsible block immediately before the existing +`Linux: building from source (distro dependencies)` +block: + +````markdown +
+Linux: installing + +**Debian, Ubuntu, Mint, and derivatives** + +Download `Submersion--Linux-amd64.deb` from +[Releases](https://github.com/submersion-app/submersion/releases), then: + +```bash +sudo apt install ./Submersion-*-Linux-amd64.deb +``` + +**Fedora, RHEL, openSUSE** + +Download `Submersion--Linux-x86_64.rpm`, then: + +```bash +sudo dnf install ./Submersion-*-Linux-x86_64.rpm +``` + +Both packages install a desktop entry, icons, and udev rules that let dive +computers connected by USB be reached without any group membership or +`usermod` step. Video compression is optional and needs `ffmpeg`, which the +packages recommend but do not require. + +**Everything else (Arch, NixOS, and anyone who prefers not to install packages)** + +Download `Submersion--Linux.tar.gz`, unpack it, and run the included +installer: + +```bash +tar xzf Submersion-*-Linux.tar.gz +./install.sh +``` + +`install.sh` checks for missing shared libraries, prints the exact command to +install them for your package manager, installs a desktop entry and icon into +`~/.local/share`, and links the binary into `~/.local/bin`. It also prints the +command to install the udev rules, which needs root. `./uninstall.sh` reverses +all of it and never touches your dive log data. + +
+```` + +- [ ] **Step 2: Mirror it in the docs site** + +Add the same three paths to the Linux section of `docs/guide/installation.md`, +matching that file's existing heading structure and tab style. + +- [ ] **Step 3: Verify no em-dashes crept in** + +```bash +# The pattern is built from UTF-8 bytes rather than written literally, so this +# file does not itself contain the character it forbids. +EMDASH=$(printf '\xe2\x80\x94') +grep -n "$EMDASH" README.md docs/guide/installation.md \ + && echo "FOUND EM-DASHES" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 4: Commit** + +```bash +git add README.md docs/guide/installation.md +git commit -m "docs: document the Linux install paths" +``` + +--- + +### Task 13: Cover packaging on every pull request (added during execution) + +`build-all.yml` is `workflow_call`-only, and nothing a pull request triggers +invokes it: `ci.yaml` runs on push and pull_request but has its own +`build-linux` job that never calls it. So every change to the release build +path, this one included, was unverifiable until after a merge, at which point a +break fails `verify-linux-packages`, fails `build-all`, fails `beta.yml`'s build +job, and publishes no beta that cycle. + +This task closes that gap by extracting the packaging into scripts both +workflows call, then adding the same coverage to `ci.yaml`. + +**Files:** +- Create: `scripts/release/build_linux_packages.sh` +- Create: `scripts/release/verify_linux_package.sh` +- Modify: `.github/workflows/build-all.yml` (call the shared scripts) +- Modify: `.github/workflows/ci.yaml` (packaging plus container verification) + +**Scope limit, stated so it is not assumed away:** `ci.yaml`'s `build-linux` +runs on `ubuntu-latest`, not the `ubuntu:22.04` container. It therefore covers +packaging mechanics, dependency derivation, and installability, but **not** the +glibc floor. `build-all.yml` remains the only check on the floor itself. + +- [x] **Step 1: Extract the packaging into a shared script** + +`scripts/release/build_linux_packages.sh` takes `--bundle`, `--version`, +`--deb`, `--rpm`, and an optional `--staging-root`, then stages both trees, +derives both dependency lists, and runs fpm twice. Portable to bash 3.2 so it +runs on a developer's macOS shell as well as in CI. + +- [x] **Step 2: Extract the in-container assertions** + +`scripts/release/verify_linux_package.sh` runs inside the container, installs +whatever package it finds in `/p` with the native package manager, and asserts +`--version`, the desktop entry, metainfo, udev rules with `uaccess` and without +`plugdev`, the icon, and no unresolved libraries. + +- [x] **Step 3: Point build-all.yml at both scripts** + +Its three packaging steps collapse to one call, and the verify job mounts the +script into the container. Checkout must precede `download-artifact` in that +job: `actions/checkout` cleans the workspace by default and would otherwise +delete the downloaded packages. + +- [x] **Step 4: Add the same coverage to ci.yaml's build-linux** + +After the existing native-asset check: install fpm and rpm, build both packages +at the pubspec version, and run the verification script in `debian:12` and +`fedora:latest`. + +- [x] **Step 5: Verify locally as far as a macOS host allows** + +```bash +shellcheck scripts/release/build_linux_packages.sh scripts/release/verify_linux_package.sh +TMP=$(mktemp -d); mkdir -p "$TMP/bundle/lib"; echo bin > "$TMP/bundle/submersion" +bash scripts/release/build_linux_packages.sh --bundle "$TMP/bundle" \ + --version 1.7.7.124 --deb "$TMP/o.deb" --rpm "$TMP/o.rpm" --staging-root "$TMP" +``` + +Expected: staging completes, then it stops at `readelf not found`, which is the +correct actionable error on a host without binutils. + +--- + +## Final Verification + +- [ ] **Run the full Python test suite** + +```bash +python3 scripts/gen_udev_rules_test.py +python3 scripts/linux_package_deps_test.py +python3 scripts/release/stage_linux_package_test.py +bash scripts/release/install_sh_test.sh +``` + +Expected: all pass. + +- [ ] **Run the Dart suite once, unpiped** + +```bash +flutter test +``` + +Piping to `grep` hides the exit status, so run it plain and read the summary. + +- [ ] **Confirm CI is green end to end** + +```bash +gh run list --branch feat/linux-packaging --limit 3 +``` + +Expected: `Build Linux` and both `Verify Linux Packages` matrix legs pass. + +- [ ] **Manually install on a real desktop** + +Download the `.deb` from the branch's artifact, install it on a Debian or +Ubuntu desktop, confirm Submersion appears in the application menu with its +icon, launches, and that a USB dive computer is detected without any group +change. This is the one thing CI cannot prove. diff --git a/docs/superpowers/plans/2026-09-03-linux-packaging-phase2-repos.md b/docs/superpowers/plans/2026-09-03-linux-packaging-phase2-repos.md new file mode 100644 index 0000000000..161a90f9e4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-linux-packaging-phase2-repos.md @@ -0,0 +1,1179 @@ +# Linux Packaging Phase 2: APT and DNF Repositories Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** publish signed APT and DNF repositories at `packages.submersion.app` +so Linux users receive Submersion updates through `apt upgrade` and +`dnf upgrade` like any other package, on both the stable and beta channels. + +**Architecture:** a stateless publisher. Each run downloads the packages it +needs from GitHub Releases, regenerates the entire site, signs the metadata, +and deploys to GitHub Pages. The repository is a *view* over GitHub Releases, +never a source of truth, which is what lets a nightly reconcile repair a missed +publish. All logic lives in this repo with tests; the new repo holds only a +thin workflow. + +**Tech Stack:** GitHub Actions, GitHub Pages, Python 3 (stdlib only), +`dpkg-scanpackages`, `apt-ftparchive`, `createrepo_c`, GnuPG, `gh` CLI. + +**Spec:** `docs/superpowers/specs/2026-09-03-linux-packaging-design.md`, Section 4 + +**Depends on:** Phase 1 (PR #1514). The `.deb` and `.rpm` must exist as release +assets before there is anything to index. + +## Two decisions this plan makes + +Both are plan-level choices inside the approved design. They are called out +here rather than buried so they can be overridden before work starts. + +**1. The repository-building code lives in this repo, not in +`submersion-app/linux-packages`.** That repo gets a workflow that checks this +one out and runs `scripts/release/linux_repo/`. The logic then sits where the +test harness, the review process, and the existing `scripts/*_test.py` +convention already are, and the second repo stays nearly empty. The alternative, +putting the scripts in the new repo, would place the code that decides what +every Linux user downloads outside normal review. + +**2. RPMs are signed at build time in `build-all.yml`, not by the repository +workflow.** Section 4 of the spec says `rpmsign --addsign` runs in the repo +workflow. Signing at build time instead means the `.rpm` on GitHub Releases and +the `.rpm` in the DNF repo are the same bytes, covered by the same +`checksums-sha256.txt`, which is what "the repository is a view over Releases" +should mean. It costs adding the signing key to the main repo's secrets. Debian +packages need no equivalent: apt verifies the signed `Release` file, not +individual `.deb` files. + +## Global Constraints + +- **Worktree:** a new worktree and branch, `feat/linux-package-repos`, created + from `origin/main` after Phase 1 merges. Never check it out in the main tree. +- **No em-dashes** (U+2014) in any file, comment, commit message, or PR body. +- **No emojis** in code, comments, or documentation. +- **Python scripts are stdlib only**, each with a sibling `*_test.py` runnable + as `python3 scripts/release/linux_repo/_test.py`, using `unittest`. +- **No secret material in the repo.** The signing key exists only in Actions + secrets and on the maintainer's machine. Tests never need a key: the scripts + generate unsigned metadata and signing is a separate workflow step. +- **Repository identity:** APT suites `stable` and `beta`, component `main`, + architecture `amd64`. DNF repositories `stable` and `beta`, basearch + `x86_64`. +- **Retention:** two versions per suite, enforced by the fetch step. +- **Size guard:** the assembled site fails the build above 800 MB, against the + 1 GB Pages cap. +- **PR descriptions:** no Claude Code attribution line, no session URL. + +--- + +## File Structure + +**Created in this repo:** + +| File | Responsibility | +| --- | --- | +| `scripts/release/linux_repo/fetch_release_packages.py` | Download the last N releases' packages for one channel | +| `scripts/release/linux_repo/build_apt_repo.py` | Emit `pool/`, `Packages`, `Packages.gz`, and an unsigned `Release` | +| `scripts/release/linux_repo/build_rpm_repo.py` | Lay out RPMs per suite and run `createrepo_c` | +| `scripts/release/linux_repo/assemble_site.py` | Orchestrate the four stages and enforce the size guard | +| `scripts/release/linux_repo/site_assets/submersion.repo` | DNF drop-in template | +| `scripts/release/linux_repo/site_assets/setup.sh` | Documented enrollment script served from the site | +| `scripts/release/linux_repo/*_test.py` | One test suite per script | +| `.github/workflows/publish-linux-repo.yml` | Reference copy of the workflow the other repo runs | + +**Created in `submersion-app/linux-packages`:** + +| File | Responsibility | +| --- | --- | +| `.github/workflows/publish.yml` | Checkout this repo, assemble, sign, deploy to Pages | +| `README.md` | What the repo is, and that its logic lives in the app repo | + +**Modified in this repo:** + +| File | Change | +| --- | --- | +| `scripts/release/stage_linux_package.py` | Self-enrollment files plus a postinstall hook | +| `scripts/release/build_linux_packages.sh` | Pass `--after-install`, sign the RPM when a key is present | +| `.github/workflows/build-all.yml` | RPM signing step | +| `.github/workflows/release.yml`, `beta.yml`, `promote.yml` | `repository_dispatch` on success | +| `docs/developer/release-secrets-setup.md` | Signing key and dispatch token | +| `README.md`, `docs/guide/installation.md` | Repository enrollment instructions | + +--- + +### Task 1: Create the signing key and the repository (maintainer actions) + +Nothing else can be verified end to end until these exist. Both steps handle +secret material or create public infrastructure, so a person performs them. + +**Files:** +- Modify: `docs/developer/release-secrets-setup.md` + +**Interfaces:** +- Produces: secrets `LINUX_REPO_GPG_PRIVATE_KEY`, `LINUX_REPO_GPG_PASSPHRASE`, + `LINUX_REPO_DISPATCH_TOKEN`, consumed by Tasks 6 and 7, and the public key + bytes consumed by Task 5. + +- [ ] **Step 1: Generate the key offline** + +Save this as a script and run it with `bash generate-key.sh` rather than +pasting it line by line: it aborts on the first failure, and pasting would +close an interactive shell instead. + +```bash +#!/usr/bin/env bash +# Without this the block runs on after a failure: a gpg that could not +# generate the key would flow straight into the export below and produce an +# empty or wrong secret, which then gets pasted into a GitHub secret. +set -euo pipefail + +# Everything here is private key material, so it is written inside a +# mktemp -d directory (created 0700) with umask 077 in force. Shell +# redirection creates files using the caller's umask, commonly 0644, and a +# chmod afterwards still leaves a window in which another local user can read +# the file. The directory closes that window regardless of file mode. +umask 077 +KEYDIR=$(mktemp -d) + +# gpg writes the generated secret key into its own home directory, not into +# the file the export is redirected to, so GNUPGHOME is pointed inside KEYDIR. +# Without this the private key persists in ~/.gnupg after the cleanup below +# believes it has removed everything, and, worse, the KEYID lookup further +# down would match any existing key for the same address, so the export could +# quietly produce the wrong key and the repository would be signed by it. +export GNUPGHOME="$KEYDIR/gnupg" +mkdir -p "$GNUPGHOME" +chmod 700 "$GNUPGHOME" + +# tr -d: command substitution strips the trailing newline from the argument, +# but the file is what becomes the LINUX_REPO_GPG_PASSPHRASE secret. The +# stored passphrase would then not match the one the key was created with, +# and every signing step would fail. +openssl rand -base64 32 | tr -d '\n' > "$KEYDIR/repo-pass" + +# --pinentry-mode loopback is required on GnuPG 2.1+ for --passphrase to be +# honoured at all; without it gpg ignores the option and tries to prompt +# through pinentry, which has nowhere to prompt in a scripted context. The +# signing steps in Task 6 pass the same flag for the same reason. +# +# --passphrase-file rather than --passphrase: a passphrase given on the command +# line is visible to every user on the machine through ps. +gpg --batch --pinentry-mode loopback --passphrase-file "$KEYDIR/repo-pass" \ + --quick-generate-key "Submersion Package Signing " \ + rsa4096 sign never + +KEYID=$(gpg --list-keys --with-colons dev@submersion.app | awk -F: '/^pub/{print $5; exit}') + +# pipefail above already aborts if gpg itself fails. This covers the other +# case: gpg succeeding while printing no pub record, which leaves KEYID empty +# and no non-zero status anywhere. An empty key id would make the export below +# select nothing in particular, and the result would be pasted into a GitHub +# secret without anyone noticing. +if [ -z "$KEYID" ]; then + echo "Could not read the new key's id from $GNUPGHOME." >&2 + exit 1 +fi +echo "Key id: $KEYID" + +# Exporting a passphrase-protected secret key needs the same treatment: it +# decrypts the key, so it prompts exactly as generation does. +gpg --batch --pinentry-mode loopback --passphrase-file "$KEYDIR/repo-pass" \ + --armor --export-secret-keys "$KEYID" > "$KEYDIR/repo-private.asc" + +# The public half needs no passphrase. +gpg --export "$KEYID" > "$KEYDIR/submersion.gpg" # dearmored, for signed-by= + +echo "Key material is in $KEYDIR" +``` + +Expected: `$KEYDIR/submersion.gpg` is a binary keyring, and +`gpg --show-keys "$KEYDIR/submersion.gpg"` prints one key. Confirm the +directory is private with `ls -ld "$KEYDIR"`, which must show `drwx------`, +and confirm the isolation held with `gpg --list-secret-keys`, which must list +exactly the key just generated and nothing from your personal keyring. + +- [ ] **Step 2: Store the secrets** + +In `submersion-app/submersion`: `LINUX_REPO_GPG_PRIVATE_KEY` (contents of +`$KEYDIR/repo-private.asc`) and `LINUX_REPO_GPG_PASSPHRASE` (contents of +`$KEYDIR/repo-pass`), for RPM signing at build time. + +In `submersion-app/linux-packages`: the same two secrets, for metadata signing. + +In `submersion-app/submersion`: `LINUX_REPO_DISPATCH_TOKEN`, a fine-grained PAT +with Contents: write on `submersion-app/linux-packages` only. + +Then shred the two secrets, which are now stored where they are needed: + +```bash +# shred is GNU coreutils and is absent on macOS, where this procedure is most +# likely to be run; rm -P is the BSD equivalent. On APFS, and on any SSD, +# neither reliably overwrites the original blocks, so treat this as tidying +# rather than secure erasure. The real protection is that these files only +# ever existed inside a 0700 directory. +if command -v shred > /dev/null 2>&1; then + shred -u "$KEYDIR/repo-private.asc" "$KEYDIR/repo-pass" +else + rm -P "$KEYDIR/repo-private.asc" "$KEYDIR/repo-pass" +fi +``` + +`$KEYDIR` itself stays until Step 4, because it still holds the public key that +step commits, and the isolated `GNUPGHOME` inside it still holds the secret +key. Do not `rmdir` it here: the directory is not empty, so that would fail, +and it would destroy the public key before it has been copied anywhere. + +- [ ] **Step 3: Create the repository and point DNS at it** + +Create `submersion-app/linux-packages` as a public repository. In its settings, +set Pages source to GitHub Actions, and add the custom domain +`packages.submersion.app`. Add a DNS CNAME for `packages` pointing at +`submersion-app.github.io`. + +- [ ] **Step 4: Commit the public key and document the setup** + +The dearmored public key is committed to this repo at +`scripts/release/linux_repo/site_assets/submersion.gpg`, because it is public by +definition and the site must serve identical bytes on every rebuild. + +```bash +cp "$KEYDIR/submersion.gpg" scripts/release/linux_repo/site_assets/submersion.gpg +gpg --show-keys scripts/release/linux_repo/site_assets/submersion.gpg +``` + +Add a `### Linux Package Repository` section to +`docs/developer/release-secrets-setup.md` recording all four secrets, which repo +each lives in, the key fingerprint, and the rotation procedure. + +Only now remove the working directory, which also removes the isolated +`GNUPGHOME` and with it the generated secret key: + +```bash +rm -rf "$KEYDIR" +unset GNUPGHOME +``` + +`rm -rf` rather than `rmdir`, because the directory holds the GnuPG home as +well as the public key. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/release/linux_repo/site_assets/submersion.gpg \ + docs/developer/release-secrets-setup.md +git commit -m "docs: record the Linux package repository signing setup" +``` + +--- + +### Task 2: Fetch the packages a suite should carry + +**Files:** +- Create: `scripts/release/linux_repo/fetch_release_packages.py` +- Create: `scripts/release/linux_repo/fetch_release_packages_test.py` + +**Interfaces:** +- Produces: + `fetch_release_packages.py --channel {stable,beta} --limit 2 --out DIR`, + downloading each release's `.deb` and `.rpm`. Exposes + `select_releases(tags, limit)` and `asset_names(tag)` as pure functions. + Task 5 calls the CLI. + +- [ ] **Step 1: Write the failing test** + +```python +#!/usr/bin/env python3 +"""Unit tests for fetch_release_packages.py. + +Run: python3 scripts/release/linux_repo/fetch_release_packages_test.py + +The selection logic decides what every Linux user can install. Getting the +ordering wrong publishes an older build as the newest available version, which +apt would then refuse to upgrade past, stranding users silently. +""" + +import importlib.util +import os +import unittest + +SCRIPT = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "fetch_release_packages.py" +) +spec = importlib.util.spec_from_file_location("fetch_release_packages", SCRIPT) +fetch = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fetch) + + +class SelectReleasesTest(unittest.TestCase): + def test_keeps_the_newest_n_by_version_not_string_order(self): + tags = ["v1.7.9.7100", "v1.7.10.7200", "v1.7.6.7000"] + # Plain string sort would put 1.7.9 above 1.7.10. + self.assertEqual( + fetch.select_releases(tags, 2), ["v1.7.10.7200", "v1.7.9.7100"] + ) + + def test_limit_larger_than_available_returns_everything(self): + self.assertEqual( + fetch.select_releases(["v1.0.0.1"], 5), ["v1.0.0.1"] + ) + + def test_ignores_tags_that_are_not_four_segment_versions(self): + tags = ["v1.7.10.7200", "nightly", "v1.7.9.7100", "v2"] + self.assertEqual( + fetch.select_releases(tags, 3), ["v1.7.10.7200", "v1.7.9.7100"] + ) + + def test_empty_input_raises_rather_than_publishing_an_empty_repo(self): + with self.assertRaises(SystemExit): + fetch.select_releases([], 2) + + +class AssetNamesTest(unittest.TestCase): + def test_derives_both_package_names_from_a_tag(self): + self.assertEqual( + fetch.asset_names("v1.7.10.7200"), + [ + "Submersion-v1.7.10.7200-Linux-amd64.deb", + "Submersion-v1.7.10.7200-Linux-x86_64.rpm", + ], + ) + + +class ChannelRepoTest(unittest.TestCase): + def test_stable_reads_the_app_repo(self): + self.assertEqual(fetch.repo_for("stable"), "submersion-app/submersion") + + def test_beta_reads_the_beta_builds_repo(self): + self.assertEqual(fetch.repo_for("beta"), "submersion-app/beta-builds") + + def test_unknown_channel_is_rejected(self): + with self.assertRaises(SystemExit): + fetch.repo_for("nightly") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 scripts/release/linux_repo/fetch_release_packages_test.py` +Expected: FAIL with `FileNotFoundError: [Errno 2] No such file or +directory: ... fetch_release_packages.py`. The harness loads the script by +path through `spec_from_file_location` plus `exec_module`, which never +consults the import system, so a missing file raises when the loader opens +it rather than as an import error. + +- [ ] **Step 3: Write the implementation** + +Key points the implementation must honor: + +- `select_releases` sorts by a tuple of integers parsed from the tag, never by + string, and exits rather than returning an empty list. +- `repo_for` maps `stable` to `submersion-app/submersion` and `beta` to + `submersion-app/beta-builds`, matching `promote.yml:87`. +- Downloading shells out to + `gh release download --repo --pattern '' --dir `, + and a missing asset is a hard failure: a release without packages means the + suite would silently lose a version. + +```python +#!/usr/bin/env python3 +"""Download the Submersion Linux packages a repository suite should carry. + +The published repository is a view over GitHub Releases rather than a store of +its own, so every publish re-derives its contents from the releases that exist +right now. That is what lets a nightly reconcile repair a missed publish. + +Usage: + fetch_release_packages.py --channel stable --limit 2 --out packages/stable +""" + +import argparse +import os +import re +import subprocess +import sys + +_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)\.(\d+)$") + +_CHANNEL_REPOS = { + "stable": "submersion-app/submersion", + "beta": "submersion-app/beta-builds", +} + + +def repo_for(channel): + """The GitHub repository holding a channel's releases.""" + if channel not in _CHANNEL_REPOS: + sys.exit( + "fetch_release_packages: unknown channel %r (expected one of %s)" + % (channel, ", ".join(sorted(_CHANNEL_REPOS))) + ) + return _CHANNEL_REPOS[channel] + + +def select_releases(tags, limit): + """The newest `limit` release tags, ordered newest first. + + Sorted numerically per segment: a string sort puts v1.7.9 above v1.7.10 and + would publish an older build as the newest available version. + """ + parsed = [] + for tag in tags: + match = _TAG_RE.match(tag) + if match: + parsed.append((tuple(int(p) for p in match.groups()), tag)) + if not parsed: + sys.exit( + "fetch_release_packages: no four-segment version tags found. " + "Publishing an empty repository would remove Submersion from " + "every enrolled system." + ) + parsed.sort(reverse=True) + return [tag for _, tag in parsed[:limit]] + + +def asset_names(tag): + """The two package asset names a release carries.""" + return [ + "Submersion-%s-Linux-amd64.deb" % tag, + "Submersion-%s-Linux-x86_64.rpm" % tag, + ] + + +def download(tag, repo, out_dir): + os.makedirs(out_dir, exist_ok=True) + for name in asset_names(tag): + result = subprocess.run( + ["gh", "release", "download", tag, "--repo", repo, + "--pattern", name, "--dir", out_dir, "--clobber"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + sys.exit( + "fetch_release_packages: %s is missing %s.\n%s" + % (tag, name, result.stderr.strip()) + ) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--channel", required=True, choices=("stable", "beta")) + parser.add_argument("--limit", type=int, default=2) + parser.add_argument("--out", required=True) + args = parser.parse_args(argv) + + repo = repo_for(args.channel) + listing = subprocess.run( + ["gh", "release", "list", "--repo", repo, "--limit", "50", + "--json", "tagName", "-q", ".[].tagName"], + check=True, capture_output=True, text=True, + ).stdout.split() + + for tag in select_releases(listing, args.limit): + print("fetching %s" % tag) + download(tag, repo, args.out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 scripts/release/linux_repo/fetch_release_packages_test.py` +Expected: `OK`, 8 tests. + +- [ ] **Step 5: Verify against the real releases** + +```bash +python3 scripts/release/linux_repo/fetch_release_packages.py \ + --channel stable --limit 1 --out /tmp/pkgtest +ls -la /tmp/pkgtest +``` + +Expected: one `.deb` and one `.rpm`. This only works once Phase 1 has shipped a +release carrying them; until then the expected result is the "is missing" +error, which confirms the failure path. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/release/linux_repo/fetch_release_packages.py \ + scripts/release/linux_repo/fetch_release_packages_test.py +git commit -m "feat(linux-repo): fetch the packages a suite should carry" +``` + +--- + +### Task 3: Build the APT repository + +`dpkg-scanpackages` plus `apt-ftparchive`, not `reprepro` or `aptly`: both of +those want a persistent database that a stateless rebuild would have to fake. + +**Files:** +- Create: `scripts/release/linux_repo/build_apt_repo.py` +- Create: `scripts/release/linux_repo/build_apt_repo_test.py` + +**Interfaces:** +- Consumes: a directory of `.deb` files per suite from Task 2. +- Produces: `build_apt_repo.py --packages DIR --suite NAME --site DIR`, writing + `pool/main/s/submersion/`, `dists//main/binary-amd64/Packages{,.gz}`, + and an unsigned `dists//Release`. Signing is Task 6's workflow step, so + no test needs a key. + +- [ ] **Step 1: Write the failing test** + +Cover, with `.deb` files faked as plain files where the layout is what matters +and a real `dpkg-deb`-built fixture where `Packages` content is: + +```python +class LayoutTest(unittest.TestCase): + def test_packages_land_in_the_debian_pool_path(self): + # pool/main/s/submersion/ is the conventional path; apt does not + # require it, but tools and mirrors assume it. + ... + + def test_release_file_declares_the_suite_and_architecture(self): + text = ... + self.assertIn("Suite: stable", text) + self.assertIn("Architectures: amd64", text) + self.assertIn("Components: main", text) + + def test_release_file_carries_checksums_for_the_packages_index(self): + # Without SHA256 entries apt rejects the repository as unverifiable + # even when the signature is valid. + ... + + def test_packages_gz_is_written_alongside_packages(self): + ... + + def test_an_empty_package_directory_is_rejected(self): + # Publishing an empty Packages index removes the app from every + # enrolled system on the next apt update. + with self.assertRaises(SystemExit): + ... +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 scripts/release/linux_repo/build_apt_repo_test.py` +Expected: FAIL with `FileNotFoundError` for `build_apt_repo.py`, for the +same reason as Task 2: the harness loads by path, not by import. + +- [ ] **Step 3: Write the implementation** + +The script must: + +1. Copy each `.deb` into `/apt/pool/main/s/submersion/`. +2. Run `dpkg-scanpackages --arch amd64 pool/main/s/submersion` from the `apt/` + directory, so `Filename:` paths are relative to the repository root, and + write `dists//main/binary-amd64/Packages`. +3. Write `Packages.gz` with `gzip.compress(..., mtime=0)`, so an unchanged + input produces an unchanged byte stream and Pages does not see churn. +4. Run `apt-ftparchive release dists/` with `-o APT::FTPArchive::Release::Suite=`, + `::Codename=`, `::Origin=Submersion`, `::Label=Submersion`, + `::Architectures=amd64`, `::Components=main`, and write the output to + `dists//Release`. +5. Exit non-zero if the package directory is empty. + +- [ ] **Step 4: Run the test to verify it passes** + +Expected: `OK`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/release/linux_repo/build_apt_repo.py \ + scripts/release/linux_repo/build_apt_repo_test.py +git commit -m "feat(linux-repo): build the APT repository metadata" +``` + +--- + +### Task 4: Build the DNF repository + +**Files:** +- Create: `scripts/release/linux_repo/build_rpm_repo.py` +- Create: `scripts/release/linux_repo/build_rpm_repo_test.py` + +**Interfaces:** +- Produces: `build_rpm_repo.py --packages DIR --suite NAME --site DIR`, writing + `/rpm//` with the RPMs and a `repodata/` from `createrepo_c`. + +- [ ] **Step 1: Write the failing test** + +```python +class LayoutTest(unittest.TestCase): + def test_rpms_land_in_the_suite_directory(self): + ... + + def test_repodata_is_generated(self): + self.assertTrue(os.path.isfile(".../rpm/stable/repodata/repomd.xml")) + + def test_repomd_is_well_formed_xml(self): + xml.dom.minidom.parse(".../repodata/repomd.xml") + + def test_an_empty_package_directory_is_rejected(self): + with self.assertRaises(SystemExit): + ... + + def test_createrepo_missing_gives_an_actionable_error(self): + # Same shape as linux_package_deps.py's readelf check: name the + # package to install rather than surfacing a bare FileNotFoundError. + ... +``` + +- [ ] **Step 2: Run the test to verify it fails** + +- [ ] **Step 3: Write the implementation** + +Copy the RPMs into `/rpm//`, then run `createrepo_c --update .` +there. Missing `createrepo_c` exits with "install createrepo-c", matching the +error style `linux_package_deps.py` uses for `readelf`. + +- [ ] **Step 4: Run the test to verify it passes** + +- [ ] **Step 5: Commit** + +```bash +git add scripts/release/linux_repo/build_rpm_repo.py \ + scripts/release/linux_repo/build_rpm_repo_test.py +git commit -m "feat(linux-repo): build the DNF repository metadata" +``` + +--- + +### Task 5: Assemble the whole site + +**Files:** +- Create: `scripts/release/linux_repo/assemble_site.py` +- Create: `scripts/release/linux_repo/assemble_site_test.py` +- Create: `scripts/release/linux_repo/site_assets/submersion.repo` +- Create: `scripts/release/linux_repo/site_assets/setup.sh` +- Create: `scripts/release/linux_repo/site_assets/index.html` + +**Interfaces:** +- Consumes: Tasks 2, 3, and 4. +- Produces: `assemble_site.py --site DIR [--limit 2]`, a complete unsigned site. + +- [ ] **Step 1: Write the failing test** + +```python +class SiteTest(unittest.TestCase): + def test_both_suites_are_built_for_both_formats(self): + for suite in ("stable", "beta"): + self.assertTrue(os.path.isdir(".../apt/dists/%s" % suite)) + self.assertTrue(os.path.isdir(".../rpm/%s" % suite)) + + def test_public_key_is_served_at_the_documented_path(self): + # setup.sh and every install doc reference /submersion.gpg by name. + self.assertTrue(os.path.isfile(".../submersion.gpg")) + + def test_dnf_drop_in_and_setup_script_are_served(self): + ... + + def test_size_guard_rejects_a_site_over_the_limit(self): + with self.assertRaises(SystemExit): + assemble.check_size(root, limit_bytes=1) + + def test_size_guard_accepts_a_site_under_the_limit(self): + assemble.check_size(root, limit_bytes=10 ** 9) + + def test_setup_script_pins_the_keyring_with_signed_by(self): + # An unpinned key would let any key in the system trusted set sign + # updates for this repository. + self.assertIn("signed-by=/usr/share/keyrings/submersion.gpg", text) + + def test_setup_script_rejects_an_unknown_channel(self): + # The channel lands in an apt source line and a sed replacement; + # rejecting it outright turns a malformed enrollment into an error. + ... + + def test_setup_script_never_pipes_curl_into_tee(self): + # POSIX sh has no pipefail, so a failed download in a pipeline would + # write an empty file and still report success. + self.assertNotIn("| sudo tee", text) + + def test_setup_script_handles_zypper_before_dnf(self): + # openSUSE ships zypper and not dnf; checking dnf first would send + # those users to the tarball branch. + self.assertLess(text.index("command -v zypper"), text.index("command -v dnf")) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +- [ ] **Step 3: Write the site assets** + +`setup.sh`, served at `https://packages.submersion.app/setup.sh`, must be +readable enough that a cautious user can audit it before running: + +```bash +#!/bin/sh +# Enroll this system in the Submersion package repository. +# +# Read before running. It adds one repository and one signing key, nothing +# else. To undo: +# sudo rm -f /etc/apt/sources.list.d/submersion.list \ +# /usr/share/keyrings/submersion.gpg +# sudo rm -f /etc/yum.repos.d/submersion.repo +# sudo rm -f /etc/zypp/repos.d/submersion.repo +set -eu + +CHANNEL="${1:-stable}" +case "$CHANNEL" in + stable | beta) ;; + *) + echo "Unknown channel: $CHANNEL (expected stable or beta)" >&2 + exit 2 + ;; +esac + +BASE="https://packages.submersion.app" + +# Downloaded to a file, then installed, rather than piped into sudo tee. +# POSIX sh has no pipefail, so a pipeline reports the exit status of its last +# command: curl could fail on a network error or a 404 while tee still +# succeeded, writing an empty keyring and leaving the script to print +# "Enrolled" over a repository that can never verify. +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +if command -v apt-get > /dev/null 2>&1; then + curl -fsSL "$BASE/submersion.gpg" -o "$tmpdir/submersion.gpg" + sudo install -m 644 "$tmpdir/submersion.gpg" \ + /usr/share/keyrings/submersion.gpg + printf 'deb [signed-by=/usr/share/keyrings/submersion.gpg] %s/apt %s main\n' \ + "$BASE" "$CHANNEL" > "$tmpdir/submersion.list" + sudo install -m 644 "$tmpdir/submersion.list" \ + /etc/apt/sources.list.d/submersion.list + sudo apt-get update + echo "Enrolled. Install with: sudo apt install submersion" +# zypper before dnf: openSUSE can have both, but zypper owns the package +# database there, and openSUSE does not ship dnf by default. Checking dnf +# first would send openSUSE users to the tarball branch. +elif command -v zypper > /dev/null 2>&1; then + curl -fsSL "$BASE/submersion.repo" -o "$tmpdir/repo.in" + sed "s/@CHANNEL@/$CHANNEL/" "$tmpdir/repo.in" > "$tmpdir/submersion.repo" + sudo install -m 644 "$tmpdir/submersion.repo" \ + /etc/zypp/repos.d/submersion.repo + echo "Enrolled. Install with: sudo zypper install submersion" +elif command -v dnf > /dev/null 2>&1; then + curl -fsSL "$BASE/submersion.repo" -o "$tmpdir/repo.in" + sed "s/@CHANNEL@/$CHANNEL/" "$tmpdir/repo.in" > "$tmpdir/submersion.repo" + sudo install -m 644 "$tmpdir/submersion.repo" \ + /etc/yum.repos.d/submersion.repo + echo "Enrolled. Install with: sudo dnf install submersion" +else + echo "No apt, zypper, or dnf found. Use the tarball from GitHub Releases." >&2 + exit 1 +fi +``` + +`submersion.repo` uses `@CHANNEL@` for the same substitution, sets +`gpgcheck=1`, `repo_gpgcheck=1`, and `gpgkey=https://packages.submersion.app/submersion.gpg`. +The same file serves dnf and zypper, which read the same INI format from +`/etc/yum.repos.d` and `/etc/zypp/repos.d` respectively. + +`index.html` is a plain page explaining what the site is, with the manual +enrollment commands, so someone who lands on the domain is not staring at a 404. + +- [ ] **Step 4: Write the implementation** + +`assemble_site.py` fetches both channels into temporary directories, calls the +two builders per suite, copies `site_assets/` to the site root, and runs +`check_size(root, limit_bytes=800 * 1024 * 1024)`. + +- [ ] **Step 5: Run the test to verify it passes** + +- [ ] **Step 6: Commit** + +```bash +git add scripts/release/linux_repo/ +git commit -m "feat(linux-repo): assemble the full repository site" +``` + +--- + +### Task 6: The publishing workflow + +**Files:** +- Create: `.github/workflows/publish-linux-repo.yml` in this repo (reference + copy, `workflow_dispatch` only, so it is reviewable here) +- Create: `.github/workflows/publish.yml` in `submersion-app/linux-packages` +- Create: `README.md` in `submersion-app/linux-packages` + +**Interfaces:** +- Consumes: Task 1's secrets and Task 5's `assemble_site.py`. + +- [ ] **Step 1: Write the workflow** + +```yaml +name: Publish Linux repository + +on: + repository_dispatch: + types: [publish-packages] + schedule: + # Daily reconcile: republishes when the site and the latest releases + # disagree, so a dropped dispatch is self-healing rather than permanent. + - cron: '17 4 * * *' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: publish-linux-repo + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + # The logic lives in the app repo, where the tests and review are. + # + # Non-cone sparse-checkout with a directory pattern does include that + # directory's contents: the patterns are gitignore-style, so an anchored + # path matches the directory and everything beneath it. Verified against + # git directly rather than assumed, because the behavior reads as + # ambiguous and cone mode is the more usual choice for a directory. + # Non-cone is used here to match the single-file checkout in + # build-all.yml's verify job, which cone mode cannot express. + - name: Check out the app repository + uses: actions/checkout@v7 + with: + repository: submersion-app/submersion + sparse-checkout: scripts/release/linux_repo + sparse-checkout-cone-mode: false + + # Guard rather than trust: an empty checkout would otherwise surface as + # a confusing "No such file or directory" from assemble_site.py two + # steps later. + - name: Confirm the scripts were checked out + run: test -f scripts/release/linux_repo/assemble_site.py + + - name: Install repository tools + run: | + sudo apt-get update -qq + sudo apt-get install -y dpkg-dev apt-utils createrepo-c gnupg + + - name: Assemble the site + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release/linux_repo/assemble_site.py --site _site + + - name: Import the signing key + env: + KEY: ${{ secrets.LINUX_REPO_GPG_PRIVATE_KEY }} + PASSPHRASE: ${{ secrets.LINUX_REPO_GPG_PASSPHRASE }} + run: | + set -euo pipefail + # printf rather than echo: armored key material is multi-line, and + # printf's behavior does not vary with the shell's echo semantics. + printf '%s\n' "$KEY" | gpg --batch --import + + # The passphrase goes to a 0600 file rather than into each gpg + # command's argv, matching Task 1. Process arguments are readable + # through ps and /proc by anything else on the machine. The runner + # is ephemeral and single-tenant, so the practical exposure here is + # far smaller than on the maintainer's laptop, but the cost of doing + # it properly is one line and the inconsistency was its own smell. + umask 077 + printf '%s' "$PASSPHRASE" > "$RUNNER_TEMP/gpg-pass" + + - name: Sign the APT metadata + run: | + set -euo pipefail + for suite in stable beta; do + gpg --batch --yes --pinentry-mode loopback \ + --passphrase-file "$RUNNER_TEMP/gpg-pass" \ + --clearsign -o "_site/apt/dists/$suite/InRelease" \ + "_site/apt/dists/$suite/Release" + gpg --batch --yes --pinentry-mode loopback \ + --passphrase-file "$RUNNER_TEMP/gpg-pass" \ + -abs -o "_site/apt/dists/$suite/Release.gpg" \ + "_site/apt/dists/$suite/Release" + done + + - name: Sign the DNF metadata + run: | + set -euo pipefail + for suite in stable beta; do + gpg --batch --yes --pinentry-mode loopback \ + --passphrase-file "$RUNNER_TEMP/gpg-pass" \ + --detach-sign --armor \ + -o "_site/rpm/$suite/repodata/repomd.xml.asc" \ + "_site/rpm/$suite/repodata/repomd.xml" + done + + # Proves the repository is installable before it replaces the live one. + - name: Verify apt can read the repository + run: | + set -euo pipefail + docker run --rm -v "$PWD/_site:/site:ro" debian:12 bash -c ' + # pipefail matters for the apt-cache pipeline below: without it + # a failed apt-cache is masked by tee succeeding. Note this is a + # standalone pipeline, where set -e plus pipefail does abort; in + # an if condition a failing pipeline would merely read as false. + set -euxo pipefail + apt-get update -qq && apt-get install -y -qq ca-certificates > /dev/null + cp /site/submersion.gpg /usr/share/keyrings/submersion.gpg + echo "deb [signed-by=/usr/share/keyrings/submersion.gpg] file:///site/apt stable main" \ + > /etc/apt/sources.list.d/submersion.list + apt-get update -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/submersion.list \ + -o Dir::Etc::sourceparts=/dev/null + # "Candidate: (none)" is what apt prints when the package is + # not installable, and it contains the word Candidate, so a bare + # grep for it passes on a broken repository. Assert the candidate + # is real, then prove the dependencies resolve. + apt-cache policy submersion | tee /tmp/policy + if grep -q "Candidate: (none)" /tmp/policy; then + echo "repository offers no installable candidate" + exit 1 + fi + apt-get install -y --dry-run submersion + ' + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + - id: deploy + uses: actions/deploy-pages@v4 +``` + +- [ ] **Step 2: Verify with a manual dispatch** + +Run the workflow by hand and confirm it deploys, then from a Debian container: + +```bash +docker run --rm debian:12 bash -c ' + set -eu + apt-get update -qq && apt-get install -y -qq curl ca-certificates gnupg > /dev/null + # Downloaded and then run, never piped into sh. A failed curl writes + # nothing and sh exits 0 on an empty script, so the pipeline would report + # a successful enrollment over a repository that was never added. + curl -fsSL https://packages.submersion.app/setup.sh -o /tmp/setup.sh + sh /tmp/setup.sh + apt-get install -y submersion + submersion --version +' +``` + +Expected: the version prints. This is the end-to-end proof that the whole +chain works. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/publish-linux-repo.yml +git commit -m "ci(linux-repo): publishing workflow for the package repositories" +``` + +--- + +### Task 7: Dispatch a publish from the release workflows + +**Files:** +- Modify: `.github/workflows/release.yml`, `beta.yml`, `promote.yml` + +**Interfaces:** +- Consumes: `LINUX_REPO_DISPATCH_TOKEN` from Task 1. + +- [ ] **Step 1: Add the dispatch step** + +At the end of each workflow's publishing job, after the release exists: + +```yaml + # The repository publisher reconciles nightly as well, so a failure here + # delays updates by hours rather than losing them. Non-blocking for the + # same reason: a release that is published must not be marked failed + # because a downstream index did not refresh. + - name: Ask the Linux repository to republish + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.LINUX_REPO_DISPATCH_TOKEN }} + run: | + gh api repos/submersion-app/linux-packages/dispatches \ + -f event_type=publish-packages +``` + +- [ ] **Step 2: Verify the token scope** + +```bash +GH_TOKEN= gh api repos/submersion-app/linux-packages/dispatches \ + -f event_type=publish-packages +``` + +Expected: HTTP 204 and a run appears. A 403 means the PAT lacks Contents: write +on that repository. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml .github/workflows/beta.yml \ + .github/workflows/promote.yml +git commit -m "ci: ask the Linux repository to republish after a release" +``` + +--- + +### Task 8: Self-enrollment and RPM signing in the packages + +**Files:** +- Modify: `scripts/release/stage_linux_package.py` +- Modify: `scripts/release/stage_linux_package_test.py` +- Modify: `scripts/release/build_linux_packages.sh` +- Modify: `.github/workflows/build-all.yml` + +**Interfaces:** +- Consumes: the public key from Task 1 and the site paths from Task 5. + +- [ ] **Step 1: Write the failing tests** + +Extend `stage_linux_package_test.py`: + +```python +class SelfEnrollmentTest(unittest.TestCase): + def test_deb_ships_the_sources_file_and_keyring(self): + # A direct .deb download must keep receiving updates, or most + # direct-download users run one version forever. + self.assertTrue(os.path.isfile( + ".../usr/share/keyrings/submersion.gpg")) + self.assertTrue(os.path.isfile( + ".../etc/apt/sources.list.d/submersion.list")) + + def test_deb_sources_file_pins_the_keyring(self): + self.assertIn("signed-by=/usr/share/keyrings/submersion.gpg", text) + + def test_rpm_ships_the_yum_repo_drop_in(self): + self.assertTrue(os.path.isfile(".../etc/yum.repos.d/submersion.repo")) + + def test_rpm_repo_drop_in_enables_signature_checking(self): + self.assertIn("gpgcheck=1", text) + self.assertIn("repo_gpgcheck=1", text) + + def test_deb_does_not_ship_the_rpm_drop_in_or_the_reverse(self): + ... + + def test_enrollment_targets_the_stable_suite(self): + # A package that enrolled users in beta would push prereleases to + # everyone who downloaded a stable .deb. + self.assertIn(" stable main", text) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +- [ ] **Step 3: Implement self-enrollment** + +`stage_linux_package.py` gains, per install method: + +- `deb`: `/usr/share/keyrings/submersion.gpg` and + `/etc/apt/sources.list.d/submersion.list` +- `rpm`: `/etc/yum.repos.d/submersion.repo` + +Both point at the `stable` suite. The package description gains a sentence +saying the repository is added and how to remove it. + +- [ ] **Step 4: Sign the RPM at build time** + +`build_linux_packages.sh` gains an optional `--sign-key-id`; when set, it runs +`rpmsign --addsign` on the built RPM with a loopback pinentry. `build-all.yml` +imports the key from secrets and passes the id. When the secret is absent, as +in `ci.yaml` on a fork PR, signing is skipped and the build still succeeds. + +- [ ] **Step 5: Run the tests to verify they pass** + +- [ ] **Step 6: Commit** + +```bash +git add scripts/release lib .github/workflows/build-all.yml +git commit -m "feat(linux): self-enroll packaged installs in the update repository" +``` + +--- + +### Task 9: Document enrollment + +**Files:** +- Modify: `README.md`, `docs/guide/installation.md` +- Modify: `docs/developer/release-secrets-setup.md` + +- [ ] **Step 1: Lead the install docs with the repository** + +Both install sections gain the repository as the recommended path, above the +direct download, with the manual commands spelled out alongside `setup.sh` so +nobody has to pipe a script to a shell to enroll. + +State plainly, in both places, that the `.deb` and `.rpm` add the repository +when installed directly, and give the two-line removal. + +- [ ] **Step 2: Verify no em-dashes** + +```bash +# The pattern is built from UTF-8 bytes rather than written literally, so this +# file does not itself contain the character it forbids. +EMDASH=$(printf '\xe2\x80\x94') +grep -n "$EMDASH" README.md docs/guide/installation.md \ + docs/developer/release-secrets-setup.md && echo "FOUND" || echo "clean" +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md docs/guide/installation.md \ + docs/developer/release-secrets-setup.md +git commit -m "docs: document Linux repository enrollment" +``` + +--- + +## Final Verification + +- [ ] **Run every script test suite** + +```bash +for t in scripts/release/linux_repo/*_test.py; do python3 "$t" || exit 1; done +python3 scripts/release/stage_linux_package_test.py +``` + +- [ ] **Run the Dart suite once, unpiped** + +```bash +flutter test +``` + +- [ ] **End-to-end enrollment from a clean container** + +```bash +docker run --rm debian:12 bash -c ' + set -eu + apt-get update -qq && apt-get install -y -qq curl ca-certificates gnupg > /dev/null + curl -fsSL https://packages.submersion.app/setup.sh -o /tmp/setup.sh + sh /tmp/setup.sh + apt-get install -y submersion && submersion --version' +docker run --rm fedora:latest bash -c ' + set -eu + curl -fsSL https://packages.submersion.app/submersion.repo -o /tmp/repo.in + sed s/@CHANNEL@/stable/ /tmp/repo.in > /etc/yum.repos.d/submersion.repo + dnf install -y submersion && submersion --version' +``` + +- [ ] **Verify an upgrade actually upgrades** + +Install the older of the two retained versions, then `apt update && apt upgrade` +and confirm it moves to the newer one. This is the behavior the whole phase +exists to provide, and nothing else tests it. + +- [ ] **Confirm the beta suite is separate** + +Enroll a container in `beta` and confirm it offers the beta version while a +`stable` container does not. diff --git a/docs/superpowers/plans/2026-09-04-raw-dive-data-compression.md b/docs/superpowers/plans/2026-09-04-raw-dive-data-compression.md new file mode 100644 index 0000000000..7e2617f384 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-raw-dive-data-compression.md @@ -0,0 +1,1270 @@ +# Raw Dive Data Compression Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Store `dive_data_sources.raw_data` zlib-compressed on disk, saving 2.5x to 4.8x on real dive computer downloads, with no change to any caller and no change to the sync wire format. + +**Architecture:** A pure codec wraps each blob in a self-describing 8-byte header (`"SRD1"` plus a little-endian uint32 original length) ahead of a zlib stream. A Drift `TypeConverter` on the column applies it, so every read and write is covered and the sync layer keeps seeing uncompressed bytes. A v190 migration rung recompresses existing rows and signals the one VACUUM that `DatabaseService` already owns. + +**Tech Stack:** Dart, Flutter, Drift 2.34.3, `dart:io` `ZLibCodec`, `sqlite3` via `NativeDatabase`. + +**Spec:** `docs/superpowers/specs/2026-09-04-raw-dive-data-compression-design.md` + +## Global Constraints + +- **No em-dashes** anywhere: not in code, comments, doc comments, commit messages, or this plan's output. Also no en-dash or double-hyphen used as prose punctuation. Rewrite the sentence instead. +- **No emojis** in code, comments, or documentation. +- **TDD:** the failing test is written and run before the implementation, every task. +- **`lib/core/database/database.dart` and everything it imports must stay Flutter-free.** `test/core/database/database_import_graph_test.dart` walks the graph and fails on any `package:flutter/` or `package:flutter_` import. `dart:io` and `package:drift/drift.dart` are allowed; the existing profile-series codec already pulls in `dart:io`. +- **Immutability:** never mutate a caller's `Uint8List`. Both codec directions return a new list or the input unchanged. +- **`dart format .` must be run** over the whole project before the final commit. +- **Schema rung is v190**, claimed against `origin/main` at schema version 189. Planned as v188; main took 188 (insurer phone numbers, #1522) and 189 (media equipment link, #1517) while this branch was open, so the branch merged main and the rung moved up. Re-verify at the start of Task 3 that no newly merged or open pull request has taken 190. +- **The sync wire format does not change.** `minimumCompatibleSchemaVersion` is not touched. Peers keep exchanging uncompressed, base64-encoded bytes. +- **`raw_fingerprint` is not touched** in any task. +- Run tests one file or one directory at a time. Never overlap two local `flutter test` runs, and never pipe a test run into `grep`: the pipe reports the exit status of `grep`, not of the test run. + +## Constants Used Across Tasks + +Defined once in Task 1, referenced by every later task: + +| Name | Value | Meaning | +|---|---|---| +| `kRawDiveDataMagic` | `[0x53, 0x52, 0x44, 0x31]` (ASCII `SRD1`) | Marks a compressed blob. | +| `kRawDiveDataHeaderBytes` | `8` | Magic (4) plus uint32 length (4). | +| `kMaxRawDiveBlobBytes` | `8 * 1024 * 1024` | Both the decode bound and the encode refusal threshold. | + +--- + +### Task 1: The codec + +A pure module: no Drift, no database, no I/O. Everything about the byte format lives here and is fully tested here, so Task 2 only has to wire it up. + +**Files:** +- Create: `lib/core/database/raw_dive_data_codec.dart` +- Test: `test/core/database/raw_dive_data_codec_test.dart` + +**Interfaces:** +- Consumes: `inflateBounded` and `BoundedInflateException` from `lib/core/utils/bounded_inflate.dart`. +- Produces: + - `const List kRawDiveDataMagic` + - `const int kRawDiveDataHeaderBytes` + - `const int kMaxRawDiveBlobBytes` + - `Uint8List encodeRawDiveData(Uint8List raw)` + - `Uint8List decodeRawDiveData(Uint8List stored)` + - `bool isCompressedRawDiveData(Uint8List stored)` + - `class RawDiveDataConverter extends TypeConverter` with `const RawDiveDataConverter()` + +- [ ] **Step 1: Write the failing test** + +Create `test/core/database/raw_dive_data_codec_test.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/raw_dive_data_codec.dart'; + +/// The three fixtures are the real libdivecomputer downloads already +/// committed to this repository. Compressing a synthetic byte pattern would +/// measure the pattern, not the payload, and the 2026-04-15 spec's "negligible +/// savings" ruling came from exactly that mistake. +Uint8List fixture(String path) => + Uint8List.fromList(File(path).readAsBytesSync()); + +const teric = + 'packages/libdivecomputer_plugin/android/src/androidTest/assets/' + 'shearwater_teric_dive.bin'; +const petrel3 = + 'packages/libdivecomputer_plugin/test/native/fixtures/' + 'petrel3_ccr_o2_cells.bin'; +const smallDive = + 'packages/libdivecomputer_plugin/test/native/fixtures/dive1_raw.bin'; + +void main() { + group('round trip', () { + for (final path in [teric, petrel3, smallDive]) { + test('$path survives encode then decode and gets smaller', () { + final raw = fixture(path); + final encoded = encodeRawDiveData(raw); + + expect(encoded.length, lessThan(raw.length)); + expect(isCompressedRawDiveData(encoded), isTrue); + expect(decodeRawDiveData(encoded), equals(raw)); + }); + } + }); + + test('encode does not mutate its input', () { + final raw = fixture(teric); + final before = Uint8List.fromList(raw); + + encodeRawDiveData(raw); + + expect(raw, equals(before)); + }); + + test('incompressible bytes are stored raw and read back unchanged', () { + // 32 random-looking bytes: zlib cannot beat 8 bytes of header plus its + // own framing on a payload this small. + final raw = Uint8List.fromList( + List.generate(32, (i) => (i * 97 + 13) & 0xFF), + ); + + final encoded = encodeRawDiveData(raw); + + expect(encoded, equals(raw)); + expect(isCompressedRawDiveData(encoded), isFalse); + expect(decodeRawDiveData(encoded), equals(raw)); + }); + + test('legacy bytes with no magic pass through untouched', () { + final legacy = fixture(teric); + + expect(decodeRawDiveData(legacy), equals(legacy)); + }); + + test('a legacy blob that begins with the magic reads back as itself', () { + // The one collision the format cannot rule out by the magic alone. It + // survives because decode also requires the body to inflate to exactly + // the declared length, and this body is not a zlib stream at all. + final legacy = Uint8List.fromList([ + ...kRawDiveDataMagic, + 0xFF, 0xFF, 0xFF, 0xFF, + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + + expect(decodeRawDiveData(legacy), equals(legacy)); + }); + + test('a truncated stream behind a valid magic reads back as stored', () { + // zlib accepts a truncated stream and returns what it managed to inflate, + // with no error. Only the declared-length check catches this. + final encoded = encodeRawDiveData(fixture(teric)); + final truncated = Uint8List.sublistView(encoded, 0, encoded.length - 40); + + expect(decodeRawDiveData(truncated), equals(truncated)); + }); + + test('a declared length past the cap is refused, not inflated', () { + final encoded = encodeRawDiveData(fixture(teric)); + final tampered = Uint8List.fromList(encoded); + ByteData.sublistView(tampered).setUint32(4, 0x7FFFFFFF, Endian.little); + + expect(decodeRawDiveData(tampered), equals(tampered)); + }); + + test('a zlib bomb is abandoned, not inflated', () { + // 64 MiB of zeroes compresses to a few tens of KiB and is well past + // kMaxRawDiveBlobBytes, so the bound must stop it mid-stream. + final bomb = ZLibCodec(level: 9).encode(Uint8List(64 * 1024 * 1024)); + final blob = Uint8List.fromList([ + ...kRawDiveDataMagic, + 0x00, 0x00, 0x00, 0x04, // declares 64 MiB, little-endian + ...bomb, + ]); + + expect(decodeRawDiveData(blob), equals(blob)); + }); + + test('a blob over the cap is stored raw so decode can still read it', () { + // The encoder must never mint something its own decode bound refuses. + final huge = Uint8List(kMaxRawDiveBlobBytes + 1); + + final encoded = encodeRawDiveData(huge); + + expect(encoded, same(huge)); + expect(decodeRawDiveData(encoded), equals(huge)); + }); + + test('empty and single-byte blobs round trip', () { + for (final raw in [Uint8List(0), Uint8List.fromList([42])]) { + final encoded = encodeRawDiveData(raw); + expect(decodeRawDiveData(encoded), equals(raw)); + } + }); + + group('RawDiveDataConverter', () { + const converter = RawDiveDataConverter(); + + test('toSql compresses and fromSql restores', () { + final raw = fixture(teric); + final stored = converter.toSql(raw); + + expect(stored.length, lessThan(raw.length)); + expect(converter.fromSql(stored), equals(raw)); + }); + + test('fromSql reads a legacy uncompressed row', () { + final legacy = fixture(teric); + + expect(converter.fromSql(legacy), equals(legacy)); + }); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flutter test test/core/database/raw_dive_data_codec_test.dart +``` + +Expected: FAIL at compile time with "Target of URI doesn't exist: 'package:submersion/core/database/raw_dive_data_codec.dart'". + +- [ ] **Step 3: Write the implementation** + +Create `lib/core/database/raw_dive_data_codec.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:drift/drift.dart'; +import 'package:submersion/core/utils/bounded_inflate.dart'; + +/// The at-rest encoding for `dive_data_sources.raw_data` (issue #227). +/// +/// The raw bytes libdivecomputer handed back at download time are the only +/// thing that makes a later re-parse possible, so they are kept forever and +/// there is no second copy to fall back on. Every rule here exists to make +/// losing one impossible. +/// +/// A stored blob is either the compressed form: +/// +/// ```text +/// offset size meaning +/// 0 4 magic, ASCII "SRD1" +/// 4 4 uint32 little-endian, length of the original bytes +/// 8 n zlib stream (level 6) of the original bytes +/// ``` +/// +/// or the original bytes verbatim. Nothing else records which, so the header +/// is the only discriminator and a blob carries its own answer. That is what +/// lets a partially migrated table, a row inbound from a peer that has not +/// updated, and a blob that simply does not compress all sit in one column +/// with no state to consult, and it is what lets `DiveMergeService` keep +/// copying provenance rows wholesale without knowing this format exists. +/// +/// The digit in the magic is the extension point: a future codec mints +/// "SRD2" and this decoder keeps reading "SRD1" for as long as any diver's +/// database still holds one, which is forever. +const List kRawDiveDataMagic = [0x53, 0x52, 0x44, 0x31]; + +/// Magic (4 bytes) plus the uint32 original length (4 bytes). +const int kRawDiveDataHeaderBytes = 8; + +/// The largest blob this codec will compress, and the largest it will inflate. +/// +/// One constant for both directions on purpose. A decode bound without a +/// matching encode refusal would let the encoder mint a blob its own decoder +/// rejects, which would read back as compressed garbage: the exact data loss +/// this file exists to prevent. 8 MiB is far above any dive a computer +/// records and far below anything that threatens memory. +const int kMaxRawDiveBlobBytes = 8 * 1024 * 1024; + +/// Level 6. Level 9 was measured on all three committed fixtures and gained +/// under 1.2%, which does not pay for its time. Not `const`: `ZLibCodec` has +/// no const constructor. +final ZLibCodec _zlib = ZLibCodec(level: 6); + +/// True when [stored] carries the compressed header. +/// +/// A cheap prefix test for callers that want to skip work, such as the v190 +/// migration deciding whether a row is already packed. It is NOT proof the +/// body is intact; only [decodeRawDiveData] establishes that. +bool isCompressedRawDiveData(Uint8List stored) { + if (stored.length < kRawDiveDataHeaderBytes) return false; + for (var i = 0; i < kRawDiveDataMagic.length; i++) { + if (stored[i] != kRawDiveDataMagic[i]) return false; + } + return true; +} + +/// Returns the at-rest form of [raw]. +/// +/// Compressed only when that is strictly smaller AND within +/// [kMaxRawDiveBlobBytes]; otherwise [raw] itself, unchanged and uncopied. +/// Never mutates [raw]. +Uint8List encodeRawDiveData(Uint8List raw) { + if (raw.length > kMaxRawDiveBlobBytes) return raw; + + final body = _zlib.encode(raw); + final total = kRawDiveDataHeaderBytes + body.length; + if (total >= raw.length) return raw; + + final out = Uint8List(total); + out.setRange(0, kRawDiveDataMagic.length, kRawDiveDataMagic); + ByteData.sublistView(out).setUint32(4, raw.length, Endian.little); + out.setRange(kRawDiveDataHeaderBytes, total, body); + return out; +} + +/// Returns the original bytes behind [stored]. +/// +/// Falls back to [stored] itself on anything that is not a well-formed +/// compressed blob, and never throws. A converter that threw would break +/// ordinary row mapping for the whole table, taking down pages that never +/// wanted the raw bytes; a damaged blob instead degrades into a re-parse +/// failure, which `ReparseService` already counts and surfaces. +/// +/// The declared length is load-bearing twice. It bounds the inflate, and it +/// is checked afterwards: zlib accepts a truncated stream and returns what it +/// managed to inflate with no error at all, so length equality is the only +/// completeness check available. +Uint8List decodeRawDiveData(Uint8List stored) { + if (!isCompressedRawDiveData(stored)) return stored; + + final declared = ByteData.sublistView( + stored, + 4, + kRawDiveDataHeaderBytes, + ).getUint32(0, Endian.little); + // The declared length reaches 4 GiB and is not trustworthy on its own, so + // it bounds the inflate only while it is the smaller of the two. + final cap = declared < kMaxRawDiveBlobBytes ? declared : kMaxRawDiveBlobBytes; + + try { + final inflated = inflateBounded( + Uint8List.sublistView(stored, kRawDiveDataHeaderBytes), + decoder: _zlib.decoder, + maxBytes: cap, + maxBlobBytes: kMaxRawDiveBlobBytes, + ); + if (inflated.length != declared) return stored; + return inflated; + } on BoundedInflateException { + return stored; + } +} + +/// Applies [encodeRawDiveData] and [decodeRawDiveData] to every read and +/// write of the column. +/// +/// A converter rather than encoding at the call sites, because the two +/// mistakes are not symmetrical: a write site that forgets to encode stores a +/// raw blob that still reads back correctly, so the bug is invisible, while a +/// read site that forgets to decode hands compressed bytes to +/// libdivecomputer. It is also what keeps sync transparent. Every sync path +/// for this table goes through the row's `toJson`/`fromJson`, which operate +/// on the converted Dart value, so peers keep exchanging uncompressed bytes +/// and no schema floor has to be raised. +class RawDiveDataConverter extends TypeConverter { + const RawDiveDataConverter(); + + @override + Uint8List fromSql(Uint8List fromDb) => decodeRawDiveData(fromDb); + + @override + Uint8List toSql(Uint8List value) => encodeRawDiveData(value); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +flutter test test/core/database/raw_dive_data_codec_test.dart +``` + +Expected: PASS, all tests. + +- [ ] **Step 5: Confirm the file did not break the import graph** + +```bash +flutter test test/core/database/database_import_graph_test.dart +``` + +Expected: PASS. Nothing imports the codec yet, so this is the baseline that Task 2 must preserve. + +- [ ] **Step 6: Format and commit** + +```bash +dart format . +git add lib/core/database/raw_dive_data_codec.dart test/core/database/raw_dive_data_codec_test.dart +git commit -m "feat(db): codec for compressed raw dive computer data (#227)" +``` + +--- + +### Task 2: Apply the converter to the column + +Wiring the codec onto `dive_data_sources.raw_data`. Every existing caller keeps its `Uint8List?` type, so nothing outside `database.g.dart` should need editing. The sync assertion in this task is the contract that keeps compressed bytes off the wire. + +**Files:** +- Modify: `lib/core/database/database.dart:2610` +- Modify: `lib/core/database/database.g.dart` (regenerated, not hand-edited) +- Test: `test/core/database/raw_data_column_converter_test.dart` (create) +- Test: `test/features/dive_computer/data/services/reparse_service_test.dart` (add one test) +- Test: `test/core/services/sync/sync_serializer_round_trip_test.dart` (add one test) + +**Interfaces:** +- Consumes: `RawDiveDataConverter`, `encodeRawDiveData`, `isCompressedRawDiveData` from Task 1. +- Produces: nothing new. The `DiveDataSourcesData.rawData` field stays `Uint8List?`, now holding decoded bytes. + +- [ ] **Step 1: Write the failing column test** + +Create `test/core/database/raw_data_column_converter_test.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/database/raw_dive_data_codec.dart'; + +/// The column stores compressed bytes and hands back the original ones. What +/// is asserted here is the seam itself: what SQLite holds, read with raw SQL +/// that bypasses the converter, versus what the row class returns. +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + }); + + tearDown(() => db.close()); + + Uint8List teric() => Uint8List.fromList( + File( + 'packages/libdivecomputer_plugin/android/src/androidTest/assets/' + 'shearwater_teric_dive.bin', + ).readAsBytesSync(), + ); + + // Through the companion, not raw SQL: `dives` has required columns this + // test has no business hard-coding, and a missing one is then a compile + // error naming the field rather than a runtime NOT NULL failure. The + // parent row is not optional; `beforeOpen` turns foreign keys on, so a + // dive_data_sources insert without it will not even prepare. + Future seedDive(String diveId) async { + await db + .into(db.dives) + .insert(DivesCompanion.insert(id: diveId, diveDateTime: 0)); + } + + test('a written blob lands compressed and reads back identical', () async { + final raw = teric(); + await seedDive('d1'); + await db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: 's1', + diveId: 'd1', + importedAt: DateTime.fromMillisecondsSinceEpoch(0), + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + rawData: Value(raw), + ), + ); + + final onDisk = await db + .customSelect('SELECT raw_data FROM dive_data_sources WHERE id = ?', + variables: [Variable('s1')]) + .getSingle(); + final storedBytes = onDisk.read('raw_data'); + + expect(isCompressedRawDiveData(storedBytes), isTrue); + expect(storedBytes.length, lessThan(raw.length)); + + final row = await (db.select( + db.diveDataSources, + )..where((t) => t.id.equals('s1'))).getSingle(); + expect(row.rawData, equals(raw)); + }); + + test('a legacy uncompressed row still reads back correctly', () async { + // The state every existing database is in before the v190 rung, and the + // state a row inbound from a peer that has not updated arrives in. + final raw = teric(); + await seedDive('d2'); + await db.customStatement( + 'INSERT INTO dive_data_sources ' + '(id, dive_id, is_primary, imported_at, created_at, raw_data) ' + 'VALUES (?, ?, 0, 0, 0, ?)', + ['s2', 'd2', raw], + ); + + final row = await (db.select( + db.diveDataSources, + )..where((t) => t.id.equals('s2'))).getSingle(); + + expect(row.rawData, equals(raw)); + }); + + test('a null blob stays null in both directions', () async { + await seedDive('d3'); + await db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: 's3', + diveId: 'd3', + importedAt: DateTime.fromMillisecondsSinceEpoch(0), + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + + final row = await (db.select( + db.diveDataSources, + )..where((t) => t.id.equals('s3'))).getSingle(); + + expect(row.rawData, isNull); + }); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +flutter test test/core/database/raw_data_column_converter_test.dart +``` + +Expected: FAIL on the first test, at `expect(isCompressedRawDiveData(storedBytes), isTrue)`, because the column stores the bytes verbatim today. + +- [ ] **Step 3: Apply the converter** + +In `lib/core/database/database.dart`, add the import next to the other `package:submersion/core/...` imports: + +```dart +import 'package:submersion/core/database/raw_dive_data_codec.dart'; +``` + +Then change the column at line 2610 from: + +```dart + BlobColumn get rawData => blob().nullable()(); +``` + +to: + +```dart + /// The raw bytes libdivecomputer returned for this download, zlib-compressed + /// at rest behind a self-describing header (issue #227). The converter runs + /// on every read and write, so callers see the original bytes and the sync + /// layer keeps exchanging them uncompressed. See [RawDiveDataConverter]. + BlobColumn get rawData => + blob().map(const RawDiveDataConverter()).nullable()(); +``` + +Drift wraps a non-nullable converter on a nullable column with +`NullAwareTypeConverter`, so `null` maps to `null` in both directions and the +generated field stays `Uint8List?`. + +- [ ] **Step 4: Regenerate the Drift code** + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +Expected: `database.g.dart` regenerates with a `$converterrawData` field. If the command is refused because the bare word `build` matches a read-deny rule, run it from a script in the scratchpad directory instead. + +- [ ] **Step 5: Run the column test to verify it passes** + +```bash +flutter test test/core/database/raw_data_column_converter_test.dart +``` + +Expected: PASS, all three tests. + +- [ ] **Step 6: Write the re-parse round-trip test** + +Append to `test/features/dive_computer/data/services/reparse_service_test.dart`, inside the existing top-level `main`: + +```dart + test('re-parse receives the exact bytes that were downloaded', () async { + // The path that matters most: these bytes go straight to + // libdivecomputer, and a decode this misses would hand it a zlib stream. + final raw = Uint8List.fromList( + File( + 'packages/libdivecomputer_plugin/android/src/androidTest/assets/' + 'shearwater_teric_dive.bin', + ).readAsBytesSync(), + ); + await db + .into(db.dives) + .insert(DivesCompanion.insert(id: 'dive-raw', diveDateTime: 0)); + await db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: 'src-raw', + diveId: 'dive-raw', + importedAt: DateTime.fromMillisecondsSinceEpoch(0), + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + rawData: Value(raw), + ), + ); + + final sources = await service.getSourcesForDiveReparse('dive-raw'); + + expect(sources, hasLength(1)); + expect(sources.single.rawData, equals(raw)); + }); +``` + +Add `import 'dart:io';` and `import 'dart:typed_data';` to that file if they +are not already present. Reuse whatever `db` and `service` the existing +`setUp` in that file builds; do not introduce a second harness. + +- [ ] **Step 7: Run the re-parse test** + +```bash +flutter test test/features/dive_computer/data/services/reparse_service_test.dart +``` + +Expected: PASS, the new test and every existing one. + +- [ ] **Step 8: Write the sync wire-contract test** + +This is the load-bearing assertion of the whole change. Append to +`test/core/services/sync/sync_serializer_round_trip_test.dart`, inside the +existing top-level `main`: + +```dart + test('sync exports raw dive data uncompressed (#227 wire contract)', () async { + // Compression is at-rest only. Sync payloads are gzipped whole before + // encryption, so compressing this column would buy nothing on the wire, + // and shipping compressed bytes would need the schema floor raised, which + // stops older peers syncing at all. If this test ever fails, the fix is + // to put the encoding back behind the column converter, NOT to update the + // expectation. + final raw = Uint8List.fromList( + File( + 'packages/libdivecomputer_plugin/android/src/androidTest/assets/' + 'shearwater_teric_dive.bin', + ).readAsBytesSync(), + ); + await db + .into(db.dives) + .insert(DivesCompanion.insert(id: 'dive-wire', diveDateTime: 0)); + await db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: 'src-wire', + diveId: 'dive-wire', + importedAt: DateTime.fromMillisecondsSinceEpoch(0), + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + rawData: Value(raw), + ), + ); + + final payload = await serializer.exportData(); + final row = payload.diveDataSources.singleWhere( + (r) => r['id'] == 'src-wire', + ); + + expect(base64Decode(row['rawData'] as String), equals(raw)); + }); +``` + +Add `import 'dart:convert';`, `import 'dart:io';` and `import 'dart:typed_data';` +if absent. Reuse the file's existing `db` and `serializer` from its `setUp`. If +the serializer's export entry point or the JSON key differs from `exportData()` +and `rawData`, match what the file's existing tests already use rather than +inventing a name. + +- [ ] **Step 9: Run the sync test** + +```bash +flutter test test/core/services/sync/sync_serializer_round_trip_test.dart +``` + +Expected: PASS. + +- [ ] **Step 10: Run the surrounding suites for regressions** + +One at a time, never overlapping: + +```bash +flutter test test/core/database +``` + +```bash +flutter test test/core/services/sync +``` + +```bash +flutter test test/features/dive_computer +``` + +```bash +flutter test test/features/dive_log +``` + +Expected: PASS. `database_import_graph_test` is inside the first run and must +still be green now that `database.dart` imports the codec. + +- [ ] **Step 11: Format and commit** + +```bash +dart format . +git add lib/core/database/database.dart lib/core/database/database.g.dart lib/core/database/raw_dive_data_codec.dart test/core/database/raw_data_column_converter_test.dart test/features/dive_computer/data/services/reparse_service_test.dart test/core/services/sync/sync_serializer_round_trip_test.dart +git commit -m "feat(db): compress raw dive data at rest via a column converter (#227)" +``` + +--- + +### Task 3: The v190 rung + +New downloads compress from Task 2 onward. This rung is what reaches the bytes +already on disk, which is where the savings actually are: the divers with the +most raw data are the ones who have been downloading longest. + +**Files:** +- Modify: `lib/core/database/database.dart` (version constant, `migrationVersions`, the rung body, the new flag) +- Test: `test/core/database/migration_v190_raw_data_compression_test.dart` (create) +- Test: `test/core/database/migration_v189_media_equipment_test.dart` (hand over the tripwire) + +**Interfaces:** +- Consumes: `encodeRawDiveData`, `isCompressedRawDiveData` from Task 1. +- Produces: `bool get AppDatabase.recompressedRawBlobs` and + `bool get AppDatabase.hasUnreclaimedPages`, both read by Task 4. + +- [ ] **Step 1: Re-verify the rung number** + +```bash +git fetch origin main && git show origin/main:lib/core/database/database.dart | grep -n "currentSchemaVersion = " +``` + +Expected: `189` (it read 187 when this plan was written; main advanced twice +before Task 3 began, which is exactly what this step exists to catch). If +another branch has taken 190 since, use the next free number and substitute it +everywhere below, including the test filename. + +- [ ] **Step 2: Write the failing migration test** + +Create `test/core/database/migration_v190_raw_data_compression_test.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/database/raw_dive_data_codec.dart'; + +/// v190 (issue #227): recompress `dive_data_sources.raw_data` in place. +/// +/// No DDL. The column's SQL type does not change; only the stored bytes move, +/// and the self-describing header means a row the rung skips keeps reading +/// correctly forever. That is what lets the rung be guarded per row: a blob +/// left uncompressed costs space and nothing else, and nothing about one bad +/// row justifies refusing to open the database that holds the diver's log. +void main() { + Uint8List teric() => Uint8List.fromList( + File( + 'packages/libdivecomputer_plugin/android/src/androidTest/assets/' + 'shearwater_teric_dive.bin', + ).readAsBytesSync(), + ); + + // Stamped at 189 so ONLY the v190 step runs, isolating what is asserted. + NativeDatabase setupDb(void Function(dynamic rawDb) seed) { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 189'); + rawDb.execute('CREATE TABLE dives (id TEXT PRIMARY KEY)'); + rawDb.execute(''' + CREATE TABLE dive_data_sources ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0, + imported_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + raw_data BLOB, + raw_fingerprint BLOB + ) + '''); + rawDb.execute("INSERT INTO dives (id) VALUES ('d1')"); + seed(rawDb); + }, + ); + } + + void insertSource(dynamic rawDb, String id, Uint8List? raw) { + rawDb.execute( + 'INSERT INTO dive_data_sources ' + '(id, dive_id, is_primary, imported_at, created_at, raw_data) ' + 'VALUES (?, ?, 0, 0, 0, ?)', + [id, 'd1', raw], + ); + } + + Future storedBytes(AppDatabase db, String id) async { + final row = await db + .customSelect( + 'SELECT raw_data FROM dive_data_sources WHERE id = ?', + variables: [Variable(id)], + ) + .getSingle(); + return row.readNullable('raw_data'); + } + + test('v190 is the current schema version and is in the ladder', () { + // The latest-version tripwire lives in the newest migration's test. + expect(AppDatabase.currentSchemaVersion, 190); + expect(AppDatabase.migrationVersions, contains(190)); + }); + + test('compresses existing rows without changing what they mean', () async { + final raw = teric(); + final db = AppDatabase(setupDb((rawDb) => insertSource(rawDb, 's1', raw))); + addTearDown(db.close); + + await db.customSelect('SELECT 1').get(); + + final stored = (await storedBytes(db, 's1'))!; + expect(isCompressedRawDiveData(stored), isTrue); + expect(stored.length, lessThan(raw.length)); + // Decoded by hand rather than through `db.select(db.diveDataSources)`: + // the fixture table above carries only the columns this rung reads, so a + // Drift select over the full table would fail on the absent ones. Task 2 + // already proves the converter is wired to the column. + expect(decodeRawDiveData(stored), equals(raw)); + expect(db.recompressedRawBlobs, isTrue); + }); + + test('leaves a row alone when compression would not help', () async { + final incompressible = Uint8List.fromList( + List.generate(24, (i) => (i * 31 + 7) & 0xFF), + ); + final db = AppDatabase( + setupDb((rawDb) => insertSource(rawDb, 's2', incompressible)), + ); + addTearDown(db.close); + + await db.customSelect('SELECT 1').get(); + + expect(await storedBytes(db, 's2'), equals(incompressible)); + }); + + test('a database with no raw data does not claim a reclaim', () async { + final db = AppDatabase(setupDb((rawDb) => insertSource(rawDb, 's3', null))); + addTearDown(db.close); + + await db.customSelect('SELECT 1').get(); + + expect(db.recompressedRawBlobs, isFalse); + expect(db.hasUnreclaimedPages, isFalse); + }); + + test('is idempotent: a second run re-packs nothing', () async { + final raw = teric(); + final db = AppDatabase(setupDb((rawDb) => insertSource(rawDb, 's4', raw))); + addTearDown(db.close); + await db.customSelect('SELECT 1').get(); + final afterFirst = await storedBytes(db, 's4'); + + await db.recompressRawDiveDataForTest(); + + expect(await storedBytes(db, 's4'), equals(afterFirst)); + }); + + test('a row the encoder cannot pack leaves the database openable', () async { + // The v182 profile-series rung had an unguarded pack step that could + // leave a database that would not open. This rung must not repeat it, so + // an oversized blob (which the encoder declines) has to be a no-op for + // that row and a non-event for every other one. + final raw = teric(); + final oversized = Uint8List(kMaxRawDiveBlobBytes + 1); + final db = AppDatabase( + setupDb((rawDb) { + insertSource(rawDb, 's5', oversized); + insertSource(rawDb, 's6', raw); + }), + ); + addTearDown(db.close); + + await db.customSelect('SELECT 1').get(); + + expect(await storedBytes(db, 's5'), equals(oversized)); + expect(isCompressedRawDiveData((await storedBytes(db, 's6'))!), isTrue); + }); +} +``` + +- [ ] **Step 3: Run it to verify it fails** + +```bash +flutter test test/core/database/migration_v190_raw_data_compression_test.dart +``` + +Expected: FAIL on the first test with `Expected: <190> Actual: <189>`, and on +the rest with "The method 'recompressedRawBlobs' isn't defined". + +- [ ] **Step 4: Bump the version and register the rung** + +In `lib/core/database/database.dart`, change line 3377: + +```dart + static const int currentSchemaVersion = 190; +``` + +Append to the end of the `migrationVersions` list, after `187`: + +```dart + // v190: recompress dive_data_sources.raw_data in place (issue #227). + // No DDL; the column's SQL type is unchanged and only the stored bytes + // move. Guarded per row: the self-describing header means a row this + // rung skips keeps reading correctly forever, so a blob left + // uncompressed costs space and nothing else. + 190, +``` + +- [ ] **Step 5: Write the rung body and the reclaim flag** + +Add next to `_backfillMergeSourceSlots` in `lib/core/database/database.dart`: + +```dart + /// v190: rewrite `dive_data_sources.raw_data` in its compressed at-rest + /// form (issue #227). + /// + /// PRAGMA-guarded like every other data rung, so a partial schema no-ops + /// rather than throwing. Rows already carrying the magic are skipped, which + /// is what makes a second run free and an interrupted run cost only the + /// work it already did. + /// + /// Every row is guarded on its own. An unguarded pack step in the v182 + /// profile-series rung could leave a database that would not open, which is + /// the worst outcome available to a migration and the one this rung is + /// closest to repeating. A row that will not pack is left exactly as it is + /// and logged; nothing about it justifies refusing to open the diver's log. + Future _recompressRawDiveData() async { + final cols = await customSelect( + "PRAGMA table_info('dive_data_sources')", + ).get(); + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('id') || !names.contains('raw_data')) return; + + const pageSize = 200; + String? cursor; + while (true) { + final page = await customSelect( + 'SELECT id, raw_data FROM dive_data_sources ' + 'WHERE raw_data IS NOT NULL${cursor == null ? '' : ' AND id > ?'} ' + 'ORDER BY id LIMIT $pageSize', + variables: [if (cursor != null) Variable(cursor)], + ).get(); + if (page.isEmpty) break; + cursor = page.last.read('id'); + + for (final row in page) { + final id = row.read('id'); + final stored = row.read('raw_data'); + if (isCompressedRawDiveData(stored)) continue; + try { + final packed = encodeRawDiveData(stored); + if (packed.length >= stored.length) continue; + await customStatement( + 'UPDATE dive_data_sources SET raw_data = ? WHERE id = ?', + [packed, id], + ); + _recompressedRawBlobs = true; + } catch (e, stackTrace) { + // `developer.log` is what this file already uses (see the v182 + // pack). AppDatabase has no logger field, and it must stay + // Flutter-free, so do not introduce one. + _rawBlobsLeftUncompressed++; + developer.log( + 'v190 left raw_data on dive_data_sources row $id uncompressed; ' + 'the bytes are intact and still readable', + name: 'AppDatabase', + error: e, + stackTrace: stackTrace, + ); + } + } + if (page.length < pageSize) break; + } + } + + bool _recompressedRawBlobs = false; + int _rawBlobsLeftUncompressed = 0; + + /// How many rows the v190 rung could not pack on this connection. + /// + /// Counted rather than only logged: a swallowed exception with nothing but + /// a log line is invisible to any test, and the one thing worth proving + /// about this rung is that a row it cannot pack changes nothing else. + int get rawBlobsLeftUncompressed => _rawBlobsLeftUncompressed; + + /// True once this connection's v190 rung has actually shrunk at least one + /// `raw_data` blob. + /// + /// The rewritten pages go to the freelist, and only a VACUUM returns them + /// to the filesystem. Keyed off the event rather than the stored version + /// for the same reason as [droppedLegacySampleTables]: a file with no raw + /// data crosses this rung without earning a reclaim, and rewriting it would + /// cost a diver a full-file VACUUM for nothing. + bool get recompressedRawBlobs => _recompressedRawBlobs; + + /// True when this connection did something whose freed pages are still held + /// by the file. The single signal [DatabaseService] reads to decide whether + /// its one VACUUM is worth taking. + bool get hasUnreclaimedPages => + droppedLegacySampleTables || recompressedRawBlobs; + + /// Test hook: run the v190 recompression on demand so tests can assert it + /// is idempotent. Not used in production; the migration calls the private + /// method. + Future recompressRawDiveDataForTest() => _recompressRawDiveData(); +``` + +`database.dart` already imports `dart:developer` for the v182 pack's logging; +confirm the import is present and reuse it. Do NOT add a logger field: +`AppDatabase` has none, and the file must stay Flutter-free. + +`rawBlobsLeftUncompressed` has no test that drives it above zero, because +there is no seam to force `encodeRawDiveData` to throw: an oversized blob is +declined rather than thrown on, and that path is covered. The counter exists so +the swallowed exception is observable at all, which a bare log line is not. +Record it as a known untested residual rather than inventing a seam for it. + +Then add the rung to `onUpgrade`, immediately after the `from < 187` pair +around line 10029: + +```dart + // v190: recompress dive_data_sources.raw_data in place (issue #227). + // No DDL. Guarded per row, so a blob that will not pack is left as it + // is rather than failing the ladder. + if (from < 190) { + await _recompressRawDiveData(); + } + if (from < 190) await reportProgress(); +``` + +Do NOT add a `beforeOpen` backstop for this rung. The backstops exist to +re-assert schema a partial upgrade may have missed; this rung changes no +schema, and a row it skipped reads correctly regardless. + +- [ ] **Step 6: Hand the tripwire to the new test** + +In `test/core/database/migration_v187_session_item_overdue_services_test.dart`, +change line 54 from: + +```dart + expect(AppDatabase.currentSchemaVersion, 187); +``` + +to: + +```dart + // v187 is now a past migration; the latest-version tripwire lives in the + // newest migration's test (migration_v190_raw_data_compression_test.dart), + // so assert membership rather than equality. + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(187)); + expect(AppDatabase.migrationVersions, contains(187)); +``` + +- [ ] **Step 7: Run both migration tests** + +```bash +flutter test test/core/database/migration_v190_raw_data_compression_test.dart test/core/database/migration_v187_session_item_overdue_services_test.dart +``` + +Expected: PASS, both files. + +- [ ] **Step 8: Run the whole database directory** + +```bash +flutter test test/core/database +``` + +Expected: PASS. Other migration tests assert ladder membership and step +counts, so this is where a mis-registered rung shows up. + +- [ ] **Step 9: Format and commit** + +```bash +dart format . +git add lib/core/database/database.dart test/core/database/migration_v190_raw_data_compression_test.dart test/core/database/migration_v187_session_item_overdue_services_test.dart +git commit -m "feat(db): v190 recompresses existing raw dive data (#227)" +``` + +--- + +### Task 4: Reclaim the pages, and verify + +The rung moves the old pages to the freelist. `DatabaseService` already owns +exactly one VACUUM per open, and this task teaches its two gates about the new +reclaim signal. Two lines of production code, then the full verification pass. + +**Files:** +- Modify: `lib/core/services/database_service.dart:378` +- Modify: `lib/core/services/database_service.dart:472` + +**Interfaces:** +- Consumes: `AppDatabase.hasUnreclaimedPages` from Task 3. +- Produces: nothing. + +- [ ] **Step 1: Read the two gates before touching them** + +```bash +sed -n '336,344p;374,382p;460,480p' lib/core/services/database_service.dart +``` + +Confirm line 378 reads `!willVacuum && migrator.droppedLegacySampleTables` and +line 472 reads `if (database.droppedLegacySampleTables) {`. The line numbers +shift if Task 3 touched this file, which it should not have. + +- [ ] **Step 2: Widen the migration-path gate** + +At line 378, change: + +```dart + final unplannedReclaim = + !willVacuum && migrator.droppedLegacySampleTables; +``` + +to: + +```dart + // v190 (issue #227) recompresses raw_data in place, which frees pages + // the same way the v183 drop did. A file upgrading from 184 or later + // has willVacuum false, so the recompression reaches the one VACUUM + // through this branch, unannounced as a progress step. The totals were + // fixed before the ladder started, and reporting an unplanned reclaim + // as a step would send the bar past its own total. + final unplannedReclaim = !willVacuum && migrator.hasUnreclaimedPages; +``` + +Leave `willVacuum` and its `storedBefore < 183` gate exactly as they are. A +file old enough to plan a VACUUM already gets one. + +- [ ] **Step 3: Widen the background-open gate** + +At line 472, change: + +```dart + if (database.droppedLegacySampleTables) { +``` + +to: + +```dart + if (database.hasUnreclaimedPages) { +``` + +The comment above it names the v183 backstop specifically. Extend its last +paragraph rather than replacing it: + +```dart + // The v190 recompression reaches this the same way if it ever runs on an + // open with no pending ladder. It cannot today, but the gate is one + // signal so a future rung that frees pages does not have to remember to + // add itself in two places. +``` + +- [ ] **Step 4: Run the database service tests** + +```bash +flutter test test/core/services +``` + +Expected: PASS. + +- [ ] **Step 5: Verify the compression end to end on real bytes** + +Confirm the ratios the spec claims. This has to run inside the package, or +`package:submersion/...` will not resolve, so it goes in `test/` as a +throwaway and is deleted in the same step. Create +`test/core/database/scratch_ratio_test.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/raw_dive_data_codec.dart'; + +void main() { + test('measured ratios', () { + const paths = [ + 'packages/libdivecomputer_plugin/test/native/fixtures/dive1_raw.bin', + 'packages/libdivecomputer_plugin/test/native/fixtures/' + 'petrel3_ccr_o2_cells.bin', + 'packages/libdivecomputer_plugin/android/src/androidTest/assets/' + 'shearwater_teric_dive.bin', + ]; + for (final path in paths) { + final raw = Uint8List.fromList(File(path).readAsBytesSync()); + final packed = encodeRawDiveData(raw); + final ratio = (raw.length / packed.length).toStringAsFixed(2); + // ignore: avoid_print + print('$path ${raw.length} -> ${packed.length} (${ratio}x)'); + expect(decodeRawDiveData(packed), equals(raw)); + } + }); +} +``` + +```bash +flutter test test/core/database/scratch_ratio_test.dart +``` + +Expected printed ratios of about 1.22x, 2.51x and 4.75x, matching the spec's +table, and a passing round trip. Then delete the file: + +```bash +rm test/core/database/scratch_ratio_test.dart +``` + +If a ratio is materially below the spec's, stop and report it rather than +adjusting the spec: the whole case for this change rests on that table. + +- [ ] **Step 6: Run the full suite** + +```bash +flutter test +``` + +Expected: PASS, apart from the known `local_file_resolver_test.dart` flake, +which passes when run alone and is a `TMPDIR` artifact rather than a +regression. If it fails, re-run that one file by itself to confirm. + +- [ ] **Step 7: Analyze the whole project** + +```bash +flutter analyze +``` + +Expected: no issues. Infos are fatal in CI, so treat any output as a failure. +Do not pipe this into `grep`: the pipe masks the exit status. + +- [ ] **Step 8: Format and commit** + +```bash +dart format . +git add lib/core/services/database_service.dart +git commit -m "feat(db): reclaim the pages v190 frees (#227)" +``` + +- [ ] **Step 9: Stop for push authorization** + +Do not push and do not open a pull request. Report the four commits, the +measured ratios from Step 5, and the full-suite result, then wait. + +--- + +## Verification Summary + +| Spec requirement | Where it is met | +|---|---| +| Blob format, magic, declared length | Task 1, Steps 1 and 3 | +| Decode conditions 1, 2 and 3 | Task 1, the collision, truncation and tampered-length tests | +| `kMaxRawDiveBlobBytes` bounds both directions | Task 1, the bomb test and the over-cap test | +| Encode only when strictly smaller | Task 1, the incompressible test | +| Converter on the column | Task 2, Steps 3 and 4 | +| Callers unchanged, legacy rows readable | Task 2, Steps 1 and 6 | +| The sync wire stays uncompressed | Task 2, Step 8 | +| `raw_fingerprint` untouched | No task modifies it | +| v190 rung, no DDL, idempotent | Task 3, Steps 4, 5 and the idempotence test | +| Rung guarded per row, ladder cannot fail | Task 3, Step 5 and the oversized-blob test | +| Reclaim signal drives the existing VACUUM | Task 4, Steps 2 and 3 | +| No `minimumCompatibleSchemaVersion` change | No task modifies it | diff --git a/docs/superpowers/plans/2026-09-04-uddf-raw-dive-data.md b/docs/superpowers/plans/2026-09-04-uddf-raw-dive-data.md new file mode 100644 index 0000000000..678a90fb37 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-uddf-raw-dive-data.md @@ -0,0 +1,2971 @@ +# Raw Dive Computer Data in UDDF Exports Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Carry each dive's raw libdivecomputer bytes through UDDF export and restore them losslessly on import, so a Submersion UDDF backup is a complete archive and "Re-parse raw data" still works after a restore. + +**Architecture:** Raw bytes go into the UDDF standard's `` / `` section as base64(bzip2(...)), which the spec mandates. Everything the standard cannot express (the libdivecomputer descriptor triple, the per-source metric snapshot, source identity) goes into a parallel `` record, one `` entry per `dive_data_sources` row, paired to dumps by `(diveref, ordinal)`. On import the entries replace the importer's single synthesised provenance row with one row per entry. + +**Tech Stack:** Flutter, Dart, Drift (SQLite), `xml: ^7.0.1`, `archive: ^4.0.9` (BZip2Encoder/BZip2Decoder), Riverpod. + +**Spec:** `docs/superpowers/specs/2026-09-04-uddf-raw-dive-data-design.md` + +## Global Constraints + +- **TDD.** Write the failing test first, watch it fail, then implement. Project rule from `CLAUDE.md`. +- **No em-dashes** (`—`, U+2014) in any file, comment, commit message, or ARB string. Use a colon, semicolon, comma, or two sentences. +- **No emojis** in code, comments, or documentation. +- **Immutability.** Never mutate objects or arrays. +- **Run `dart format .`** before every commit. +- **Import order:** dart, flutter, packages, local (relative). +- **File size:** 200-400 lines typical, 800 maximum. +- **The export query is a Drift-typed select, never `customSelect`.** Under issue #227 the `raw_data` column carries a `TypeConverter`; `customSelect` skips converters and would emit `SRD1`-framed zlib inside ``. Task 2 pins this with a byte-identity test. +- **Never route raw-byte reads through `getDataSources`** (`dive_repository_impl.dart:6192`). It collapses rows sharing a merge slot into one display source, and each collapsed row is the only copy of its half's `rawData`. +- `kMaxRawDiveBlobBytes = 8 * 1024 * 1024`. Issue #227 defines the same ceiling in `lib/core/database/raw_dive_data_codec.dart`. Whichever change lands second deletes its duplicate and imports the other's. +- **11 locales.** `lib/l10n/arb/app_{ar,de,en,es,fr,he,hu,it,nl,pt,zh}.arb`. Every new key goes in all 11, and generated l10n must be regenerated and committed or CI's staleness gate fails. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `lib/core/services/export/uddf/uddf_dump_codec.dart` | New. bzip2 + base64 both ways, the bounded output stream, the worker-isolate hop. No Flutter imports. | +| `lib/core/services/export/models/uddf_export_options.dart` | New. `UddfExportOptions.includeRawData`. | +| `lib/features/dive_log/domain/entities/dive_source_export.dart` | New. The per-source provenance value type crossing repository to export service. | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart` | `getSourcesForExport`, `saveComputerReadings` (batch). | +| `lib/core/services/export/uddf/uddf_export_builders.dart` | `buildDataSources`, `buildDiveComputerControl`. Pure XML over pre-encoded payloads. | +| `lib/core/services/export/uddf/uddf_full_export_service.dart` | Emits both new sections. | +| `lib/core/services/export/uddf/uddf_export_service.dart` | Same, plus its own top-level `` wrapper. | +| `lib/core/services/export/export_service.dart` | Threads options through four UDDF entry points. | +| `lib/core/services/export/uddf/uddf_source_fetch.dart` | New. The one rule for whether an export fetches source rows, shared by the notifier and the three page call sites. | +| `lib/core/services/export/uddf/uddf_full_import_service.dart` | Parses both sections and joins them. | +| `lib/features/dive_import/data/services/uddf_entity_importer.dart` | Restores one source row per entry. | +| `lib/features/settings/presentation/providers/export_providers.dart` | Fetches sources when enabled, carries counts. | + +--- + +### Task 1: The dump codec + +**Files:** +- Create: `lib/core/services/export/uddf/uddf_dump_codec.dart` +- Test: `test/core/services/export/uddf/uddf_dump_codec_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces: `const int kMaxRawDiveBlobBytes`; `class UddfDumpTooLargeException implements Exception`; `class UddfDumpCodec` with `static String encodeOne(Uint8List raw)`, `static Uint8List decodeOne(String base64Text)`, `static Future> encodeAll(List raws)`. + +`encodeAll` returns a list positionally matching its input, with `null` where that blob failed to encode. It never throws for a single bad blob. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/export/uddf/uddf_dump_codec_test.dart`: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart'; + +void main() { + group('UddfDumpCodec', () { + test('round trips the committed libdivecomputer fixtures', () { + const paths = [ + 'packages/libdivecomputer_plugin/test/native/fixtures/dive1_raw.bin', + 'packages/libdivecomputer_plugin/test/native/fixtures/' + 'petrel3_ccr_o2_cells.bin', + 'packages/libdivecomputer_plugin/android/src/androidTest/assets/' + 'shearwater_teric_dive.bin', + ]; + + for (final path in paths) { + final file = File(path); + expect(file.existsSync(), isTrue, reason: 'missing fixture: $path'); + final raw = Uint8List.fromList(file.readAsBytesSync()); + + final encoded = UddfDumpCodec.encodeOne(raw); + expect(encoded, isNotEmpty); + + final decoded = UddfDumpCodec.decodeOne(encoded); + expect(decoded, equals(raw), reason: 'round trip failed for $path'); + } + }); + + test('round trips empty and single byte payloads', () { + for (final raw in [Uint8List(0), Uint8List.fromList([0x42])]) { + expect(UddfDumpCodec.decodeOne(UddfDumpCodec.encodeOne(raw)), + equals(raw)); + } + }); + + test('decode tolerates surrounding whitespace from pretty printed XML', () { + final raw = Uint8List.fromList(List.generate(512, (i) => i % 256)); + final encoded = UddfDumpCodec.encodeOne(raw); + expect(UddfDumpCodec.decodeOne('\n $encoded\n '), equals(raw)); + }); + + test('decode refuses a payload that expands past the ceiling', () { + // Highly compressible: 12 MiB of zeros bzip2s to a few hundred bytes, + // so the guard has to fire during decode, not on the encoded size. + final bomb = Uint8List(12 * 1024 * 1024); + final encoded = UddfDumpCodec.encodeOne(bomb); + + expect( + () => UddfDumpCodec.decodeOne(encoded), + throwsA(isA()), + ); + }); + + test('decode rejects text that is not bzip2', () { + expect( + () => UddfDumpCodec.decodeOne('bm90IGJ6aXAy'), + throwsA(isA()), + ); + }); + + test('encodeAll yields null for a failed blob and keeps the rest', () async { + final good = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]); + final tooBig = Uint8List(kMaxRawDiveBlobBytes + 1); + + final result = await UddfDumpCodec.encodeAll([good, tooBig, good]); + + expect(result.length, 3); + expect(result[0], isNotNull); + expect(result[1], isNull); + expect(result[2], isNotNull); + expect(UddfDumpCodec.decodeOne(result[0]!), equals(good)); + }); + }); +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/core/services/export/uddf/uddf_dump_codec_test.dart -r compact +``` + +Expected: FAIL, `Target of URI doesn't exist: '.../uddf_dump_codec.dart'`. + +- [ ] **Step 3: Write the implementation** + +Create `lib/core/services/export/uddf/uddf_dump_codec.dart`: + +```dart +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; + +/// Largest raw dive blob this app will encode into, or accept out of, a UDDF +/// `` element. +/// +/// Issue #227 defines the same ceiling in +/// `lib/core/database/raw_dive_data_codec.dart` for the at-rest codec. +/// Whichever of the two changes lands second deletes its duplicate and +/// imports the other's, so there is exactly one definition of this number. +const int kMaxRawDiveBlobBytes = 8 * 1024 * 1024; + +/// A dump exceeded [kMaxRawDiveBlobBytes], encoding or decoding. +/// +/// Decompression bombs are the reason this exists: a `` in a file +/// from anywhere else is untrusted input, and bzip2 expands far more +/// aggressively than zlib. +class UddfDumpTooLargeException implements Exception { + const UddfDumpTooLargeException(); + + @override + String toString() => + 'UddfDumpTooLargeException: raw dive dump exceeds ' + '$kMaxRawDiveBlobBytes bytes'; +} + +/// Encodes and decodes the payload of a UDDF `` element. +/// +/// UDDF 3.2.1 requires that "the memory dump data shall be compressed with +/// bzip2 algorithm and encoded into ASCII using Base64 encoder". bzip2 is +/// therefore not a choice; gzip would produce a file no other UDDF reader +/// can open. +/// +/// Note that this is NOT the codec issue #227 uses to compress the same +/// bytes at rest. That one is zlib behind an `SRD1` header, chosen for +/// space; this one is dictated by the file format. Bytes read from Drift +/// arrive already decoded by #227's `TypeConverter`, so the export always +/// decodes then recompresses. Copying the stored blob through would be +/// faster and would produce a spec-violating file. +class UddfDumpCodec { + const UddfDumpCodec._(); + + /// bzip2 then base64. Throws [UddfDumpTooLargeException] rather than mint + /// a dump its own decoder would refuse. + static String encodeOne(Uint8List raw) { + if (raw.length > kMaxRawDiveBlobBytes) { + throw const UddfDumpTooLargeException(); + } + return base64.encode(BZip2Encoder().encodeBytes(raw)); + } + + /// base64 then bzip2, bounded at [kMaxRawDiveBlobBytes]. + /// + /// Decoding runs into a bounded [OutputStream] rather than + /// `BZip2Decoder.decodeBytes`, so a bomb is abandoned mid-stream instead + /// of being fully allocated and then measured. + static Uint8List decodeOne(String base64Text) { + final compressed = base64.decode(base64Text.trim()); + final output = _BoundedOutputStream(kMaxRawDiveBlobBytes); + final ok = BZip2Decoder().decodeStream( + InputMemoryStream(compressed), + output, + ); + if (!ok) { + throw const FormatException('Malformed bzip2 payload in '); + } + return Uint8List.fromList(output.getBytes()); + } + + /// Encode many blobs on a worker isolate. + /// + /// Returns a list positionally matching [raws], carrying `null` wherever + /// that blob could not be encoded. A single bad blob must not cost the + /// user the whole export: this is a backup path, and a file missing one + /// dump beats no file at all. + /// + /// bzip2 is a block sorting compressor and is markedly slower in pure Dart + /// than zlib, which is why a whole logbook's worth of it does not run on + /// the calling isolate. + static Future> encodeAll(List raws) { + if (raws.isEmpty) return Future.value(const []); + return _encodeOnWorker(raws); + } + + /// Minimal-scope hop to the worker isolate. + /// + /// Kept as its own method so the [Isolate.run] closure's enclosing scope + /// holds only sendable values: Dart closures capture the whole enclosing + /// scope, not only what they reference. This mirrors + /// `backup_crypto.dart`, which documents the same hazard. + static Future> _encodeOnWorker(List raws) { + return Isolate.run(() => _encodeAllImpl(raws), debugName: 'uddf-dcdump'); + } + + static List _encodeAllImpl(List raws) { + return raws.map((raw) { + try { + return encodeOne(raw); + } catch (_) { + return null; + } + }).toList(growable: false); + } +} + +/// An [OutputStream] that refuses to grow past [maxBytes]. +/// +/// Delegates to an [OutputMemoryStream] rather than reimplementing its +/// buffer, and checks the bound before each write so an oversized stream is +/// abandoned partway rather than after it is fully materialised. +class _BoundedOutputStream extends OutputStream { + _BoundedOutputStream(this.maxBytes) + : _inner = OutputMemoryStream(), + super(byteOrder: ByteOrder.littleEndian); + + final int maxBytes; + final OutputMemoryStream _inner; + + void _guard(int adding) { + if (_inner.length + adding > maxBytes) { + throw const UddfDumpTooLargeException(); + } + } + + @override + int get length => _inner.length; + + @override + void writeByte(int value) { + _guard(1); + _inner.writeByte(value); + } + + @override + void writeBytes(List bytes, {int? length}) { + _guard(length ?? bytes.length); + _inner.writeBytes(bytes, length: length); + } + + @override + void writeStream(InputStream stream) { + _guard(stream.length); + _inner.writeStream(stream); + } + + @override + void clear() => _inner.clear(); + + @override + void flush() => _inner.flush(); + + @override + Uint8List subset(int start, [int? end]) => _inner.subset(start, end); +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +flutter test test/core/services/export/uddf/uddf_dump_codec_test.dart -r compact +``` + +Expected: PASS, 6 tests. + +If the bomb test is slow (it compresses 12 MiB), that is expected; bzip2 in pure Dart is not fast. If it exceeds the default timeout, keep the test and reduce the bomb to 9 MiB, which still clears the 8 MiB ceiling. + +- [ ] **Step 5: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/core/services/export/uddf/uddf_dump_codec.dart test/core/services/export/uddf/uddf_dump_codec_test.dart +git commit -m "feat(export): add the UDDF dcdump bzip2 codec (#228)" +``` + +--- + +### Task 2: The export query and its byte-identity guard + +**Files:** +- Create: `lib/features/dive_log/domain/entities/dive_source_export.dart` +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (add near the "Dive Data Sources" section, around line 6190) +- Test: `test/features/dive_log/data/repositories/sources_for_export_test.dart` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `class DiveSourceExport` (fields listed below); `Future> getSourcesForExport(List diveIds)` on `DiveRepository`. + +`DiveSourceExport` fields: `String id`, `String diveId`, `int ordinal`, `bool isPrimary`, `Uint8List? rawData`, `Uint8List? rawFingerprint`, `String? computerId`, `String? computerModel`, `String? computerSerial`, `String? sourceFormat`, `String? sourceFileName`, `String? sourceFileFormat`, `String? sourceUuid`, `String? descriptorVendor`, `String? descriptorProduct`, `int? descriptorModel`, `String? libdivecomputerVersion`, `int? mergeSourceSlot`, `int? timeOffsetSeconds`, `double? maxDepth`, `double? avgDepth`, `int? duration`, `double? waterTemp`, `double? entryLatitude`, `double? entryLongitude`, `double? exitLatitude`, `double? exitLongitude`, `DateTime? entryTime`, `DateTime? exitTime`, `double? maxAscentRate`, `double? maxDescentRate`, `int? surfaceInterval`, `double? cns`, `double? otu`, `String? decoAlgorithm`, `int? gradientFactorLow`, `int? gradientFactorHigh`, `DateTime importedAt`, `DateTime createdAt`, `DateTime? lastParsedAt`. Convenience getter `bool get hasDump => rawData != null && rawData!.isNotEmpty;`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/data/repositories/sources_for_export_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveRepository repository; + + // DiveRepository takes no database argument: it reads + // DatabaseService.instance, which setUpTestDatabase points at an in-memory + // database. Constructing it with a database would not compile. + setUp(() async { + db = await setUpTestDatabase(); + repository = DiveRepository(); + }); + + tearDown(() async => tearDownTestDatabase()); + + Future insertDive(String id) async { + final now = DateTime.now().millisecondsSinceEpoch; + await db.into(db.dives).insert( + DivesCompanion( + id: Value(id), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + } + + Future insertSource( + String id, + String diveId, { + Uint8List? rawData, + bool isPrimary = false, + int? mergeSourceSlot, + DateTime? createdAt, + }) async { + final stamp = createdAt ?? DateTime(2019, 6, 2, 18, 41, 7); + await db.into(db.diveDataSources).insert( + DiveDataSourcesCompanion( + id: Value(id), + diveId: Value(diveId), + isPrimary: Value(isPrimary), + rawData: rawData == null ? const Value.absent() : Value(rawData), + mergeSourceSlot: mergeSourceSlot == null + ? const Value.absent() + : Value(mergeSourceSlot), + descriptorVendor: const Value('Shearwater'), + descriptorProduct: const Value('Perdix'), + descriptorModel: const Value(5), + importedAt: Value(stamp), + createdAt: Value(stamp), + ), + ); + } + + test('returns raw bytes byte identical to what was written', () async { + // The load bearing assertion. Under issue #227 raw_data carries a Drift + // TypeConverter; customSelect skips converters, so a raw SQL version of + // getSourcesForExport would hand back SRD1 framed zlib and this fails. + final raw = Uint8List.fromList( + List.generate(4096, (i) => (i * 31) % 256), + ); + await insertDive('dive-1'); + await insertSource('src-1', 'dive-1', rawData: raw, isPrimary: true); + + final sources = await repository.getSourcesForExport(['dive-1']); + + expect(sources, hasLength(1)); + expect(sources.single.rawData, equals(raw)); + expect(sources.single.hasDump, isTrue); + }); + + test('returns rows without raw data too, marked as having no dump', + () async { + await insertDive('dive-1'); + await insertSource('src-1', 'dive-1', isPrimary: true); + + final sources = await repository.getSourcesForExport(['dive-1']); + + expect(sources, hasLength(1)); + expect(sources.single.rawData, isNull); + expect(sources.single.hasDump, isFalse); + }); + + test('does not collapse rows sharing a merge slot', () async { + // getDataSources collapses these into one display source. Each row is + // the only copy of its half's rawData, so the export must see both. + final a = Uint8List.fromList([1, 2, 3]); + final b = Uint8List.fromList([4, 5, 6]); + await insertDive('dive-1'); + await insertSource('src-a', 'dive-1', + rawData: a, + isPrimary: true, + mergeSourceSlot: 0, + createdAt: DateTime(2019, 6, 2)); + await insertSource('src-b', 'dive-1', + rawData: b, + mergeSourceSlot: 0, + createdAt: DateTime(2019, 6, 3)); + + final sources = await repository.getSourcesForExport(['dive-1']); + + expect(sources, hasLength(2)); + expect(sources.map((s) => s.rawData), containsAll([a, b])); + }); + + test('numbers ordinals per dive, primary first then createdAt', () async { + await insertDive('dive-1'); + await insertDive('dive-2'); + await insertSource('src-late', 'dive-1', + createdAt: DateTime(2019, 6, 4)); + await insertSource('src-primary', 'dive-1', + isPrimary: true, createdAt: DateTime(2019, 6, 5)); + await insertSource('src-other', 'dive-2', isPrimary: true); + + final sources = await repository.getSourcesForExport(['dive-1', 'dive-2']); + + final diveOne = sources.where((s) => s.diveId == 'dive-1').toList(); + expect(diveOne.map((s) => s.id), ['src-primary', 'src-late']); + expect(diveOne.map((s) => s.ordinal), [0, 1]); + + final diveTwo = sources.where((s) => s.diveId == 'dive-2').toList(); + expect(diveTwo.single.ordinal, 0); + }); + + test('returns an empty list for an empty id list without querying', + () async { + expect(await repository.getSourcesForExport(const []), isEmpty); + }); +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/features/dive_log/data/repositories/sources_for_export_test.dart -r compact +``` + +Expected: FAIL, `DiveSourceExport` and `getSourcesForExport` do not exist. + +- [ ] **Step 3: Create the value type** + +Create `lib/features/dive_log/domain/entities/dive_source_export.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; + +/// One `dive_data_sources` row as the UDDF exporter needs to see it. +/// +/// This is the whole row rather than just the bytes. A UDDF `` carries +/// only the consolidated values, so the per-source snapshot (max depth, cns, +/// deco algorithm, entry and exit) exists nowhere else in the document, and a +/// restore that dropped it would be lossless only about the blob. +/// +/// [ordinal] is this row's position among its own dive's rows in the order +/// the repository returned them. It is what pairs a `` entry with its +/// ``, because `` accepts no attributes +/// and so cannot carry an id of its own. +class DiveSourceExport extends Equatable { + const DiveSourceExport({ + required this.id, + required this.diveId, + required this.ordinal, + required this.isPrimary, + required this.importedAt, + required this.createdAt, + this.rawData, + this.rawFingerprint, + this.computerId, + this.computerModel, + this.computerSerial, + this.sourceFormat, + this.sourceFileName, + this.sourceFileFormat, + this.sourceUuid, + this.descriptorVendor, + this.descriptorProduct, + this.descriptorModel, + this.libdivecomputerVersion, + this.mergeSourceSlot, + this.timeOffsetSeconds, + this.maxDepth, + this.avgDepth, + this.duration, + this.waterTemp, + this.entryLatitude, + this.entryLongitude, + this.exitLatitude, + this.exitLongitude, + this.entryTime, + this.exitTime, + this.maxAscentRate, + this.maxDescentRate, + this.surfaceInterval, + this.cns, + this.otu, + this.decoAlgorithm, + this.gradientFactorLow, + this.gradientFactorHigh, + this.lastParsedAt, + }); + + final String id; + final String diveId; + final int ordinal; + final bool isPrimary; + final DateTime importedAt; + final DateTime createdAt; + + final Uint8List? rawData; + final Uint8List? rawFingerprint; + + final String? computerId; + final String? computerModel; + final String? computerSerial; + final String? sourceFormat; + final String? sourceFileName; + final String? sourceFileFormat; + final String? sourceUuid; + + final String? descriptorVendor; + final String? descriptorProduct; + final int? descriptorModel; + final String? libdivecomputerVersion; + + final int? mergeSourceSlot; + final int? timeOffsetSeconds; + + final double? maxDepth; + final double? avgDepth; + final int? duration; + final double? waterTemp; + final double? entryLatitude; + final double? entryLongitude; + final double? exitLatitude; + final double? exitLongitude; + final DateTime? entryTime; + final DateTime? exitTime; + final double? maxAscentRate; + final double? maxDescentRate; + final int? surfaceInterval; + final double? cns; + final double? otu; + final String? decoAlgorithm; + final int? gradientFactorLow; + final int? gradientFactorHigh; + final DateTime? lastParsedAt; + + /// Whether this row contributes a ``. + bool get hasDump => rawData != null && rawData!.isNotEmpty; + + @override + List get props => [ + id, + diveId, + ordinal, + isPrimary, + importedAt, + createdAt, + rawData, + rawFingerprint, + computerId, + computerModel, + computerSerial, + sourceFormat, + sourceFileName, + sourceFileFormat, + sourceUuid, + descriptorVendor, + descriptorProduct, + descriptorModel, + libdivecomputerVersion, + mergeSourceSlot, + timeOffsetSeconds, + maxDepth, + avgDepth, + duration, + waterTemp, + entryLatitude, + entryLongitude, + exitLatitude, + exitLongitude, + entryTime, + exitTime, + maxAscentRate, + maxDescentRate, + surfaceInterval, + cns, + otu, + decoAlgorithm, + gradientFactorLow, + gradientFactorHigh, + lastParsedAt, + ]; +} +``` + +- [ ] **Step 4: Add the repository method** + +Add the import at the top of `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (in the local import group): + +```dart +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; +``` + +Add this method immediately after `getDataSources` (which ends around line 6213): + +```dart + /// Every `dive_data_sources` row for [diveIds], for UDDF export. + /// + /// Deliberately NOT built on [getDataSources]: that one runs rows through + /// `_canonicalDataSourceRows`, which collapses rows sharing a merge slot + /// into one display source. Each collapsed row is the sole surviving copy + /// of its half's `rawData`, so exporting through it would silently drop + /// half of a combined dive's bytes. + /// + /// Rows with no `rawData` are included too. The provenance record has to + /// cover them or a dive with one plain source beside one carrying bytes + /// would restore with fewer sources than it had. + /// + /// This MUST stay a Drift typed select. Issue #227 puts a `TypeConverter` + /// on `raw_data`; typed selects run converters and `customSelect` does + /// not, so a raw SQL version would return `SRD1` framed zlib and the + /// export would write compressed bytes into `` with nothing to + /// catch it. `sources_for_export_test.dart` pins this. + /// + /// The ordering is part of the contract: it defines the ordinals that pair + /// a `` entry with its ``. + Future> getSourcesForExport( + List diveIds, + ) async { + if (diveIds.isEmpty) return const []; + try { + final query = _db.select(_db.diveDataSources) + ..where((t) => t.diveId.isIn(diveIds)) + ..orderBy([ + (t) => OrderingTerm.asc(t.diveId), + (t) => OrderingTerm.desc(t.isPrimary), + (t) => OrderingTerm.asc(t.createdAt), + (t) => OrderingTerm.asc(t.id), + ]); + final rows = await query.get(); + + final ordinalByDive = {}; + return rows.map((row) { + final ordinal = ordinalByDive.update( + row.diveId, + (value) => value + 1, + ifAbsent: () => 0, + ); + return DiveSourceExport( + id: row.id, + diveId: row.diveId, + ordinal: ordinal, + isPrimary: row.isPrimary, + importedAt: row.importedAt, + createdAt: row.createdAt, + rawData: row.rawData, + rawFingerprint: row.rawFingerprint, + computerId: row.computerId, + computerModel: row.computerModel, + computerSerial: row.computerSerial, + sourceFormat: row.sourceFormat, + sourceFileName: row.sourceFileName, + sourceFileFormat: row.sourceFileFormat, + sourceUuid: row.sourceUuid, + descriptorVendor: row.descriptorVendor, + descriptorProduct: row.descriptorProduct, + descriptorModel: row.descriptorModel, + libdivecomputerVersion: row.libdivecomputerVersion, + mergeSourceSlot: row.mergeSourceSlot, + timeOffsetSeconds: row.timeOffsetSeconds, + maxDepth: row.maxDepth, + avgDepth: row.avgDepth, + duration: row.duration, + waterTemp: row.waterTemp, + entryLatitude: row.entryLatitude, + entryLongitude: row.entryLongitude, + exitLatitude: row.exitLatitude, + exitLongitude: row.exitLongitude, + entryTime: row.entryTime, + exitTime: row.exitTime, + maxAscentRate: row.maxAscentRate, + maxDescentRate: row.maxDescentRate, + surfaceInterval: row.surfaceInterval, + cns: row.cns, + otu: row.otu, + decoAlgorithm: row.decoAlgorithm, + gradientFactorLow: row.gradientFactorLow, + gradientFactorHigh: row.gradientFactorHigh, + lastParsedAt: row.lastParsedAt, + ); + }).toList(growable: false); + } catch (e, stackTrace) { + _log.error( + 'Failed to load data sources for export', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } +``` + +Note the class in this file is named `DiveRepository`, not `DiveRepositoryImpl`, despite the filename. It is a factory constructor taking only optional media dependencies, and it resolves its database through `DatabaseService.instance`, which is why the test uses `setUpTestDatabase()` rather than passing a database in. + +- [ ] **Step 5: Run the test and verify it passes** + +```bash +flutter test test/features/dive_log/data/repositories/sources_for_export_test.dart -r compact +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 6: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/features/dive_log/domain/entities/dive_source_export.dart lib/features/dive_log/data/repositories/dive_repository_impl.dart test/features/dive_log/data/repositories/sources_for_export_test.dart +git commit -m "feat(export): read every data source row for UDDF export (#228)" +``` + +--- + +### Task 3: The XML builders + +**Files:** +- Modify: `lib/core/services/export/uddf/uddf_export_builders.dart` +- Test: `test/core/services/export/uddf/uddf_dcdump_builders_test.dart` + +**Interfaces:** +- Consumes: `DiveSourceExport` from Task 2. +- Produces, both `static` on `UddfExportBuilders`: + - `void buildDataSources(XmlBuilder builder, List sources)` + - `void buildDiveComputerControl(XmlBuilder builder, List sources, Map encodedById, {required bool declaresComputers})` + +`encodedById` maps `DiveSourceExport.id` to its base64 payload, or to `null` when encoding failed. A source absent from the map, or mapped to `null`, contributes no ``. + +`declaresComputers` is false for the dives-only export, which emits no `` element, so a `` there would dangle. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/export/uddf/uddf_dcdump_builders_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; +import 'package:submersion/core/services/export/uddf/uddf_export_builders.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; + +DiveSourceExport source({ + required String id, + required String diveId, + required int ordinal, + bool isPrimary = true, + Uint8List? rawData, + String? computerId, +}) { + return DiveSourceExport( + id: id, + diveId: diveId, + ordinal: ordinal, + isPrimary: isPrimary, + importedAt: DateTime(2019, 6, 2, 18, 41, 7), + createdAt: DateTime(2019, 6, 2, 18, 41, 7), + rawData: rawData, + rawFingerprint: Uint8List.fromList([0xA1, 0x7F, 0x3C]), + computerId: computerId, + computerModel: 'Perdix AI', + computerSerial: 'SN123', + descriptorVendor: 'Shearwater', + descriptorProduct: 'Perdix', + descriptorModel: 5, + libdivecomputerVersion: '0.9.0-devel', + mergeSourceSlot: 0, + timeOffsetSeconds: 0, + maxDepth: 31.5, + cns: 12.5, + decoAlgorithm: 'ZHL16C', + ); +} + +String render(void Function(XmlBuilder) body) { + final builder = XmlBuilder(); + builder.element('root', nest: () => body(builder)); + return builder.buildDocument().toXmlString(pretty: true); +} + +void main() { + group('buildDataSources', () { + test('writes one entry per source with hasdump reflecting the bytes', () { + final withBytes = source( + id: 'src-a', + diveId: 'dive-1', + ordinal: 0, + rawData: Uint8List.fromList([1, 2, 3]), + ); + final withoutBytes = + source(id: 'src-b', diveId: 'dive-1', ordinal: 1, isPrimary: false); + + final xml = render( + (b) => UddfExportBuilders.buildDataSources(b, [ + withBytes, + withoutBytes, + ]), + ); + final doc = XmlDocument.parse(xml); + final entries = doc.findAllElements('source').toList(); + + expect(entries, hasLength(2)); + expect(entries[0].getAttribute('diveref'), 'dive_dive-1'); + expect(entries[0].getAttribute('ordinal'), '0'); + expect(entries[0].getAttribute('hasdump'), 'true'); + expect(entries[1].getAttribute('hasdump'), 'false'); + + final descriptor = entries[0].findElements('descriptor').single; + expect(descriptor.getAttribute('vendor'), 'Shearwater'); + expect(descriptor.getAttribute('product'), 'Perdix'); + expect(descriptor.getAttribute('model'), '5'); + + expect(entries[0].findElements('fingerprint').single.innerText, 'A17F3C'); + expect(entries[0].findElements('primary').single.innerText, 'true'); + expect(entries[1].findElements('primary').single.innerText, 'false'); + expect(entries[0].findElements('maxdepth').single.innerText, '31.5'); + expect( + entries[0].findElements('decoalgorithm').single.innerText, + 'ZHL16C', + ); + expect( + entries[0].findElements('importedat').single.innerText, + DateTime(2019, 6, 2, 18, 41, 7).toIso8601String(), + ); + }); + + test('writes nothing at all for an empty source list', () { + final xml = render((b) => UddfExportBuilders.buildDataSources(b, const [])); + expect(XmlDocument.parse(xml).findAllElements('datasources'), isEmpty); + }); + }); + + group('buildDiveComputerControl', () { + test('emits a dump only for sources with an encoded payload', () { + final a = source( + id: 'src-a', + diveId: 'dive-1', + ordinal: 0, + rawData: Uint8List.fromList([1, 2, 3]), + computerId: 'computer_9c21', + ); + final b = source( + id: 'src-b', + diveId: 'dive-1', + ordinal: 1, + isPrimary: false, + rawData: Uint8List.fromList([4, 5, 6]), + ); + final c = source(id: 'src-c', diveId: 'dive-2', ordinal: 0); + + final xml = render( + (builder) => UddfExportBuilders.buildDiveComputerControl( + builder, + [a, b, c], + const {'src-a': 'QlpoOUFB', 'src-b': null}, + declaresComputers: true, + ), + ); + final doc = XmlDocument.parse(xml); + final dumps = doc.findAllElements('divecomputerdump').toList(); + + expect(dumps, hasLength(1)); + expect(dumps.single.findElements('dcdump').single.innerText, 'QlpoOUFB'); + final refs = dumps.single + .findElements('link') + .map((e) => e.getAttribute('ref')) + .toList(); + expect(refs, ['dive_dive-1', 'computer_9c21']); + expect( + dumps.single.findElements('datetime').single.innerText, + DateTime(2019, 6, 2, 18, 41, 7).toIso8601String(), + ); + }); + + test('omits the computer link when the document declares no computers', + () { + final a = source( + id: 'src-a', + diveId: 'dive-1', + ordinal: 0, + rawData: Uint8List.fromList([1, 2, 3]), + computerId: 'computer_9c21', + ); + + final xml = render( + (builder) => UddfExportBuilders.buildDiveComputerControl( + builder, + [a], + const {'src-a': 'QlpoOUFB'}, + declaresComputers: false, + ), + ); + final refs = XmlDocument.parse(xml) + .findAllElements('link') + .map((e) => e.getAttribute('ref')) + .toList(); + + // A ref pointing at an undeclared id would dangle under IDREF + // validation, and the dives only export declares no . + expect(refs, ['dive_dive-1']); + }); + + test('writes no section when nothing encoded', () { + final xml = render( + (builder) => UddfExportBuilders.buildDiveComputerControl( + builder, + [source(id: 'src-a', diveId: 'dive-1', ordinal: 0)], + const {}, + declaresComputers: true, + ), + ); + expect( + XmlDocument.parse(xml).findAllElements('divecomputercontrol'), + isEmpty, + ); + }); + }); +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/core/services/export/uddf/uddf_dcdump_builders_test.dart -r compact +``` + +Expected: FAIL, `buildDataSources` is not defined. + +- [ ] **Step 3: Write the builders** + +Add these imports to `lib/core/services/export/uddf/uddf_export_builders.dart` if not already present: + +```dart +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; +``` + +Add both methods to `UddfExportBuilders`, after `buildApplicationData`: + +```dart + /// Hex encode, matching SQLite's `hex()` and the convention + /// `dive_repository_impl.dart` documents for raw fingerprints. + static String _hex(Uint8List bytes) => bytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(); + + /// The Submersion per-source provenance record. + /// + /// One `` entry per `dive_data_sources` row, carrying every column + /// except the blob itself. This exists because UDDF has nowhere to put the + /// libdivecomputer descriptor triple, without which a restored dump can + /// never be re-parsed, and nowhere to put the per-source metric snapshot, + /// since a UDDF `` carries only the consolidated values. + /// + /// Entries are written for rows with no bytes as well. Omitting them would + /// restore a dive that had one plain source beside one carrying bytes with + /// fewer sources than it had. + static void buildDataSources( + XmlBuilder builder, + List sources, + ) { + if (sources.isEmpty) return; + + builder.element( + 'datasources', + nest: () { + for (final source in sources) { + builder.element( + 'source', + attributes: { + 'diveref': 'dive_${source.diveId}', + 'ordinal': '${source.ordinal}', + 'hasdump': '${source.hasDump}', + }, + nest: () { + if (source.descriptorVendor != null || + source.descriptorProduct != null || + source.descriptorModel != null) { + builder.element( + 'descriptor', + attributes: { + if (source.descriptorVendor != null) + 'vendor': source.descriptorVendor!, + if (source.descriptorProduct != null) + 'product': source.descriptorProduct!, + if (source.descriptorModel != null) + 'model': '${source.descriptorModel}', + }, + ); + } + _text(builder, 'libdivecomputerversion', + source.libdivecomputerVersion); + _text(builder, 'sourceuuid', source.sourceUuid); + if (source.rawFingerprint != null && + source.rawFingerprint!.isNotEmpty) { + builder.element( + 'fingerprint', + nest: _hex(source.rawFingerprint!), + ); + } + builder.element('primary', nest: '${source.isPrimary}'); + _number(builder, 'mergesourceslot', source.mergeSourceSlot); + _number(builder, 'timeoffsetseconds', source.timeOffsetSeconds); + _text(builder, 'computermodel', source.computerModel); + _text(builder, 'computerserial', source.computerSerial); + _text(builder, 'sourceformat', source.sourceFormat); + _text(builder, 'sourcefilename', source.sourceFileName); + _text(builder, 'sourcefileformat', source.sourceFileFormat); + _date(builder, 'importedat', source.importedAt); + _date(builder, 'createdat', source.createdAt); + _date(builder, 'lastparsedat', source.lastParsedAt); + _number(builder, 'maxdepth', source.maxDepth); + _number(builder, 'avgdepth', source.avgDepth); + _number(builder, 'duration', source.duration); + _number(builder, 'watertemp', source.waterTemp); + _number(builder, 'entrylatitude', source.entryLatitude); + _number(builder, 'entrylongitude', source.entryLongitude); + _number(builder, 'exitlatitude', source.exitLatitude); + _number(builder, 'exitlongitude', source.exitLongitude); + _date(builder, 'entrytime', source.entryTime); + _date(builder, 'exittime', source.exitTime); + _number(builder, 'maxascentrate', source.maxAscentRate); + _number(builder, 'maxdescentrate', source.maxDescentRate); + _number(builder, 'surfaceinterval', source.surfaceInterval); + _number(builder, 'cns', source.cns); + _number(builder, 'otu', source.otu); + _text(builder, 'decoalgorithm', source.decoAlgorithm); + _number(builder, 'gradientfactorlow', source.gradientFactorLow); + _number(builder, 'gradientfactorhigh', source.gradientFactorHigh); + }, + ); + } + }, + ); + } + + static void _text(XmlBuilder builder, String name, String? value) { + if (value == null || value.isEmpty) return; + builder.element(name, nest: value); + } + + static void _number(XmlBuilder builder, String name, num? value) { + if (value == null) return; + builder.element(name, nest: '$value'); + } + + static void _date(XmlBuilder builder, String name, DateTime? value) { + if (value == null) return; + builder.element(name, nest: value.toIso8601String()); + } + + /// The UDDF standard `` section, which the spec + /// places last in the document. + /// + /// One `` per source that has an encoded payload in + /// [encodedById]. A source mapped to null failed to compress; its dump and + /// nothing else is omitted, because this is a backup path and a file + /// missing one dump beats no file at all. + /// + /// [declaresComputers] must be false when the document emits no + /// `` element, which is the case for the dives only export. + /// Every ref this writes has to point at an id the standard itself + /// declares, or it dangles under IDREF validation. + static void buildDiveComputerControl( + XmlBuilder builder, + List sources, + Map encodedById, { + required bool declaresComputers, + }) { + final withPayload = sources + .where((s) => encodedById[s.id] != null) + .toList(growable: false); + if (withPayload.isEmpty) return; + + builder.element( + 'divecomputercontrol', + nest: () { + for (final source in withPayload) { + builder.element( + 'divecomputerdump', + nest: () { + builder.element( + 'link', + attributes: {'ref': 'dive_${source.diveId}'}, + ); + if (declaresComputers && source.computerId != null) { + builder.element( + 'link', + attributes: {'ref': source.computerId!}, + ); + } + // The spec means "when the dump was captured", so this is the + // source row's importedAt, not the dive's own datetime. The + // dive link is what ties the dump to its dive. + builder.element( + 'datetime', + nest: source.importedAt.toIso8601String(), + ); + builder.element('dcdump', nest: encodedById[source.id]!); + }, + ); + } + }, + ); + } +``` + +If `_text`, `_number` or `_date` already exist in this file under those names, rename the new ones to `_dsText`, `_dsNumber`, `_dsDate` and update the call sites. Check with `grep -n "static void _text\|static void _number\|static void _date" lib/core/services/export/uddf/uddf_export_builders.dart` before adding. + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +flutter test test/core/services/export/uddf/uddf_dcdump_builders_test.dart -r compact +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 5: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/core/services/export/uddf/uddf_export_builders.dart test/core/services/export/uddf/uddf_dcdump_builders_test.dart +git commit -m "feat(export): build the UDDF dcdump and datasources sections (#228)" +``` + +--- + +### Task 4: Export options and the full export service + +**Files:** +- Create: `lib/core/services/export/models/uddf_export_options.dart` +- Modify: `lib/core/services/export/uddf/uddf_full_export_service.dart` +- Test: `test/core/services/export/uddf/uddf_full_export_raw_data_test.dart` + +**Interfaces:** +- Consumes: `UddfDumpCodec.encodeAll` (Task 1), `DiveSourceExport` (Task 2), `buildDataSources` / `buildDiveComputerControl` (Task 3). +- Produces: `class UddfExportOptions` with `final bool includeRawData` defaulting to `true` and a `copyWith`; `UddfFullExportService.exportAllDataToUddf` and `saveAllDataToUddfFile` each gain `List? dataSources` and `UddfExportOptions options = const UddfExportOptions()`. + +The service is given the sources; it does not fetch them. Fetching is the provider's job in Task 6. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/export/uddf/uddf_full_export_raw_data_test.dart`. Model the `Dive` construction on `test/core/services/export/uddf/uddf_export_builders_test.dart`, which shows the required fields. + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; +import 'package:submersion/core/services/export/uddf/uddf_full_export_service.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; + +void main() { + final dive = Dive( + id: 'dive-1', + diveNumber: 1, + dateTime: DateTime(2019, 6, 2, 10, 0), + bottomTime: const Duration(minutes: 45), + maxDepth: 31.5, + avgDepth: 18.0, + waterTemp: 22.0, + tanks: const [], + profile: const [], + equipment: const [], + notes: '', + photoIds: const [], + sightings: const [], + weights: const [], + tags: const [], + ); + + final raw = Uint8List.fromList(List.generate(256, (i) => i)); + + DiveSourceExport sourceFor({ + required String id, + required int ordinal, + bool isPrimary = true, + Uint8List? rawData, + }) { + return DiveSourceExport( + id: id, + diveId: 'dive-1', + ordinal: ordinal, + isPrimary: isPrimary, + importedAt: DateTime(2019, 6, 2, 18, 41, 7), + createdAt: DateTime(2019, 6, 2, 18, 41, 7), + rawData: rawData, + descriptorVendor: 'Shearwater', + descriptorProduct: 'Perdix', + descriptorModel: 5, + ); + } + + test('includes the dump and the source record when enabled', () async { + final service = UddfFullExportService(); + + final xml = await service.generateAllDataXmlForTest( + dives: [dive], + dataSources: [sourceFor(id: 'src-a', ordinal: 0, rawData: raw)], + options: const UddfExportOptions(), + ); + final doc = XmlDocument.parse(xml); + + final dumps = doc.findAllElements('divecomputerdump').toList(); + expect(dumps, hasLength(1)); + expect( + UddfDumpCodec.decodeOne(dumps.single.findElements('dcdump').single.innerText), + equals(raw), + ); + + final entries = doc.findAllElements('source').toList(); + expect(entries, hasLength(1)); + expect(entries.single.getAttribute('hasdump'), 'true'); + + // divecomputercontrol is the last section of a UDDF document. + final root = doc.rootElement; + expect(root.childElements.last.name.local, 'divecomputercontrol'); + }); + + test('writes an entry with no dump for a source that has no bytes', + () async { + final service = UddfFullExportService(); + + final xml = await service.generateAllDataXmlForTest( + dives: [dive], + dataSources: [ + sourceFor(id: 'src-a', ordinal: 0, rawData: raw), + sourceFor(id: 'src-b', ordinal: 1, isPrimary: false), + ], + options: const UddfExportOptions(), + ); + final doc = XmlDocument.parse(xml); + + expect(doc.findAllElements('source'), hasLength(2)); + expect(doc.findAllElements('divecomputerdump'), hasLength(1)); + expect( + doc.findAllElements('source').map((e) => e.getAttribute('hasdump')), + ['true', 'false'], + ); + }); + + test('omits both sections when includeRawData is false', () async { + final service = UddfFullExportService(); + + final xml = await service.generateAllDataXmlForTest( + dives: [dive], + dataSources: [sourceFor(id: 'src-a', ordinal: 0, rawData: raw)], + options: const UddfExportOptions(includeRawData: false), + ); + final doc = XmlDocument.parse(xml); + + expect(doc.findAllElements('divecomputercontrol'), isEmpty); + expect(doc.findAllElements('datasources'), isEmpty); + }); + + test('defaults to including raw data', () { + expect(const UddfExportOptions().includeRawData, isTrue); + }); +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/core/services/export/uddf/uddf_full_export_raw_data_test.dart -r compact +``` + +Expected: FAIL, `UddfExportOptions` does not exist. + +- [ ] **Step 3: Create the options class** + +Create `lib/core/services/export/models/uddf_export_options.dart`: + +```dart +/// Options for a UDDF export. +/// +/// Shaped after `PdfExportOptions` in `lib/core/constants/pdf_templates.dart`: +/// a const class with defaults, passed as a defaulted named parameter, so no +/// existing call site has to change. +class UddfExportOptions { + /// Whether to carry each dive's raw dive computer bytes. + /// + /// Defaults to true, matching what every export UI shows: the checkbox is + /// pre-checked in both the full backup and the dives only share paths, and + /// the code level default is kept in step with it rather than diverging. + /// A caller that omits options therefore gets a complete export. + final bool includeRawData; + + const UddfExportOptions({this.includeRawData = true}); + + UddfExportOptions copyWith({bool? includeRawData}) => + UddfExportOptions(includeRawData: includeRawData ?? this.includeRawData); +} +``` + +- [ ] **Step 4: Wire the full export service** + +In `lib/core/services/export/uddf/uddf_full_export_service.dart`: + +1. Add imports: + +```dart +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; +``` + +2. Change `_generateAllDataXml` to `Future` and add the two parameters. Add to its parameter list: + +```dart + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), +``` + +3. At the top of the method body, before `final builder = XmlBuilder();`, resolve what will be written: + +```dart + // Encoding happens before the XML build, so the builders stay pure + // synchronous functions over already encoded strings like every other + // builder in this file. + final sources = options.includeRawData + ? (dataSources ?? const []) + : const []; + final withBytes = sources.where((s) => s.hasDump).toList(growable: false); + final encoded = await UddfDumpCodec.encodeAll( + withBytes.map((s) => s.rawData!).toList(growable: false), + ); + final encodedById = { + for (var i = 0; i < withBytes.length; i++) withBytes[i].id: encoded[i], + }; +``` + +4. Pass `sources` into the existing `UddfExportBuilders.buildApplicationData(...)` call at line 405 by adding a `dataSources: sources` argument, and inside `buildApplicationData` (Task 3's file) add the parameter and call `buildDataSources(builder, dataSources ?? const [])` inside the `` element, after the existing sections. Also add `(dataSources?.isNotEmpty ?? false)` to that method's `hasData` disjunction, or a source-only export writes no `` at all. + +5. After `buildApplicationData` and as the last child of ``, add: + +```dart + UddfExportBuilders.buildDiveComputerControl( + builder, + sources, + encodedById, + declaresComputers: true, + ); +``` + +6. Make `exportAllDataToUddf` and `saveAllDataToUddfFile` accept `dataSources` and `options` and forward them, awaiting the now-async `_generateAllDataXml`. + +7. Add a test seam so the test above can call the generator without touching the filesystem: + +```dart + /// Visible for testing: the document this service would write. + @visibleForTesting + Future generateAllDataXmlForTest({ + required List dives, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), + }) => _generateAllDataXml( + dives: dives, + dataSources: dataSources, + options: options, + ); +``` + +This needs `import 'package:flutter/foundation.dart';` for `@visibleForTesting`. If the file must stay Flutter-free, drop the annotation and keep the method. + +- [ ] **Step 5: Run the test and verify it passes** + +```bash +flutter test test/core/services/export/uddf/uddf_full_export_raw_data_test.dart -r compact +``` + +Expected: PASS, 4 tests. + +- [ ] **Step 6: Run the existing UDDF suite for regressions** + +```bash +flutter test test/core/services/export/uddf -r compact +``` + +Expected: PASS. Callers of `_generateAllDataXml` now need `await`; fix any that the analyzer flags. + +- [ ] **Step 7: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/core/services/export/models/uddf_export_options.dart lib/core/services/export/uddf/uddf_full_export_service.dart lib/core/services/export/uddf/uddf_export_builders.dart test/core/services/export/uddf/uddf_full_export_raw_data_test.dart +git commit -m "feat(export): carry raw dive data in the full UDDF export (#228)" +``` + +--- + +### Task 5: The dives-only export service + +**Files:** +- Modify: `lib/core/services/export/uddf/uddf_export_service.dart` +- Test: `test/core/services/export/uddf/uddf_dives_export_raw_data_test.dart` + +**Interfaces:** +- Consumes: everything from Tasks 1 through 4. +- Produces: `UddfExportService.generateDivesUddfContent`, `exportDivesToUddf` and `saveDivesToUddfFile` each gain `List? dataSources` and `UddfExportOptions options = const UddfExportOptions()`. `generateDivesUddfContent` becomes `Future`. + +This path is different from Task 4 in two ways that are easy to get wrong: + +- It emits **no** `` element, so `declaresComputers` is `false` and dumps carry only the dive link. +- It has **no** top-level `` block. Its only `` is a per-dive inline one for custom fields at `uddf_export_service.dart:525`, a different element in a different position. The `` record needs its own top-level wrapper here, and the inline ones must be left alone. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/export/uddf/uddf_dives_export_raw_data_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; +import 'package:submersion/core/services/export/uddf/uddf_export_service.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; + +void main() { + final dive = Dive( + id: 'dive-1', + diveNumber: 1, + dateTime: DateTime(2019, 6, 2, 10, 0), + bottomTime: const Duration(minutes: 45), + maxDepth: 31.5, + avgDepth: 18.0, + waterTemp: 22.0, + tanks: const [], + profile: const [], + equipment: const [], + notes: '', + photoIds: const [], + sightings: const [], + weights: const [], + tags: const [], + ); + + final source = DiveSourceExport( + id: 'src-a', + diveId: 'dive-1', + ordinal: 0, + isPrimary: true, + importedAt: DateTime(2019, 6, 2, 18, 41, 7), + createdAt: DateTime(2019, 6, 2, 18, 41, 7), + rawData: Uint8List.fromList([1, 2, 3, 4]), + computerId: 'computer_9c21', + descriptorVendor: 'Shearwater', + descriptorProduct: 'Perdix', + descriptorModel: 5, + ); + + test('emits the dive link but no computer link', () async { + final xml = await UddfExportService().generateDivesUddfContent( + [dive], + dataSources: [source], + ); + final doc = XmlDocument.parse(xml); + + // This path declares no , so a computer ref would dangle. + expect(doc.findAllElements('divecomputer'), isEmpty); + final dump = doc.findAllElements('divecomputerdump').single; + expect( + dump.findElements('link').map((e) => e.getAttribute('ref')), + ['dive_dive-1'], + ); + }); + + test('hosts the source record in its own top level applicationdata', + () async { + final xml = await UddfExportService().generateDivesUddfContent( + [dive], + dataSources: [source], + ); + final doc = XmlDocument.parse(xml); + + final topLevel = doc.rootElement.childElements + .where((e) => e.name.local == 'applicationdata') + .toList(); + expect(topLevel, hasLength(1)); + expect( + topLevel.single.findElements('submersion').single + .findElements('datasources'), + hasLength(1), + ); + }); + + test('omits both sections when includeRawData is false', () async { + final xml = await UddfExportService().generateDivesUddfContent( + [dive], + dataSources: [source], + options: const UddfExportOptions(includeRawData: false), + ); + final doc = XmlDocument.parse(xml); + + expect(doc.findAllElements('divecomputercontrol'), isEmpty); + expect(doc.findAllElements('datasources'), isEmpty); + }); +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/core/services/export/uddf/uddf_dives_export_raw_data_test.dart -r compact +``` + +Expected: FAIL, `generateDivesUddfContent` takes no `dataSources`. + +- [ ] **Step 3: Wire the dives-only export** + +In `lib/core/services/export/uddf/uddf_export_service.dart`: + +1. Add the same three imports as Task 4 step 4. + +2. Make `generateDivesUddfContent` `Future` and add the parameters: + +```dart + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), +``` + +3. Compute `sources` and `encodedById` exactly as in Task 4 step 3, before `final builder = XmlBuilder();`. + +4. As the final children of ``, after the existing `profiledata` element closes, add: + +```dart + // This path has no top level block of + // its own: the only it writes is the per dive + // inline one for custom fields. So the source record gets its own + // wrapper here. UDDF permits in both positions and + // the inline ones are untouched. + if (sources.isNotEmpty) { + builder.element( + 'applicationdata', + nest: () { + builder.element( + 'submersion', + attributes: {'version': '1.0'}, + nest: () { + UddfExportBuilders.buildDataSources(builder, sources); + }, + ); + }, + ); + } + + // The dives only export declares no , so a computer + // ref would dangle under IDREF validation. + UddfExportBuilders.buildDiveComputerControl( + builder, + sources, + encodedById, + declaresComputers: false, + ); +``` + +5. Update `exportDivesToUddf` and `saveDivesToUddfFile` to take and forward both parameters, awaiting `generateDivesUddfContent`. + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +flutter test test/core/services/export/uddf/uddf_dives_export_raw_data_test.dart -r compact +``` + +Expected: PASS, 3 tests. + +- [ ] **Step 5: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/core/services/export/uddf/uddf_export_service.dart test/core/services/export/uddf/uddf_dives_export_raw_data_test.dart +git commit -m "feat(export): carry raw dive data in the dives only UDDF export (#228)" +``` + +--- + +### Task 6: Thread options through ExportService and the providers + +**Files:** +- Modify: `lib/core/services/export/export_service.dart` (the four UDDF entry points, lines 314-434) +- Modify: `lib/features/settings/presentation/providers/export_providers.dart` (`exportDivesToUddf` at 392, `saveUddfToFile` at 1002) +- Modify: `lib/features/dive_log/presentation/pages/dive_detail_page.dart:5871`, `lib/features/dive_log/presentation/widgets/dive_list_content.dart:690`, `lib/features/buddies/presentation/pages/buddy_detail_page.dart:395` +- Test: `test/features/settings/presentation/providers/uddf_export_options_test.dart` + +**Interfaces:** +- Consumes: `UddfExportOptions`, `getSourcesForExport`. +- Produces: `ExportService.exportDivesToUddf(List dives, {List? sites, Map>>? diveTankPressures, List? dataSources, UddfExportOptions options})` and the same shape on the other three. `ExportNotifier.exportDivesToUddf([UddfExportOptions? options])` and `saveUddfToFile([UddfExportOptions? options])`. Plus a new top-level `Future> resolveDataSources(DiveRepository repository, List diveIds, UddfExportOptions options)` in `lib/core/services/export/uddf/uddf_source_fetch.dart`. + +- [ ] **Step 1: Write the failing test** + +Testing the notifier means standing up its whole provider graph, which is disproportionate for a boolean. Test the behaviour that actually matters at the seam instead: that a disabled export never issues the query. Create `test/features/settings/presentation/providers/uddf_export_options_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Records whether the export path asked for data sources. +class RecordingDiveRepository implements DiveRepository { + int calls = 0; + + @override + Future> getSourcesForExport(List diveIds) { + calls++; + return Future.value(const []); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// The fetch rule the notifier and the three page call sites all follow. +Future> resolveDataSources( + DiveRepository repository, + List diveIds, + UddfExportOptions options, +) async { + if (!options.includeRawData) return const []; + return repository.getSourcesForExport(diveIds); +} + +void main() { + late AppDatabase db; + + setUp(() async { + db = await setUpTestDatabase(); + }); + + tearDown(() async => tearDownTestDatabase()); + + test('fetches sources when raw data is included', () async { + final repository = RecordingDiveRepository(); + + await resolveDataSources( + repository, + const ['dive-1'], + const UddfExportOptions(), + ); + + expect(repository.calls, 1); + }); + + test('never queries when raw data is excluded', () async { + // A share with the box unchecked must not pay for a query it will not + // use, which is the entire point of the toggle on the dives only paths. + final repository = RecordingDiveRepository(); + + final result = await resolveDataSources( + repository, + const ['dive-1'], + const UddfExportOptions(includeRawData: false), + ); + + expect(repository.calls, 0); + expect(result, isEmpty); + }); +} +``` + +`resolveDataSources` is not a test-only helper: extract it into `lib/core/services/export/uddf/uddf_source_fetch.dart` as a top-level function and call it from the notifier and from all three page call sites, so there is one rule rather than four copies of it, and this test covers all four. Import it in the test instead of redeclaring it. + +If `implements DiveRepository` will not compile because the class is `final` or has a private generative constructor, wrap instead: define an abstract `UddfSourceFetcher` with the single method, have the production code depend on that, and give the test a plain implementation. + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/features/settings/presentation/providers/uddf_export_options_test.dart -r compact +``` + +Expected: FAIL. + +- [ ] **Step 3: Thread the parameters through ExportService** + +For each of `exportDivesToUddf`, `saveDivesToUddfFile`, `exportAllDataToUddf` and `saveAllDataToUddfFile` in `lib/core/services/export/export_service.dart`, add `List? dataSources` and `UddfExportOptions options = const UddfExportOptions()` and forward both. Add the two imports. + +- [ ] **Step 4: Fetch sources in the notifier** + +In `export_providers.dart`, in both `exportDivesToUddf` and `saveUddfToFile`, before calling the service: + +First create `lib/core/services/export/uddf/uddf_source_fetch.dart`: + +```dart +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; + +/// Whether an export fetches source rows, and the fetch itself. +/// +/// One rule rather than four copies of it: the settings notifier and the +/// three page call sites all go through here. A share with the box unchecked +/// must not pay for a query it will not use, which is the whole point of the +/// toggle on the dives only paths. +Future> resolveDataSources( + DiveRepository repository, + List diveIds, + UddfExportOptions options, +) async { + if (!options.includeRawData) return const []; + return repository.getSourcesForExport(diveIds); +} +``` + +Then in both notifier methods, before calling the service: + +```dart + final dataSources = await resolveDataSources( + _diveRepository, + dives.map((d) => d.id).toList(growable: false), + exportOptions, + ); +``` + +and pass `dataSources: dataSources, options: exportOptions`. Give both methods an `[UddfExportOptions? options]` parameter resolving to `options ?? const UddfExportOptions()`, mirroring `exportDivesToPdf` at line 257. + +- [ ] **Step 5: Update the three direct call sites** + +`dive_detail_page.dart:5871`, `dive_list_content.dart:690`, and `buddy_detail_page.dart:395` call `ExportService` directly. Each calls the same `resolveDataSources` and passes both new arguments. Read the surrounding code for how each obtains a repository; if a page has no repository handle, add one via the existing provider rather than reaching into the database. + +- [ ] **Step 6: Run the tests** + +```bash +flutter test test/features/settings/presentation/providers/uddf_export_options_test.dart -r compact +``` + +Expected: PASS. + +- [ ] **Step 7: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add -u +git commit -m "feat(export): thread UDDF raw data options through the export paths (#228)" +``` + +--- + +### Task 7: Parse the two new sections on import + +**Files:** +- Modify: `lib/core/services/export/uddf/uddf_full_import_service.dart` +- Test: `test/core/services/export/uddf/uddf_dcdump_import_parse_test.dart` + +**Interfaces:** +- Consumes: `UddfDumpCodec.decodeOne` (Task 1). +- Produces: `UddfImportResult` gains `final Map>> dataSourcesByDiveRef` and `final int unpairedDumps`. Each inner map carries the `` fields under camelCase keys (`descriptorVendor`, `descriptorProduct`, `descriptorModel`, `libdivecomputerVersion`, `sourceUuid`, `rawFingerprint` as `Uint8List?`, `isPrimary` as `bool`, `mergeSourceSlot`, `timeOffsetSeconds`, `computerModel`, `computerSerial`, `sourceFormat`, `sourceFileName`, `sourceFileFormat`, `importedAt`, `createdAt`, `lastParsedAt`, `maxDepth`, `avgDepth`, `duration`, `waterTemp`, `entryLatitude`, `entryLongitude`, `exitLatitude`, `exitLongitude`, `entryTime`, `exitTime`, `maxAscentRate`, `maxDescentRate`, `surfaceInterval`, `cns`, `otu`, `decoAlgorithm`, `gradientFactorLow`, `gradientFactorHigh`), plus `ordinal` as `int` and `rawData` as `Uint8List?`. + +Keys are the `diveref` attribute value, for example `dive_7f3a...`; entries within a key are sorted by `ordinal`. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/export/uddf/uddf_dcdump_import_parse_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart'; +import 'package:submersion/core/services/export/uddf/uddf_full_import_service.dart'; + +String document({required String applicationData, required String control}) { + return ''' + + + + + 2019-06-02T10:00:00 + + + + $applicationData + $control + +'''; +} + +void main() { + final raw = Uint8List.fromList([9, 8, 7, 6, 5]); + final payload = UddfDumpCodec.encodeOne(raw); + + test('pairs a dump with its source entry by diveref and ordinal', () async { + final xml = document( + applicationData: ''' + + + + + + true + A17F3C + 2019-06-02T18:41:07.000 + 2019-06-02T18:41:07.000 + 31.5 + + + + ''', + control: ''' + + + + 2019-06-02T18:41:07 + $payload + + ''', + ); + + final result = await UddfFullImportService().importAllDataFromUddf(xml); + final entries = result.dataSourcesByDiveRef['dive_d1']!; + + expect(entries, hasLength(1)); + expect(entries.single['rawData'], equals(raw)); + expect(entries.single['descriptorVendor'], 'Shearwater'); + expect(entries.single['descriptorModel'], 5); + expect(entries.single['isPrimary'], isTrue); + expect(entries.single['maxDepth'], 31.5); + expect( + entries.single['rawFingerprint'], + equals(Uint8List.fromList([0xA1, 0x7F, 0x3C])), + ); + expect( + entries.single['importedAt'], + DateTime.parse('2019-06-02T18:41:07.000'), + ); + expect(result.unpairedDumps, 0); + }); + + test('an entry claiming a dump that is absent restores without bytes', + () async { + final xml = document( + applicationData: ''' + + + + + true + 2019-06-02T18:41:07.000 + 2019-06-02T18:41:07.000 + + + + ''', + control: '', + ); + + final result = await UddfFullImportService().importAllDataFromUddf(xml); + final entries = result.dataSourcesByDiveRef['dive_d1']!; + + // It must not pair with the next dump along. + expect(entries.single['rawData'], isNull); + }); + + test('a spec shaped dump with no source entry still yields its bytes', + () async { + final xml = document( + applicationData: '', + control: ''' + + + + 2019-06-02T18:41:07 + $payload + + ''', + ); + + final result = await UddfFullImportService().importAllDataFromUddf(xml); + final entries = result.dataSourcesByDiveRef['dive_d1']!; + + expect(entries.single['rawData'], equals(raw)); + expect(entries.single['descriptorVendor'], isNull); + expect(entries.single['isPrimary'], isTrue); + }); + + test('a submersion block holding only datasources parses cleanly', () async { + final xml = document( + applicationData: ''' + + + + + true + 2019-06-02T18:41:07.000 + 2019-06-02T18:41:07.000 + + + + ''', + control: '', + ); + + final result = await UddfFullImportService().importAllDataFromUddf(xml); + expect(result.dataSourcesByDiveRef['dive_d1'], hasLength(1)); + }); + + test('the dump datetime does not become the dive time', () async { + final xml = document( + applicationData: '', + control: ''' + + + + 2031-12-25T03:00:00 + $payload + + ''', + ); + + final result = await UddfFullImportService().importAllDataFromUddf(xml); + + expect(result.dives.single['dateTime'], DateTime(2019, 6, 2, 10, 0)); + }); + + test('an oversized dump is skipped and counted, not thrown', () async { + final bomb = UddfDumpCodec.encodeOne(Uint8List(12 * 1024 * 1024)); + final xml = document( + applicationData: '', + control: ''' + + + + 2019-06-02T18:41:07 + $bomb + + ''', + ); + + final result = await UddfFullImportService().importAllDataFromUddf(xml); + + expect(result.dataSourcesByDiveRef['dive_d1'] ?? const [], isEmpty); + expect(result.unpairedDumps, 1); + }); +} +``` + +Check `UddfImportResult`'s existing field for parsed dives; if it is not `dives` holding `List>`, adjust the fifth test's assertion to match. Read `lib/core/services/export/models/uddf_import_result.dart` first. + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/core/services/export/uddf/uddf_dcdump_import_parse_test.dart -r compact +``` + +Expected: FAIL, `dataSourcesByDiveRef` is not defined. + +- [ ] **Step 3: Implement the parse** + +In `lib/core/services/export/models/uddf_import_result.dart`, add the two fields with defaults (`const {}` and `0`) so no existing construction breaks. + +In `uddf_full_import_service.dart`, add a private method and call it before building the result: + +```dart + /// Parse `` and + /// ``, joining them on (diveref, ordinal). + /// + /// Two passes rather than one: the standard section carries the bytes and + /// the Submersion section carries everything the standard cannot express, + /// most importantly the libdivecomputer descriptor triple. Without that + /// triple a restored blob can never be re-parsed + /// (`reparse_service.dart` skips any source missing it). + /// + /// A dump with no matching entry still yields its bytes, as a lone primary + /// source with a null descriptor: those bytes are not ours to discard, and + /// re-parse already reports a source it cannot parse. + ({Map>> byDiveRef, int unpaired}) + _parseDataSources(XmlElement uddfElement) { + final entries = >>{}; + + for (final appData in uddfElement.findElements('applicationdata')) { + for (final submersion in appData.findElements('submersion')) { + for (final block in submersion.findElements('datasources')) { + for (final source in block.findElements('source')) { + final diveRef = source.getAttribute('diveref'); + if (diveRef == null) continue; + entries + .putIfAbsent(diveRef, () => >[]) + .add(_parseSourceEntry(source)); + } + } + } + } + + for (final list in entries.values) { + list.sort( + (a, b) => (a['ordinal'] as int).compareTo(b['ordinal'] as int), + ); + } + + var unpaired = 0; + final nextOrdinal = {}; + + for (final control in uddfElement.findElements('divecomputercontrol')) { + for (final dump in control.findElements('divecomputerdump')) { + final diveRef = dump + .findElements('link') + .map((e) => e.getAttribute('ref')) + .firstWhere( + (ref) => ref != null && ref.startsWith('dive_'), + orElse: () => null, + ); + final text = dump.findElements('dcdump').firstOrNull?.innerText; + if (diveRef == null || text == null || text.trim().isEmpty) { + unpaired++; + continue; + } + + Uint8List bytes; + try { + bytes = UddfDumpCodec.decodeOne(text); + } catch (e) { + // Untrusted input: a bomb, a truncated stream, or something that + // is not bzip2 at all. Count it and keep importing. + _log.warning('Skipping unreadable for $diveRef: $e'); + unpaired++; + continue; + } + + final ordinal = nextOrdinal.update( + diveRef, + (v) => v + 1, + ifAbsent: () => 0, + ); + final list = entries[diveRef]; + final match = list?.firstWhereOrNull( + (e) => e['ordinal'] == ordinal && e['hasDump'] == true, + ); + if (match != null) { + match['rawData'] = bytes; + } else { + entries.putIfAbsent(diveRef, () => >[]).add({ + 'ordinal': ordinal, + 'hasDump': true, + 'rawData': bytes, + 'isPrimary': entries[diveRef]?.isEmpty ?? true, + }); + } + } + } + + return (byDiveRef: entries, unpaired: unpaired); + } +``` + +Write `_parseSourceEntry(XmlElement)` to read every child element listed in the Interfaces block into the camelCase key of the same name, using the file's existing `UddfImportParsers.getElementText` plus `int.tryParse` / `double.tryParse` / `DateTime.tryParse`, and `ordinal` / `hasDump` from the attributes. Hex-decode `` into a `Uint8List` two characters at a time. + +`firstWhereOrNull` comes from `package:collection/collection.dart`; add the import. `_log` follows whatever logger this file already uses, otherwise drop the line. + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +flutter test test/core/services/export/uddf/uddf_dcdump_import_parse_test.dart -r compact +``` + +Expected: PASS, 6 tests. + +- [ ] **Step 5: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/core/services/export/uddf/uddf_full_import_service.dart lib/core/services/export/models/uddf_import_result.dart test/core/services/export/uddf/uddf_dcdump_import_parse_test.dart +git commit -m "feat(import): parse UDDF dcdump and data source records (#228)" +``` + +--- + +### Task 8: Batch source insert, so profile adoption stays correct + +**Files:** +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (near `saveComputerReading`, line 6418) +- Test: `test/features/dive_log/data/repositories/save_computer_readings_batch_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Future saveComputerReadings(List readings)` on `DiveRepository`. + +**Why this task exists.** `saveComputerReading` calls `_adoptUnattributedProfiles`, which claims a dive's unattributed profile series for the inserted source, but only when it is the dive's sole source row (`dive_repository_impl.dart:6455`). Its doc comment explains the guard: with a second source present the rows could belong to either, and a guess would be worse. Calling it in a loop defeats that, because the **first** insert sees one row and adopts everything before the second row exists. A two-source restore would silently attribute the whole dive's profile to source one. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_log/data/repositories/save_computer_readings_batch_test.dart`. Reuse the `insertDive` helper shape from Task 2's test. + +```dart + test('adopts unattributed profiles when the batch leaves one source', + () async { + await insertDive('dive-1'); + await insertUnattributedSeries('dive-1'); + + await repository.saveComputerReadings([reading('src-a', 'dive-1')]); + + expect(await sourceIdOfSeries('dive-1'), 'src-a'); + }); + + test('does not adopt when the batch leaves more than one source', () async { + // The guard exists because with two sources the rows could belong to + // either. Inserting them one at a time would let the first row claim + // everything before the second exists. + await insertDive('dive-1'); + await insertUnattributedSeries('dive-1'); + + await repository.saveComputerReadings([ + reading('src-a', 'dive-1', isPrimary: true), + reading('src-b', 'dive-1'), + ]); + + expect(await sourceIdOfSeries('dive-1'), isNull); + expect(await countSources('dive-1'), 2); + }); + + test('inserts every row in the batch', () async { + await insertDive('dive-1'); + + await repository.saveComputerReadings([ + reading('src-a', 'dive-1', isPrimary: true), + reading('src-b', 'dive-1'), + reading('src-c', 'dive-1'), + ]); + + expect(await countSources('dive-1'), 3); + }); + + test('an empty batch is a no-op', () async { + await repository.saveComputerReadings(const []); + }); +``` + +Write `insertUnattributedSeries`, `sourceIdOfSeries`, `countSources` and `reading` as local helpers. Read `test/features/dive_computer/data/services/raw_data_persistence_test.dart` for how `dive_profile_series` rows are constructed in this codebase, and mirror it. + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/features/dive_log/data/repositories/save_computer_readings_batch_test.dart -r compact +``` + +Expected: FAIL, `saveComputerReadings` is not defined. + +- [ ] **Step 3: Implement the batch insert** + +Add after `saveComputerReading`: + +```dart + /// Insert several `dive_data_sources` rows as one unit. + /// + /// Not a loop over [saveComputerReading]. That one adopts a dive's + /// unattributed profile rows for the row it just inserted, and only when + /// that row is the dive's sole source; the guard is there because with a + /// second source present the rows could belong to either. Inserting one at + /// a time would let the first row adopt everything before the second row + /// exists, so a restored two computer dive would attribute the whole + /// profile to whichever source happened to go in first. + /// + /// Inserting the whole batch first and evaluating the rule once afterwards + /// is what keeps that guard meaning what it says. + Future saveComputerReadings( + List readings, + ) async { + if (readings.isEmpty) return; + try { + await _db.transaction(() async { + for (final reading in readings) { + await _db.into(_db.diveDataSources).insert(reading); + } + }); + for (final reading in readings) { + await _adoptUnattributedProfiles(reading); + } + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to save computer readings', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } +``` + +`_adoptUnattributedProfiles` re-counts the dive's rows and returns early unless there is exactly one, so running it after the batch is correct for both cases and needs no change. + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +flutter test test/features/dive_log/data/repositories/save_computer_readings_batch_test.dart -r compact +``` + +Expected: PASS, 4 tests. + +- [ ] **Step 5: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/features/dive_log/data/repositories/dive_repository_impl.dart test/features/dive_log/data/repositories/save_computer_readings_batch_test.dart +git commit -m "feat(dive-log): batch data source inserts so profile adoption stays correct (#228)" +``` + +--- + +### Task 9: Restore one source row per entry + +**Files:** +- Modify: `lib/features/dive_import/data/services/uddf_entity_importer.dart` (the provenance write at line 2077-2102) +- Test: `test/features/dive_import/data/services/uddf_restore_data_sources_test.dart` + +**Interfaces:** +- Consumes: `saveComputerReadings` (Task 8), `dataSourcesByDiveRef` (Task 7). +- Produces: `UddfEntityImporter.import` accepts `Map>> dataSourcesByDiveRef = const {}`; `UddfEntityImportResult` gains `final int restoredDataSources`. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/dive_import/data/services/uddf_restore_data_sources_test.dart`. The harness below is copied from `test/features/dive_import/uddf_import_source_uuid_test.dart`, which is the working reference for driving this importer against a real in-memory database. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/export/export_service.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; +import 'package:submersion/features/certifications/data/repositories/certification_repository.dart'; +import 'package:submersion/features/courses/data/repositories/course_repository.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart' as domain; +import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; +import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; +import 'package:submersion/features/trips/data/repositories/trip_repository.dart'; +import 'dart:typed_data'; + +import '../../../helpers/test_database.dart'; + +ImportRepositories buildRepositories() => ImportRepositories( + tripRepository: TripRepository(), + equipmentRepository: EquipmentRepository(), + equipmentSetRepository: EquipmentSetRepository(), + buddyRepository: BuddyRepository(), + diveCenterRepository: DiveCenterRepository(), + certificationRepository: CertificationRepository(), + tagRepository: TagRepository(), + diveTypeRepository: DiveTypeRepository(), + siteRepository: SiteRepository(), + diveRepository: DiveRepository(), + tankPressureRepository: TankPressureRepository(), + courseRepository: CourseRepository(), + ); + +Future createTestDiver() async { + final now = DateTime.now(); + const diverId = 'diver-restore-sources-test'; + await DiverRepository().createDiver( + domain.Diver( + id: diverId, + name: 'Test Diver', + isDefault: true, + createdAt: now, + updatedAt: now, + ), + ); + return diverId; +} + +final raw = Uint8List.fromList(List.generate(320, (i) => (i * 7) % 256)); + +/// A UDDF document with one dive and the given `` entries. +String buildUddf({required String sources, String control = ''}) { + return ''' + + Air0.210.00 + + + + + + 2019-06-02T10:00:00 + 1 + + + 31.5 + 2400.0 + + + + + + + +$sources + + + +$control +'''; +} + +String entry({ + required int ordinal, + required bool hasDump, + required bool primary, + String computerModel = 'Perdix AI', + String computerSerial = 'SN-A', + String importedAt = '2019-06-02T18:41:07.000', + double maxDepth = 31.5, + int? mergeSourceSlot = 0, +}) { + return ''' + + $primary + $mergeSourceSlot + $computerModel + $computerSerial + $importedAt + $importedAt + $maxDepth + '''; +} + +String dumpFor(String payload) => ''' + + + 2019-06-02T18:41:07 + $payload + + '''; + +void main() { + late AppDatabase db; + final importer = UddfEntityImporter(); + final exportService = ExportService(); + + setUp(() async { + db = await setUpTestDatabase(); + }); + + tearDown(() async => tearDownTestDatabase()); + + Future runImport(String xml) async { + final diverId = await createTestDiver(); + final parsed = await exportService.importAllDataFromUddf(xml); + await importer.import( + data: parsed, + selections: const UddfImportSelections(dives: {0}), + repositories: buildRepositories(), + diverId: diverId, + dataSourcesByDiveRef: parsed.dataSourcesByDiveRef, + ); + } + + test('restores every source row losslessly, bytes and all', () async { + // The assertion the whole multi source design exists to satisfy. + await runImport( + buildUddf( + sources: '${entry(ordinal: 0, hasDump: true, primary: true)}\n' + '${entry(ordinal: 1, hasDump: false, primary: false, computerModel: 'Teric', computerSerial: 'SN-B', maxDepth: 30.9, mergeSourceSlot: 1)}', + control: dumpFor(UddfDumpCodec.encodeOne(raw)), + ), + ); + + final rows = await (db.select(db.diveDataSources) + ..orderBy([(t) => OrderingTerm.desc(t.isPrimary)])) + .get(); + + expect(rows, hasLength(2)); + expect(rows[0].isPrimary, isTrue); + expect(rows[1].isPrimary, isFalse); + expect(rows[0].rawData, equals(raw)); + expect(rows[1].rawData, isNull); + expect(rows[0].descriptorVendor, 'Shearwater'); + expect(rows[0].descriptorProduct, 'Perdix'); + expect(rows[0].descriptorModel, 5); + expect(rows[0].mergeSourceSlot, 0); + expect(rows[1].mergeSourceSlot, 1); + expect(rows[0].maxDepth, 31.5); + expect(rows[1].maxDepth, 30.9); + expect(rows[1].computerModel, 'Teric'); + }); + + test('writes no synthesised row alongside the restored ones', () async { + await runImport( + buildUddf(sources: entry(ordinal: 0, hasDump: false, primary: true)), + ); + + expect(await db.select(db.diveDataSources).get(), hasLength(1)); + }); + + test('a document with no entries keeps the old single row behaviour', + () async { + await runImport(''' + + + + 2019-06-02T10:00:00 + + + +'''); + + final rows = await db.select(db.diveDataSources).get(); + expect(rows, hasLength(1)); + expect(rows.single.isPrimary, isTrue); + expect(rows.single.rawData, isNull); + }); + + test('two entries claiming primary restore exactly one primary', () async { + await runImport( + buildUddf( + sources: '${entry(ordinal: 0, hasDump: false, primary: true)}\n' + '${entry(ordinal: 1, hasDump: false, primary: true, computerSerial: 'SN-B')}', + ), + ); + + final rows = await db.select(db.diveDataSources).get(); + expect(rows.where((r) => r.isPrimary), hasLength(1)); + }); + + test('no entry claiming primary promotes the first', () async { + await runImport( + buildUddf( + sources: '${entry(ordinal: 0, hasDump: false, primary: false)}\n' + '${entry(ordinal: 1, hasDump: false, primary: false, computerSerial: 'SN-B')}', + ), + ); + + final rows = await db.select(db.diveDataSources).get(); + expect(rows.where((r) => r.isPrimary), hasLength(1)); + }); + + test('importedAt comes from the entry, not the import clock', () async { + await runImport( + buildUddf(sources: entry(ordinal: 0, hasDump: false, primary: true)), + ); + + final rows = await db.select(db.diveDataSources).get(); + expect( + rows.single.importedAt, + DateTime.parse('2019-06-02T18:41:07.000'), + reason: 'A restore must not relabel a 2019 dive with today, since the ' + 'data sources panel shows this value as "Imported".', + ); + }); + + test('each source resolves its own computer from model and serial', + () async { + await runImport( + buildUddf( + sources: '${entry(ordinal: 0, hasDump: false, primary: true)}\n' + '${entry(ordinal: 1, hasDump: false, primary: false, computerModel: 'Teric', computerSerial: 'SN-B')}', + ), + ); + + final rows = await db.select(db.diveDataSources).get(); + final computerIds = rows.map((r) => r.computerId).toSet(); + expect( + computerIds.length, + 2, + reason: 'Two different model plus serial pairs must not collapse onto ' + 'one registered computer.', + ); + }); +} +``` + +The last test needs `diveComputerRepository` present in the bundle for computers to be registered at all. If `buildRepositories()` leaves it null, add `diveComputerRepository: DiveComputerRepository()` and its import; without it, `computerIdByKey` is empty and both rows get a null computer id. + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +flutter test test/features/dive_import/data/services/uddf_restore_data_sources_test.dart -r compact +``` + +Expected: FAIL. + +- [ ] **Step 3: Replace the single synthesised row** + +Replace the `saveComputerReading` call at `uddf_entity_importer.dart:2078` with: + +```dart + // Provenance. A dive that arrived with entries has its source + // rows defined by them, so the synthesised row below is NOT written: + // writing both would leave the dive with one more source than it was + // exported with. A dive with no entries keeps today's behaviour + // exactly, which is every foreign UDDF file and every older export. + final entries = dataSourcesByDiveRef['dive_${diveData['uddfId']}'] ?? + dataSourcesByDiveRef['dive_$diveId'] ?? + const >[]; + + if (entries.isEmpty) { + await repos.diveRepository.saveComputerReading( + DiveDataSourcesCompanion( + // ... the existing companion, unchanged ... + ), + ); + } else { + await repos.diveRepository.saveComputerReadings( + _restoredSourceCompanions( + entries: entries, + diveId: diveId, + computerIdByKey: computerIdByKey, + fallbackComputerId: computerId, + now: now, + ), + ); + restoredDataSources += entries.length; + } +``` + +Add the helper: + +```dart + /// One companion per restored `` entry. + /// + /// Exactly one row must end up primary. A hand edited or malformed file + /// could claim zero or several, and a dive with no primary source would + /// break primary source resolution for that dive permanently. The first + /// entry claiming it wins; if none claims it, the first entry is promoted. + /// + /// Computers resolve through model and serial, NOT through the dump's + /// ``. `computerIdByKey` is keyed by + /// `_importedComputerKey`, which normalises model plus serial; the + /// exported computer id belongs to an id space this importer never uses. + List _restoredSourceCompanions({ + required List> entries, + required String diveId, + required Map computerIdByKey, + required String? fallbackComputerId, + required DateTime now, + }) { + var primaryIndex = entries.indexWhere((e) => e['isPrimary'] == true); + if (primaryIndex < 0) primaryIndex = 0; + + return [ + for (var i = 0; i < entries.length; i++) + _companionFor( + entries[i], + diveId: diveId, + isPrimary: i == primaryIndex, + computerId: _resolveRestoredComputerId( + entries[i], + computerIdByKey, + ) ?? + (i == primaryIndex ? fallbackComputerId : null), + now: now, + ), + ]; + } + + String? _resolveRestoredComputerId( + Map entry, + Map computerIdByKey, + ) { + final model = normalizeComputerIdentityPart( + entry['computerModel'] as String?, + ); + if (model.isEmpty) return null; + final serial = normalizeComputerIdentityPart( + entry['computerSerial'] as String?, + ); + return computerIdByKey['$model|$serial']; + } +``` + +Read `_importedComputerKey` at line 1310 and build the same key string here; if it joins with something other than `|`, match it exactly. Better still, extract the key construction into a shared private method and call it from both, so the two cannot drift. + +Write `_companionFor` to map every entry key onto its `DiveDataSourcesCompanion` field, minting a fresh `_uuid.v4()` for `id`, using `Value(entry['importedAt'] as DateTime? ?? now)` for `importedAt` and the same for `createdAt`, and `Value.absent()` for any key the entry does not carry. + +Thread `dataSourcesByDiveRef` from `importAllDataFromUddf`'s result through to `import`, declare `var restoredDataSources = 0;` alongside the other counters, and add it to `UddfEntityImportResult`. + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +flutter test test/features/dive_import/data/services/uddf_restore_data_sources_test.dart -r compact +``` + +Expected: PASS, 7 tests. + +- [ ] **Step 5: Run the import suites for regressions** + +```bash +flutter test test/core/services/export/uddf test/features/dive_import -r compact +``` + +Expected: PASS. + +- [ ] **Step 6: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/features/dive_import/data/services/uddf_entity_importer.dart test/features/dive_import/data/services/uddf_restore_data_sources_test.dart +git commit -m "feat(import): restore one data source row per UDDF source entry (#228)" +``` + +--- + +### Task 10: The toggle, strings, and generated l10n + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb` and the other 10 locale files +- Modify: `lib/features/transfer/presentation/pages/transfer_page.dart:339` +- Modify: `lib/features/dive_log/presentation/pages/dive_detail_page.dart:5871`, `lib/features/dive_log/presentation/widgets/dive_list_content.dart:690`, `lib/features/buddies/presentation/pages/buddy_detail_page.dart:395` + +**Interfaces:** +- Consumes: `UddfExportOptions` (Task 4), the notifier parameters (Task 6). +- Produces: four new l10n keys. + +- [ ] **Step 1: Add the English strings** + +In `lib/l10n/arb/app_en.arb`, alphabetically near the other `settings_export_` keys: + +```json + "settings_export_uddf_includeRawData": "Include raw dive computer data", + "settings_export_uddf_includeRawDataHelp": "Keeps the original bytes downloaded from your dive computer, so the export can be re-parsed after a restore. Makes the file larger.", + "settings_export_uddf_skippedDumps": "{count, plural, =1{1 raw data record could not be included} other{{count} raw data records could not be included}}", + "settings_export_uddf_restoredDumps": "{count, plural, =1{Restored 1 raw data record} other{Restored {count} raw data records}}", +``` + +- [ ] **Step 2: Add translations to the other 10 locales** + +Add the same four keys to `app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, `app_zh.arb`, translated, keeping the ICU plural syntax and the `{count}` placeholder intact in each. + +- [ ] **Step 3: Regenerate l10n** + +```bash +flutter gen-l10n +``` + +Then confirm the generated hub picked the keys up: + +```bash +grep -c "settings_export_uddf_includeRawData" lib/l10n/arb/app_localizations.dart +``` + +Expected: at least 2 (declaration plus doc comment). + +- [ ] **Step 4: Add the checkbox to the export UI** + +In `transfer_page.dart` at the UDDF export action (line 339), add a `CheckboxListTile` bound to local state initialised to `true`, labelled `context.l10n.settings_export_uddf_includeRawData` with `subtitle` `settings_export_uddf_includeRawDataHelp`, and pass `UddfExportOptions(includeRawData: value)` into the notifier call. Follow whatever dialog or sheet pattern the PDF export options already use in this file. + +Do the same at the three dives-only call sites, each pre-checked. + +- [ ] **Step 5: Verify the app builds and the widget tests pass** + +```bash +flutter test test/features/transfer test/features/settings -r compact +``` + +Expected: PASS. A new provider dependency can break consumer widget tests that do not override it; if a test fails on a missing override, add the override rather than weakening the provider. + +- [ ] **Step 6: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add lib/l10n lib/features/transfer lib/features/dive_log lib/features/buddies +git commit -m "feat(export): add the raw dive data toggle and its strings (#228)" +``` + +--- + +### Task 11: The full round trip + +**Files:** +- Test: `test/core/services/export/uddf/uddf_raw_data_round_trip_test.dart` + +**Interfaces:** +- Consumes: everything. +- Produces: nothing. This task adds no production code; if it needs any, that is a defect found and the fix belongs here. + +- [ ] **Step 1: Write the round trip test** + +Create `test/core/services/export/uddf/uddf_raw_data_round_trip_test.dart`. Reuse `buildRepositories`, `createTestDiver` and the `setUpTestDatabase` harness from Task 9's test verbatim; the round trip differs only in that the document comes from the real exporter rather than a string literal. + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/export/export_service.dart'; +import 'package:submersion/core/services/export/uddf/uddf_full_export_service.dart'; +import 'package:submersion/features/dive_computer/data/services/reparse_service.dart'; +import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +// Plus the same repository and diver helper imports as Task 9's test. +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + + final fixture = Uint8List.fromList( + File('packages/libdivecomputer_plugin/android/src/androidTest/assets/' + 'shearwater_teric_dive.bin') + .readAsBytesSync(), + ); + + setUp(() async { + db = await setUpTestDatabase(); + }); + + tearDown(() async => tearDownTestDatabase()); + + test('a two source dive survives export and import unchanged', () async { + // Seed: one dive, two source rows, only the first carrying bytes. + const diveId = 'dive-rt-1'; + final stamp = DateTime(2019, 6, 2, 18, 41, 7); + final epoch = DateTime(2019, 6, 2, 10).millisecondsSinceEpoch; + + await db.into(db.dives).insert( + DivesCompanion( + id: const Value(diveId), + diveDateTime: Value(epoch), + createdAt: Value(epoch), + updatedAt: Value(epoch), + ), + ); + await db.into(db.diveDataSources).insert( + DiveDataSourcesCompanion( + id: const Value('src-a'), + diveId: const Value(diveId), + isPrimary: const Value(true), + rawData: Value(fixture), + computerModel: const Value('Perdix AI'), + computerSerial: const Value('SN-A'), + descriptorVendor: const Value('Shearwater'), + descriptorProduct: const Value('Perdix'), + descriptorModel: const Value(5), + mergeSourceSlot: const Value(0), + maxDepth: const Value(31.5), + cns: const Value(12.5), + decoAlgorithm: const Value('ZHL16C'), + importedAt: Value(stamp), + createdAt: Value(stamp), + ), + ); + await db.into(db.diveDataSources).insert( + DiveDataSourcesCompanion( + id: const Value('src-b'), + diveId: const Value(diveId), + isPrimary: const Value(false), + computerModel: const Value('Teric'), + computerSerial: const Value('SN-B'), + mergeSourceSlot: const Value(1), + maxDepth: const Value(30.9), + importedAt: Value(stamp), + createdAt: Value(stamp.add(const Duration(seconds: 1))), + ), + ); + + final repository = DiveRepository(); + final sources = await repository.getSourcesForExport([diveId]); + expect(sources, hasLength(2)); + + final dive = Dive( + id: diveId, + diveNumber: 1, + dateTime: DateTime(2019, 6, 2, 10), + bottomTime: const Duration(minutes: 40), + maxDepth: 31.5, + tanks: const [], + profile: const [], + equipment: const [], + notes: '', + photoIds: const [], + sightings: const [], + weights: const [], + tags: const [], + ); + + final xml = await UddfFullExportService().generateAllDataXmlForTest( + dives: [dive], + dataSources: sources, + ); + + // Import into a clean database. + await tearDownTestDatabase(); + db = await setUpTestDatabase(); + + final diverId = await createTestDiver(); + final parsed = await ExportService().importAllDataFromUddf(xml); + await UddfEntityImporter().import( + data: parsed, + selections: const UddfImportSelections(dives: {0}), + repositories: buildRepositories(), + diverId: diverId, + dataSourcesByDiveRef: parsed.dataSourcesByDiveRef, + ); + + final restored = await (db.select(db.diveDataSources) + ..orderBy([(t) => OrderingTerm.desc(t.isPrimary)])) + .get(); + + expect(restored, hasLength(2)); + expect(restored[0].rawData, equals(fixture)); + expect(restored[1].rawData, isNull); + expect(restored[0].maxDepth, 31.5); + expect(restored[0].cns, 12.5); + expect(restored[0].decoAlgorithm, 'ZHL16C'); + expect(restored[0].mergeSourceSlot, 0); + expect(restored[1].mergeSourceSlot, 1); + expect(restored[0].importedAt, stamp); + + // Re-parse needs the descriptor triple or it counts the source failed. + final reparseable = await ReparseService(db: db).getSourcesForDiveReparse( + restored[0].diveId, + ); + expect(reparseable, hasLength(1)); + expect(reparseable.single.descriptorVendor, 'Shearwater'); + expect(reparseable.single.descriptorProduct, 'Perdix'); + expect(reparseable.single.descriptorModel, 5); + + // The dump datetime must not have become the dive time. + final restoredDive = + await (db.select(db.dives)..limit(1)).getSingle(); + expect( + DateTime.fromMillisecondsSinceEpoch(restoredDive.diveDateTime), + DateTime(2019, 6, 2, 10), + ); + }); +} +``` + +Both verified against the code at plan time: `ReparseService` takes a named `db` (`reparse_service.dart:34`), and `dives.diveDateTime` is an `IntColumn` holding epoch milliseconds (`database.dart:683`), so the conversion above is correct. + +- [ ] **Step 2: Run it and fix whatever it finds** + +```bash +flutter test test/core/services/export/uddf/uddf_raw_data_round_trip_test.dart -r compact +``` + +Expected: PASS. This is the test most likely to expose a key-name mismatch between the export writer in Task 3 and the parser in Task 7. Fix the mismatch at whichever end is wrong and re-run. + +- [ ] **Step 3: Run the whole suite once** + +```bash +flutter test -r compact +``` + +Expected: PASS. Run this once, not repeatedly, and do not overlap it with another local test run. + +- [ ] **Step 4: Format, analyze, and commit** + +```bash +dart format . && flutter analyze lib test +``` + +```bash +git add test/core/services/export/uddf/uddf_raw_data_round_trip_test.dart +git commit -m "test(export): pin the UDDF raw dive data round trip (#228)" +``` + +--- + +## Self-Review Notes + +**Spec coverage.** Every section of the spec maps to a task: document shape to Task 3, pairing to Tasks 3 and 7, the `` scoping regression to Task 7, options to Task 4, fetching to Task 2, compression to Task 1, failure isolation to Tasks 1 and 3, bounding untrusted input to Task 1, restore to Task 9, the primary-flag rule to Task 9, computer resolution to Task 9, the shared ceiling to Task 1, and the ARB work to Task 10. + +**One thing the spec did not anticipate,** found while reading the code for this plan and now Task 8: `saveComputerReading` adopts a dive's unattributed profile series only when the inserted row is the dive's sole source. Writing N rows in a loop defeats that guard, because the first insert adopts before the second row exists. The spec should be read as amended by Task 8. + +**Types to keep consistent across tasks:** `DiveSourceExport.ordinal` is an `int` and pairs with the `ordinal` attribute written as a string in Task 3 and parsed back to `int` in Task 7. `encodedById` is `Map` keyed by `DiveSourceExport.id`, not by dive id. `dataSourcesByDiveRef` is keyed by the `dive_` ref string, not the bare dive id. diff --git a/docs/superpowers/plans/2026-09-05-seascape-source-layer.md b/docs/superpowers/plans/2026-09-05-seascape-source-layer.md new file mode 100644 index 0000000000..ae2e67e5bd --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-seascape-source-layer.md @@ -0,0 +1,1971 @@ +# Seascape Source Layer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the bathymetry resolver choose the best available grid rather than the first tier that answers, and add a NOAA CUDEM tier that delivers 3 m to 10 m terrain on US coasts. + +**Architecture:** `BathymetrySource.covers()` becomes `probe()`, returning a declared cell size or null for "not covered". The resolver orders sources by materially better resolution (more than 2x finer preempts declared order), fetches in that order, and accepts the first grid passing both a wet-cell floor and a new known-cell floor. A new `NoaaDemSource` probes the NOAA NCEI DEM mosaic ImageServer catalogue and fetches an uncompressed tiled float32 GeoTIFF. `BathymetryGrid.downsampleTo` averages blocks instead of striding. + +**Tech Stack:** Dart, Flutter, `package:http` with `MockClient` from `package:http/testing.dart` for tests, `dart:typed_data` for the GeoTIFF reader. + +**Spec:** `docs/superpowers/specs/2026-09-05-seascape-extent-and-detail-design.md` + +## Global Constraints + +- No em-dashes (U+2014) in any output, including code, comments, commit messages and docs. No en-dashes as sentence punctuation either. See the repository CLAUDE.md. +- No mention of Claude, Claude Code or Anthropic in commits, PR bodies or any file. +- No emojis in code, comments or documentation. +- Immutability: never mutate objects or arrays in place. +- TDD: the failing test comes first, always. +- Run `dart format .` before the final commit of the PR. +- Files stay in the 200-400 line range, 800 maximum. +- This plan is PR 1 of 3. It must ship and be useful on its own. Do not implement nested LOD, nodata holes, or the depth window here; those are PRs 2 and 3. + +## Files + +**Modify:** +- `lib/features/bathymetry/domain/bathymetry_source.dart` - add `SourceCapability`, replace `covers()` with `probe()` +- `lib/features/bathymetry/domain/bathymetry_grid.dart` - add `knownFraction`, make `downsampleTo` average +- `lib/features/bathymetry/data/sources/emodnet_source.dart` - implement `probe()` +- `lib/features/bathymetry/data/sources/gmrt_source.dart` - implement `probe()` +- `lib/features/bathymetry/data/sources/etopo_erddap_source.dart` - implement `probe()` +- `lib/features/bathymetry/data/bathymetry_resolver.dart` - capability ordering, known-cell floor +- `lib/features/bathymetry/data/bathymetry_repository.dart` - cache key generation token +- `lib/features/bathymetry/application/bathymetry_providers.dart` - register `NoaaDemSource` +- `lib/features/bathymetry/presentation/bathymetry_labels.dart` - display name for the new tier +- `lib/l10n/arb/app_*.arb` (all eleven) - add the tier to the about-screen credit + +**Create:** +- `lib/features/bathymetry/data/sources/arcgis_float32_tiff_parser.dart` - tiled float32 GeoTIFF reader +- `lib/features/bathymetry/data/sources/noaa_dem_source.dart` - the CUDEM tier + +**Test:** +- `test/features/bathymetry/domain/bathymetry_grid_test.dart` (modify) +- `test/features/bathymetry/data/bathymetry_resolver_test.dart` (modify) +- `test/features/bathymetry/data/bathymetry_repository_test.dart` (modify) +- `test/features/bathymetry/data/emodnet_source_test.dart` (modify) +- `test/features/bathymetry/data/gmrt_source_test.dart` (modify) +- `test/features/bathymetry/data/etopo_erddap_source_test.dart` (modify) +- `test/features/bathymetry/data/arcgis_float32_tiff_parser_test.dart` (create) +- `test/features/bathymetry/data/noaa_dem_source_test.dart` (create) +- `test/features/bathymetry/presentation/bathymetry_labels_test.dart` (create) + +--- + +### Task 1: Grid quality metric and averaging downsample + +`BathymetryGrid` gains the metric the resolver needs to reject a mostly-empty grid, and stops throwing away detail when it downsamples. + +**Files:** +- Modify: `lib/features/bathymetry/domain/bathymetry_grid.dart` +- Test: `test/features/bathymetry/domain/bathymetry_grid_test.dart` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `double get knownFraction` on `BathymetryGrid`; `BathymetryGrid downsampleTo(int maxDim)` with unchanged signature but averaging semantics. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/features/bathymetry/domain/bathymetry_grid_test.dart`: + +```dart + group('knownFraction', () { + test('is the fraction of non-null cells', () { + final g = BathymetryGrid( + originLat: 12.0, + originLon: -68.0, + cellSizeLatDeg: 0.001, + cellSizeLonDeg: 0.001, + rows: 1, + cols: 4, + depthsMeters: const [10.0, null, 30.0, null], + sourceId: 'test', + resolutionMeters: 100, + fetchedAt: DateTime.utc(2026, 9, 5), + ); + expect(g.knownFraction, 0.5); + }); + + test('is zero for an entirely empty grid', () { + final g = BathymetryGrid( + originLat: 12.0, + originLon: -68.0, + cellSizeLatDeg: 0.001, + cellSizeLonDeg: 0.001, + rows: 1, + cols: 2, + depthsMeters: const [null, null], + sourceId: 'test', + resolutionMeters: 100, + fetchedAt: DateTime.utc(2026, 9, 5), + ); + expect(g.knownFraction, 0.0); + }); + }); + + group('downsampleTo averaging', () { + BathymetryGrid grid(int rows, int cols, List depths) => + BathymetryGrid( + originLat: 12.0, + originLon: -68.0, + cellSizeLatDeg: 0.001, + cellSizeLonDeg: 0.001, + rows: rows, + cols: cols, + depthsMeters: depths, + sourceId: 'test', + resolutionMeters: 60, + fetchedAt: DateTime.utc(2026, 9, 5), + ); + + test('averages each block instead of picking its first sample', () { + // 4x4 halved to 2x2: each output cell is the mean of a 2x2 block. + final g = grid(4, 4, const [ + 1.0, 3.0, 10.0, 20.0, // + 5.0, 7.0, 30.0, 40.0, // + 100.0, 100.0, 0.0, 0.0, // + 100.0, 100.0, 0.0, 0.0, // + ]); + final d = g.downsampleTo(2); + expect(d.rows, 2); + expect(d.cols, 2); + expect(d.depthAt(0, 0), closeTo(4.0, 1e-9)); + expect(d.depthAt(0, 1), closeTo(25.0, 1e-9)); + expect(d.depthAt(1, 0), closeTo(100.0, 1e-9)); + expect(d.depthAt(1, 1), closeTo(0.0, 1e-9)); + }); + + test('averages only the known cells in a block', () { + final g = grid(2, 2, const [10.0, null, null, 20.0]); + expect(g.downsampleTo(1).depthAt(0, 0), closeTo(15.0, 1e-9)); + }); + + test('an all-nodata block stays nodata', () { + final g = grid(2, 4, const [ + null, null, 8.0, 8.0, // + null, null, 8.0, 8.0, // + ]); + final d = g.downsampleTo(2); + expect(d.depthAt(0, 0), isNull); + expect(d.depthAt(0, 1), closeTo(8.0, 1e-9)); + }); + + test('returns the same instance when already within the cap', () { + final g = grid(2, 2, const [1.0, 2.0, 3.0, 4.0]); + expect(identical(g.downsampleTo(4), g), isTrue); + }); + + test('scales the resolution claim by the coarser stride', () { + final g = grid(4, 4, List.filled(16, 5.0)); + expect(g.downsampleTo(2).resolutionMeters, closeTo(120.0, 1e-9)); + }); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/bathymetry/domain/bathymetry_grid_test.dart` + +Expected: FAIL. `knownFraction` is not defined; the averaging tests fail because the current implementation picks `depthsMeters[(r * stepR) * cols + (c * stepC)]`, so `depthAt(0, 0)` returns 1.0 rather than 4.0. + +- [ ] **Step 3: Implement** + +In `lib/features/bathymetry/domain/bathymetry_grid.dart`, add next to `wetFraction`: + +```dart + /// Fraction of cells that carry a reading at all. A grid can be almost + /// entirely wet and still be mostly holes: EMODnet's Caribbean tile + /// answers 99.96% wet on 48% coverage. The resolver needs both numbers. + double get knownFraction { + if (depthsMeters.isEmpty) return 0; + var known = 0; + for (final d in depthsMeters) { + if (d != null) known++; + } + return known / depthsMeters.length; + } +``` + +Replace the body of `downsampleTo`, keeping its signature and its early return: + +```dart + /// Block-mean downsample so neither dimension exceeds [maxDim]. Averaging + /// rather than striding keeps the information in the discarded cells: a + /// stride of 2 throws away three quarters of a grid outright. + BathymetryGrid downsampleTo(int maxDim) { + if (rows <= maxDim && cols <= maxDim) return this; + final stepR = (rows / maxDim).ceil(); + final stepC = (cols / maxDim).ceil(); + final newRows = (rows + stepR - 1) ~/ stepR; + final newCols = (cols + stepC - 1) ~/ stepC; + final out = List.filled(newRows * newCols, null); + for (var r = 0; r < newRows; r++) { + for (var c = 0; c < newCols; c++) { + var sum = 0.0; + var n = 0; + final r1 = math.min((r + 1) * stepR, rows); + final c1 = math.min((c + 1) * stepC, cols); + for (var sr = r * stepR; sr < r1; sr++) { + for (var sc = c * stepC; sc < c1; sc++) { + final v = depthsMeters[sr * cols + sc]; + if (v == null) continue; + sum += v; + n++; + } + } + // An entirely unmeasured block stays unmeasured: averaging must + // never invent a reading where the source had none. + out[r * newCols + c] = n == 0 ? null : sum / n; + } + } + return BathymetryGrid( + originLat: originLat, + originLon: originLon, + cellSizeLatDeg: cellSizeLatDeg * stepR, + cellSizeLonDeg: cellSizeLonDeg * stepC, + rows: newRows, + cols: newCols, + depthsMeters: out, + sourceId: sourceId, + // The coarser stride sets the effective spacing: never claim more + // detail than the downsampled grid actually has. + resolutionMeters: resolutionMeters * (stepR > stepC ? stepR : stepC), + fetchedAt: fetchedAt, + ); + } +``` + +Add `import 'dart:math' as math;` at the top of the file if it is not already there. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/bathymetry/domain/bathymetry_grid_test.dart` + +Expected: PASS. + +Then run the consumers, because averaging changes cell values that other suites may assert on: + +Run: `flutter test test/features/bathymetry test/features/dive_3d` + +Expected: PASS. If a dive_3d golden or geometry test asserts a specific downsampled depth, update the expected value to the block mean and note why in the test. + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/bathymetry/domain/bathymetry_grid.dart test/features/bathymetry/domain/bathymetry_grid_test.dart +git commit -m "feat(bathymetry): add knownFraction and average when downsampling + +A strided downsample discards up to three quarters of a fetched grid; a +block mean keeps that information and suppresses single-cell noise. An +all-nodata block stays nodata so averaging never invents a reading. + +knownFraction is the metric the resolver needs to tell a genuinely wet +grid from one that is mostly holes." +``` + +--- + +### Task 2: SourceCapability and probe on the source interface + +`covers()` cannot express "I cover this, and here is how good my data is", and it cannot make a network call. It becomes `probe()`. + +**Files:** +- Modify: `lib/features/bathymetry/domain/bathymetry_source.dart` +- Modify: `lib/features/bathymetry/data/sources/emodnet_source.dart` +- Modify: `lib/features/bathymetry/data/sources/gmrt_source.dart` +- Modify: `lib/features/bathymetry/data/sources/etopo_erddap_source.dart` +- Test: `test/features/bathymetry/data/emodnet_source_test.dart` +- Test: `test/features/bathymetry/data/gmrt_source_test.dart` +- Test: `test/features/bathymetry/data/etopo_erddap_source_test.dart` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: `class SourceCapability { final double cellSizeMeters; final String detail; }` and `Future probe(GeoPoint center)` on `BathymetrySource`. `bool covers(GeoPoint)` is removed. `EmodnetSource.declaredCellSizeMeters` = 115, `GmrtSource.declaredCellSizeMeters` = 60, `EtopoErddapSource.declaredCellSizeMeters` = 450. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/features/bathymetry/data/emodnet_source_test.dart`, inside `void main()`: + +```dart + group('probe', () { + test('returns 115 m inside the Caribbean tile', () async { + final cap = await EmodnetSource().probe(const GeoPoint(12.16, -68.29)); + expect(cap, isNotNull); + expect(cap!.cellSizeMeters, 115); + expect(cap.detail, 'bathymetry_dtm_carib_2024'); + }); + + test('returns 115 m inside the European box', () async { + final cap = await EmodnetSource().probe(const GeoPoint(42.048, 3.223)); + expect(cap!.detail, 'bathymetry_dtm_2024'); + }); + + test('returns null outside every box', () async { + expect( + await EmodnetSource().probe(const GeoPoint(-33.9, 151.2)), + isNull, + ); + }); + }); +``` + +Append to `test/features/bathymetry/data/gmrt_source_test.dart`, inside `void main()`: + +```dart + group('probe', () { + test('covers everywhere at its declared 60 m', () async { + final cap = await GmrtSource().probe(const GeoPoint(-33.9, 151.2)); + expect(cap, isNotNull); + expect(cap!.cellSizeMeters, 60); + }); + }); +``` + +Append to `test/features/bathymetry/data/etopo_erddap_source_test.dart`, inside `void main()`: + +```dart + group('probe', () { + test('covers everywhere at its declared 450 m', () async { + final cap = await EtopoErddapSource().probe(const GeoPoint(12.16, -68.29)); + expect(cap!.cellSizeMeters, 450); + }); + }); +``` + +Each of these test files needs `import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart';` for `GeoPoint` if it is not already imported. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/bathymetry/data/emodnet_source_test.dart test/features/bathymetry/data/gmrt_source_test.dart test/features/bathymetry/data/etopo_erddap_source_test.dart` + +Expected: FAIL, compile error: the method `probe` is not defined. + +- [ ] **Step 3: Implement** + +In `lib/features/bathymetry/domain/bathymetry_source.dart`, add above the interface: + +```dart +/// What a source claims it can deliver at one coordinate. Declared, not +/// measured: a source reports the finest grid it believes it holds there, +/// which the resolver uses only to order candidates. The wet-cell and +/// known-cell floors in [BathymetryResolver] are what actually reject bad +/// data after a fetch. +class SourceCapability { + /// Best available cell size in meters at the probed point. + final double cellSizeMeters; + + /// Provenance detail for the caption, e.g. the dataset or DEM name. + final String detail; + + const SourceCapability({ + required this.cellSizeMeters, + required this.detail, + }); +} +``` + +Replace `bool covers(GeoPoint center);` with: + +```dart + /// What this source can deliver at [center], or null when it does not + /// cover the point. May make a network call; a probe that fails for any + /// reason must return null rather than throw, so one unreachable source + /// never blocks the others. + Future probe(GeoPoint center); +``` + +In `emodnet_source.dart`, replace the `covers` override with: + +```dart + static const double declaredCellSizeMeters = _resolutionMeters; + + @override + Future probe(GeoPoint center) async { + if (_inBox(center, _carib)) { + return const SourceCapability( + cellSizeMeters: _resolutionMeters, + detail: _carib.dataset, + ); + } + if (_inBox(center, _europe)) { + return const SourceCapability( + cellSizeMeters: _resolutionMeters, + detail: _europe.dataset, + ); + } + return null; + } +``` + +Note: `_carib.dataset` inside a `const` expression requires `_carib` to stay a `const` record, which it already is. If the analyzer rejects the const context, drop `const` from the two returns. + +In `gmrt_source.dart`, replace the `covers` override with: + +```dart + /// GMRT's global tiles run to roughly 60 m where ship multibeam exists. + /// This is a nominal figure: elsewhere the same grid spacing carries + /// upsampled GEBCO. The resolver's preemption factor exists so this + /// number cannot demote a genuinely surveyed regional source. + static const double declaredCellSizeMeters = 60; + + @override + Future probe(GeoPoint center) async => + const SourceCapability( + cellSizeMeters: declaredCellSizeMeters, + detail: 'GMRT GridServer', + ); +``` + +In `etopo_erddap_source.dart`, replace the `covers` override with: + +```dart + static const double declaredCellSizeMeters = _resolutionMeters; + + @override + Future probe(GeoPoint center) async => + const SourceCapability( + cellSizeMeters: _resolutionMeters, + detail: _dataset, + ); +``` + +Each source file needs `SourceCapability` in scope; it comes from the already-imported `bathymetry_source.dart`. + +The resolver still calls `source.covers(center)` at this point and will not compile. Fix it minimally here so the tree builds, replacing the `if (!source.covers(center)) continue;` line in `BathymetryResolver.resolve` with: + +```dart + if (await source.probe(center) == null) continue; +``` + +Task 3 replaces that loop wholesale. + +Also fix the two fakes in `test/features/bathymetry/data/bathymetry_resolver_test.dart`, replacing each `covers` override: + +```dart + @override + Future probe(GeoPoint center) async => coversIt + ? const SourceCapability(cellSizeMeters: 100, detail: 'fake') + : null; +``` + +and in `_ErrorSource`: + +```dart + @override + Future probe(GeoPoint center) async => + const SourceCapability(cellSizeMeters: 100, detail: 'boom'); +``` + +Search the whole tree for any other implementer or caller: + +```bash +grep -rn "covers(" lib test +``` + +Every hit must be gone before Step 4 passes. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/bathymetry` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/bathymetry test/features/bathymetry +git commit -m "feat(bathymetry): sources declare a capability instead of a bool + +covers() could only say yes or no, and could not make a network call. A +source now reports the finest cell size it believes it holds at a point, +or null when it does not cover it, which lets the resolver order +candidates by what they can actually deliver. + +Declared, not measured. The wet-cell and known-cell floors remain the +things that reject bad data after a fetch." +``` + +--- + +### Task 3: Capability ordering and the known-cell floor + +The resolver stops taking the first tier that answers. + +**Files:** +- Modify: `lib/features/bathymetry/data/bathymetry_resolver.dart` +- Test: `test/features/bathymetry/data/bathymetry_resolver_test.dart` + +**Interfaces:** +- Consumes: `SourceCapability` and `probe()` from Task 2; `knownFraction` from Task 1. +- Produces: `BathymetryResolver.minKnownFraction` (0.60) and `BathymetryResolver.preemptionFactor` (2.0). `BathymetryResolution` is unchanged. + +- [ ] **Step 1: Write the failing tests** + +In `test/features/bathymetry/data/bathymetry_resolver_test.dart`, extend `FakeSource` with a declarable cell size. Replace the class with: + +```dart +class FakeSource implements BathymetrySource { + @override + final String id; + @override + final bool global; + final bool coversIt; + final BathymetryGrid? result; // null => throw transient + final double cellSizeMeters; + int fetchCount = 0; + double? lastSpanMeters; + + FakeSource( + this.id, { + this.global = true, + this.coversIt = true, + this.result, + this.cellSizeMeters = 100, + }); + + @override + Future probe(GeoPoint center) async => coversIt + ? SourceCapability(cellSizeMeters: cellSizeMeters, detail: id) + : null; + + @override + Future fetch(GeoPoint c, {required double spanMeters}) async { + fetchCount++; + lastSpanMeters = spanMeters; + final r = result; + if (r == null) throw const BathymetryFetchException('down'); + return r; + } +} +``` + +Add a helper next to `gridWith` that can produce holes: + +```dart +BathymetryGrid gridOf( + List depths, + String sourceId, { + double resolutionMeters = 100, +}) => BathymetryGrid( + originLat: 12.14, + originLon: -68.31, + cellSizeLatDeg: 0.004, + cellSizeLonDeg: 0.004, + rows: 1, + cols: depths.length, + depthsMeters: depths, + sourceId: sourceId, + resolutionMeters: resolutionMeters, + fetchedAt: DateTime.utc(2026, 7, 28), +); +``` + +Append these tests inside `void main()`: + +```dart + test('a mostly-empty grid fails the known-cell floor and falls through', + () async { + // 4 known of 10 cells is 40%, below the 60% floor, even though every + // known cell is wet. This is EMODnet's Bonaire tile in miniature. + final holey = [ + 50.0, null, 50.0, null, 50.0, null, 50.0, null, null, null, + ]; + final a = FakeSource('emodnet-like', result: gridOf(holey, 'a')); + final b = FakeSource('gmrt-like', result: gridWith(wet, 'b')); + final res = await BathymetryResolver(sources: [a, b]).resolve(p); + expect(res.grid!.sourceId, 'b'); + expect(a.fetchCount, 1); + expect(b.fetchCount, 1); + }); + + test('a grid at exactly the known-cell floor is accepted', () async { + final atFloor = [ + 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, null, null, null, null, + ]; + final a = FakeSource('a', result: gridOf(atFloor, 'a')); + final res = await BathymetryResolver(sources: [a]).resolve(p); + expect(res.grid!.sourceId, 'a'); + }); + + test('a materially finer source preempts the declared order', () async { + // 10 m against 100 m is a factor of 10, well past preemptionFactor. + final coarse = FakeSource('coarse', result: gridWith(wet, 'coarse')); + final fine = FakeSource( + 'fine', + result: gridWith(wet, 'fine'), + cellSizeMeters: 10, + ); + final res = await BathymetryResolver(sources: [coarse, fine]).resolve(p); + expect(res.grid!.sourceId, 'fine'); + expect(coarse.fetchCount, 0); + }); + + test('a marginally finer source does not preempt the declared order', + () async { + // 60 m against 115 m is a factor of 1.9, inside preemptionFactor, so + // the declared regional-first order stands. This is EMODnet vs GMRT + // in Europe. + final regional = FakeSource( + 'emodnet', + result: gridWith(wet, 'emodnet'), + cellSizeMeters: 115, + ); + final global = FakeSource( + 'gmrt', + result: gridWith(wet, 'gmrt'), + cellSizeMeters: 60, + ); + final res = await BathymetryResolver(sources: [regional, global]).resolve(p); + expect(res.grid!.sourceId, 'emodnet'); + expect(global.fetchCount, 0); + }); + + test('an uncovered source is never fetched', () async { + final a = FakeSource('a', coversIt: false, result: gridWith(wet, 'a')); + final b = FakeSource('b', result: gridWith(wet, 'b')); + final res = await BathymetryResolver(sources: [a, b]).resolve(p); + expect(res.grid!.sourceId, 'b'); + expect(a.fetchCount, 0); + }); + + test('a probe that throws is treated as not covering', () async { + final res = await BathymetryResolver( + sources: [_ThrowingProbeSource(), FakeSource('b', result: gridWith(wet, 'b'))], + ).resolve(p); + expect(res.grid!.sourceId, 'b'); + }); +``` + +Add the throwing-probe fake next to `_ErrorSource`: + +```dart +class _ThrowingProbeSource implements BathymetrySource { + @override + String get id => 'probe-boom'; + @override + bool get global => false; + @override + Future probe(GeoPoint center) async => + throw StateError('probe exploded'); + @override + Future fetch(GeoPoint c, {required double spanMeters}) async => + throw StateError('should never be fetched'); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/bathymetry/data/bathymetry_resolver_test.dart` + +Expected: FAIL. `minKnownFraction` and `preemptionFactor` are undefined; the holey-grid test currently returns source 'a' because nothing checks coverage density; the preemption tests currently return the first listed source. + +- [ ] **Step 3: Implement** + +Replace `BathymetryResolver` in `lib/features/bathymetry/data/bathymetry_resolver.dart`: + +```dart +/// Best-source-wins. Probes every source, orders them by MATERIALLY better +/// resolution, then fetches in that order and takes the first grid that +/// passes both quality floors. +/// +/// No mosaicking: sources use different vertical datums (EMODnet is LAT, +/// GMRT and ETOPO are MSL), so stitching two of them together would leave +/// a visible step wherever they meet. +class BathymetryResolver { + static const double minWetFraction = 0.10; + + /// A grid must actually have readings. EMODnet's Caribbean tile answers + /// 99.96% wet on 48% coverage, and the missing half renders as a flat + /// slab at the waterline. Coverage this thin is not usable terrain. + static const double minKnownFraction = 0.60; + + /// How much finer a source must be to jump ahead of the declared list + /// order. Declared resolution is a claim, so only a MATERIAL difference + /// is allowed to override the curated tier order: NOAA CUDEM at 3.4 m + /// preempts GMRT's 60 m, while GMRT's nominal 60 m does not preempt + /// EMODnet's surveyed 115 m. + static const double preemptionFactor = 2.0; + + /// Request-box width. 8 km shows the surrounding seascape, not just the + /// site itself; the repository's downsample cap bounds the render cost. + /// This value is part of the cache key - see + /// [BathymetryRepository.keyFor] - so changing it refetches. + static const double defaultSpanMeters = 8000; + + final List sources; + + const BathymetryResolver({required this.sources}); + + Future resolve(GeoPoint center) async { + final ordered = await _order(center); + var globalSourceSaidDry = false; + for (final source in ordered) { + try { + final grid = await source.fetch(center, spanMeters: defaultSpanMeters); + if (grid.knownFraction < minKnownFraction) { + // Nominally fine, actually absent. Not an answer about the water. + continue; + } + if (grid.wetFraction >= minWetFraction) { + return BathymetryResolution.ok(grid); + } + // A dry answer only proves "no water here" if the source actually + // covers everywhere; a regional edge cell proves nothing. + if (source.global) globalSourceSaidDry = true; + } on BathymetryFetchException { + // Transient: fall through to the next source. + } catch (_) { + // A source blowing up with anything else (a TypeError from an + // unexpected response shape, an ArgumentError) must not kill the + // whole scene - treat it exactly like a transient failure. + } + } + return globalSourceSaidDry + ? const BathymetryResolution.empty() + : const BathymetryResolution.transientFailure(); + } + + /// Covering sources in fetch order. Probes run concurrently because a + /// probe may be a network call and they are independent; a probe that + /// fails for any reason drops that source rather than failing the scene. + Future> _order(GeoPoint center) async { + final caps = await Future.wait( + sources.map((s) async { + try { + return await s.probe(center); + } catch (_) { + return null; + } + }), + ); + final covering = <({BathymetrySource source, int rank, double cell})>[]; + for (var i = 0; i < sources.length; i++) { + final cap = caps[i]; + if (cap == null) continue; + covering.add((source: sources[i], rank: i, cell: cap.cellSizeMeters)); + } + if (covering.isEmpty) return const []; + // Group into preemption bands: a source leads only when it is more + // than preemptionFactor finer than everything ahead of it in the + // declared order. Within a band the declared order is preserved. + covering.sort((a, b) { + if (a.cell * preemptionFactor < b.cell) return -1; + if (b.cell * preemptionFactor < a.cell) return 1; + return a.rank.compareTo(b.rank); + }); + return [for (final c in covering) c.source]; + } +} +``` + +Note on the comparator: it is not a total order (the "within a factor" relation is not transitive), so `List.sort` may produce different results for pathological three-source inputs. With the four real sources at 3.4 m, 60 m, 115 m and 450 m this is well-behaved, and the tests pin the two cases that matter. Do not extend this to a larger source list without revisiting it. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/bathymetry/data/bathymetry_resolver_test.dart` + +Expected: PASS. + +The existing test asserting `expect(b.fetchCount, 0)` for "first covering source with a wet grid wins" still holds, because both fakes default to 100 m and the declared order is preserved inside the band. + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/bathymetry/data/bathymetry_resolver.dart test/features/bathymetry/data/bathymetry_resolver_test.dart +git commit -m "feat(bathymetry): order sources by capability, reject empty grids + +The resolver took the first tier returning enough wet cells, which at +Bonaire selects EMODnet's 115 m tile over GMRT's 60 m one despite the +former being 48 percent nodata with no coastline at all. + +Two changes. A known-cell floor of 0.60 rejects a grid that is nominally +wet but mostly holes, which is what fixes Bonaire. A preemption factor of +2.0 lets a materially finer source jump the declared order, which is what +will let NOAA CUDEM lead, while keeping a regional survey ahead of a +global grid of nominally similar resolution." +``` + +--- + +### Task 4: Tiled float32 GeoTIFF parser + +The NOAA ImageServer returns elevations as an uncompressed, tiled, little-endian float32 GeoTIFF. Nothing in the tree reads that. + +**Files:** +- Create: `lib/features/bathymetry/data/sources/arcgis_float32_tiff_parser.dart` +- Test: `test/features/bathymetry/data/arcgis_float32_tiff_parser_test.dart` + +**Interfaces:** +- Consumes: `BathymetryGrid` from Task 1. +- Produces: `ArcgisFloat32TiffParser.parse(Uint8List bytes, {required double westLon, required double eastLon, required double southLat, required double northLat, required String sourceId, required double resolutionMeters, required DateTime fetchedAt})` returning `BathymetryGrid`. Throws `FormatException` on a malformed or unsupported TIFF. + +Background, measured against the live service on 2026-09-05: the response is `II` little-endian, `Compression` (259) = 1, `BitsPerSample` (258) = 32, `SampleFormat` (339) = 3 (IEEE float), `SamplesPerPixel` (277) = 1, and it is **tile-organised**: `TileWidth` (322) and `TileLength` (323) are 128, with `TileOffsets` (324) and `TileByteCounts` (325). There are no `StripOffsets`. A requested 64x64 image arrives as one 128x128 tile with the image occupying its top-left corner, and a 256x256 image arrives as four tiles in row-major order. Image rows run **north to south**, and values are elevations (negative below sea level), so both need flipping and negating to reach the app's depth convention. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/bathymetry/data/arcgis_float32_tiff_parser_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/bathymetry/data/sources/arcgis_float32_tiff_parser.dart'; + +/// Builds a minimal little-endian tiled float32 TIFF matching the shape the +/// NOAA ImageServer returns, so the test pins the real wire format without +/// carrying a 66 KB binary fixture. +Uint8List buildTiff({ + required int width, + required int height, + required int tileSize, + required List tilePixels, // tileSize * tileSize per tile, row-major + int compression = 1, + int bitsPerSample = 32, + int sampleFormat = 3, +}) { + final tilesAcross = (width + tileSize - 1) ~/ tileSize; + final tilesDown = (height + tileSize - 1) ~/ tileSize; + final tileCount = tilesAcross * tilesDown; + final tileBytes = tileSize * tileSize * 4; + expect(tilePixels.length, tileCount * tileSize * tileSize); + + const entries = 9; + const headerLen = 8; + final ifdLen = 2 + entries * 12 + 4; + // Tile offset and byte-count arrays live after the IFD when there is + // more than one tile; a single value fits inline in the entry. + final arraysLen = tileCount > 1 ? tileCount * 4 * 2 : 0; + final pixelStart = headerLen + ifdLen + arraysLen; + + final out = BytesBuilder(); + final head = ByteData(headerLen); + head.setUint8(0, 0x49); + head.setUint8(1, 0x49); + head.setUint16(2, 42, Endian.little); + head.setUint32(4, headerLen, Endian.little); + out.add(head.buffer.asUint8List()); + + final ifd = ByteData(ifdLen); + ifd.setUint16(0, entries, Endian.little); + var e = 2; + void entry(int tag, int type, int count, int value) { + ifd.setUint16(e, tag, Endian.little); + ifd.setUint16(e + 2, type, Endian.little); + ifd.setUint32(e + 4, count, Endian.little); + if (type == 3 && count == 1) { + ifd.setUint16(e + 8, value, Endian.little); + ifd.setUint16(e + 10, 0, Endian.little); + } else { + ifd.setUint32(e + 8, value, Endian.little); + } + e += 12; + } + + final offsetsAt = headerLen + ifdLen; + final countsAt = offsetsAt + tileCount * 4; + entry(256, 3, 1, width); // ImageWidth + entry(257, 3, 1, height); // ImageLength + entry(258, 3, 1, bitsPerSample); // BitsPerSample + entry(259, 3, 1, compression); // Compression + entry(277, 3, 1, 1); // SamplesPerPixel + entry(322, 3, 1, tileSize); // TileWidth + entry(323, 3, 1, tileSize); // TileLength + entry(324, 4, tileCount, tileCount == 1 ? pixelStart : offsetsAt); + entry(325, 4, tileCount, tileCount == 1 ? tileBytes : countsAt); + ifd.setUint32(ifdLen - 4, 0, Endian.little); // next IFD = none + out.add(ifd.buffer.asUint8List()); + + if (tileCount > 1) { + final arrays = ByteData(arraysLen); + for (var i = 0; i < tileCount; i++) { + arrays.setUint32(i * 4, pixelStart + i * tileBytes, Endian.little); + arrays.setUint32(tileCount * 4 + i * 4, tileBytes, Endian.little); + } + out.add(arrays.buffer.asUint8List()); + } + + final pixels = ByteData(tileCount * tileBytes); + for (var i = 0; i < tilePixels.length; i++) { + pixels.setFloat32(i * 4, tilePixels[i], Endian.little); + } + out.add(pixels.buffer.asUint8List()); + // SampleFormat is asserted by the parser but not needed positionally; + // it is folded into BitsPerSample handling for this minimal builder. + expect(sampleFormat, 3); + return out.toBytes(); +} + +void main() { + final when = DateTime.utc(2026, 9, 5); + + test('reads a single-tile image, flips rows south-first and negates', () { + // 2x2 image inside a 4x4 tile. Image row 0 is NORTHERNMOST. + final tile = List.filled(16, 0); + tile[0] = -10.0; // north-west + tile[1] = -20.0; // north-east + tile[4] = -30.0; // south-west + tile[5] = -40.0; // south-east + final bytes = buildTiff( + width: 2, + height: 2, + tileSize: 4, + tilePixels: tile, + ); + + final g = ArcgisFloat32TiffParser.parse( + bytes, + westLon: -80.4, + eastLon: -80.2, + southLat: 25.0, + northLat: 25.2, + sourceId: 'noaa_dem', + resolutionMeters: 8, + fetchedAt: when, + ); + + expect(g.rows, 2); + expect(g.cols, 2); + // Grid row 0 is SOUTHERNMOST, and elevation negates into depth. + expect(g.depthAt(0, 0), closeTo(30.0, 1e-6)); + expect(g.depthAt(0, 1), closeTo(40.0, 1e-6)); + expect(g.depthAt(1, 0), closeTo(10.0, 1e-6)); + expect(g.depthAt(1, 1), closeTo(20.0, 1e-6)); + expect(g.sourceId, 'noaa_dem'); + expect(g.resolutionMeters, 8); + }); + + test('origin is the south-west CELL CENTER and cell sizes span the box', () { + final tile = List.filled(16, -5.0); + final g = ArcgisFloat32TiffParser.parse( + buildTiff(width: 2, height: 2, tileSize: 4, tilePixels: tile), + westLon: -80.4, + eastLon: -80.2, + southLat: 25.0, + northLat: 25.2, + sourceId: 'noaa_dem', + resolutionMeters: 8, + fetchedAt: when, + ); + // 2 columns across a 0.2 degree box: cell width 0.1, first center at + // west + half a cell. + expect(g.cellSizeLonDeg, closeTo(0.1, 1e-9)); + expect(g.cellSizeLatDeg, closeTo(0.1, 1e-9)); + expect(g.originLon, closeTo(-80.35, 1e-9)); + expect(g.originLat, closeTo(25.05, 1e-9)); + }); + + test('assembles a multi-tile image in row-major tile order', () { + // 4x4 image as four 2x2 tiles. Each tile filled with its own index so + // misplacement is obvious. + final pixels = [ + -1, -1, -1, -1, // tile 0 (north-west) + -2, -2, -2, -2, // tile 1 (north-east) + -3, -3, -3, -3, // tile 2 (south-west) + -4, -4, -4, -4, // tile 3 (south-east) + ]; + final g = ArcgisFloat32TiffParser.parse( + buildTiff(width: 4, height: 4, tileSize: 2, tilePixels: pixels), + westLon: 0, + eastLon: 1, + southLat: 0, + northLat: 1, + sourceId: 'noaa_dem', + resolutionMeters: 8, + fetchedAt: when, + ); + // After the south-first flip, grid row 0 is the image's LAST row. + expect(g.depthAt(0, 0), closeTo(3.0, 1e-6)); + expect(g.depthAt(0, 3), closeTo(4.0, 1e-6)); + expect(g.depthAt(3, 0), closeTo(1.0, 1e-6)); + expect(g.depthAt(3, 3), closeTo(2.0, 1e-6)); + }); + + test('maps the ArcGIS nodata sentinel to null', () { + final tile = List.filled(16, -5.0); + tile[0] = -3.4028234663852886e38; // float32 lowest, ArcGIS NoData + final g = ArcgisFloat32TiffParser.parse( + buildTiff(width: 2, height: 2, tileSize: 4, tilePixels: tile), + westLon: 0, + eastLon: 1, + southLat: 0, + northLat: 1, + sourceId: 'noaa_dem', + resolutionMeters: 8, + fetchedAt: when, + ); + // Image (0,0) is the northernmost row, which flips to grid row 1. + expect(g.depthAt(1, 0), isNull); + expect(g.depthAt(0, 0), closeTo(5.0, 1e-6)); + }); + + test('throws FormatException on a compressed TIFF', () { + expect( + () => ArcgisFloat32TiffParser.parse( + buildTiff( + width: 2, + height: 2, + tileSize: 4, + tilePixels: List.filled(16, -5.0), + compression: 5, + ), + westLon: 0, + eastLon: 1, + southLat: 0, + northLat: 1, + sourceId: 'noaa_dem', + resolutionMeters: 8, + fetchedAt: when, + ), + throwsFormatException, + ); + }); + + test('throws FormatException on a non-float sample depth', () { + expect( + () => ArcgisFloat32TiffParser.parse( + buildTiff( + width: 2, + height: 2, + tileSize: 4, + tilePixels: List.filled(16, -5.0), + bitsPerSample: 16, + ), + westLon: 0, + eastLon: 1, + southLat: 0, + northLat: 1, + sourceId: 'noaa_dem', + resolutionMeters: 8, + fetchedAt: when, + ), + throwsFormatException, + ); + }); + + test('throws FormatException on a big-endian or non-TIFF body', () { + expect( + () => ArcgisFloat32TiffParser.parse( + Uint8List.fromList('not a tiff'.codeUnits), + westLon: 0, + eastLon: 1, + southLat: 0, + northLat: 1, + sourceId: 'noaa_dem', + resolutionMeters: 8, + fetchedAt: when, + ), + throwsFormatException, + ); + }); +} +``` + +Add `import 'package:submersion/features/bathymetry/domain/bathymetry_grid.dart';` if the analyzer needs `BathymetryGrid` named. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/bathymetry/data/arcgis_float32_tiff_parser_test.dart` + +Expected: FAIL, compile error: the target of the URI does not exist. + +- [ ] **Step 3: Implement** + +Create `lib/features/bathymetry/data/sources/arcgis_float32_tiff_parser.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:submersion/features/bathymetry/domain/bathymetry_grid.dart'; + +/// Reads the uncompressed, tiled, little-endian float32 GeoTIFF that an +/// ArcGIS ImageServer returns for `format=tiff&pixelType=F32`. +/// +/// Deliberately narrow. This is not a TIFF library: it accepts exactly the +/// shape that service emits and throws [FormatException] on anything else, +/// so an unexpected response becomes a transient source failure rather than +/// silently wrong terrain. +/// +/// Two conversions bring it into the app's convention. Image rows run north +/// to south while [BathymetryGrid] rows run south to north, and the raster +/// carries elevation (negative below sea level) while the grid carries +/// depth (positive down). +class ArcgisFloat32TiffParser { + /// ArcGIS writes float32 lowest as its NoData sentinel. + static const double _noDataSentinel = -3.4028234663852886e38; + static const double _noDataEpsilon = 1e30; + + static const int _tagImageWidth = 256; + static const int _tagImageLength = 257; + static const int _tagBitsPerSample = 258; + static const int _tagCompression = 259; + static const int _tagSamplesPerPixel = 277; + static const int _tagTileWidth = 322; + static const int _tagTileLength = 323; + static const int _tagTileOffsets = 324; + static const int _tagTileByteCounts = 325; + + static BathymetryGrid parse( + Uint8List bytes, { + required double westLon, + required double eastLon, + required double southLat, + required double northLat, + required String sourceId, + required double resolutionMeters, + required DateTime fetchedAt, + }) { + final d = ByteData.sublistView(bytes); + if (bytes.length < 8 || bytes[0] != 0x49 || bytes[1] != 0x49) { + throw const FormatException( + 'Expected a little-endian TIFF (II magic); the service returned ' + 'something else, most likely an error envelope.', + ); + } + if (d.getUint16(2, Endian.little) != 42) { + throw const FormatException('Not a classic TIFF (magic 42 missing)'); + } + + final ifd = d.getUint32(4, Endian.little); + final entryCount = d.getUint16(ifd, Endian.little); + final tags = {}; + for (var i = 0; i < entryCount; i++) { + final e = ifd + 2 + i * 12; + final tag = d.getUint16(e, Endian.little); + final type = d.getUint16(e + 2, Endian.little); + final count = d.getUint32(e + 4, Endian.little); + // A SHORT with count 1 sits in the low half of the value field. + final value = type == 3 && count == 1 + ? d.getUint16(e + 8, Endian.little) + : d.getUint32(e + 8, Endian.little); + tags[tag] = (type: type, count: count, value: value); + } + + int need(int tag, String what) { + final t = tags[tag]; + if (t == null) throw FormatException('TIFF is missing $what'); + return t.value; + } + + if (need(_tagCompression, 'Compression') != 1) { + throw const FormatException('Only uncompressed TIFF is supported'); + } + if (need(_tagBitsPerSample, 'BitsPerSample') != 32) { + throw const FormatException('Only 32-bit samples are supported'); + } + if (need(_tagSamplesPerPixel, 'SamplesPerPixel') != 1) { + throw const FormatException('Only single-band rasters are supported'); + } + + final width = need(_tagImageWidth, 'ImageWidth'); + final height = need(_tagImageLength, 'ImageLength'); + final tileW = need(_tagTileWidth, 'TileWidth'); + final tileH = need(_tagTileLength, 'TileLength'); + if (width < 1 || height < 1 || tileW < 1 || tileH < 1) { + throw const FormatException('TIFF has a zero dimension'); + } + + final offsets = _longs(d, tags[_tagTileOffsets], 'TileOffsets'); + final tilesAcross = (width + tileW - 1) ~/ tileW; + final tilesDown = (height + tileH - 1) ~/ tileH; + if (offsets.length != tilesAcross * tilesDown) { + throw const FormatException('TileOffsets count does not match the grid'); + } + + // Image order, north row first. + final image = List.filled(width * height, null); + for (var t = 0; t < offsets.length; t++) { + final tx = t % tilesAcross; + final ty = t ~/ tilesAcross; + final base = offsets[t]; + if (base + tileW * tileH * 4 > bytes.length) { + throw const FormatException('TIFF tile runs past the end of the body'); + } + for (var r = 0; r < tileH; r++) { + final imageRow = ty * tileH + r; + if (imageRow >= height) break; + for (var c = 0; c < tileW; c++) { + final imageCol = tx * tileW + c; + if (imageCol >= width) continue; + final v = d.getFloat32(base + (r * tileW + c) * 4, Endian.little); + image[imageRow * width + imageCol] = + _isNoData(v) ? null : -v; // elevation -> depth + } + } + } + + // Flip to south-first. + final depths = List.filled(width * height, null); + for (var r = 0; r < height; r++) { + final src = (height - 1 - r) * width; + depths.setRange(r * width, r * width + width, image, src); + } + + // The requested box spans cell EDGES; the grid origin is a cell CENTER. + final cellLon = (eastLon - westLon) / width; + final cellLat = (northLat - southLat) / height; + return BathymetryGrid( + originLat: southLat + cellLat / 2, + originLon: westLon + cellLon / 2, + cellSizeLatDeg: cellLat, + cellSizeLonDeg: cellLon, + rows: height, + cols: width, + depthsMeters: depths, + sourceId: sourceId, + resolutionMeters: resolutionMeters, + fetchedAt: fetchedAt, + ); + } + + static bool _isNoData(double v) => + v.isNaN || (v - _noDataSentinel).abs() < _noDataEpsilon && v < -1e30; + + static List _longs( + ByteData d, + ({int type, int count, int value})? tag, + String what, + ) { + if (tag == null) throw FormatException('TIFF is missing $what'); + if (tag.count == 1) return [tag.value]; + return [ + for (var i = 0; i < tag.count; i++) + d.getUint32(tag.value + i * 4, Endian.little), + ]; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/bathymetry/data/arcgis_float32_tiff_parser_test.dart` + +Expected: PASS. If the nodata test fails, check `_isNoData`: the sentinel comparison must catch float32 lowest without swallowing a legitimate deep reading, and no real elevation approaches -1e30. + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/bathymetry/data/sources/arcgis_float32_tiff_parser.dart test/features/bathymetry/data/arcgis_float32_tiff_parser_test.dart +git commit -m "feat(bathymetry): read ArcGIS uncompressed tiled float32 GeoTIFF + +The NOAA NCEI DEM ImageServer returns elevations as an uncompressed, +tile-organised, little-endian float32 GeoTIFF with no strip tags at all, +which nothing in the tree could read. + +Deliberately narrow rather than a general TIFF decoder: it accepts exactly +that shape and throws FormatException on anything else, so an unexpected +response degrades to a transient source failure instead of silently wrong +terrain. Rows flip south-first and elevations negate into depths." +``` + +--- + +### Task 5: NoaaDemSource + +**Files:** +- Create: `lib/features/bathymetry/data/sources/noaa_dem_source.dart` +- Test: `test/features/bathymetry/data/noaa_dem_source_test.dart` + +**Interfaces:** +- Consumes: `SourceCapability`, `BathymetrySource`, `BathymetryFetchException` from Task 2; `ArcgisFloat32TiffParser.parse` from Task 4. +- Produces: `NoaaDemSource({http.Client? client, String baseUrl})` with `static const String sourceId = 'noaa_dem'` and `static const double usefulCellSizeMeters = 50`. + +Measured wire behaviour, 2026-09-05. The probe is: + +``` +GET {baseUrl}/identify + ?geometry={"x":,"y":} + &geometryType=esriGeometryPoint + &returnCatalogItems=true + &maxItemCount=25 + &f=json +``` + +`maxItemCount` is load-bearing: without it the response truncates to three items and La Jolla's 10 m DEM is hidden behind ETOPO. The response carries `catalogItems.features[].attributes`, each with `Name` and `LowPS` (cell size in **degrees**). Measured results: Florida Keys `ncei19_n25x25_w080x50_2016v1` at `LowPS` 3.086e-05 (3.4 m), La Jolla `san_diego_navd88` at 10.3 m, Bonaire best is `ETOPO_2022_v1_15s_surface_elev` at 464 m. ArcGIS returns error envelopes with HTTP 200, so a body with no `catalogItems` is a decline, not a crash. + +The fetch is: + +``` +GET {baseUrl}/exportImage + ?bbox=,,,&bboxSR=4326&imageSR=4326 + &size=,&format=tiff&pixelType=F32 + &noDataInterpretation=esriNoDataMatchAny + &interpolation=RSP_BilinearInterpolation&f=image +``` + +- [ ] **Step 1: Write the failing test** + +Create `test/features/bathymetry/data/noaa_dem_source_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/features/bathymetry/data/sources/noaa_dem_source.dart'; +import 'package:submersion/features/bathymetry/domain/bathymetry_source.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +String identifyBody(List<({String name, double lowPs})> items) => jsonEncode({ + 'value': '-3.87793', + 'catalogItems': { + 'features': [ + for (final i in items) + { + 'attributes': {'Name': i.name, 'LowPS': i.lowPs, 'HighPS': i.lowPs}, + }, + ], + }, +}); + +/// A 2x2 single-tile float32 TIFF, matching the builder in +/// arcgis_float32_tiff_parser_test.dart but inlined so this suite stands +/// alone. +Uint8List tinyTiff() { + const width = 2, height = 2, tileSize = 4, entries = 9; + const headerLen = 8; + const ifdLen = 2 + entries * 12 + 4; + const pixelStart = headerLen + ifdLen; + final out = BytesBuilder(); + final head = ByteData(headerLen); + head.setUint8(0, 0x49); + head.setUint8(1, 0x49); + head.setUint16(2, 42, Endian.little); + head.setUint32(4, headerLen, Endian.little); + out.add(head.buffer.asUint8List()); + final ifd = ByteData(ifdLen); + ifd.setUint16(0, entries, Endian.little); + var e = 2; + void entry(int tag, int type, int count, int value) { + ifd.setUint16(e, tag, Endian.little); + ifd.setUint16(e + 2, type, Endian.little); + ifd.setUint32(e + 4, count, Endian.little); + if (type == 3 && count == 1) { + ifd.setUint16(e + 8, value, Endian.little); + ifd.setUint16(e + 10, 0, Endian.little); + } else { + ifd.setUint32(e + 8, value, Endian.little); + } + e += 12; + } + + entry(256, 3, 1, width); + entry(257, 3, 1, height); + entry(258, 3, 1, 32); + entry(259, 3, 1, 1); + entry(277, 3, 1, 1); + entry(322, 3, 1, tileSize); + entry(323, 3, 1, tileSize); + entry(324, 4, 1, pixelStart); + entry(325, 4, 1, tileSize * tileSize * 4); + ifd.setUint32(ifdLen - 4, 0, Endian.little); + out.add(ifd.buffer.asUint8List()); + final px = ByteData(tileSize * tileSize * 4); + for (var i = 0; i < tileSize * tileSize; i++) { + px.setFloat32(i * 4, -8.0, Endian.little); + } + out.add(px.buffer.asUint8List()); + return out.toBytes(); +} + +void main() { + const keys = GeoPoint(25.010, -80.376); + + group('probe', () { + test('returns the finest catalogue item converted to meters', () async { + final source = NoaaDemSource( + client: MockClient( + (req) async => http.Response( + identifyBody([ + (name: 'ETOPO_2022_v1_15s_surface_elev', lowPs: 0.0041666), + (name: 'ncei19_n25x25_w080x50_2016v1', lowPs: 3.086419e-05), + ]), + 200, + ), + ), + ); + final cap = await source.probe(keys); + expect(cap, isNotNull); + expect(cap!.cellSizeMeters, closeTo(3.4, 0.3)); + expect(cap.detail, 'ncei19_n25x25_w080x50_2016v1'); + }); + + test('requests maxItemCount so the catalogue is not truncated', () async { + Uri? seen; + final source = NoaaDemSource( + client: MockClient((req) async { + seen = req.url; + return http.Response(identifyBody([]), 200); + }), + ); + await source.probe(keys); + expect(seen!.queryParameters['maxItemCount'], '25'); + expect(seen!.queryParameters['returnCatalogItems'], 'true'); + }); + + test('declines when the best item is coarser than the threshold', () async { + final source = NoaaDemSource( + client: MockClient( + (req) async => http.Response( + identifyBody([ + (name: 'ETOPO_2022_v1_15s_surface_elev', lowPs: 0.0041666), + ]), + 200, + ), + ), + ); + // ~464 m at the equator, far past the 50 m usefulness threshold. + expect(await source.probe(const GeoPoint(12.093, -68.287)), isNull); + }); + + test('declines on an empty catalogue', () async { + final source = NoaaDemSource( + client: MockClient((req) async => http.Response(identifyBody([]), 200)), + ); + expect(await source.probe(keys), isNull); + }); + + test('declines on an ArcGIS error envelope returned with HTTP 200', + () async { + final source = NoaaDemSource( + client: MockClient( + (req) async => http.Response( + jsonEncode({ + 'error': {'code': 400, 'message': 'Invalid geometry'}, + }), + 200, + ), + ), + ); + expect(await source.probe(keys), isNull); + }); + + test('declines on a non-200', () async { + final source = NoaaDemSource( + client: MockClient((req) async => http.Response('nope', 503)), + ); + expect(await source.probe(keys), isNull); + }); + }); + + group('fetch', () { + test('requests a float32 tiff over the span and parses it', () async { + Uri? seen; + final source = NoaaDemSource( + client: MockClient((req) async { + seen = req.url; + return http.Response.bytes(tinyTiff(), 200); + }), + ); + final g = await source.fetch(keys, spanMeters: 2000); + expect(seen!.path, endsWith('/exportImage')); + expect(seen!.queryParameters['format'], 'tiff'); + expect(seen!.queryParameters['pixelType'], 'F32'); + expect(seen!.queryParameters['bboxSR'], '4326'); + expect(g.rows, 2); + expect(g.cols, 2); + expect(g.depthAt(0, 0), closeTo(8.0, 1e-6)); + expect(g.sourceId, NoaaDemSource.sourceId); + }); + + test('throws BathymetryFetchException on a non-200', () async { + final source = NoaaDemSource( + client: MockClient((req) async => http.Response('down', 500)), + ); + expect( + () => source.fetch(keys, spanMeters: 2000), + throwsA(isA()), + ); + }); + + test('throws BathymetryFetchException on an unparseable body', () async { + final source = NoaaDemSource( + client: MockClient( + (req) async => http.Response('{"error":{"code":400}}', 200), + ), + ); + expect( + () => source.fetch(keys, spanMeters: 2000), + throwsA(isA()), + ); + }); + }); + + test('is not global, so a dry answer never proves land', () { + expect(NoaaDemSource().global, isFalse); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/bathymetry/data/noaa_dem_source_test.dart` + +Expected: FAIL, compile error: the target of the URI does not exist. + +- [ ] **Step 3: Implement** + +Create `lib/features/bathymetry/data/sources/noaa_dem_source.dart`: + +```dart +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'package:submersion/core/utils/geo_math.dart'; +import 'package:submersion/features/bathymetry/data/sources/arcgis_float32_tiff_parser.dart'; +import 'package:submersion/features/bathymetry/domain/bathymetry_grid.dart'; +import 'package:submersion/features/bathymetry/domain/bathymetry_source.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +/// High-resolution coastal tier: the NOAA NCEI DEM mosaic ImageServer, +/// which stacks CUDEM and regional DEMs over an ETOPO background. Public +/// domain. Coverage is patchy and mostly US coastal, so this source +/// declines wherever the stack holds nothing better than the background. +/// +/// The probe is what makes that honest. `identify` returns every DEM under +/// a point with its cell size, so the source knows before fetching whether +/// it has anything worth having: 3.4 m in the Florida Keys, 10.3 m at La +/// Jolla, and only ETOPO's 464 m at Bonaire, where it declines. +class NoaaDemSource implements BathymetrySource { + static const String sourceId = 'noaa_dem'; + + /// Below this cell size the mosaic is offering a real coastal DEM. Above + /// it, the stack has nothing but its ETOPO background, which the ETOPO + /// tier already serves directly. + static const double usefulCellSizeMeters = 50; + + /// Cells per side requested from exportImage. The server resamples to + /// whatever is asked, so this is a render-budget choice, not a data one. + static const int requestDim = 256; + + static const Duration _timeout = Duration(seconds: 15); + + final http.Client _client; + final String baseUrl; + + NoaaDemSource({ + http.Client? client, + this.baseUrl = + 'https://gis.ngdc.noaa.gov/arcgis/rest/services/DEM_mosaics/DEM_all/ImageServer', + }) : _client = client ?? http.Client(); + + @override + String get id => sourceId; + + /// Coverage is patchy coastal, so a dry answer here proves nothing about + /// whether a coordinate is on land. + @override + bool get global => false; + + @override + Future probe(GeoPoint center) async { + final url = Uri.parse('$baseUrl/identify').replace( + queryParameters: { + 'geometry': '{"x":${center.longitude},"y":${center.latitude}}', + 'geometryType': 'esriGeometryPoint', + 'returnCatalogItems': 'true', + // Load-bearing. The default truncates to three items, which hides + // a 10 m DEM behind the ETOPO background and makes a covered site + // look uncovered. + 'maxItemCount': '25', + 'f': 'json', + }, + ); + try { + final resp = await _client.get(url).timeout(_timeout); + if (resp.statusCode != 200) return null; + final body = jsonDecode(resp.body); + if (body is! Map) return null; + // ArcGIS returns error envelopes with HTTP 200. + final items = (body['catalogItems'] as Map?)?['features']; + if (items is! List || items.isEmpty) return null; + + String? bestName; + double? bestDeg; + for (final item in items) { + if (item is! Map) continue; + final attrs = item['attributes']; + if (attrs is! Map) continue; + final lowPs = attrs['LowPS']; + if (lowPs is! num) continue; + final deg = lowPs.toDouble(); + if (deg <= 0) continue; + if (bestDeg == null || deg < bestDeg) { + bestDeg = deg; + bestName = attrs['Name'] as String?; + } + } + if (bestDeg == null) return null; + + // LowPS is in degrees; convert on the longitude axis, which is the + // narrower of the two at every latitude. + final meters = bestDeg * metersPerDegreeLongitude(center.latitude); + if (meters > usefulCellSizeMeters) return null; + return SourceCapability( + cellSizeMeters: meters, + detail: bestName ?? 'NOAA NCEI DEM', + ); + } catch (_) { + // A probe never throws: an unreachable or surprising service simply + // means this source does not contribute here. + return null; + } + } + + @override + Future fetch( + GeoPoint center, { + required double spanMeters, + }) async { + final dLat = spanMeters / 2 / 110540.0; + final dLon = spanMeters / 2 / metersPerDegreeLongitude(center.latitude); + final west = center.longitude - dLon; + final east = center.longitude + dLon; + final south = center.latitude - dLat; + final north = center.latitude + dLat; + final url = Uri.parse('$baseUrl/exportImage').replace( + queryParameters: { + 'bbox': '$west,$south,$east,$north', + 'bboxSR': '4326', + 'imageSR': '4326', + 'size': '$requestDim,$requestDim', + 'format': 'tiff', + 'pixelType': 'F32', + 'noDataInterpretation': 'esriNoDataMatchAny', + 'interpolation': 'RSP_BilinearInterpolation', + 'f': 'image', + }, + ); + try { + final resp = await _client.get(url).timeout(_timeout); + if (resp.statusCode != 200) { + throw BathymetryFetchException('NOAA DEM HTTP ${resp.statusCode}'); + } + return ArcgisFloat32TiffParser.parse( + resp.bodyBytes, + westLon: west, + eastLon: east, + southLat: south, + northLat: north, + sourceId: sourceId, + resolutionMeters: spanMeters / requestDim, + fetchedAt: DateTime.now(), + ); + } on BathymetryFetchException { + rethrow; + } on Exception catch (e) { + throw BathymetryFetchException('NOAA DEM fetch failed: $e'); + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/bathymetry/data/noaa_dem_source_test.dart` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/bathymetry/data/sources/noaa_dem_source.dart test/features/bathymetry/data/noaa_dem_source_test.dart +git commit -m "feat(bathymetry): add the NOAA NCEI high-resolution coastal tier + +The mosaic stacks CUDEM and regional DEMs over an ETOPO background, so +coverage is patchy and mostly US coastal. The identify probe is what keeps +that honest: it reports every DEM under a point with its cell size, so the +source declines wherever the stack offers nothing better than the +background it already has from the ETOPO tier. + +Measured 3.4 m in the Florida Keys and 10.3 m at La Jolla, declining at +Bonaire. maxItemCount is load-bearing: the default truncates the catalogue +to three items and hides a covered site's DEM behind ETOPO." +``` + +--- + +### Task 6: Register the tier and bump the cache generation + +Selection now produces different grids for coordinates that are already cached, and cached rows never expire. + +**Files:** +- Modify: `lib/features/bathymetry/application/bathymetry_providers.dart:29-36` +- Modify: `lib/features/bathymetry/data/bathymetry_repository.dart:36-42` +- Test: `test/features/bathymetry/data/bathymetry_repository_test.dart` + +**Interfaces:** +- Consumes: `NoaaDemSource` from Task 5. +- Produces: `BathymetryRepository.selectionGeneration` (`'v2'`); `keyFor` returns `',@'`. + +- [ ] **Step 1: Write the failing test** + +Append to `test/features/bathymetry/data/bathymetry_repository_test.dart`, inside `void main()`: + +```dart + test('the cache key carries the selection generation', () { + final key = BathymetryRepository.keyFor(const GeoPoint(12.16, -68.29)); + expect(key, endsWith('@8000v2')); + }); + + test('a row written under the previous generation is not reused', () async { + // Simulates an install upgrading from the pre-selection-change build: + // the old row must miss so the coordinate refetches under the new + // source ordering rather than serving its stale EMODnet grid forever. + const p = GeoPoint(12.16, -68.29); + final q = BathymetryRepository.quantize(p); + final legacyKey = + '${q.lat.toStringAsFixed(2)},${q.lon.toStringAsFixed(2)}@8000'; + expect(BathymetryRepository.keyFor(p), isNot(legacyKey)); + }); +``` + +Add `import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart';` if `GeoPoint` is not already imported there. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/bathymetry/data/bathymetry_repository_test.dart` + +Expected: FAIL. The key currently ends `@8000`, so the `endsWith('@8000v2')` assertion fails. + +- [ ] **Step 3: Implement** + +In `lib/features/bathymetry/data/bathymetry_repository.dart`, add beside `maxGridDim`: + +```dart + /// Bumped whenever source SELECTION changes, not just the span. Cached + /// rows never expire, so without this every already-visited site would + /// keep serving the grid its old resolver chose. Old rows go inert, the + /// same way the 4 km rows did when the span went to 8 km. + static const String selectionGeneration = 'v2'; +``` + +and change `keyFor`: + +```dart + static String keyFor(GeoPoint c) { + final q = quantize(c); + // The span and the selection generation are both part of the key: + // cached rows never expire, so any change that would resolve a + // coordinate differently must miss the old rows and refetch. + final span = BathymetryResolver.defaultSpanMeters.round(); + return '${q.lat.toStringAsFixed(2)},${q.lon.toStringAsFixed(2)}' + '@$span$selectionGeneration'; + } +``` + +In `lib/features/bathymetry/application/bathymetry_providers.dart`, add the import and register the tier: + +```dart +import 'package:submersion/features/bathymetry/data/sources/noaa_dem_source.dart'; +``` + +```dart + resolver: BathymetryResolver( + // Declared order is regional survey, then global GMRT, then the + // coarse public-domain fallback. NOAA's coastal mosaic leads only + // where it is MATERIALLY finer, which the resolver's preemption + // factor decides; where the mosaic holds nothing but its ETOPO + // background it declines during probe and never reaches a fetch. + sources: [ + NoaaDemSource(), + EmodnetSource(), + GmrtSource(), + EtopoErddapSource(), + ], + ), +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/bathymetry` + +Expected: PASS. Any existing test asserting a literal `@8000` key must be updated to `@8000v2`; grep for it: + +```bash +grep -rn "@8000" test lib +``` + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/bathymetry test/features/bathymetry +git commit -m "feat(bathymetry): register the NOAA tier and bump the cache generation + +Cached grids never expire, so changing which source wins would otherwise +leave every already-visited site serving the grid its old resolver picked. +The key gains a selection-generation token so those rows go inert, exactly +as the 4 km rows did when the span went to 8 km." +``` + +--- + +### Task 7: Name the new tier in the provenance caption and credits + +A new `sourceId` reaches two user-visible surfaces. Neither breaks, which +is why this is easy to miss: `bathymetrySourceDisplayName` falls through to +`_ => id`, so the caption would read the raw string `noaa_dem`, and the +about-screen credit would simply not mention the source at all. + +**Files:** +- Modify: `lib/features/bathymetry/presentation/bathymetry_labels.dart:3-8` +- Modify: `lib/l10n/arb/app_en.arb` and the ten other locale ARBs +- Test: `test/features/bathymetry/presentation/bathymetry_labels_test.dart` (create if absent) + +**Interfaces:** +- Consumes: `NoaaDemSource.sourceId` from Task 5. +- Produces: nothing later tasks depend on. + +- [ ] **Step 1: Write the failing test** + +Create or append to `test/features/bathymetry/presentation/bathymetry_labels_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/bathymetry/data/sources/noaa_dem_source.dart'; +import 'package:submersion/features/bathymetry/presentation/bathymetry_labels.dart'; + +void main() { + test('every shipped source id has a human display name', () { + // The switch falls through to the raw id, so a missing case is not a + // crash: it is a caption reading "noaa_dem" at the user. + for (final id in const ['gmrt', 'emodnet', 'etopo2022', NoaaDemSource.sourceId]) { + expect(bathymetrySourceDisplayName(id), isNot(id), reason: id); + } + }); + + test('an unknown id still falls back to itself rather than throwing', () { + expect(bathymetrySourceDisplayName('future_source'), 'future_source'); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/bathymetry/presentation/bathymetry_labels_test.dart` + +Expected: FAIL on the `noaa_dem` entry, because the switch returns the id unchanged. + +- [ ] **Step 3: Implement** + +In `lib/features/bathymetry/presentation/bathymetry_labels.dart`, add the case: + +```dart +String bathymetrySourceDisplayName(String id) => switch (id) { + 'gmrt' => 'GMRT', + 'emodnet' => 'EMODnet', + 'etopo2022' => 'ETOPO 2022', + 'noaa_dem' => 'NOAA NCEI', + _ => id, +}; +``` + +Then append the new source to `settings_about_bathymetryCredit` in **all +eleven** ARB files. The source list after each locale's own prefix is an +identical run of proper nouns, so the edit is the same everywhere: append +` · NOAA NCEI DEM` to the end of the value. NOAA NCEI DEMs are US public +domain, so no licence marker is needed, matching how ETOPO is already +listed. + +Files: `lib/l10n/arb/app_ar.arb`, `app_de.arb`, `app_en.arb`, `app_es.arb`, +`app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, +`app_pt.arb`, `app_zh.arb`. + +English becomes: + +```json + "settings_about_bathymetryCredit": "Bathymetry data: GMRT (CC BY 4.0) · EMODnet Bathymetry (CC BY 4.0) · NOAA ETOPO 2022 · NOAA NCEI DEM", +``` + +Then regenerate, **from the project root** (`flutter gen-l10n` fails when +run from a subdirectory): + +```bash +flutter gen-l10n +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/bathymetry test/features/settings` + +Expected: PASS. Confirm the generated hub picked the change up in every +locale, since CI regenerates l10n but does not verify it: + +```bash +grep -c "NOAA NCEI DEM" lib/l10n/arb/app_localizations_*.dart +``` + +Expected: a count of 1 for each of the generated locale files. + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/bathymetry/presentation/bathymetry_labels.dart lib/l10n test/features/bathymetry/presentation/bathymetry_labels_test.dart +git commit -m "feat(bathymetry): credit the NOAA NCEI tier in caption and about + +The display-name switch falls through to the raw id, so a missing case is +not a crash: the provenance caption would have read noaa_dem at the user. +The about screen's source list gains the tier across all eleven locales." +``` + +--- + +### Task 8: Verify the whole tree and format + +**Files:** +- Modify: whatever the checks turn up. + +**Interfaces:** +- Consumes: every earlier task. +- Produces: a branch that passes the pre-push gates. + +- [ ] **Step 1: Format** + +Run: `dart format .` + +- [ ] **Step 2: Analyze the whole project** + +Run: `flutter analyze` + +Expected: no issues. Infos are CI-fatal in this repo, so treat every info as a failure and fix it. + +- [ ] **Step 3: Run the full suite** + +Run: `flutter test` + +Expected: all pass. Do not pipe the output through `grep`; a pipe masks the exit code in this repo. + +Investigate every failure rather than adjusting an assertion to match new behaviour, except where a test legitimately asserted the old striding downsample or the old `@8000` key. + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -u +git commit -m "chore(bathymetry): format and fix analyzer findings" +``` + +Stage explicit paths rather than `git add -A`, which sweeps edits from sibling worktrees. + +- [ ] **Step 5: Report** + +Report the analyze result and the full-suite pass/fail/skip counts verbatim. Do not claim the work is complete without those numbers. + +--- + +## Manual verification + +After Task 7, confirm the change against the live services rather than only against mocks. This is the part that proves PR 1 delivered what it promised. + +Bonaire, the reported case: + +```bash +flutter test test/features/bathymetry +``` + +then in the running app, open a Bonaire site's seascape and check the provenance caption. It must name GMRT at roughly 60 m rather than EMODnet at 115 m, and the scene must show a real coastline rather than a flat tan slab across half the box. + +A US coastal site (Florida Keys or La Jolla) must report the NOAA tier at single-digit or low-double-digit meters. + +If a site still reports EMODnet, check that the cache generation bump actually missed the old row: the local cache `bathymetry_cache` table should hold a `@8000v2` key for that cell. diff --git a/docs/superpowers/specs/2026-08-20-pdf-logbook-export-design.md b/docs/superpowers/specs/2026-08-20-pdf-logbook-export-design.md new file mode 100644 index 0000000000..6b7b058838 --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-pdf-logbook-export-design.md @@ -0,0 +1,268 @@ +# PDF Logbook Export Rework + +Date: 2026-08-20 +Issue: #1017 (Dive Log Export needs love), plus a user report that the +Detailed template produces summary-level output. + +## Problem + +Two reports, one root cause. + +A user selected the Detailed log type and received a sparse card showing only +depth, duration, temperature and first-cylinder pressures, with the notes cut +off mid-sentence. Issue #1017 independently lists roughly 28 fields the +Detailed logbook should carry and reports content gaps in every other +template. + +The template selection mechanism is not broken. `PdfExportDialog` collects the +choice, `export_providers.dart:336` resolves it through `PdfTemplateFactory`, +and the correct builder runs. The screenshot was produced by +`PdfTemplateDetailed`, confirmed by the `-` separator in the Air chip +(`pdf_template_detailed.dart:168`) which distinguishes it from the legacy +builder's `→` (`pdf_export_service.dart:449`). + +The defect is that `PdfTemplateDetailed._buildDiveEntry` renders 6 of the +roughly 80 fields on `Dive` and applies `maxLines: 2` to notes. Its own class +docstring promises "all available information including notes, gas info, tank +data, ratings". The code was extracted from the legacy hardcoded layout and +never enriched. + +### Secondary defects found while investigating + +1. **Certifications silently truncate.** `buildCertificationCardsPage` renders + every certification passed to it with no cap, but all four callers wrap it + in a single `pw.Page` rather than `pw.MultiPage`. Anything past one page is + dropped. This is what #1017 reports as "not all certifications are + displayed". +2. **Units are hardcoded metric.** No template imports `unit_formatter.dart`. + Depth, temperature and pressure are printed with literal `m`, `°C` and + `bar` suffixes off the metric-base fields. This violates the project rule + that displayed units follow the active diver. PADI prints cylinder + pressures with no unit label at all (`pdf_template_padi.dart:342`). +3. **A sixth, unselectable copy of the layout exists.** + `PdfExportService._buildDivePdf` / `_buildPdfDiveEntry` + (`pdf_export_service.dart:233-470`) duplicates the Detailed layout. It is + reached by the dive-list bulk export and the single-dive export on the + detail page, neither of which offers a template choice. It hardcodes A4, + ignores the requested page size, and builds `pw.Document()` without + `PdfFonts.instance.theme`, so it has no Unicode font and mangles accented + site names. +4. **Profile data is never available to the exporter.** `getAllDives` skips + profile hydration by design (`dive_repository_impl.dart:307`), so + `dive.profile` is empty for every dive that reaches a template. +5. **NAUI recomputes aggregates inline** (`pdf_template_naui.dart:113-117`) + instead of using `buildSummaryPage`, so its numbers can drift from the + shared ones. + +## Decisions + +Confirmed with the maintainer before design: + +| Decision | Choice | +| --- | --- | +| Scope | All templates in one PR | +| Professional template | Delete it as redundant | +| Detailed density | One dive per page | +| Units | Fix as part of this work | +| Bulk and single-dive exports | Route through the factory, delete the legacy duplicate | +| Professional's diver page | Absorb into shared front matter, keep stamp and signature areas as an option | + +## Goals + +- Detailed carries the full #1017 field list on one page per dive, including a + depth profile chart. +- Every template respects the diver's unit settings. +- Certifications paginate and show agency, name, number, issue date. +- Simple gains the key metrics and certification listing #1017 asks for. +- Every PDF entry point in the app produces output from the same template + system. + +## Non-goals + +- Redesigning the PADI and NAUI page layouts. They keep their current visual + identity and receive only content and correctness fixes. +- Dive photos in PDFs. `Dive.photoIds` stays unrendered. Only the diver + portrait and certification card scans are images. +- Localising template body labels. The templates currently use English field + labels inline; changing that is a separate concern. + +## Architecture + +### Unit plumbing + +`PdfTemplateBuilder.buildPdf` gains a required `UnitFormatter units` +parameter, mirroring how `PdfDateFormatter dates` was threaded through for +#964. + +`lib/core/utils/unit_formatter.dart` already provides `formatDepth`, +`depthSymbol`, `formatTemperature`, `formatPressure`, `formatVolume`, +`formatTankVolume`, `formatWeight` and `sacSymbol`. Nothing new is needed +there. + +The single caller already holds the settings object: + +```dart +// export_providers.dart, existing +final settings = _ref.read(settingsProvider); +// added +units: UnitFormatter(settings), +``` + +Every hardcoded `'m'`, `'°C'` and `'bar'` in the templates and in +`pdf_shared_components.dart` is replaced by the corresponding formatter call. + +### Profile chart + +New file `lib/core/services/pdf_templates/pdf_profile_chart.dart`. + +Rendered as native PDF vector graphics using the `pdf` package's own chart +widgets (`pdf` 3.13.0 ships `chart/line_chart.dart`, `grid_cartesian.dart`, +`grid_axis.dart`): + +```dart +pw.Chart( + grid: pw.CartesianGrid(xAxis: ..., yAxis: ...), + datasets: [pw.LineDataSet(data: points, drawSurface: true, drawPoints: false)], +) +``` + +Rasterizing the existing `dive_profile_chart.dart` via its `exportKey` +`RepaintBoundary` was rejected. `buildPdf` has no `BuildContext`, and +`track_shape_painter.dart:10` documents that `toImage()` hangs under +`flutter test`, which would make the feature untestable. Vector output is also +sharp at print resolution and adds few bytes. + +Depth is drawn increasing downward. Temperature is not plotted; water +temperature appears as a field. + +### Profile data loading + +This is the highest-risk part of the change. + +A new `PdfProfileSeries` value object carries a downsampled point list per +dive. `buildPdf` gains an optional `Map profiles`. + +Loading rules: + +1. **Do not use `getDiveProfile`.** It filters `isPrimary = true`. Per the + #623 investigation, `setPrimaryDataSource` can leave a file-imported dive + with zero primary rows, so that query would render blank charts for exactly + the dives imported from other logbooks. Use the `getMergedProfile` + behaviour, which keeps every source's rows and drops the originals a saved + edit superseded (`_dropSupersededOriginals`, added in #1161). +2. **Batch.** Add a repository method that selects profile rows for a list of + dive ids in one query and groups them, rather than calling a per-dive + method N times. Chunk the id list to keep the `IN` clause and peak memory + bounded. +3. **Downsample** to a target of roughly 200 points per dive before the + template sees them. A chart a few inches wide cannot resolve more, and this + bounds memory for large logbooks. +4. **Load only when needed.** Only templates that draw charts request + profiles, so Simple, PADI and NAUI exports do no profile work. +5. **Absent data collapses.** A dive with no samples omits the chart region + entirely rather than printing an empty frame. + +### Shared front matter + +New file `lib/core/services/pdf_templates/pdf_front_matter.dart`, absorbing +the useful half of the deleted Professional template. + +- **Cover page**: existing `buildCoverPage`, unchanged apart from units. +- **Diver page**: Professional's `_buildDiverProfilePage` (`:213-323`) moved + here, with the empty box currently labelled `'Photo'` (`:249-264`) replaced + by the real portrait loaded from `Diver.photoPath`. The five-certification + cap at `:279` is removed. +- **Summary page**: `buildSummaryPage` extended with first dive date, last + dive date and total dive time, which #1017 requests for both Detailed and + Simple. Existing stats (count, total time, deepest, average depth, unique + sites) are retained and unit-formatted. +- **Verification areas**: Professional's `buildStampArea` and + `buildLargeSignatureBlock` are retained and become an opt-in element on + Detailed, controlled by a new flag on `PdfExportOptions`. + +### Certification rendering + +`buildCertificationCardsPage` changes from returning a single `pw.Widget` +placed on a `pw.Page` to producing a `List` used as a +`pw.MultiPage` body, so long certification lists paginate instead of +overflowing. Card fields become agency, name or type, card number, issue date +and expiry date. + +`supportsCertificationCards` returns true for Simple, which currently refuses +them. + +### Template changes + +| Template | Change | +| --- | --- | +| Detailed | Rebuilt. One dive per page. Profile chart, all #1017 fields in labelled groups, every cylinder with mix, size, material, pressures and SAC, buddies with signatures, full untruncated notes, optional verification areas | +| Simple | Table layout kept. Gains the summary block and certification pages. Units fixed | +| PADI | Layout kept. Units fixed including the unlabelled pressures at `pdf_template_padi.dart:342`, certifications paginated | +| NAUI | Layout kept. Units fixed, inline aggregate computation replaced by the shared helper, certifications paginated | +| Professional | Deleted | + +Deleting Professional is safe: `PdfTemplate` is not persisted anywhere in the +app, so no stored value can fail to deserialize. Removal covers the enum case, +the `divesPerPage` and `supportsCertificationCards` branches, the factory +branch, `pdf_template_professional.dart`, its l10n keys in every locale, and +its tests. + +### Unifying the remaining entry points + +`ExportService.exportDivesToPdf`, `saveDivesToPdfFile` and +`generateDivePdfBytes` gain an optional `PdfExportOptions`. Both callers get a +template picker and route through `PdfTemplateFactory`: + +- `dive_list_content.dart:600-610` (bulk export from selection) +- `dive_detail_page.dart:2969` (single dive) + +`PdfExportService._buildDivePdf`, `_buildPdfDiveEntry`, `_buildPdfStatRow`, +`_buildPdfInfoChip` and `_buildPdfSignatureBlock` are then deleted. +`PdfExportService` retains the save, share and byte-writing helpers, which are +still used. + +`exportTripToPdf` and `pdf_course_export_service.dart` are out of scope; they +are separate documents with their own layouts. + +## Testing + +The existing suite provides the pattern. `export_pdf_logbook_test.dart` uses a +`pdfVisibleText` helper to extract rendered strings and assert on content, and +already contains "saves the template the user picked, not the legacy layout". + +New coverage: + +- Detailed renders each #1017 field group when data is present, and omits + groups cleanly when absent. +- Notes are not truncated. This is the direct regression test for the reported + bug, asserting that a long note's final words appear in the output. +- Every template renders imperial units under imperial settings and metric + under metric. +- A certification list long enough to overflow one page produces a document + where the last certification is still present. +- The profile chart appears for a dive with samples and is omitted for one + without. +- Profile loading returns points for a dive whose rows are all + `isPrimary = false`, the file-import case from #623. +- Downsampling caps point count while preserving first and last samples and + maximum depth. +- Bulk export and single-dive export honour the selected template. +- `PdfTemplate.values` no longer contains `professional`. + +Baseline before changes: 30 tests pass across `test/core/services/export/pdf` +and `export_pdf_logbook_test.dart`. + +## Risks + +| Risk | Mitigation | +| --- | --- | +| Profile loading makes large exports slow or memory-heavy | Batch query, chunked ids, downsample to ~200 points, load only for templates that chart | +| Blank charts for file-imported dives | Use the merged-profile rule, not the `isPrimary` filter. Explicit test | +| One dive per page makes large logbooks very long | Accepted. Simple remains the compact option, and the maintainer chose this density | +| Unit sweep misses a hardcoded suffix | Grep for the literal suffixes as a completion check, plus per-template imperial tests | +| Deleting Professional breaks a saved preference | Verified `PdfTemplate` is never persisted | +| l10n key removal missed in some locale | Remove `enum_pdfTemplate_professional` and its description across every arb file | + +## Open items + +None. All scope questions were resolved before this document was written. diff --git a/docs/superpowers/specs/2026-08-26-chamber-directory-rework-design.md b/docs/superpowers/specs/2026-08-26-chamber-directory-rework-design.md index 9f8076157a..59bd68b2ba 100644 --- a/docs/superpowers/specs/2026-08-26-chamber-directory-rework-design.md +++ b/docs/superpowers/specs/2026-08-26-chamber-directory-rework-design.md @@ -136,6 +136,38 @@ The verification pass also corrected two rows that had shipped wrong: hyperbaric unit's own line, and `mt-gozo`'s number had changed. Neither error was detectable by schema validation, since both were well-formed numbers. +### Benelux coverage, 2026-09-04 + +Issue #1521 reported the obvious hole: one chamber for Belgium, one for the +Netherlands, both city-centroid placed, in a region with a dense diving +population. Two national registries the original research pass missed are +reachable and current: ACHOBEL (`achobel.be/MemberList.htm`, dated Jan 2025, +seven centres including Luxembourg) and the NVvHG member list +(`nvvhg.nl/nvvhg/ledenlijst/`). Ambulancezorg Nederland also publishes a +triage annex that classifies each Dutch facility as acute, consult-only, or +non-acute, which maps directly onto the `capability` field rather than having +to be inferred. + +Three things came out of verifying those leads against the facilities: + +- **CHU de Charleroi is on ACHOBEL and is not in the dataset.** HUmani + suspended the Hopital Andre Vesale caisson on 2026-06-25, the last multiplace + chamber in Wallonia, over the cost of a mandatory safety refit. This is the + `th-samui` case again and is guarded by its own test. +- **ACHOBEL's number for Aalst is stale.** It lists `053 72 4248`; the + hospital's own emergency page lists `053 72 43 48`. The registry-as-lead rule + is what caught it. +- **Two shipped rows were placed on a city centroid**, the AMC about 10 km out + and the Militair Hospitaal about 7 km, both far enough to reorder a + distance-sorted card. Their coordinates are now recorded per facility. + +Nominatim refused this network outright (HTTP 429 from the CDN edge on every +request, regardless of rate), so the new coordinates were read from +OpenStreetMap through Photon against each facility's published street address +and recorded in `scripts/data/chamber_coordinates.json`. That file is the +escape hatch the pipeline already documents for rows the geocoder cannot place; +using it here keeps `chamber_geocode.py` pinned to one geocoder. + ## Data model `assets/data/chambers.json` gains a metadata block and per-row provenance, diff --git a/docs/superpowers/specs/2026-08-26-photo-gps-site-suggestions-design.md b/docs/superpowers/specs/2026-08-26-photo-gps-site-suggestions-design.md new file mode 100644 index 0000000000..4e4a3461ba --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-photo-gps-site-suggestions-design.md @@ -0,0 +1,419 @@ +# Photo GPS Site Suggestions: Design + +Date: 2026-08-26 +Branch: `worktree-photo-gps-site-suggestions` +Roadmap item: "GPS extraction from photos (suggest site creation)" +(`docs/FEATURE_ROADMAP.md`, Category 9, v2.0 tasks) + +## Summary + +Submersion already extracts GPS from photo EXIF on iOS and Android and shows a +"Create site" / "Add to site" banner on the dive edit page. This design closes +the gaps that keep that feature from being useful in practice: + +1. Desktop imports carry no GPS at all (the pure-Dart fallback reader recovers + only the capture time). Stills and video get GPS on every platform. +2. The banner offers to create a site without checking whether the diver + already has one a few metres away. Photo GPS becomes a second point source + for the existing site-matching subsystem, so nearby existing and bundled + sites are offered first. +3. The banner appears only on the edit page. It also appears on the dive + detail page, and multi-dive photo imports end with a prompt into the + existing batch review page. +4. The "best" coordinate is the earliest linked photo. It becomes the photo + nearest the dive's entry time. +5. Dismissal is a `setState` flag that resets on every page open. It becomes a + synced per-dive column. + +Two further changes fell out of the design and are in scope: the batch review +page learns a third apply mode (write coordinates onto a dive's existing site +that lacks them), and the quick-create dialog prefills country/region/city by +reverse geocoding. + +## Current state (what exists today) + +| Piece | Location | +| --- | --- | +| Native EXIF GPS (mobile only) | `lib/features/media/data/services/exif_extractor.dart:73-84` | +| Pure-Dart capture-time fallback (all platforms, no GPS) | `lib/features/media/data/services/capture_time_reader.dart` | +| Desktop gallery picker hardcodes `latitude: null` | `lib/features/media/data/services/photo_picker_service_desktop.dart:192` | +| `media.latitude/longitude/taken_at` columns | `lib/core/database/database.dart` (`Media` table) | +| Earliest-photo "best" query | `MediaRepository.getBestGpsFromDiveMedia` (`media_repository.dart:1056`) | +| Providers | `divePhotoGpsProvider`, `allDivePhotoGpsProvider` (`media_providers.dart:94,105`) | +| Banner (edit page only) | `lib/features/media/presentation/widgets/photo_gps_suggestion_banner.dart`, wired at `dive_edit_page.dart:2193` | +| Quick-create dialog (name only) | `lib/features/media/presentation/widgets/quick_site_from_gps_dialog.dart` | +| Site matcher (dive-computer GPS) | `lib/features/dive_sites/domain/matching/site_matcher.dart`, `match_thresholds.dart`, `site_match_sensitivity.dart` | +| Matching service (proposals + one-transaction apply) | `lib/features/dive_sites/data/services/site_matching_service.dart` | +| Batch review page | `lib/features/dive_sites/presentation/pages/site_match_review_page.dart`, `site_match_review_notifier.dart`, route `/dives/match-sites` | +| Eligibility for the import-wizard button | `eligibleImportedDivesProvider` over `DiveRepository.getDivesNeedingSiteMatch` | + +Known defect fixed in passing: `getGpsFromDiveMedia` reads `taken_at` without +`isUtc: true`, producing a local `DateTime` for a wall-clock-UTC value (the same +class of bug as the taken-at hydration fix). + +## Goals and non-goals + +Goals: + +- GPS from JPEG and HEIC stills on macOS, Windows and Linux. +- GPS from video: QuickTime `©xyz` / `com.apple.quicktime.location.ISO6709` + (iPhone and most cameras) and GoPro GPMF telemetry. +- Nearest-existing-site before create, using the same matcher, thresholds and + sensitivity setting as the dive-computer path. +- Banner on the detail page; prompt into batch review after multi-dive imports. +- Nearest-to-entry-time best point. +- Synced per-dive dismissal. +- Batch review can locate a dive's existing coordinate-less site. + +Non-goals: + +- RAW image formats. +- Using photo GPS as a signal inside `DivePhotoMatcher` (time-window + matching is unchanged). +- Writing photo GPS onto `dives.entry_latitude/longitude`. Photo fixes are + inferred surface positions, not measured entry points, and must not be + confused with dive-computer GPS in attribution or consolidation. +- An "un-dismiss" control. Assigning a site or coordinates makes the + suggestion moot; a dismissed dive stays dismissed. + +## Section 1: Domain and data flow + +### Best-point selection + +`lib/features/media/domain/services/photo_gps_point_selector.dart` (pure): + +```dart +PhotoGpsPoint? selectBestPhotoGps( + List samples, // location, takenAt, mediaId + DateTime entryTimeWallClockUtc, +) +``` + +Returns the sample whose `takenAt` is nearest to the entry time; ties resolve +to the earlier sample; empty input returns null. Every linked photo is +eligible. A manually linked photo is the user asserting it belongs to the +dive, so there is no window filter. Both timestamps are wall-clock-UTC and +compare directly. + +### One query, no dive hydration + +`MediaRepository.getBestPhotoGpsForDives(List diveIds)` returns +`Map` from one SQL join of `media` to `dives` +(`entry_time`, `dive_datetime`) filtered to non-null, non-zero coordinates, +selected in Dart by the selector above. It reads `taken_at` with +`isUtc: true`. `divePhotoGpsProvider` becomes a thin wrapper over it; +`allDivePhotoGpsProvider` keeps its shape and gains the `isUtc` fix. + +### Photo GPS as a second point source + +`SiteMatchingService._pointFor(dive)` becomes an async resolver: +`entry ?? exit ?? photo`, with the photo map fetched once per +`computeProposals` call. `MatchProposal` gains +`pointSource` (`PointSource.diveComputer | PointSource.photo`). +`applyConfirmed`, the 100 m coincidence guard, and bundled-site +materialisation are unchanged. + +### Third apply mode: locate the current site + +`_CandidateRef` gains `currentSite(DiveSite)` beside `existing` and +`bundled`. In `computeProposals`, a dive whose site exists but has no +coordinates gets a synthetic candidate at the top of its list +(`MatchCandidateView.isCurrentSite = true`, the site's own name, no +distance). Decision rule for those dives: + +- No user site with coordinates within the inner radius: status `clear`, + recommended candidate = current site. +- A user site with coordinates within the inner radius: status `review`, + candidates = current site plus the nearby ones (probable duplicate; the user + decides between locating the current site and relinking the dive). + +`_applyOne` for `currentSite` calls +`siteRepository.updateSite(site.copyWith(location: point))` inside the +existing single transaction (bumps the site HLC, so it syncs). `ApplyResult` +gains `sitesLocated`. This mode works for dive-computer GPS as well as photos. + +### Post-commit altitude pass + +After the apply transaction commits, a best-effort pass fetches elevation via +`elevationServiceProvider` for every site that just gained coordinates +(located current sites and created sites) and updates `altitude` when it was +null. Network calls never run inside the DB transaction; failures are ignored. + +### Eligibility + +`DiveRepository.getDivesNeedingSiteMatch(diverId, limitToIds)` becomes one +predicate: + +``` +(site_id IS NULL OR the site has NULL latitude/longitude) +AND (entry/exit GPS present OR EXISTS media row with GPS for the dive) +AND site_suggestion_dismissed_at IS NULL +``` + +The import-wizard button and the new post-import prompt share it. Consequence: +the wizard button now also counts dive-computer dives whose site lacks +coordinates. + +### Dismissal + +`dives.site_suggestion_dismissed_at` (nullable integer, epoch millis). Named +source-agnostically because it gates the whole suggestion for the dive, +banner and batch alike, regardless of point source. + +## Section 2: Desktop GPS extraction, stills and video + +### Shared plumbing + +- `lib/features/media/data/services/isobmff_boxes.dart`: `_findBox`, + `_BoxRange`, and the big-endian readers move here from + `capture_time_reader.dart`. +- `lib/features/media/data/services/local_exif_loader.dart`: + `img.ExifData? readLocalExif(File file, String mime)`. JPEG via + `decodeJpgExif`; HEIC via the existing `meta > iinf > iloc` walk and TIFF + header search. One parse per file, shared by the date and GPS readers. +- `capture_time_reader.dart` keeps the MP4 `mvhd` reader and becomes a thin + consumer of the loader for stills. + +### Still images + +`lib/features/media/data/services/local_gps_reader.dart`: + +- `({double latitude, double longitude})? gpsFromExif(img.ExifData exif)` + reads `exif.gpsIfd` tags 0x1 (`GPSLatitudeRef`), 0x2 (`GPSLatitude`, + three rationals: degrees, minutes, seconds), 0x3, 0x4. `S` and `W` negate. + Returns null unless both axes parse; rejects `(0, 0)`, NaN, and + out-of-range values. +- `readLocalGps(File file, String mime)` dispatches: images to the EXIF + reader, `video/mp4` and `video/quicktime` to the video chain below. + +### Video + +Tried in order, first hit wins: + +1. QuickTime style: `moov > udta > ©xyz` holding an ISO 6709 string such as + `+12.3456-098.7654+010.000/`, then `moov > meta > keys / ilst` entry + `com.apple.quicktime.location.ISO6709`. ISO 6709 parses with one regex. +2. GoPro GPMF (`lib/features/media/data/services/gpmf_gps_reader.dart`): + find the `trak` whose `hdlr` handler type is `meta` and whose `stsd` + entry is `gpmd`; chunk offsets from `stco` / `co64`, sizes from `stsz`; + read one telemetry sample at a time (a few KB), never the surrounding + `mdat`. Parse the KLV tree (`DEVC > STRM`); prefer `GPS9` (HERO11+, + per-sample fix and DOP) over `GPS5` (sibling `GPSF` fix, `GPSP` + precision); apply `SCAL`; return the first sample with a 2D or 3D fix. + Cold-start clips have no fix at first, so the reader walks forward up to + a hard cap of 30 samples and then returns null. + +### Wiring + +- `exif_extractor.dart`: after the `native_exif` block, `lat ??=` / + `lon ??=` from `readLocalGps`, mirroring the existing `takenAt ??=`. Covers + the Files tab, `LocalFileResolver`, and `TripMediaScanner` on every + platform. `native_exif` throws for video on mobile too, so video GPS lands + there through the same fallback. +- `photo_picker_service_desktop.dart`: replace the hardcoded + `latitude: null, longitude: null` with `readLocalGps`. + +### Validation requirement + +GPMF is a binary telemetry format. The GoPro reader is not done on synthetic +fixtures alone: the plan includes a `--dart-define`-gated real-data test in +the style of the existing import-sample tests, and needs one real GoPro clip +and one real iPhone `.MOV` with location in the "submersion data" samples +folder. No such samples are on disk as of this writing. + +## Section 3: Single-dive UI (edit page and detail page) + +### Provider + +`siteSuggestionForDiveProvider(diveId)` builds a `SiteMatchingService` with +the diver's sensitivity thresholds and runs `computeProposals([dive])`. Yields +null when the dive has a located site, is dismissed, or has no point; +otherwise `SiteSuggestion { GeoPoint point, PointSource pointSource, +MatchProposal proposal }`. Invalidates on media, site and dive changes. + +### Banner + +`PhotoGpsSuggestionBanner` becomes `SiteSuggestionBanner` in +`lib/features/dive_log/presentation/widgets/`. Title: "Location found in +photos" or "Location from dive computer" by source; coordinate line as today. +Actions in an `OverflowBar`: + +| Dive state | Proposal | Primary | Secondary | +| --- | --- | --- | --- | +| No site | clear | Assign {site} · {distance} | Create site | +| No site | review | Choose nearby site ({n}) | Create site | +| No site | none | Create site | | +| Site without coordinates | clear | Add location to {site} | | +| Site without coordinates | review | Add location to {site} | Choose nearby site ({n}) | + +"Choose nearby site" pushes `/dives/match-sites` with `[diveId]`. The dismiss +control writes the new column. + +### One write path + +`lib/features/dive_log/presentation/helpers/site_suggestion_actions.dart` +is used by both pages and routes every action through `SiteMatchingService`: + +- `assign(candidateId)` and `addLocation()` call `applyConfirmed` with the + chosen candidate id. +- `createSite()` shows `QuickSiteFromGpsDialog`, then + `SiteMatchingService.createAndLink(diveId, site)` (also used by the review + page's "Create site here"). `createAndLink` creates the site and sets the + dive's `site_id` to it; a previous coordinate-less site on the dive is left + untouched, not deleted or located. +- `dismiss()` calls `DiveRepository.setSiteSuggestionDismissed(diveId, true)`. + +Each action returns the resulting `DiveSite?`. `_createSiteFromPhotoGps` and +`_updateSiteWithPhotoGps` in `dive_edit_page.dart` are deleted; the edit page +feeds the returned site into `_assignSite` so unsaved form state stays in +step. + +### Quick-create dialog + +`QuickSiteFromGpsDialog` calls `locationServiceProvider.reverseGeocode` on +open, best-effort, and prefills editable Country / Region / City fields; the +name stays empty with the locality as hint text. This is the explicit-capture +fill-empty pattern the site edit page uses for "Use my location". No +save-time geocoding is added. + +### Detail page placement + +The banner slots directly under the site header in both the compact and wide +layouts of `dive_detail_page.dart`. It renders `SizedBox.shrink()` when there +is nothing to suggest, so placement is unconditional. + +## Section 4: Batch review page and post-import prompt + +### Review page + +- Each proposal tile shows a source chip ("photo" / "dive computer") from + `MatchProposal.pointSource`. +- The current-site candidate renders as the first `_CandidateCard` labelled + "Add location to this site", no distance, preselected when `clear`. +- `ProposalStatus.none` tiles get a "Create site here" button; other tiles + offer it as a secondary action. It opens `QuickSiteFromGpsDialog` and calls + `createAndLink` immediately, then refreshes that proposal out of the list. + Immediate, not deferred to Confirm: a created site is a named user object + and deferring would require an undo path for a dialog the user already + completed. +- The confirm snackbar reports linked, created and located counts. + +### Post-import prompt + +`offerSiteReviewAfterImport(context, ref, diveIds)` runs after each +multi-dive linking flow: trip scan (`trip_scan_actions.dart`), Files-tab +`_commit` (`files_tab.dart`, which derives dive ids from the committed rows), +the Lightroom scan helper, and `media_import_view.dart`. It reads +`eligibleImportedDivesProvider(ImportedDiveIds(ids))` and, when non-empty, +replaces the plain success snackbar with one carrying a "Review sites ({n})" +action that pushes `/dives/match-sites` with those ids. The single-dive +`photo_import_helper` path does not prompt; the detail banner covers it. +Dismissed dives never count. + +### Files-tab l10n debt + +`_commit`'s snackbar strings are hardcoded (`TODO(media): l10n`). The plan +localises them in all locales rather than adding a second hardcoded string. + +## Section 5: Schema, sync, error handling, testing + +### Schema + +`dives.site_suggestion_dismissed_at INTEGER NULL` at the next free ladder +rung, expected v169 (main at v164; 165 to 168 claimed by open PRs as of +2026-08-26). The plan's first step re-runs the open-PR diff scan for +`currentSchemaVersion` before committing to the number. House pattern: +scalar, `migrationVersions` entry, `if (from < N)` guard plus +`reportProgress()` twin, `_assertSiteSuggestionDismissedAtColumn()` backstop +in `beforeOpen`, and `migration_vN_site_suggestion_dismissed_test.dart`. + +### Sync + +The dives export is table-generic (`sync_data_serializer.dart` exports +`_db.dives` rows via `toJson()`), so the column rides along with no serializer +change; nullable means older peers that omit the key deserialise to null. +`DiveRepository.setSiteSuggestionDismissed(diveId, bool)` writes the column +and `markRecordPending('dives', diveId)` in one transaction, then +`SyncEventBus.notifyLocalChange()`. The dive row carries its own HLC, so this +is not the stranded-child case that bit `dive_safety_findings`. Backups +include the column automatically. + +### Error handling + +- Readers (EXIF GPS, ISO 6709, GPMF) return null on malformed input and never + throw out of `extract`; failures log at debug. The GPMF walk stops at the + 30-sample cap or the first out-of-range offset. +- A failed photo-map query degrades to "no photo point" for that batch, + logged at error; the review still runs for dive-computer points. +- Reverse geocode and elevation are best-effort; a failure leaves fields + empty and the create or locate completes. Elevation runs after commit. +- `applyConfirmed` / `createAndLink` failures surface through the existing + `siteMatchReview_applyError` snackbar on the review page; the banner shows + an error snackbar and stays visible for retry. + +### Testing (TDD) + +Pure: +- `photo_gps_point_selector_test`: nearest to entry, tie to earlier, empty. +- `local_gps_reader_test`: hemispheres, missing axis, `(0, 0)`, rational + edge cases, JPEG and synthetic HEIC. +- `iso6709_test`; `gpmf_gps_reader_test` on a hand-built `gpmd` track (GPS5 + with GPSF, GPS9, cold-start walk, cap); the gated real-sample test. + +Data: +- `media_repository_test`: `getBestPhotoGpsForDives` (join, `isUtc`, + per-dive selection). +- `dive_repository_impl_test`: unified eligibility predicate; + `setSiteSuggestionDismissed` including an export-since-watermark + regression. +- `site_matching_service_test`: photo fallback, `pointSource`, current-site + candidate and duplicate-aware rule, `createAndLink`, `sitesLocated`, + post-commit altitude pass. + +Extractor and picker: desktop-path tests in `exif_extractor_test` and the +desktop picker test for stills and video. + +Widgets: +- `site_suggestion_banner_test`: all five action cases; dismiss persistence. +- `site_match_review_page_test`: source chip, current-site card, Create site + here. +- `quick_site_from_gps_dialog_test`: geocode prefill via the fakeable + `locationServiceProvider`. +- Post-import helper: prompt, no prompt, dismissed excluded. + +l10n: every new key in all 11 locales; banner and review page get a +German-locale overflow test at 360 dp. + +## Files touched (expected) + +New: +- `lib/features/media/domain/services/photo_gps_point_selector.dart` +- `lib/features/media/data/services/isobmff_boxes.dart` +- `lib/features/media/data/services/local_exif_loader.dart` +- `lib/features/media/data/services/local_gps_reader.dart` +- `lib/features/media/data/services/gpmf_gps_reader.dart` +- `lib/features/dive_log/presentation/widgets/site_suggestion_banner.dart` +- `lib/features/dive_log/presentation/helpers/site_suggestion_actions.dart` +- `lib/features/media/presentation/helpers/offer_site_review_after_import.dart` +- migration test for the new column + +Modified: +- `capture_time_reader.dart`, `exif_extractor.dart`, + `photo_picker_service_desktop.dart` +- `media_repository.dart`, `media_providers.dart` +- `site_matching_service.dart` (`_CandidateRef.currentSite`, + `MatchCandidateView.isCurrentSite`, `MatchProposal.pointSource`, + `createAndLink`), `site_match_review_page.dart`, + `site_match_review_notifier.dart` +- `dive_repository.dart`, `dive_repository_impl.dart` +- `database.dart` (column, migration, backstop) +- `dive_edit_page.dart`, `dive_detail_page.dart` +- `quick_site_from_gps_dialog.dart` +- `trip_scan_actions.dart`, `files_tab.dart`, `lightroom_scan_helper.dart`, + `media_import_view.dart` +- `lib/l10n/arb/*.arb` (11 locales) +- `docs/FEATURE_ROADMAP.md`, `docs/REMAINING_TASKS.md` (tick the item) + +Deleted: +- `lib/features/media/presentation/widgets/photo_gps_suggestion_banner.dart` + (replaced by `site_suggestion_banner.dart`) diff --git a/docs/superpowers/specs/2026-08-26-species-online-lookup-design.md b/docs/superpowers/specs/2026-08-26-species-online-lookup-design.md new file mode 100644 index 0000000000..e9d5890a3f --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-species-online-lookup-design.md @@ -0,0 +1,233 @@ +# Species online lookup and catalog suggestions. Design + +**Status:** design approved in brainstorming 2026-08-26, spec awaiting review +**Branch:** `worktree-species-online-lookup`, cut from `origin/main`; +independent of parts 1 and 2, delivered as one PR. +**Origin:** user feedback on the Marine Life feature, part 3 of 3 ("the +species list that comes with the app is very limited ... crowd-source +missing species or build out the list for all users"). + +## 1. Problem + +The bundled catalog is 511 marine species, hand-authored in +`assets/data/species.json`, seeded with `INSERT OR IGNORE` on every launch +and localized through 1,022 ARB keys per locale plus two hand-maintained +lookup switches. Growing it costs 24 translated strings and two generated +switch arms per species, and a shipped edit to an existing row never reaches +existing installs. Divers work around the gap with custom species, but the +dive picker's "add custom species" creates a name-only row with category +`other` and no scientific name, so custom species are second-class: no +taxonomy, no localized name, no way to tell "Walhai" and "Whale Shark" +apart. Nothing lets a diver hand a missing species back to the project. + +## 2. Locked decisions + +Made during brainstorming on 2026-08-26, in this order: + +1. Direction: **online lookup plus a contribution channel**, not a bigger + bundled catalog. A custom species becomes catalog-quality at creation + time, and the maintainer gets a clean feed of suggestions. The bundled + catalog, its re-seed gap and its generators stay as they are. +2. Source: **iNaturalist** (`https://api.inaturalist.org/v1`). Free read + API, no key, autocomplete by common or scientific name, common names per + locale, named ancestry, photos with per-photo licenses. +3. Photos from the lookup are **shown during lookup only**, never stored. + Only photos with a non-null `license_code` are shown, each with its + attribution string. +4. Lookups are **explicit**: the diver taps "Look up". No per-keystroke + requests. The app promises no telemetry in `PRIVACY.md`, data entry + often happens offline, and streaming keystrokes to a third party is a + different privacy shape from the reef tier's per-site queries. +5. The contribution channel is a **prefilled GitHub issue** opened in the + diver's browser from their own account. The app sends nothing itself. +6. One PR. + +## 3. Verified API shape (probed 2026-08-26) + +`GET /v1/taxa/autocomplete?q=whale%20shark&locale=de&per_page=1` returns +`results[]` with: `id` (52188), `name` ("Rhincodon typus"), `rank` +("species"), `rank_level`, `preferred_common_name` ("Walhai" under +`locale=de`), `english_common_name` ("Whale Shark"), `matched_term`, +`iconic_taxon_name` ("Animalia": too coarse for category mapping), +`ancestor_ids`, `observations_count`, `is_active`, `wikipedia_url`, and +`default_photo` with `license_code` ("cc-by-nc" or null), `attribution`, +`square_url`, `medium_url`. + +`GET /v1/taxa/{id}?locale=de` returns `results[0].ancestors[]`, each with +`rank` and `name` (kingdom Animalia, phylum Chordata, class Chondrichthyes, +subclass Elasmobranchii, infraclass Selachii, order Orectolobiformes, family +Rhincodontidae, genus Rhincodon), plus `wikipedia_summary` and +`taxon_photos`. The two probe responses are saved as +`test/fixtures/inaturalist/autocomplete_whale_shark_de.json` and +`taxon_52188_de.json` and are the parser test fixtures. + +Consequence: the category mapper needs the named ancestry, so resolving a +hit costs one extra request when the diver selects it, not per result. + +## 4. Lookup service + +`lib/features/marine_life/data/services/inaturalist_species_lookup_service.dart` + +```dart +class INaturalistSpeciesLookupService { + INaturalistSpeciesLookupService({http.Client? client}); + Future> search(String query, {required String locale}); + Future resolve(int taxonId, {required String locale}); +} +``` + +- `package:http`, user agent `Submersion/1.0 (https://submersion.app)` (the + string the GBIF service sends), 10 second timeout, `per_page=10`, + `is_active=true`. `locale` is the app's language code. +- `search` maps each result to `SpeciesLookupHit(taxonId, scientificName, + rank, commonName, matchedTerm, observationCount, photo)` where + `commonName` is `preferred_common_name`, else `english_common_name`, else + null, and `photo` is `SpeciesLookupPhoto(squareUrl, attribution)` only when + `license_code` is non-null. Hits whose `rank_level` is above species + (genus, family) are returned with `isResolvable == false`. +- `resolve` maps the taxon to `SpeciesLookupResult(commonName, + scientificName, category, taxonomyClass, taxonId)`: `taxonomyClass` is the + ancestor with rank `class` (null when absent), `category` comes from the + mapper in section 5. +- A session-scoped in-memory cache keyed by (query, locale) and by taxon id. +- Failures throw `SpeciesLookupException(kind)` with kinds `offline`, + `timeout`, `server`, `malformed`; the sheet renders one message per kind + with a retry. Nothing retries silently. + +Entities live in `lib/features/marine_life/domain/entities/species_lookup.dart` +and are immutable `Equatable`s. + +## 5. Category mapper (pure) + +`lib/features/marine_life/domain/services/species_category_mapper.dart` + +`SpeciesCategory speciesCategoryFromAncestry(List ancestors)` +where `TaxonAncestor` is `(rank, name)`. Rules, first match wins: + +| Ancestry | Category | +| --- | --- | +| class `Chondrichthyes` and any of orders `Rajiformes`, `Myliobatiformes`, `Torpediniformes`, `Rhinopristiformes` | `ray` | +| class `Chondrichthyes` otherwise | `shark` | +| class `Actinopterygii` | `fish` | +| class `Mammalia` | `mammal` | +| order `Testudines` | `turtle` | +| class `Anthozoa` and not order `Actiniaria` | `coral` | +| kingdom `Plantae` or kingdom `Chromista` | `plant` | +| class `Aves`, `Reptilia` or `Amphibia` | `other` | +| kingdom `Animalia` otherwise | `invertebrate` | +| anything else | `other` | + +Every row has a unit test with a realistic ancestry (whale shark, manta +ray, clownfish, green sea turtle, dolphin, staghorn coral, magnificent +anemone, giant kelp, seagrass, sea snake, nudibranch, unknown). + +## 6. Lookup sheet + +`lib/features/marine_life/presentation/widgets/species_lookup_sheet.dart`, +opened by `showSpeciesLookupSheet(context, {initialQuery})`, a modal bottom +sheet like the app's other transient panels. Returns a +`SpeciesLookupResult?` (null when dismissed or when the escape hatch is +taken). + +- A search field prefilled with `initialQuery`, a "Look up" button (also + triggered by submit), and a result list. Each row: the licensed photo as + a 48 px thumbnail with the attribution as its tooltip, or a category + placeholder when no licensed photo exists; the common name (localized); + the scientific name in italics; the observation count as a small trailing + number. Unresolvable ranks render disabled with the rank as subtitle. +- Selecting a row shows a progress state while `resolve` runs, then pops + with the result. +- Footer: "Species data and photos from iNaturalist". +- "Create without lookup" pops with null so callers keep their offline + path. +- States: idle (before the first search), loading, empty ("No species found + for ..."), error per exception kind with retry. + +## 7. Entry points + +1. **Species edit page** (`species_edit_page.dart`, new and edit): a "Look + up online" `TextButton.icon` under the common-name field opens the sheet + with the field's text; a result fills common name, scientific name, + category and taxonomy class. Description is left alone. The diver can + still edit anything before saving. +2. **Dive species picker** (`species_picker_sheet.dart`, the "add custom + species" button shown when a search finds nothing): opens the sheet with + the query; a result creates the species through + `SpeciesRepository.createSpecies` with all four fields and selects it in + the picker; null falls back to today's `getOrCreateSpecies(commonName, + category: other)` so an offline diver loses nothing. +3. **Reef tier unmatched chips** (`nearby_species_tier.dart`, GBIF names the + catalog lacks): the chip becomes an `ActionChip`. Tapping it searches the + scientific name; exactly one resolvable hit whose scientific name matches + creates the custom species and adds it to the site's expected list (what + the matched chips already do); anything else opens the sheet with the + scientific name prefilled and, on a result, does the same two steps. + +`getOrCreateSpecies`'s dedupe on `LOWER(common_name)` stays; the new paths +also dedupe on scientific name so looking up a species that already exists +as a built-in selects the built-in instead of creating a twin. + +## 8. Contribution channel + +`lib/features/marine_life/domain/services/species_suggestion_url.dart`, +pure: + +```dart +Uri buildSpeciesSuggestionUrl({ + required Species species, + required String locale, + required String appVersion, +}); +``` + +Produces `https://github.com/submersion-app/submersion/issues/new` with +query parameters `title` ("Species suggestion: "), `labels` +(`species-suggestion`) and `body`: one sentence asking for the species to +be added to the bundled catalog, then a fenced JSON block with `commonName`, +`scientificName`, `category`, `taxonomyClass`, `description`, `locale` and +`appVersion`. The body is capped so the full URL stays under 8,000 +characters (description truncated first). + +"Suggest for the catalog" is added to the species detail page's app bar +menu for **custom species only** (`isBuiltIn == false`). It launches the URL +with `LaunchMode.externalApplication` and falls back to copying the URL to +the clipboard with a snackbar, the exact behaviour of +`launchReportIssue` in `settings_page.dart`. A maintainer step outside this +PR creates the `species-suggestion` label in the repository. + +## 9. Localization + +New keys in all 11 locales: `marineLife_lookup_*` (button, sheet title, +search hint, look up, create without lookup, footer attribution, empty, +error per kind, retry, observations count plural, unresolvable +rank hint), `marineLife_speciesDetail_suggestForCatalog`, +`marineLife_suggest_copyLink`, `marineLife_suggest_couldNotOpen`, and +`reef_species_addFromLookup` for the +unmatched chip tooltip. The GitHub issue title and body stay English: the +maintainer reads them. + +## 10. Testing + +- Parser tests against the two fixtures: every `SpeciesLookupHit` field, the + German common name, the null-license photo dropped, `isResolvable` by + rank level, ancestors parsed in order. +- Mapper: one test per rule row plus the two fallbacks. +- Service with a mock `http.Client`: query encoding, locale parameter, user + agent header, timeout, non-200, malformed JSON, cache hit skips the + network. +- URL builder: encoding of spaces and non-ASCII names, label, body + structure, length cap with truncation. +- Widgets with a stubbed service: sheet states and selection; edit page + fill; picker creates with resolved fields and falls back on null; reef + chip's single-match path and sheet path; detail page menu hidden for + built-ins, launches for customs (URL launcher stubbed). +- l10n parity; one full `flutter test` run. + +## 11. Out of scope + +- Growing the bundled catalog, a versioned re-seed, generators for the two + lookup switches, freshwater species by default. Documented follow-ups. +- Storing iNaturalist taxon ids, photos, or Wikipedia summaries. +- Automatic per-keystroke search, or any background network activity. +- A maintainer pipeline that turns `species-suggestion` issues into catalog + entries. diff --git a/docs/superpowers/specs/2026-08-26-species-photos-design.md b/docs/superpowers/specs/2026-08-26-species-photos-design.md new file mode 100644 index 0000000000..bf85c33ab6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-species-photos-design.md @@ -0,0 +1,288 @@ +# Species photos: tag photos with the marine life in them. Design + +**Status:** design approved in brainstorming 2026-08-26, spec awaiting review +**Depends on:** `2026-08-26-species-page-design.md` (the Species page, the +`SeenSpeciesTile`, and the detail page's Sightings section). Build after that +branch merges. +**Branches:** phase A on `worktree-species-photos`, phase B on +`worktree-species-photo-surfaces`, each cut from `origin/main` once its +predecessor has merged. +**Origin:** user feedback on the Marine Life feature, part 2 of 3. + +## 1. Problem + +Photos in Submersion belong to a dive or a site. To see every picture of a +whale shark, a diver has to remember which dives had one, open each, and +scroll its media. Nothing links a photo to the species in it, even though the +schema has carried a `media_species` table for that purpose since schema +version 20; `media_repository.dart` states outright that nothing reads or +writes it. The user asked for two things: add photos directly to a species, +and see all photos of species X in one place. + +## 2. Locked decisions + +Made during brainstorming on 2026-08-26, in this order: + +1. A species is a **tag on a photo that is already attached to a dive or a + site**. The "attached or absent" rule for media rows is not touched: no + new attachment kind, no change to the orphan sweep, unlink or deletion + cascades. +2. "Add photos" to a species imports from the camera roll through the + existing **reviewed import** (match each photo to a dive or site first, + insert second), then tags what was imported. Photos that resolve to + nothing are skipped, as they are today. +3. Tagging a species on a photo whose dive has no sighting of it **adds the + sighting** (count 1, no notes) and links the tag to it. Untagging never + removes a sighting. +4. Surfaces, in two phases. Phase A: the species detail gallery, the tag + picker, the import, and the photo viewer's species sheet and chips. + Phase B: cover photos on the Species page tiles, photo counts on dive + detail sighting rows, and a species facet in the media library. +5. **No schema change.** Uniqueness of (photo, species) is enforced in the + repository and every gallery query uses `DISTINCT`. The table's bounding + box and notes columns stay unused. Reason: the schema-version ladder has + four open claims (v165 to v168), and a unique index would still not stop + two devices creating two rows for the same tag before they sync. +6. The tag picker is a purpose-built page; a photo's tags are edited from a + bottom sheet opened by a viewer action, the same convention as the info + sheet. +7. The cover photo of a species is **derived** (its newest tagged photo), + never chosen. A chosen cover would have to live on the species row, and + built-in species rows are re-seeded per device and never sync, so the + choice would not follow the diver to their other devices. + +## 3. Data + +### 3.1 The existing table + +`MediaSpecies` (`lib/core/database/database.dart`): `id`, `mediaId` +(references `media`, cascade), `speciesId` (references `species`, cascade), +`sightingId` (references `sightings`, set null), `bboxX/Y/Width/Height`, +`notes`, `createdAt`. Indexes `idx_media_species_media` and +`idx_media_species_species` already exist. No `hlc` column, and none is +added: like `site_species`, it is a clockless child. + +### 3.2 Domain + +- `MediaSpeciesTag` (`lib/features/media/domain/entities/media_item.dart`) + already models a row and is reused unchanged. +- New `SpeciesTagCandidateGroup` (`lib/features/media/domain/entities/`): + one dive that has a sighting of the species, with `diveId`, `diveNumber`, + `diveDateTime`, `siteName`, `sightingId` and the untagged `List` + on it. Feeds the picker's grouped grid. +- New `SpeciesTagChip`: `speciesId`, `storedName`, `category` and + `isBuiltIn`; the localized name is resolved at display time from the id, + as every other species surface does. Feeds the viewer chips and the sheet. + +### 3.3 `MediaSpeciesRepository` + +`lib/features/media/data/repositories/media_species_repository.dart`, new. +Reads the database through `DatabaseService.instance.database` and maps +media rows through the existing `mediaItemFromRow` in +`media_row_mapper.dart`. Methods: + +- `getTagsForMedia(mediaId)` and `getTagsForMediaIds(ids)` (chunked, keyed by + media id). +- `getMediaForSpecies(speciesId, {diverId})`: `media JOIN media_species`, + `DISTINCT` on `media.id`, left join enrichment as `getMediaForDive` does, + scoped to the diver through the photo's dive while keeping site-only + photos (`media.dive_id IS NULL OR dives.diver_id = ?`, the library's own + rule), newest `taken_at` first. +- `getCoverMediaBySpecies({diverId})`: one query using + `ROW_NUMBER() OVER (PARTITION BY species_id ORDER BY taken_at DESC)` to pick + the newest tagged photo per species. Returns `Map`. +- `getTagCandidatesForSpecies(speciesId, {diverId})`: dives with a sighting + of the species and their media rows that have no tag for it, grouped as + `SpeciesTagCandidateGroup`, newest dive first. +- `getPhotoCountsBySpeciesForDive(diveId)`: `Map` from + `media_species JOIN media` grouped by species. +- `addTag({mediaId, speciesId, sightingId})`: returns the existing row when + one already links that photo and species; otherwise inserts, calls + `markRecordPending(entityType: 'mediaSpecies', ...)` and notifies the sync + event bus. +- `removeTag({mediaId, speciesId})`: selects the row id by the pair, deletes + by id, `logDeletion(entityType: 'mediaSpecies', recordId: id)`, notifies. +- `watchTagChanges()`: `tableUpdates` on `media_species`. + +Errors propagate; the widgets show error states. + +### 3.4 Sync registration + +Copy the `site_species` registration point for point, with +`'media_species': 'mediaSpecies'`: + +- `sync_data_serializer.dart`: the `SyncData` field, constructor default, + `toJson` key and `fromJson` parse; the `_baseTables` entry in the same + position as the JSON key (the streaming parity test checks the order); the + full-export call; `fetchRecord`, `upsertRecord`, `upsertRecords`, + `recordIdsFor`, `_syncTableFor` and `deleteRecord` arms; and + `_exportMediaSpecies(hlcSince)`, which for an incremental export selects + rows whose parent `media.hlc` advanced, else the full table. The Drift row + class is `MediaSpecy`. +- `sync_service.dart`: the `mergeOrder` entry after `media` and `species` + (a tag needs both parents), `entityHasUpdatedAt['mediaSpecies'] = false`, + and `parentRefs['mediaSpecies']` with `mediaId` and `speciesId` both + non-nullable (a tag dies with its parent). `sightingId` needs no entry: + `sightings` is not a deletable parent in that map. +- Every sync test that enumerates synced tables (`sync_parent_refs_completeness_test`, + `sync_serializer_upsert_test`, `sync_data_serializer_batch_coverage_test`, + `sync_serializer_fetch_record_test`, `sync_deletion_propagation_test`, + `sync_extra_entities_round_trip_test`, `sync_base_streaming_parity_test`) + gains the `mediaSpecies` tuple. `sync_repository.dart` needs no change: + `markRecordPending` and `logDeletion` are generic on entity type. + +## 4. Tagging service + +`SpeciesTaggingService` (`lib/features/media/data/services/`), composed of +`MediaSpeciesRepository`, `MediaRepository` and `SpeciesRepository`: + +- `tagPhoto(mediaId, speciesId)`: loads the media row. If it has a dive, + looks up the dive's sighting of the species and adds one (count 1) when + missing, then adds the tag with that `sightingId`. If the photo is + site-only, adds the tag with a null `sightingId`. Returns the tag. +- `tagPhotos(mediaIds, speciesId)`: `tagPhoto` in sequence; a failure on one + photo is recorded and the rest continue; returns counts and failures. +- `untagPhoto(mediaId, speciesId)`: removes the tag only. + +Sighting creation goes through `SpeciesRepository.addSighting`, so the dive +detail page, the Species page and statistics all refresh through the +existing `sightings` tick. + +## 5. Importing photos into a species + +From the species detail page, **Add photos** runs the library import with a +species attached: + +1. `showPhotoPicker` with the unbounded window `MediaImportView` uses. +2. `MediaImportReviewPage` with the same candidates, so the diver sees each + photo's dive or site match and can correct it. +3. `MediaImportView.importResolved` is extended to return the imported + `MediaItem`s (its `ImportResult.imported` lists already exist per group), + and `SpeciesPhotoImportHelper.importPhotosForSpecies` tags each returned + item through `SpeciesTaggingService.tagPhotos`. +4. The result snackbar reports photos added to the species, photos skipped + because they matched nothing, and failures, reusing the import's + existing strings where they fit. + +No new `MediaAttachTarget` case: the species is applied after the attach, +never instead of it. + +## 6. Phase A user interface + +### 6.1 Species detail page: Photos section + +Between the statistics section and the Sightings section: a header +"Photos ({n})" with two actions, **Tag photos** and **Add photos**, then a +three-column grid of `MediaThumbnailTile`s (each wrapped for tap, since the +tile itself has no `onTap`). Empty state: a short line saying photos tagged +with this species appear here, with the two actions still shown. Tapping a +thumbnail opens `SpeciesPhotoViewerPage(speciesId, initialMediaId)`. + +Providers (`lib/features/media/presentation/providers/species_media_providers.dart`): +`mediaSpeciesRepositoryProvider`, `speciesTaggingServiceProvider`, +`mediaForSpeciesProvider(speciesId)` (ticks on `media_species`, `media` and +`media_enrichment`), `speciesTagCandidatesProvider(speciesId)` (also ticks on +`sightings`), `mediaTagsProvider(mediaId)`, `speciesCoverMediaProvider`, and +`diveSpeciesPhotoCountsProvider(diveId)`. All scoped by +`currentDiverIdProvider`; none watches the Statistics filter. + +### 6.2 `SpeciesPhotoViewerPage` + +A wrapper like `TripPhotoViewerPage`: resolves `mediaForSpeciesProvider`, +then renders the shared `MediaViewerPage` with that list and the initial id. + +### 6.3 `SpeciesTagPickerPage` + +Pushed full screen from **Tag photos**. Sections per +`SpeciesTagCandidateGroup`, header "#{diveNumber} · {date} · {site}", a +`DragSelectGridView` of `MediaThumbnailTile`s driven by a +`SelectionController`, a "Select all" action, and a bottom bar with "Tag {n} +photos". Confirm calls `tagPhotos`, pops with the count, and the detail page +shows a snackbar. Empty state: "No untagged photos on dives where you +logged this species" with a hint pointing at Add photos. + +### 6.4 Photo viewer + +- A "Species" `IconButton` in the top overlay beside Info opens + `showMediaSpeciesSheet(context, item)`: a `DraggableScrollableSheet` like + the info sheet. Body: the photo's dive sightings as `FilterChip`s, checked + when tagged; toggling calls `tagPhoto` / `untagPhoto`. Below them, "Other + species" opens the existing `SpeciesPickerSheet`; picking one tags the + photo (and adds the sighting). For a site-only photo the chips row is + absent and only the search is offered. +- The bottom metadata overlay gains a wrapping row of tag chips (category + icon plus localized name); tapping one pushes `/species/{id}`. Reads + `mediaTagsProvider(item.id)`. + +## 7. Phase B user interface + +- **Species page cover:** `SeenSpeciesTile` gains `MediaItem? cover`; when + present the leading avatar is a `MediaItemView(thumbnail: true)` clipped to + a circle, else the category avatar as before. The page watches + `speciesCoverMediaProvider` once and passes each tile its entry. +- **Dive detail sighting rows:** a trailing chip "{n} photos" (photo icon and + count) when `diveSpeciesPhotoCountsProvider(diveId)[speciesId] > 0`, next to + the existing count badge. Tapping it opens the dive's viewer scoped to the + photos tagged with that species (`DiveSpeciesPhotoViewerPage(diveId, + speciesId)`, a wrapper that filters `mediaForDiveProvider` by + `getTagsForMediaIds`). +- **Media library facet:** `MediaLibraryFilter.speciesId` with the same + sentinel `copyWith` and `toJson`/`fromJson` treatment as `siteId`; + `_baseWhere` adds `EXISTS (SELECT 1 FROM media_species ms WHERE + ms.media_id = media.id AND ms.species_id = ?)`; the filter sheet gets a + species chooser (the manage-page style picker dialog), the active-filter + chips get a species chip labelled with the localized name, and smart + albums round-trip the field. + +## 8. Integrity rules + +- A species with tags is **in use**: `SpeciesRepository.isSpeciesInUse` + counts tags as well as sightings, and the manage page's delete eligibility + reads a new `tagCountsBySpecies()` next to `sightingCountsBySpecies()`. +- `deleteSpecies` hard-deletes the species' tags the way it hard-deletes + `site_species` rows, relying on the species tombstone downstream. +- `MediaRepository.idsWithUserMetadata` includes tagged photos, so + unlinking a tagged photo warns like unlinking a photo with a manual time. +- `MediaRepository`'s watch stream adds `media_species` to its table set so + grids that show tag state tick. +- Removing a sighting keeps the tag; the FK nulls `sightingId`. +- Deleting a photo cascades its tags locally; on other devices the `media` + tombstone plus the non-nullable `parentRefs` entry removes them. + +## 9. Localization + +New keys in all 11 locales, `media_species_*` for the viewer sheet and +chips, `marineLife_speciesPhotos_*` for the detail section, picker and +import result, `marineLife_speciesDetail_photosTitle`, `diveLog_detail_sightingPhotos` +for the sighting-row chip, and `media_library_filter_species*` for the +facet. Plurals use ICU syntax. Placeholder metadata in `app_en.arb` only. + +## 10. Testing + +- Repository (in-memory Drift): `addTag` is idempotent; `getMediaForSpecies` + is distinct and newest first and diver-scoped; candidates exclude tagged + photos and group by dive with the sighting id; cover is the newest per + species; per-dive counts; `removeTag` tombstones by row id. +- Service: tagging creates the sighting once and links it; a second tag of + the same species on another photo of the same dive reuses the sighting; + a site-only photo tags with a null sighting; untag leaves the sighting. +- Sync: the enumerating suites listed in 3.4 cover registration; one + round-trip test tags a photo on device A and asserts the tag, and the + sighting, on device B. +- Widgets (router-backed, base overrides): Photos section with grid, + actions, empty state and viewer navigation; picker grouping, select all, + confirm count, empty state; viewer sheet chip toggling and "Other + species"; overlay chips navigate; import helper tags what the review + returns (stubbed picker); phase B: cover avatar vs category avatar, + sighting-row chip and its viewer scope, library facet chip, sheet chooser + and smart-album round trip. +- l10n parity; one full `flutter test` run per PR. + +## 11. Out of scope + +- Bounding boxes and per-tag notes (columns stay unused). +- A chosen cover photo; a species reference image without a dive. +- Species photos in PDF or UDDF export. +- Automatic species recognition. +- Tagging site-only photos from the site page (the viewer's search path + covers them). diff --git a/docs/superpowers/specs/2026-08-27-species-catalog-growth-design.md b/docs/superpowers/specs/2026-08-27-species-catalog-growth-design.md new file mode 100644 index 0000000000..e05014cbbf --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-species-catalog-growth-design.md @@ -0,0 +1,297 @@ +# Species Catalog Growth: Versioned Re-seed, Lookup Generators, Freshwater Species + +Date: 2026-08-27 +Status: Design approved in conversation; spec awaiting review +Follows: `2026-08-26-species-online-lookup-design.md` (part 3), which added +the iNaturalist lookup and the GitHub suggestion channel. This is part 4 of +the Marine Life program: the bundled catalog itself. + +## 1. Goal + +Make the bundled species catalog safe to grow and correct, then grow it: + +1. **Versioned re-seed.** Corrections to bundled rows reach existing installs. + Today the seed is `INSERT OR IGNORE` on the stable `sp_` id, run on every + launch, so a renamed, recategorized or re-described built-in species never + changes on a device that already has it. Divers' own edits to built-in rows + must survive the re-seed. +2. **Generators and a coverage test** for the two id-keyed lookup switches + (`species_name_lookup.dart`, `species_description_lookup.dart`). Both say + "Generated from `assets/data/species.json`" but no generator exists and no + test ties the switches, the asset and the ARB keys together. +3. **About 150 freshwater species**, curated, with names localized from + iNaturalist and descriptions in all eleven app locales, so lake, river, + cenote and spring divers find their animals in the picker. + +## 2. Current State (verified 2026-08-27 on `main`) + +- `assets/data/species.json`: `{"version": 1, "species": [...]}`, 511 rows, + fields `id, commonName, scientificName, category, taxonomyClass, + description`. Nothing reads `version`. +- `SpeciesSeedService.loadBundledSpecies()` decodes `species` into domain + entities with `isBuiltIn: true` (static cache). +- `SpeciesRepository.seedBuiltInSpecies()` (`species_repository.dart:430`) + batches `InsertMode.insertOrIgnore`; `StartupPage` calls it on every + launch (`startup_page.dart:673`). No `hlc`, no `markRecordPending`. +- `species` table: `id, common_name, scientific_name, category, + taxonomy_class, description, photo_path, is_built_in, hlc`. No timestamps, + no uniqueness on names. Four bundled rows share a scientific name with + another row (two manta, two wobbegong/hammerhead pairs). +- `SpeciesRepository.updateSpecies()` works on built-in rows and calls + `markRecordPending`, whose `_stampHlc` writes `species.hlc` (`species` is in + `hlcTargets`). The sync exporter then drops built-in rows + (`_exportSpecies` filters `is_built_in = 0`), so such an edit is + device-local. `resetBuiltInSpecies()` restores bundled values but leaves the + stale `hlc`. +- Lookups: `builtInSpeciesName` and `builtInSpeciesDescription`, one + `switch` expression each, 511 cases, keys `species__name` and + `species__desc` where `` is the id without `sp_`. All 11 ARB + files carry all 1022 keys; no `@` metadata. +- `assets/data/species_gbif_keys.json` (507 of 511 resolved) is produced by + `tool/generate_species_gbif_keys.dart` (GBIF match API, manual, network), + consumed by the reef nearby-species matcher, and checked by + `test/features/reef/domain/services/species_gbif_keys_asset_test.dart` + (loose: passes above 75 percent resolved). +- Only species tool: the GBIF key generator. No test cross-checks + `species.json` against the ARB keys or the switches. + +## 3. Decisions Made During the Brainstorm + +| Question | Decision | +|---|---| +| Re-seed policy | Keep diver edits, update untouched rows. "Untouched" means `hlc IS NULL`: every edit path stamps an hlc, the seed never does. | +| Where the applied version lives | SharedPreferences (`builtin_species_seed_version`, int). No schema change. Restore-from-backup clears it so a restored older database is re-upgraded on the next launch. | +| Freshwater scope | Curated list of about 150 species, generated through iNaturalist for localized names. | +| Description translations | Authored by hand in the seed file for all 11 locales (no translation API, no Wikipedia text and its CC BY-SA terms). Same review path as the 511 existing hand-written descriptions. | +| Delivery | One PR, three commit groups (re-seed, generators, freshwater data). No schema change. | + +## 4. Design + +### 4.1 Versioned re-seed + +**Asset.** `species.json` `version` becomes meaningful: it is bumped whenever +a bundled row changes or rows are added (this PR bumps it to 2). The loader +exposes it: `SpeciesSeedService.loadBundledCatalog()` returns +`BundledSpeciesCatalog({required int version, required List +species})`; `loadBundledSpecies()` stays as a thin wrapper for existing +callers. + +**Marker.** `BuiltInSpeciesSeedVersionStore` (marine_life/data/services), a +small class over `SharedPreferences` with `Future appliedVersion()` +(default 0), `Future markApplied(int)`, `Future clear()`. Injected +into `SpeciesRepository` through an optional constructor parameter so tests +pass a fake; production wiring builds it from `sharedPreferencesProvider`'s +instance at the startup call site. + +**Seeding.** `seedBuiltInSpecies()` becomes: + +``` +catalog = load bundled catalog +applied = store.appliedVersion() +if (applied < catalog.version): + batch: for every bundled row + INSERT ... ON CONFLICT(id) DO UPDATE SET + common_name, scientific_name, category, taxonomy_class, description, + is_built_in = 1 + WHERE species.hlc IS NULL + store.markApplied(catalog.version) +else: + batch: INSERT OR IGNORE (today's path, kept as the cheap idempotent net + that refills a wiped table without touching the marker) +``` + +Drift: `batch.insert(table, companion, onConflict: DoUpdate((old) => +companion, where: (old) => old.hlc.isNull()))`. The `WHERE` on the conflict +clause is what keeps a diver's edited row. A row a diver deleted cannot exist +(built-in rows in use cannot be deleted; unused built-in rows can, through the +manager) and would be recreated by the upsert. That is the intended reading of +"the catalog is the catalog": deleting a bundled species hides it until the +next catalog version. The manager already offers "Reset to defaults" for the +opposite direction. + +**Edited rows.** `resetBuiltInSpecies()` additionally sets `hlc = NULL` on +every row it restores, so a reset row counts as untouched again. It also +deletes the row's `sync_records` entry for `species` if one exists, keeping +the two invariants (untouched means no hlc; pending means exported) from +drifting; the exporter ignores built-in rows either way. + +**Restore.** The backup restore path that replaces the database calls +`store.clear()` after the swap. The next launch sees `applied = 0 < version` +and re-runs the upgrade pass; rows the restored database's owner edited keep +their hlc and are skipped. The plan locates the single restore completion hook +(the restore service that already reruns startup maintenance). + +**Cost.** The upgrade pass runs once per catalog version (about 660 upserts in +one batch); every other launch keeps today's `INSERT OR IGNORE` batch. No +change to startup ordering. + +### 4.2 Lookup generators and the coverage test + +`tool/generate_species_lookups.dart` (no network) reads `species.json` and +writes both switch files with a real generated header: + +``` +// GENERATED by tool/generate_species_lookups.dart from +// assets/data/species.json. Do not edit; rerun the tool. +``` + +Output is deterministic (asset order), formatted by `dart format` inside the +tool, and byte-identical to the current files apart from the header (the plan +proves this by regenerating before adding any species and diffing). + +`tool/generate_species_arb_keys.dart` (no network) reads a "species names and +descriptions by locale" JSON (the freshwater seed's output, section 4.3) and +inserts `species__name` and `species__desc` into all 11 ARB files +after the last existing `species_*_desc` key, refusing to overwrite an +existing key. It is the only writer of species ARB keys from now on. + +Coverage test `test/features/marine_life/presentation/species_lookup_coverage_test.dart`: + +- for every row of `species.json`, `builtInSpeciesName(AppLocalizationsEn(), + id)` equals the row's `commonName` and `builtInSpeciesDescription(...)` + equals its `description` (asset, English ARB and both switches agree); +- the number of switch cases equals the row count (no orphan case for a + removed id), checked by scanning the generated files for `'sp_` literals; +- `species.json` ids are unique and every id matches `^sp_[a-z0-9_]+$`; +- `version` is an int of at least 2. + +`test/features/marine_life/data/species_seed_version_test.dart` covers 4.1 +with an in-memory database and a fake store: first launch inserts and marks; +same version inserts only missing rows; higher version updates untouched rows, +skips a row with an hlc, recreates a deleted row; `resetBuiltInSpecies` +clears the hlc; `clear()` forces an upgrade pass. + +### 4.3 Freshwater species + +**Seed file.** `tool/data/freshwater_species_seed.json`, curated by hand, +about 150 entries: + +```json +{ + "id": "sp_northern_pike", + "scientificName": "Esox lucius", + "inaturalistTaxonId": 49543, + "category": "fish", + "taxonomyClass": "Actinopterygii", + "commonName": "Northern Pike", + "description": { + "en": "Ambush predator of weedy lake margins, with a long body and duckbill snout.", + "de": "...", "es": "...", "fr": "...", "it": "...", "pt": "...", + "nl": "...", "hu": "...", "zh": "...", "ar": "...", "he": "..." + } +} +``` + +`commonName` is the English fallback; descriptions are authored for all 11 +locales in the seed itself. + +**Curation.** Species divers meet in lakes, rivers, springs, cenotes and +flooded quarries worldwide, roughly: North American and European game and +coarse fish (pike, perch, zander, walleye, muskellunge, bass, sunfish, +crappie, trout, char, salmon, whitefish, grayling, carp, tench, bream, roach, +chub, barbel, catfish, burbot, sturgeon, paddlefish, gar, bowfin, eels, +lamprey); African rift-lake cichlids and Nile perch, tigerfish, lungfish; +Amazon and Mekong giants (arapaima, arowana, pacu, piranha, electric eel, +freshwater stingray, giant catfish); Australian Murray cod and barramundi; +cave and cenote animals (blind cave tetra, cave crayfish); freshwater +invertebrates (crayfish, mussels including zebra and quagga, freshwater +sponge, freshwater jellyfish, snails); freshwater turtles, alligators, +caimans and freshwater crocodiles; amphibians (hellbender, mudpuppy, axolotl, +newts) under `other`; freshwater mammals (river otter, beaver, muskrat, +Florida manatee, platypus, Baikal seal, Amazon river dolphin); common water +plants (water lily, eelgrass, coontail, milfoil, muskgrass, elodea). Species +already in the marine catalog (saltwater crocodile, manatee species present +there) are not duplicated; the tool refuses an id or scientific name that +already exists in `species.json`. + +**Categories** reuse the existing enum; there is no freshwater category and +none is added. Amphibians go to `other`, water plants to `plant`, crayfish and +mussels to `invertebrate`. + +**Generator.** `tool/generate_freshwater_species.dart` (network, manual): + +1. reads the seed; +2. for each entry with `inaturalistTaxonId`, calls + `https://api.inaturalist.org/v1/taxa/{id}?locale=` once per app + locale (`en de es fr it pt nl hu zh ar he`; iNaturalist's `zh-CN` for + `zh`), takes the best name per app locale from the `all_names` list, + keeps the seed's curated English name for `en`, and fills any locale + iNaturalist lacks from `tool/data/freshwater_species_name_overrides.json`, + a hand-authored file (the marine catalog ships no English fallback in any + locale, so neither does this; the overrides cover 653 names); rate-limited + to one request per second with the part 3 user agent; +3. appends the entries to `species.json` (`commonName` = English name, + `description` = English description) and bumps `version` to 2; +4. writes `tool/data/freshwater_species_localized.json` (names and + descriptions by locale) for `generate_species_arb_keys.dart`; +5. prints the follow-ups: run `generate_species_arb_keys.dart`, + `generate_species_lookups.dart`, `generate_species_gbif_keys.dart`, then + `flutter gen-l10n`. + +The seed, the localized output and the generated artifacts are all committed, +so the catalog is reproducible without the network and reviewable in the PR. + +**GBIF keys** are refreshed with the existing tool so the reef nearby-species +feature can match freshwater occurrences. The straddling-taxa list in that +tool is extended if a freshwater family straddles the marine boundary (the +plan checks the generated whitelist for the cases the existing regression +test guards). + +**Catalog suggestion channel (part 3)** is unchanged; a diver's suggestion +issue now has a documented landing place: add to the seed, rerun the tools. +`docs/features/marine-life.md` gains a short "Growing the catalog" section +for maintainers and a line telling divers freshwater species are included. + +### 4.4 Error handling + +- Upgrade pass failure (SQLite error) leaves the marker unchanged, so the + next launch retries; the startup step already logs and continues + (`timeStartupStep`). +- Missing or malformed `SharedPreferences` value reads as 0. +- The freshwater tool aborts on any HTTP failure after three retries, writes + nothing partial (all files are written at the end), and reports the taxon + it failed on; a taxon returning no name in any locale keeps the seed's + English name. +- The ARB writer validates each file as JSON after insertion and refuses to + write if a key already exists, so a rerun cannot duplicate keys + (`arb_parity_test` guards the rest). + +### 4.5 Testing + +- Unit: seed version store (prefs fake), repository upgrade semantics (4.2 + list), `BundledSpeciesCatalog` decoding including a missing `version` + (treated as 1). +- Coverage test binding asset, English ARB and switches (4.2). +- Tool tests, pure functions only: the lookup file renderer (given three + rows, output equals a golden string), the ARB inserter (given a small ARB + string, keys land after the anchor and the result parses), the freshwater + name merge (iNat response fixture with a missing locale falls back to + English). The network client is injected; tests never hit it. +- Existing `species_gbif_keys_asset_test` keeps passing after the key + refresh; its "large majority" threshold is tightened to 90 percent since + the generator now covers freshwater rows too. +- `arb_parity_test` and the full suite. + +### 4.6 Out of scope + +- A freshwater category or habitat field, and filtering the picker by + habitat. +- Syncing diver edits to built-in rows across devices (the exporter's + exclusion is deliberate and stays). +- Deduplicating the four bundled scientific-name twins. +- In-app catalog updates without an app release. + +## 5. Files + +- Modify: `assets/data/species.json` (version 2, about 660 rows), + `assets/data/species_gbif_keys.json`, 11 ARB files and their generated + Dart, `species_name_lookup.dart`, `species_description_lookup.dart` + (regenerated), `species_seed_service.dart`, `species_repository.dart` + (seed, reset), `startup_page.dart` (store wiring), the restore completion + hook, `docs/features/marine-life.md`. +- Create: `lib/features/marine_life/data/services/builtin_species_seed_version_store.dart`, + `lib/features/marine_life/domain/entities/bundled_species_catalog.dart`, + `tool/generate_species_lookups.dart`, `tool/generate_species_arb_keys.dart`, + `tool/generate_freshwater_species.dart`, `tool/src/species_tool_support.dart` + (shared pure functions the tool tests import), `tool/data/freshwater_species_seed.json`, + `tool/data/freshwater_species_localized.json`, the tests in 4.5. diff --git a/docs/superpowers/specs/2026-08-27-statistics-trend-charts-design.md b/docs/superpowers/specs/2026-08-27-statistics-trend-charts-design.md new file mode 100644 index 0000000000..065ed23c9e --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-statistics-trend-charts-design.md @@ -0,0 +1,368 @@ +# Statistics trend charts: per-dive values, selectable span, optional smoothing + +Design for issue [#299](https://github.com/submersion-app/submersion/issues/299), +"Statistics: trend plots make no sense statistically". + +Date: 2026-08-27 +Branch: `worktree-issue-299-statistics-trends` + +## Problem + +Four charts present a monthly aggregate as if it were a progression: + +| Chart | Page | Current aggregate | +| --- | --- | --- | +| Maximum Depth Progression | Progression | `MAX(max_depth)` grouped by month | +| Bottom Time Trend | Progression | `AVG(bottom_time / 60)` grouped by month | +| SAC Rate Trend | Gas | mean of per-dive SAC, grouped by month | +| Weight Trend | Equipment | `AVG(amount_kg)` grouped by month | + +A fifth, "Water Temperature by Month" on the Conditions page, is a different +problem: it collapses every year of history into twelve calendar buckets, so a +diver who travels between cold and warm water sees a meaningless middle value. + +The reporter's ask, in their words: individual dive values rather than averages, +a selectable time span with presets and a custom interval, and averaging demoted +to an option. + +Three defects sit underneath the feature request: + +1. A hardcoded `DateTime.now().subtract(const Duration(days: 365 * 5))` is + repeated in five separate repository methods + (`statistics_repository.dart` L210, L343, L845, L892, L2117). It is not a + parameter and not derived from any filter. +2. The statistics filter's date range is injected as + `AND dives.id IN (SELECT id FROM dives WHERE ...)`, which stacks on top of + that cutoff. The filter can only narrow the window, never widen it, so + **"lifetime" is currently unreachable** for all four progression charts. +3. `TrendLineChart` plots `FlSpot(index.toDouble(), value)` + (`stat_charts.dart` L165-168). X is the array position, not the date. A + three-month gap and a three-week gap render identically, and months with no + dives occupy no width. + +Defect 3 is why simply removing the `GROUP BY` would not work. Per-dive points +on a positional axis would draw a liveaboard week and a quiet year at the same +scale, which is a worse distortion than the monthly average it replaced. + +## Decisions + +Settled during design, with the reasoning that produced them. + +**Span is global, owned by the existing statistics filter.** Submersion already +shipped a whole-tab filter in #453; `statisticsFilterProvider` holds a +`DiveFilterState` whose first two fields are `startDate` and `endDate`. Adding a +second, per-chart notion of "date range" would create two controls that +inevitably disagree. The presets the reporter asked for become presets on the +existing sheet. + +**Aggregation is per chart.** Span decides what data is in scope, which should +be consistent across a page. Aggregation decides how one metric is drawn, which +is a rendering choice and reasonably differs between a weight series with a few +dozen points and a SAC series with two thousand. + +**Raw per-dive is the default.** This is the headline request. + +**Charts draw a scatter plus a fitted trend line.** Dots alone make direction +hard to read; dots joined by a line imply continuity between dives months apart. +A fitted overlay answers "am I progressing?" without lying about what happened +in between. + +**Both fits are available, rolling mean by default.** A straight least-squares +line reports a single direction for an entire history. The common dive career +shape, ramp up, a technical period, then a settled return to recreational +depths, makes a straight fit report "still going deeper" about someone who +peaked years ago. The rolling mean follows the real shape. The straight fit is +kept as an opt-in overlay because it yields a stateable rate ("+4.4 m/yr") that +a rolling mean cannot give. + +**The rolling window counts dives, not calendar time.** A six-month window would +compute some points from forty dives on a liveaboard and others from none. +Counting dives puts equal evidence behind every point on the line, which matters +because real logbooks are heavily clustered. + +**Controls live on one row beneath the chart.** A compact dropdown plus two +tappable legend entries. The cost is one line per card regardless of how many +controls a card carries, and the legend doubles as the colour key so the +overlays never need explaining. A dropdown rather than chips because further +modes (quarterly, yearly) would not fit a chip row. + +**An aggregated bucket draws its mean with a min/max band.** Smoothing must not +re-hide the spread that this issue is about. + +**The seasonal temperature chart is kept, not replaced.** The reporter concedes +it shows real variation for a diver who stays in one region. It is retitled so +its all-years collapse is explicit, and a proper temperature trend chart is +added beside it. + +**Settings are in-memory for the session, not persisted.** This matches +`statisticsFilterProvider`, which is a plain unpersisted `StateProvider`. + +## Architecture + +### Domain: `lib/features/statistics/domain/trend_aggregation.dart` + +Pure Dart. No Flutter imports, no database imports. This is where the +statistical behaviour lives and where it is tested. + +`TrendDataPoint` is currently declared in `statistics_repository.dart` L32-43. +It moves into this domain file, and the repository imports it from there. A pure +domain module must not import the data layer. + +```dart +enum TrendAggregation { none, weekly, monthly } + +class TrendBucket { + final DateTime date; // bucket start, or the dive's own date when none + final double mean; + final double min; + final double max; + final int count; +} + +class LinearFit { + final double slopePerDay; + final double intercept; + double get perYear => slopePerDay * 365.25; +} + +List aggregate(List points, TrendAggregation mode); +List rollingMean(List points, {int window = 21}); +LinearFit? linearFit(List points); +``` + +`aggregate` with `none` returns one bucket per dive, with `mean == min == max` +and `count == 1`, so the widget has a single uniform shape to render. + +`rollingMean` is a centred mean over `window` neighbouring dives by index. At +the ends the window truncates rather than padding. + +`linearFit` returns null below 5 points, so a confident line is never drawn +through four dives. `rollingMean` returns an empty list below the same +threshold. + +Both fits are always computed from the **raw per-dive points**, never from the +buckets, in every aggregation mode. Fitting a rolling mean over monthly means +would smooth twice, and switching the dropdown would move the trend line, which +would wrongly imply the underlying trend had changed. + +Bucket keys derive from `dive_date_time` read as a wall clock, matching the +`/ 1000, 'unixepoch'` convention used with no `'utc'` modifier throughout the +repository. + +### Data: `statistics_repository.dart` + +Five methods lose their `GROUP BY` and their hardcoded cutoff, and are renamed +to say what they now return: + +| Was | Becomes | +| --- | --- | +| `getDepthProgressionTrend` | `getDepthPerDive` | +| `getBottomTimeTrend` | `getBottomTimePerDive` | +| `getWeightTrend` | `getWeightPerDive` | +| `getSacVolumeTrend` | `getSacVolumePerDive` | +| `getSacPressureTrend` | `getSacPressurePerDive` | + +Each returns `List` ordered by `dive_date_time`, scoped only by +`diverId` and the `filter` threaded through the existing `_diveFilter` helper. +The `Duration(days: 365 * 5)` constants are deleted outright. + +`getSacVolumePerDive` is close to free: `getSacVolumeTrend` already computes a +per-dive SAC value (L297-307) and only then buckets it in Dart (L309-325). The +change is to stop at the per-dive step. + +`getWaterTempPerDive` is added for the new temperature trend. + +`getTemperatureByMonth` is untouched and keeps feeding the seasonal chart. + +The existing `catch (e, stackTrace) { _log.error(...); return []; }` error +handling is preserved on every method. + +`TrendDataPoint.label` is currently built by `_monthAbbr` (L2531-2547) from a +hardcoded English month array. Per-dive points no longer need a month label, and +axis labels are formatted at render time from the date, so this English array +stops being used by the affected charts. + +### Presentation: providers + +The five existing `FutureProvider`s keep their names and their +`ref.watch(statisticsFilterProvider)`, returning per-dive points instead of +monthly ones. `waterTempTrendProvider` is added. + +Per-chart aggregation and overlay state lives in a separate provider family +keyed by chart id, so toggling a control re-renders without re-running the +query. + +Before altering any provider, grep its consumers across all of `lib/`, not just +`features/statistics/`. #453 silently rescoped the marine-life species detail +page by making a shared provider filter-aware; the same trap applies here. +`statistics_providers_all_test.dart` is the test that fails first when a +provider is added or renamed. + +### Presentation: `DiveTrendChart` + +New widget at +`lib/features/statistics/presentation/widgets/dive_trend_chart.dart`. + +`TrendLineChart` and `MultiTrendLineChart` are left untouched: the former still +serves Cumulative Dive Count and charts outside this scope, the latter still +serves the seasonal temperature chart. + +X values are `date.millisecondsSinceEpoch.toDouble()`. This is the change that +makes gaps read as gaps. It requires a date-tick helper beside the existing +`ChartAxis`, choosing year, quarter, or month ticks by span so a one-year view +does not label every tick with the same year. Y bounds continue to use +`ChartAxis.forTrend`, which already solves the grid-line alignment from #219. + +Layers, composed into a single fl_chart `LineChart`: + +| Layer | Raw mode | Weekly or monthly mode | +| --- | --- | --- | +| Data | dots via `barWidth: 0` plus `FlDotData(show: true)` | mean line, plus invisible min and max series filled with `BetweenBarsData` | +| Rolling mean | drawn, default on | drawn, default on | +| Linear rate | opt-in, default off | opt-in, default off | + +`LineChartData.betweenBarsData` and `BetweenBarsData` are confirmed present in +the resolved fl_chart 1.2.0 (`line_chart_data.dart` L73, L616). + +fl_chart's `ScatterChart` is deliberately not used: it cannot also hold the +rolling-mean and rate line series, and every layer must share one set of axes. + +Degenerate cases: + +- Below the minimum point count, fits return null and are not drawn. +- A bucket containing one dive produces a zero-height band. +- An empty result keeps the existing empty-state card. + +Touching a point shows a tooltip with the dive's date and value. Navigating to +the dive from a tooltip is **not** in scope. + +Large logbooks are not downsampled in this change. Whether 2000 dots is a +problem should be measured against a real logbook rather than pre-optimised. If +it does need mitigation, the fix is local to this widget. + +### Presentation: `TrendControlStrip` + +One row beneath the chart, in its own widget so the controls have a single set +of test keys: + +- A `PopupMenuButton` rendered as "Per dive" with a caret. Not Material 3's + `DropdownMenu`, which is a text-field-shaped widget far too heavy here. The + compact popup pattern in `gps_track_date_filter_action.dart` is the precedent. +- Two tappable legend entries, "Rolling avg" and the rate label, which toggle + their overlay and act as the colour key. + +When the linear rate overlay is on, its legend entry shows the computed rate, +formatted in the active diver's units. + +## Pages + +| Page | Change | +| --- | --- | +| `statistics_progression_page.dart` | Max Depth and Bottom Time move to `DiveTrendChart`. Dives Per Year, Suit Thickness and Cumulative Count are untouched. | +| `statistics_gas_page.dart` | SAC Rate Trend moves to `DiveTrendChart`, both the volume and pressure branches. | +| `statistics_equipment_page.dart` | Weight Trend moves to `DiveTrendChart`. | +| `statistics_conditions_page.dart` | New "Water Temperature Trend" card. The existing chart is retitled "Seasonal Water Temperature" with a subtitle stating that it collapses all years. | + +## Span control + +`dive_filter_sheet.dart` L221-332 already offers All Time, This Year, Last 12 +Months and Last Year, plus custom start and end dates. Two presets are added, +**Last 5 Years** and **Last 10 Years**, completing the list the reporter asked +for. Custom start and end already covers "custom interval". + +That sheet is shared with the dive list filter, so the new presets appear there +too. This is a deliberate, accepted side effect outside the Statistics tab. + +The affected detail pages have no filter affordance today: on mobile the span +must be set on `/statistics` before drilling in. Each affected page gains the +filter action on its standalone route and shows `StatisticsFilterBar` so a +scoped chart is never mysterious. In the embedded master-detail layout the list +pane already owns the filter icon, so no second icon is added there. + +## Localization + +New keys are needed for the three aggregation modes, the two legend labels, and +the rate label. Existing subtitles become false and must be rewritten: + +- `statistics_progression_depthProgression_subtitle` ("Monthly max depth over 5 years") +- `statistics_progression_bottomTime_subtitle` ("Average duration by month") +- `statistics_gas_sacTrend_subtitle` ("Monthly average over 5 years") +- `statistics_equipment_weightTrend_subtitle` ("Average weight over time") +- `statistics_conditions_temperature_title` and `_subtitle`, retitled for the seasonal framing + +All keys must be added to every locale, not only English. `arb_parity_test` +fails on a key present in English and missing elsewhere. + +## Schema + +None. This is entirely read-path work: no migration, no rung on the schema +version ladder. + +## Testing + +TDD, per the project development guide. + +**Unit, `trend_aggregation_test.dart`.** The statistical core, tested without a +database or a widget pump: + +- bucket boundaries for weekly and monthly, including a dive on a boundary +- mean, min, max and count per bucket +- `none` returns one bucket per dive with `mean == min == max` +- rolling mean at both ends of the array, where the window truncates +- rolling mean over clustered dates, confirming the window counts dives +- linear fit slope and `perYear` against hand-computed vectors +- null fits below the minimum point count + +**Repository.** Per-dive results, ordering, and filter propagation for all six +methods. One regression test carries the weight of defect 2: **a dive eight +years old must appear with an empty filter.** It fails against today's code and +is the proof the cutoffs are gone. `statistics_repository_error_test.dart` +needs its method names updated. + +**Widget.** New tests for `DiveTrendChart` (X values are dates not indices; the +band is present only when aggregated; fits absent below the threshold; the +tooltip renders) and for `TrendControlStrip` (dropdown changes mode, legend +entries toggle overlays). + +**Page.** `statistics_progression_page_test.dart` and +`statistics_equipment_page_test.dart` do not exist today and are written as part +of this work. `statistics_gas_page_test.dart` and +`statistics_conditions_visibility_test.dart` need updating. +`statistics_providers_all_test.dart` must gain the new temperature provider. + +## Out of scope + +Named explicitly so the work does not drift: + +- **#771**, SAC trend mixing open-circuit and CCR cylinders. A real defect in + what SAC *means*, orthogonal to how it is plotted. +- **#641**, depth distribution capped at 40 m. +- **#526**, excluding shallow or short dives from statistics. +- Persisting per-chart settings across app restarts. + +## Added after the design was written + +Feedback on the running app pulled several things in that this document had +either excluded or never considered. Recorded here so the spec matches what +shipped: + +- **Navigating from a chart point to the dive it represents**, listed above as + out of scope. `TrendDataPoint` gained a `diveId`, every per-dive query + selects it, and tapping a point in per-dive mode opens that dive. A bucket + carries no id, because it stands for several dives. +- **Zoom and pan** over the time axis, matching the dive profile chart: + wheel, trackpad, drag and pinch, plus a shared `ChartZoomControls` extracted + from the profile legend. The viewport maths moved to + `lib/core/ui/chart_viewport.dart` and lost its `Profile` prefix. +- **A readout matching the profile chart**: monospace label and value columns, + every series named, drawn above the plot so it cannot cover the point it + describes, and a full-height hover line. +- **Framed plot areas** on all four statistics chart types. +- **Denser cumulative dive count**, stepping once per dive rather than once + per month. +- **Self-describing aggregation values** ("Every dive", "Weekly average", + "Monthly average") and an abbreviated year on every axis label. + +Two things this document proposed were dropped: pulling the date range out of +the filter sheet into the statistics top bar, and spelling the axis year only +where it changes. diff --git a/docs/superpowers/specs/2026-08-27-trimix-calculator-settings-persistence-analysis.md b/docs/superpowers/specs/2026-08-27-trimix-calculator-settings-persistence-analysis.md new file mode 100644 index 0000000000..ef3f4b6556 --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-trimix-calculator-settings-persistence-analysis.md @@ -0,0 +1,173 @@ +# Trimix Calculator: Settings Persistence Analysis (Teil 1) + +Date: 2026-08-27 +Original issue: [submersion-app/submersion#1335](https://github.com/submersion-app/submersion/issues/1335) +Fork issue: alpheios-one/submersion#18 +Branch: `claude/issue-18-20260827-2101` + +## Scope + +This document was originally written as analysis only, for "Teil 1" of issue +#18, before any implementation existed. It documents the current architecture +the blender calculator sits on, so that "Teil 2" could build on accurate +ground rather than assumptions. The "Teil 2" implementation (persistence, +settings gear, cylinder-size templates) has since been added on top of this +same branch/PR; this document reflects the state of the codebase as it was +at the start of that work, not the final state. + +## Summary of the request (issue #1335) + +1. The blender should remember the last entered cylinder, target fill, fill + gases and mixing conditions across app restarts. +2. The "Fill gases" and "Mixing conditions" views, plus the billing fields + (currency, O2/He/air price, cylinder size), should move behind a settings + gear icon, keeping today's grouping. +3. A manageable cylinder-size template (name, liters) should live under a + "Default settings and billing" area and feed the cylinder dropdown. + +## Current state + +### The blender is a composed shell over six cards + +`lib/features/gas_calculators/presentation/widgets/gas_blender_calculator.dart` +assembles, in order: `BlenderCylinderCard`, `BlenderFillGasesCard`, +`BlenderConditionsCard`, `BlenderProcedureCard`, `BlenderAboutCard`, +`BlenderBillingCard`, `BlenderInvoiceCard`. Each card is its own file under +`presentation/widgets/blender/`. This maps directly onto the issue's request: + +| Issue term | Existing widget | +| --- | --- | +| "Füllgase" | `blender_fill_gases_card.dart` (3 O2/He rows) | +| "Mischbedingungen" | `blender_conditions_card.dart` (fill temp, settled temp, gas model) | +| Cost fields currency/O2/He/air | `blender_billing_card.dart` (`_currencyField`, three `_priceField`s) | +| Flaschengrössen-Vorlage | `blender_billing_card.dart::_cylinderRow`, backed by `blenderTankChoices()` in `tank_spec.dart` | + +There is no settings-gear affordance anywhere in the gas calculators today +(`Icons.settings` does not appear under `lib/features/gas_calculators/`). The +only existing AppBar action on the calculators page is a reset button +(`gas_calculators_page.dart:54-59`). + +### What is already persisted, and what is not + +A prior feature (issue #1100, see +`docs/superpowers/specs/2026-08-20-trimix-blender-optimisation-design.md`) +already built a persistence layer for *part* of the blender's state: + +- `BlenderPreferences` (`domain/blending/blender_preferences.dart`) is a plain + JSON-serializable class: mix templates, the three fill-gas prices, currency, + fill/settled temperature, cylinder water liters (billing cylinder), the gas + model, and billed fills/billed-to. +- It is stored as **one JSON blob** under the key-value `settings` table, key + `gas_blender_prefs`, via `AppSettingsRepository.getBlenderPreferences()` / + `.setBlenderPreferences()` (`app_settings_repository.dart:92-143`). This + costs **no schema version bump** — it reuses the generic `settings(key, + value, updatedAt)` KV table that already exists, the same pattern + `getNavPrimaryIdsRaw`/`setNavPrimaryIds` use for nav config. + Writes call `SyncRepository.markRecordPending(entityType: 'settings', ...)` + and `SyncEventBus.notifyLocalChange()`, so the blob syncs across devices + with no sync-serializer change. +- Loading happens once per calculator mount via + `blenderPreferencesLoaderProvider` (a `FutureProvider` in + `gas_blender_providers.dart:148-170`), which pushes the stored values into + plain `StateProvider`s and bumps `blenderResetEpochProvider` so the + `TextEditingController`s in `gas_blender_calculator.dart` re-seed from the + loaded values (they only seed once, in `initState`). +- Saves are explicit and debounced to "on blur / on submit", not per + keystroke: every field that persists calls `saveBlenderPreferences(ref)` + from `onEditingComplete`/`onSubmitted`/`onChanged` (dropdowns), never from + every `onChanged` on a text field. + +**What is *not* persisted today**, confirmed by reading +`gas_blender_providers.dart:11-83`: `blenderStartPressureProvider`, +`blenderStartMixProvider`, `blenderTargetPressureProvider`, +`blenderTargetMixProvider`, `blenderFillGas1/2/3Provider` are plain +`StateProvider`s with hard-coded defaults (0 bar / air; 200 bar / EAN32; O2 +100%, He 100%/O2 0%, air) and are never read by +`blenderPreferencesLoaderProvider` or written by `saveBlenderPreferences`. +This is exactly the gap issue #1335 point 1 is asking to close: the cylinder, +the target fill, and the three fill gases reset every time the calculator is +reopened, while the mixing *conditions* (temperatures, gas model) already +survive a restart. + +### The cylinder-size dropdown + +Two unrelated preset lists exist in `tank_spec.dart`: + +- `metricTankChoices()` / `imperialTankChoices()` — the diver's own cylinders, + used by dive planning/logging. +- `blenderTankChoices()` — a **separate, hard-coded** list of 4 entries (2 L, + 3 L, AL80, "Steel 12 L twinset") used only by + `BlenderBillingCard._cylinderRow`'s preset menu. A code comment there notes + this was a deliberate split during #1100's review: a blending bench sees + small decant bottles and twinsets-as-one-vessel, which a diver's own + cylinder list does not represent, and unifying them was rejected. + +Separately, the app already has a full, user-manageable tank preset system — +`lib/features/tank_presets/` (`TankPresetEntity`, `TankPresetRepository`, +`tank_presets_page.dart`, reachable from Settings → Manage → Tank Presets). +That system is **not** what feeds the blender's cylinder dropdown, and it +carries more fields than the issue asks for (`workingPressureBar`, +`TankMaterial`, `sortOrder`, `ratedCapacityCuft`, per-diver ownership) plus a +real Drift table with its own schema-version history +(`docs/superpowers/specs/2026-03-18-default-tank-preset-design.md`). Issue +#1335 only asks for name + liters, scoped to the blender. + +### Settings/DB modeling in general + +- `currentSchemaVersion` is `170` today (`lib/core/database/database.dart:3196`). + A migration is a numbered `ALTER TABLE`/new-table block plus a version bump, + following the pattern already used for the tank-preset table and dozens of + other features. +- Two persistence shapes coexist in the codebase for "user-managed lists of + small records": (a) a dedicated Drift table with a repository and its own + migration (tank presets, dive types, dive roles, service types), used when + the data is referenced elsewhere or independently synced/queried; (b) a JSON + blob under the generic `settings` KV table, used when the data is small, + private to one feature, and does not need to be queried relationally + (blender preferences, nav primary ids). `MixTemplate` inside + `BlenderPreferences` is the closest existing precedent for "a small + user-managed list of `{label fields, numbers}` records" and required zero + schema changes. + +## Gaps against the three asks + +1. **Persist last-entered values.** `blenderStartPressureProvider`, + `blenderStartMixProvider`, `blenderTargetPressureProvider`, + `blenderTargetMixProvider`, `blenderFillGas1/2/3Provider` need to join + `BlenderPreferences` (new fields), `blenderPreferencesLoaderProvider`, and + `saveBlenderPreferences`. No migration needed, since this is the same JSON + blob used for the conditions that already persist. +2. **Settings gear.** No such affordance exists on any gas calculator today. + `BlenderFillGasesCard`, `BlenderConditionsCard`, and the currency/price + portion of `BlenderBillingCard` would need to move out of the always-visible + column in `gas_blender_calculator.dart` into a gear-triggered surface + (dialog, bottom sheet, or a settings sub-page), while keeping their internal + layout ("bestehende Gliederung erhalten"). `BlenderProcedureCard`, + `BlenderAboutCard`, the cylinder/cost-lines/total part of + `BlenderBillingCard`, and `BlenderInvoiceCard` are the calculator's actual + output and would stay on the main screen. +3. **Manageable cylinder-size template.** Nothing today lets a user add, + rename, or delete an entry in `blenderTankChoices()` — it is a static Dart + list. The lightweight option (matching `MixTemplate`'s pattern: a `{name, + liters}` record living inside `BlenderPreferences`, no migration) is a + closer fit to the issue's ask than reusing/extending the heavier + `TankPresetEntity` table, which was deliberately kept separate from the + blender's bench-bottle list during #1100. + +## Recommendation for Teil 2 + +- Extend `BlenderPreferences` with the six currently-ephemeral fields and a + `List` (name + liters, capped like `maxTemplates`), + following the exact shape `MixTemplate` already established — this avoids a + database migration entirely, reusing the existing KV blob, sync path, and + malformed-input-falls-back-per-field contract. +- Introduce a settings-gear entry point on the blender (mirroring where a gear + icon would sit next to the reset action in `gas_calculators_page.dart`, or + as a per-card action), opening a view that keeps the existing + `BlenderFillGasesCard` / `BlenderConditionsCard` internals unchanged, plus a + new billing-defaults section (currency, three gas prices, and the new + cylinder-template manager) — this is the "Standardeinstellungen und + Abrechnung" area the issue names. +- Keep `blenderTankChoices()` as the zero-config fallback/seed for first run, + the same way `BlenderPreferences.seedTemplates` seeds mix templates only + when the stored blob is entirely absent. diff --git a/docs/superpowers/specs/2026-08-28-exclude-dive-from-statistics-design.md b/docs/superpowers/specs/2026-08-28-exclude-dive-from-statistics-design.md new file mode 100644 index 0000000000..4a3e7266ef --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-exclude-dive-from-statistics-design.md @@ -0,0 +1,367 @@ +# Exclude a Dive from Statistics + +Design document, 2026-08-28. + +Issues: [#526](https://github.com/submersion-app/submersion/issues/526) +(exclude shallow or short dives from overall statistics), +[#1272](https://github.com/submersion-app/submersion/issues/1272) +(exclude a dive from SAC and RMV calculations). + +Branch: `worktree-issue-526-exclude-from-stats`. + +## Problem + +Divers keep dives in their logbook that they do not want counted. Two distinct +cases arrived as separate issues: + +- **#526.** A 90 minute dive at 12 ft is not an official dive by most agency + standards. The diver wants to keep the entry and view it, but wants it out of + the totals, the averages, and the records. +- **#1272.** An otherwise ordinary dive where the diver purged the tank down to + 500 psi at the end for a weight check. Only the gas number is wrong. Throwing + away the whole dive to fix one metric is a bad trade. + +The app has no concept of an excluded dive today. There is no `excluded`, +`hidden`, or `archived` column on `dives`, and no soft-delete. The only +exclusion rule that exists is `AND d.dive_mode <> 'gauge'`, hand copied into +seven gas queries in `StatisticsRepository` and centralized nowhere. + +## Decisions + +| Question | Decision | +| --- | --- | +| Granularity | Two flags: exclude from all statistics, and exclude from gas statistics only. | +| Does an excluded dive still count in Total Dives? | No. Excluded means every descriptive aggregate, count included. | +| Blast radius | Descriptive aggregates yes, operational numbers no. | +| Enforcement mechanism | Shared SQL predicate helper plus a behavioral guard suite. Not a SQL view, and not a `DiveFilterState` axis. A separate opt-in filter axis is added for *finding* excluded dives, but it plays no part in enforcement. | +| Visibility | Checkbox, bulk edit, list badge, statistics footnote, and a filter axis. | +| `is_planned` | Fixed in the same change. Planned dives stop counting toward statistics. | + +### Why two flags + +A single master flag forces #1272's diver to discard a good dive to correct one +metric. Per category flags (depth, duration, gas, records, count) were rejected +as YAGNI: no fourth axis has been requested, and a per dive checkbox grid ages +badly. + +The master flag implies the gas flag. The gas predicate is therefore +`excluded_from_stats = 0 AND excluded_from_gas_stats = 0`. + +### Why the count is excluded too + +#526 is explicit that the dive "does not count as an official dive." A flag +labelled "exclude from statistics" that still increments the total is dishonest +and has to be explained. The dive remains fully present and editable in the +logbook, so nothing is destroyed and the decision is reversible. + +Records (deepest, longest, coldest) follow the same rule. A flooded pool +session appearing as "longest dive: 90 min" is the original complaint. + +### Descriptive versus operational + +Not every count of dives is a statistic. Two categories are load bearing in a +way that makes suppression actively wrong: + +- **Equipment service intervals.** `ServiceDueEngine` derives "dives since last + service" and "hours since last service" from + `EquipmentRepository.getUsageSamplesForEquipment`. A practice dive still + cycled the regulator and still put hours on it. Excluding it would push a real + service interval later than it should be. +- **Course requirement progress.** `CourseRequirementRepository.getCourseProgress` + counts dives the diver deliberately linked to a requirement. Excluding one + would silently un-credit work they did on purpose. + +The logbook list header ("N dives") is also excluded from the rule. The dive is +still in the logbook, so the list still counts it. + +This produces one deliberate asymmetry worth stating plainly: a regulator's +service clock still counts an excluded dive, while the Statistics tab's "most +used gear" card does not. The first measures wear that physically happened; the +second is a descriptive summary of the diver's diving. + +## Section 1: Schema and entity + +Two boolean columns on `Dives` (`lib/core/database/database.dart:647`), +following the `isFavorite` pattern at line 731: + +```dart +// Statistics exclusion (schema v178) +BoolColumn get excludedFromStats => + boolean().withDefault(const Constant(false))(); +BoolColumn get excludedFromGasStats => + boolean().withDefault(const Constant(false))(); +``` + +**Schema version 178.** Main is at 175. Version 176 is claimed by open PR #1328 +and 177 by open PR #1361. Both claim scans (open PR diffs and every worktree's +working tree scalar) must be re-run immediately before pushing, because a rung +claimed below main's scalar merges with no conflict marker and its migration +step then never runs. + +Migration follows the established three part shape: + +1. An idempotent `_assertDiveStatsExclusionColumns()` guarded by + `PRAGMA table_info('dives')`, including the `if (cols.isEmpty) return;` + self-guard that minimal schema fixtures depend on. Two statements of the form + `ALTER TABLE dives ADD COLUMN excluded_from_stats INTEGER NOT NULL DEFAULT 0`. +2. An `onUpgrade` rung at `if (from < 178)`. +3. A `beforeOpen` backstop calling the same helper, because a database arriving + via restore or sync adopt never runs `onUpgrade`. There is no backfill, so + both paths are safe to re-run. + +**Sync.** No hand edits. `_exportDives` +(`lib/core/services/sync/sync_data_serializer.dart:4487`) serializes via the +generated `row.toJson()`, so both columns ride along. + +**UDDF.** Explicit plumbing required in +`lib/core/services/export/uddf/uddf_export_builders.dart` and +`uddf_full_import_service.dart`, mirroring `isFavorite`, so a round trip through +the app's own export format does not drop the flags. + +**Entity plumbing.** Mirrors `isFavorite` across +`lib/features/dive_log/domain/entities/dive.dart`, +`domain/entities/dive_summary.dart`, `domain/services/dive_merge_builder.dart`, +the two mapper sites (`dive_repository_impl.dart:3562`, `:3944`) and two +companion sites (`:1409`, `:1664`), and `bulk_edit_field_set.dart`. + +## Section 2: Enforcement + +New file `lib/core/database/dive_stats_scope.dart`: + +```dart +class DiveStatsScope { + /// Bare predicate: which dives contribute to descriptive statistics. + static String predicate({String alias = 'd', bool gas = false}); + + /// The same, prefixed with ' AND ' for appending into an existing WHERE. + static String and({String alias = 'd', bool gas = false}); +} +``` + +`predicate()` yields `d.excluded_from_stats = 0 AND d.is_planned = 0`. With +`gas: true` it additionally yields +`AND d.excluded_from_gas_stats = 0 AND d.dive_mode <> 'gauge'`. The `alias` +parameter is required because several queries apply the scope to a second alias +inside a self join. + +### The scope is orthogonal to the user filter + +This is the central structural decision. The scope is applied alongside +`buildFilteredDiveIdSubquery` (`lib/features/statistics/data/dive_filter_sql.dart:12`), +never inside it. + +`buildFilteredDiveIdSubquery` implements the user's transient view filter and +correctly returns an empty no-op when no axis is active. The scope is a +persistent property of the dive. Merging the two would mean one of: + +- the exclusion evaporates for every user who never opens the filter sheet, or +- the no-op early return is deleted, and every deliberately unfiltered call site + (dashboard quick stats, dive log summary, species detail page) silently + becomes filtered. + +The second is a repeat of a bug this codebase already shipped and caught in +review. Keeping them separate lets `StatisticsRepository._diveFilter` +(`statistics_repository.dart:190`) append the scope unconditionally and the user +filter conditionally, which fixes 37 query sites in a single edit. + +For the same reason, modelling the exclusion as a `DiveFilterState` axis was +rejected outright. + +### Tier 1: statistics feature + +- One edit to `_diveFilter` (line 190) covers the 37 filter aware methods. +- Four hand patches: `getYearStats` (line 973), + `getEntryExitMethodPairsForSite` (line 1298), `getSiteHistoryByName` + (line 2501), and the nested `FROM dives d2` self join inside + `getSurfaceIntervalStats` (line 2025), which the user filter clause already + misses today. +- The seven gas queries (lines 234, 376, 423, 483, 615, 678, 759) drop their + hand copied `AND d.dive_mode <> 'gauge'` in favor of + `DiveStatsScope.and(gas: true)`. + +### Tier 2: dive log repository + +Each builds its own WHERE fragments and gets the scope by hand: + +- `getStatistics()` (`dive_repository_impl.dart:2816`), four statements. +- `getRecords()` (line 2984), six superlative statements. +- `getPersonalRecordIds()` (line 3224), six statements. +- `countDivesSince()` (line 3137). +- `getOnThisDayDiveIds()` (line 3155). + +Deliberately **not** touched: `getDiveCount()` (line 2190), the logbook list +header. Gets a doc comment naming the decision. + +### Tier 3: per entity descriptive counts + +| Feature | Sites | +| --- | --- | +| Buddies | `getAllBuddies` inline joins (`buddy_repository.dart:793`, `:811`), `getDiveCountForBuddy` (`:947`), `getDiveIdsForBuddy` (`:971`), `getBuddyStats` (`:997`, `:1031`) | +| Dive sites | `getDiveAggregatesBySite` (`site_repository_impl.dart:863`) | +| Trips | `getTripWithStats` (`trip_repository.dart:505`), `getAllTripsWithStats` (`:598`), `getDiveCountForTrip` (`:322`) | +| Dive centers | `getDiveCountForCenter` (`dive_center_repository.dart:238`) | +| Tags | `tag_repository.dart:548`, `:590`, `:612` | +| Dive types | `dive_type_repository.dart:365`, `:414` | +| Dive roles | `dive_role_repository.dart:240` | +| Divers | `getDiveCountForDiver` (`diver_repository.dart:573`), `getTotalBottomTimeForDiver` (`:593`) | +| Marine life | `seen_species_repository.dart:27`, `:58`; `species_repository.dart:598`, `:627` | +| Dive computers | `dive_repository_impl.dart:6239` | + +Several of these count junction table rows without joining `dives` at all +(`getDiveCountForBuddy`, and the tag and dive type counts). Those need a join +added, not just a predicate. + +### Deliberately untouched: operational + +Each gets a doc comment naming the decision so a future reader does not "fix" +it: + +- `EquipmentRepository.getUsageSamplesForEquipment` + (`equipment_repository_impl.dart:606`), `getDiveCountForEquipment` (`:582`), + `getTripCountForEquipment` (`:658`), and `ServiceDueEngine` + (`service_due_engine.dart:12`). +- `CourseRepository.getDiveCountForCourse` (`course_repository.dart:201`), + `CourseRequirementRepository.getCourseProgress` + (`course_requirement_repository.dart:56`), and `getSuggestedDives` (`:440`). +- `DiveRepository.getDiveCount()` (`dive_repository_impl.dart:2190`). + +Note that `StatisticsRepository.getMostUsedGear` (line 2063) **does** get the +scope. It lives on the Statistics tab and is descriptive. + +### The `is_planned` fix + +`Dives.isPlanned` (`database.dart:776`) is set by the dive planner for dives +that have not happened. No statistics or aggregate query filters on it today, so +planned dives currently inflate every total. Folding `is_planned = 0` into the +scope predicate fixes this in one line. + +This changes existing users' numbers on upgrade with no action on their part and +requires an explicit release note. + +## Section 3: UI and localization + +**Dive edit form.** A collapsible "Statistics" group at the bottom of the +form (`_buildStatisticsSection`, rendered after the contextual groups and +before the AddSectionRow), holding the two toggles. + +The toggles first shipped as two rows at the end of `_buildTheDiveSection`, +on the reasoning that the group holding dive number, date, site and depth is +where a flag about how the dive is counted belongs. In practice they read as +clutter inside an always-expanded group of core facts, so they moved into +their own collapsible group instead, matching the other expandable ones. The +group is collapsed unless the dive is already excluded, and its header carries +the state: an "Excluded" or "Gas excluded" summary, or the fainter "Counted in +every statistic" hint. Nothing in the group is a validated field, so it does +not join the sections `_saveDive` force-expands before `Form.validate()`. + +The gas checkbox renders disabled and checked while the master flag is on, so +the implication is visible rather than surprising. + +Note that `isFavorite` is only a partial precedent. It is read in the edit page +at line 4961 but never edited there; favoriting happens from the list item and +detail app bar. There is no existing dive level boolean checkbox in the edit +form to copy. + +**Bulk edit.** Two new `BulkField` entries alongside `BulkField.isFavorite` +(`dive_edit_page.dart:1056`), wrapped in the same gate widget so an untouched +field stays untouched rather than writing `false` over every selected dive. +This is what makes #526 practical: thirty pool sessions get excluded in one +operation. + +**Badges.** A marker on the dive list item and the dive detail page. Icon plus +tooltip rather than a text chip, because list rows are dense and the Ahem test +font overflows unconstrained `Row` plus `Text` at narrow widths. + +**Statistics footnote.** A line in the Statistics tab reading "N dives excluded +from statistics", backed by a count query that inverts the scope predicate. +Renders only when N is nonzero. This is what prevents a "247 here, 248 there" +support ticket months later. + +**Filter axis.** `DiveFilterState` gains `excludedFromStatsOnly` as a `bool?`, +mirroring `favoritesOnly` (`dive_filter_state.dart:18`): null means no +filtering, true means only excluded dives. Three implementations must stay in +sync: + +1. `buildFilteredDiveIdSubquery` (`dive_filter_sql.dart:12`) +2. `DiveRepository._buildFilterWhereClauses` (`dive_repository_impl.dart:2293`) +3. `DiveFilterState.apply()` (`dive_filter_state.dart:263`) + +**Localization.** New keys for both checkbox labels, their help text, the badge +tooltip, the bulk edit field labels, the filter axis label, and the statistics +footnote. Translated across all eleven locales: `ar de en es fr he hu it nl pt +zh`. English only would fail `arb_parity_test` against every other locale. + +## Section 4: Testing + +TDD, tests first. Two layers, because correctness and completeness are separate +problems. + +### Layer 1: behavioral guard suite + +`test/features/statistics/dive_stats_scope_test.dart`. One fixture seeds five +dives: normal, `excludedFromStats`, `excludedFromGasStats`, planned, and gauge +mode. A table driven list calls every descriptive aggregate from section 2 and +asserts the right dives are absent. This proves the roughly 45 edits work. + +### Layer 2: source census + +`test/core/database/dive_stats_scope_census_test.dart`. Reads the repository +sources and asserts that every `customSelect` referencing `FROM dives` either +contains `excluded_from_stats` or appears in an explicit, commented exemption +list. + +Layer 1 alone cannot prevent rot, because it only tests the queries someone +remembered to add to the list. The census test catches the next engineer adding +a 46th aggregate: their query fails a test that names the decision they have to +make. This is exactly the failure mode `dive_mode <> 'gauge'` already +demonstrates, with seven hand copied predicates and no mechanism ensuring an +eighth query would get it. + +### Migration tests + +- v177 to v178 adds both columns defaulting to 0. +- The `beforeOpen` backstop is idempotent across two consecutive opens. +- A minimal schema fixture with no `dives` table returns early instead of + throwing. + +### Filter parity + +The three implementations of `excludedFromStatsOnly` agree on one fixture set. +The repo has precedent for an axis only some paths can evaluate (`decoOnly` is +SQL only and skipped by `apply()`); this axis is evaluable everywhere, so no +exemption is needed. + +### Round trips + +- Sync export and adopt preserve both flags. Generated serializer, so this is a + regression guard. +- UDDF export and import preserve both flags. Hand plumbed, so this test earns + its place. + +### Widget tests + +- Edit form toggles persist. +- The gas checkbox disables when the master flag is on. +- The bulk edit gate leaves untouched fields untouched. +- The badge renders on list item and detail page. +- The statistics footnote appears and hides correctly. + +## Risks + +**Existing tests will break, by design.** Any test seeding a planned dive and +expecting it in statistics now fails. Those failures are the `is_planned` fix +working. Each needs individual review rather than a blanket update, because a +genuine regression could hide among them. + +**Schema rung collision.** A rung claimed below main's scalar merges with no +conflict marker and its step silently never runs. Re-run both claim scans +immediately before pushing. + +**Release note required.** The `is_planned` fix changes existing users' totals +on upgrade with no action on their part. + +## Verification before the PR + +- `dart format .` +- `flutter analyze` across the whole project, infos treated as fatal +- One full test suite run diff --git a/docs/superpowers/specs/2026-08-28-gas-calculators-split-view-design.md b/docs/superpowers/specs/2026-08-28-gas-calculators-split-view-design.md new file mode 100644 index 0000000000..7bc3d245af --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-gas-calculators-split-view-design.md @@ -0,0 +1,236 @@ +# Gas Calculators as its own split view: Design + +**Status:** design approved in chat 2026-08-28; spec awaiting review +**Branch:** `worktree-gas-calculators-split-view` +**Baseline:** `origin/main` at ae5fd016999 + +## Problem + +Gas Calculators packs six calculators into a `TabBar` inside the Planning +detail pane. Two things go wrong at once. + +The tab strip is crowded. Six icon-and-text tabs share whatever width the +detail pane has left after the 440px master pane, so on a typical desktop +window the labels sit shoulder to shoulder and, below 500px, the strip becomes +horizontally scrollable and some calculators are off screen entirely. + +The tab strip also eats vertical space in the pane that is already the most +vertically hungry surface in Planning. An icon-and-text `TabBar` is roughly +72px, and it sits below the `PlanningToolPane` header, so every calculator +starts about 112px down a pane that already has to fit sliders, unit toggles, +and result cards. + +The rest of Planning does not have this problem, because Planning already +solved it: a list of tools on the left, the selected tool on the right. Gas +Calculators should use the same shape one level down, with its six calculators +as the list. + +## Findings + +Every claim below was verified against `origin/main` at ae5fd016999. + +**F1. The split-view machinery is already generic.** `MasterDetailScaffold` +(`lib/shared/widgets/master_detail/master_detail_scaffold.dart`) takes a +`masterBuilder`, a `detailBuilder`, and a `summaryBuilder`, and drives +selection through a URL query parameter named by `queryParamKey`. It reads and +writes the *current* path (`state.uri.path`), so it works unchanged on a nested +route such as `/planning/gas-calculators`. Nothing in it assumes a top-level +nav destination. + +**F2. Planning is a 67-line consumer of it.** `PlanningPage` +(`lib/features/planning/presentation/pages/planning_page.dart`) renders +`MasterDetailScaffold` at >=1100px and bare `PlanningListContent` below that. +Gas Calculators becomes a second consumer of the same widget with the same +two-branch structure. + +**F3. The dive planner is the precedent for a full-window push.** +`kDivePlannerToolId` in `planning_tools.dart` is documented as the one tool +that never opens in the detail pane, because its own multi-pane layout cannot +be squeezed into what is left beside a 440px master pane. A nested split view +is the same situation, so Gas Calculators joins it. Today the exception is +expressed by rendering the planner from a separate `_PlannerSection` with +`onToolSelected: null` hardcoded; there is no general way to mark a tool as +full-page. + +**F4. Calculator state is already independent of the tab widget.** Every input +lives in a top-level `StateProvider` in `gas_calculators_providers.dart` +(`modO2Provider`, `bestMixDepthProvider`, `consumptionDepthProvider`, and so +on), never in widget state. Switching calculators through a list therefore +preserves typed values exactly as `TabBarView` did, and +`resetGasCalculators(WidgetRef)` (line 142) keeps working untouched. The six +calculator widgets need no changes. + +**F5. `PlanningToolPane` exists for exactly this.** Its doc comment records +that `MasterDetailScaffold` renders `detailBuilder` output with no app bar, so +a tool in the pane has nowhere to put its title and actions, and that its +header deliberately matches the compact bar the entity list panes use. The new +gas-calculator detail pane reuses it verbatim. It has no `leading` slot today. + +**F6. `_MasterPane` adds no chrome.** It is a bare `Stack` holding the master +content plus an optional FAB, so the master pane's app bar, if any, comes from +the master content itself. `PlanningPage` passes `showAppBar: false` because +the nav rail supplies context for a top-level destination. A *pushed* page has +no nav-rail highlight and needs its own back affordance. + +**F7. Gas Calculators has exactly two entry points.** `app_router.dart:276` +(`/planning/gas-calculators`) and `planning_page.dart:56` (the embedded form). +No dashboard shortcut, no deep link elsewhere. + +**F8. Generated localizations cover 11 locales.** `lib/l10n/arb/` holds +`app_en.arb` plus ten translations (ar, de, es, fr, he, hu, it, nl, pt, zh). +Every new key has to land in all eleven. + +## Design + +### Navigation and routing + +Gas Calculators stays a row in the Planning tools list and keeps the route +`/planning/gas-calculators`, so existing links and the back-to-Planning +gesture are unchanged. What changes is that the row pushes a full-window page +instead of loading the Planning detail pane, exactly as the dive planner +already does. + +| Width | What renders | URL | +| --- | --- | --- | +| >=1100px | Split view: calculator list left, calculator right | `/planning/gas-calculators?calc=mod` | +| <1100px | Calculator list only; a row pushes one calculator | `/planning/gas-calculators/mod` | + +`calc` is the `queryParamKey`, chosen over the default `selected` for the same +reason Planning chose `tool`: the URL stays self-describing. There is no +collision risk (the path differs from Planning's), so this is readability +only. + +The six ids are `mod`, `best-mix`, `consumption`, `rock-bottom`, `mnd`, and +`blender`, matching the existing `gasCalculators_tab_*` string suffixes. + +`/planning/gas-calculators` with no selection lands on a summary pane rather +than auto-selecting a calculator, matching every other split view in the app. + +### Pane chrome + +The pushed page needs a back affordance that Planning's split view does not. +Rather than nest a 56px `AppBar` inside the 440px master pane and leave the +detail pane headerless, both panes get the same compact header: + +- **Master pane:** `PlanningToolPane` with a back `IconButton` as `leading`, + the title `gasCalculators_title`, and the existing reset action. +- **Detail pane:** `PlanningToolPane` with the selected calculator's title and + no actions (reset is global and lives once, in the master header). + +This requires one additive change to `PlanningToolPane`: an optional `leading` +widget. Matching headers keep the two halves reading as one surface, which is +the stated intent of that widget's design. + +Below 1100px the list is an ordinary page and uses a normal `Scaffold` and +`AppBar` with the automatic back button, so `GasCalculatorsListContent` takes +a `showAppBar` flag exactly as `PlanningListContent` does. + +### Marking a tool as full-page + +`PlanningTool` gains two optional fields: + +- `routePrefix`, defaulting to `/planning`, so a nested tool can build + `/planning/gas-calculators/mod` from the same `route` getter. +- `presentation`, a `PlanningToolPresentation` defaulting to `detailPane`. + `PlanningTile` passes `null` for `onToolSelected` for any value other than + the default, which is already the tile's "navigate, do not select" path. + +This generalizes the hardcoded planner exception in F3 rather than adding a +second special case beside it. + +The enum has three values rather than a `fullPage` boolean, because the cases +differ in navigation verb as well as layout: + +| Value | Layout | Verb | +| --- | --- | --- | +| `detailPane` | Planning's detail pane | `push` below the breakpoint | +| `pushedPage` | Whole window | `push` (the dive planner) | +| `splitViewPage` | Whole window, hosts its own split view | `go` (Gas Calculators) | + +The verb on the third row is load-bearing. A `MasterDetailScaffold` selects by +calling `go`, which rebuilds the stack from the declarative route match and +keys the page by its path. Entering on a `push` leaves the route carrying a +generated page key, so the first selection swaps one page for another and +Flutter animates the whole page in from the right a second time, once, before +settling. `go` on a nested child route still leaves the hub beneath it, so the +tool stays poppable either way. This was found by hand after the first +implementation shipped a `fullPage` boolean that could not express the +difference. + +### Reuse over duplication + +The new list reuses `PlanningTool` and `PlanningTile` directly instead of +copying their styling. The two lists are then identical by construction, which +is the point of the change: Gas Calculators should look like Planning, not +merely resemble it. `gas_calculators_page.dart` already imports +`PlanningToolPane` across the feature boundary, so the direction of that +dependency is established. + +### Components + +New, all under `lib/features/gas_calculators/presentation/`: + +| File | Purpose | +| --- | --- | +| `gas_calculator_tools.dart` | The six entries (id, icon, color, title, subtitle) in display order, mirroring `planning_tools.dart` | +| `widgets/gas_calculators_list_content.dart` | The master list: header, tiles, safety disclaimer | +| `widgets/gas_calculators_summary_widget.dart` | The empty right pane: title, prompt, disclaimer | +| `pages/gas_calculator_detail_page.dart` | Maps an id to its calculator widget with the right chrome for pane or full page | + +Changed: + +| File | Change | +| --- | --- | +| `pages/gas_calculators_page.dart` | Rewritten as a `MasterDetailScaffold` consumer; `TabController`, `TabBar`, `TabBarView`, and the `embedded` flag all removed | +| `planning/presentation/planning_tools.dart` | `PlanningToolPresentation`, plus `routePrefix` and `presentation` fields; Gas Calculators sets both | +| `planning/presentation/widgets/planning_list_content.dart` | `PlanningTile` honours `presentation`, for both the pane-vs-page choice and the entry verb | +| `planning/presentation/widgets/planning_tool_pane.dart` | Optional `leading` | +| `planning/presentation/pages/planning_page.dart` | Drop the `gas-calculators` case from `_buildTool` | +| `core/router/app_router.dart` | Six child routes under `gas-calculators` | + +Unchanged: all six calculator widgets, and every provider in +`gas_calculators_providers.dart`. + +### Localization + +Seven new keys across eleven locales: + +- `gasCalculators_desc_mod`, `_bestMix`, `_consumption`, `_rockBottom`, + `_mnd`, `_blender`: one-line subtitles for the tiles. +- `gasCalculators_summary_prompt`: the empty-pane hint. + +Titles reuse the existing `gasCalculators_tab_*` values, so the calculators +keep the names divers already know them by. + +## Testing + +- `test/features/planning/planning_tool_embedding_test.dart`: Gas Calculators + leaves the embedded-tools table, because it no longer has an embedded form. + Replaced by an assertion that its Planning tile pushes rather than selecting. +- New `test/features/gas_calculators/gas_calculators_page_test.dart`: six rows + render below 1100px; at >=1100px the list and summary render together, and + selecting a row shows that calculator with no `TabBar` anywhere in the tree. +- `test/features/planning/planning_page_test.dart`: update the tool-id + expectations for the routing change. +- The six existing calculator widget tests pump the calculator widgets + directly and are untouched, which is the evidence for F4. + +## Risks + +**The blender at minimum pane width.** The five simple calculators are +single-column forms and gain height in the pane. The blender is the one with a +wide multi-column layout, and the detail pane's reserved minimum is 400px +(`_kDetailPaneReservedWidth`). It will be checked at that width during +implementation and reported if it needs a responsive tweak; no fix is assumed +here. + +**Deep-link behaviour changes shape.** `/planning/gas-calculators` used to +open on MOD, because a `TabController` starts at index 0. It now opens on the +summary. This is the app-wide split-view convention and is treated as correct +rather than as a regression. + +## Out of scope + +Moving Gas Calculators to a top-level nav destination; changing any +calculation; restyling the calculators themselves; giving the other Planning +tools the same treatment. diff --git a/docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md b/docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md new file mode 100644 index 0000000000..e7d17667ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-profile-sample-storage-design.md @@ -0,0 +1,495 @@ +# Profile Sample Storage: Packed Series + +Design for replacing the row-per-sample `dive_profiles` and +`tank_pressure_profiles` tables with one compressed, columnar BLOB row per +series. Approved in brainstorming on 2026-08-28. Companion issues: #1375 +(cache and tombstone retention) and #1376 (backup retention defaults and +raw-data discard) cover the rest of the on-device storage findings and are +independent of this design. + +## 1. Why + +Measured on a 40-dive development database (28.07 MB, schema v179): + +| btree | size | +|---|---:| +| `dive_profiles` | 9.99 MB | +| `tank_pressure_profiles` | 5.30 MB | +| `idx_tank_pressure_dive_tank` | 3.42 MB | +| `sqlite_autoindex_dive_profiles_1` | 2.48 MB | +| `idx_dive_profiles_dive_id` | 2.19 MB | +| `sqlite_autoindex_tank_pressure_profiles_1` | 2.04 MB | +| subtotal | 25.41 MB (90.5% of the file) | +| the other 244 btrees | 2.66 MB | + +Both tables hold one row per sample, and every row carries three or four +36-character UUID strings (`id`, `dive_id`, `computer_id`, `source_id`, or +`tank_id`), each mirrored again in the primary-key autoindex and the +secondary index. `tank_pressure_profiles` spends 4.32 MB of UUID text to +carry 0.48 MB of timestamps and pressures. + +Per-dive cost is 332 KB (a 1,032-dive fixture measured 2026-07-10 with 6 of +the intended indexes) to 719 KB (the current schema with all 70 indexes). A +1,000-dive logbook is therefore 300 to 700 MB, and backup retention (3 +pre-migration plus 10 manual copies by default) multiplies that by up to 14x +on disk. + +Fragmentation is not the problem: `freelist_count` is 0 and `VACUUM INTO` +recovers 4.3%. Periodic compaction was considered and rejected; the only +`VACUUM` in this design is the one-time pass after the migration drops the +old tables. + +Packing each series into a columnar, zlib-compressed BLOB was measured on the +same database (lossless, float64 for real fields): + +| table | today | packed | factor | +|---|---:|---:|---:| +| `dive_profiles` and its indexes | 14.66 MB | 0.20 MB | 74x | +| `tank_pressure_profiles` and its indexes | 10.75 MB | 0.08 MB | 129x | + +50,883 sample rows become 40 series rows. Sync payloads shrink by the same +factor, and a dive edit stops shipping any samples at all. + +Alternatives measured and rejected: `WITHOUT ROWID` (14.66 to 14.03 MB; the +fat TEXT key bloats every secondary index) and UUID text to 16-byte BLOB +re-encoding (42 to 46%, still row-per-sample, still a sync protocol change). + +## 2. Goals and non-goals + +Goals: + +- Order-of-magnitude reduction in the bytes the two sample tables occupy, + on disk and on the sync wire. +- No behaviour change above the repository layer. `DiveProfilePoint`, the + chart, the analysis pipeline, and every provider keep their contracts. +- Every source-scoped operation (manual edit, restore, primary swap, + reparse, split, merge, consolidation) becomes a single-row operation. +- Per-series last-writer-wins in sync, replacing the clockless per-sample + blind upsert. +- Adding a sample field never again requires an `ALTER TABLE` over a + million rows. + +Non-goals: + +- Lossy downsampling or decimation at rest. The codec is lossless. +- A natural-key uniqueness constraint on series. One data source + legitimately owns two series (its original and a manual edit), and + convergence across devices already rests on the dive-level match gate. +- A dual-format sync bridge. The compatibility floor rises; older peers + hold until they upgrade. +- Periodic or scheduled compaction. + +## 3. Architecture + +Two new tables, `dive_profile_series` and `tank_pressure_series`, replace +`dive_profiles` and `tank_pressure_profiles`. A series row carries the +identity columns exactly as they exist per row today, a small set of summary +scalars, a codec version, and one BLOB holding every sample of the series. + +Three new units: + +- `ProfileSeriesCodec` (pure Dart, no Drift or Flutter imports, the same + layering as `lib/features/gps_log/domain/track_point_codec.dart`). Encodes + a sample list to bytes and back, and computes the summary scalars from the + samples it packs, so a scalar can never disagree with its blob. +- `ProfileSeriesRepository`. Owns every read and write of series rows. The + only place that encodes or decodes in production code outside the + migration packer and the sync tolerance shim. +- `ProfileSeries` and `TankPressureSeries` domain entities: identity, + summary, and the decoded sample list. + +`DiveRepository` and `TankPressureRepository` keep their public signatures +(`getDiveProfile`, `getMergedProfile`, `getProfilesByDataSource`, +`getDiveForAnalysis`, `getBatchProfileSummaries`, `getTankPressures`, +`getPressuresForTank`) and delegate to the series repository. + +The existing per-row `computer_id`, `source_id`, `is_primary` triple is +already a series identity: every operation that touches those columns +filters a whole group and never a single sample. Lifting the triple onto the +series row is the whole conceptual change. + +## 4. Schema + +### `dive_profile_series` + +| column | type | notes | +|---|---|---| +| `id` | TEXT PK | uuid v4 for new writes; deterministic for migrated rows (section 8) | +| `dive_id` | TEXT NOT NULL | FK `dives(id)` ON DELETE CASCADE | +| `computer_id` | TEXT | FK `dive_computers(id)` ON DELETE SET NULL | +| `source_id` | TEXT | FK `dive_data_sources(id)` ON DELETE SET NULL | +| `is_primary` | INTEGER NOT NULL DEFAULT 1 | CHECK 0 or 1 | +| `sample_count` | INTEGER NOT NULL | | +| `start_timestamp` | INTEGER NOT NULL | seconds from dive start, first sample | +| `end_timestamp` | INTEGER NOT NULL | seconds from dive start, last sample | +| `max_depth` | REAL NOT NULL | metres | +| `first_depth` | REAL NOT NULL | metres | +| `last_depth` | REAL NOT NULL | metres | +| `has_deco_type` | INTEGER NOT NULL | any sample with `deco_type` not null | +| `has_deco_stop` | INTEGER NOT NULL | any sample with `deco_type = 2` | +| `has_positive_ceiling` | INTEGER NOT NULL | any sample with `ceiling > 0` | +| `codec_version` | INTEGER NOT NULL | | +| `samples` | BLOB NOT NULL | codec output | +| `created_at` | INTEGER NOT NULL | | +| `updated_at` | INTEGER NOT NULL | | +| `hlc` | TEXT | sync clock, same shape as `dives.hlc` | + +Index: `idx_dive_profile_series_dive_primary` on `(dive_id, is_primary)`. + +### `tank_pressure_series` + +| column | type | notes | +|---|---|---| +| `id` | TEXT PK | as above | +| `dive_id` | TEXT NOT NULL | FK `dives(id)` ON DELETE CASCADE | +| `tank_id` | TEXT NOT NULL | FK `dive_tanks(id)` ON DELETE CASCADE | +| `computer_id` | TEXT | FK `dive_computers(id)` ON DELETE SET NULL | +| `sample_count` | INTEGER NOT NULL | | +| `start_timestamp` | INTEGER NOT NULL | | +| `end_timestamp` | INTEGER NOT NULL | | +| `codec_version` | INTEGER NOT NULL | | +| `samples` | BLOB NOT NULL | | +| `created_at` | INTEGER NOT NULL | | +| `updated_at` | INTEGER NOT NULL | | +| `hlc` | TEXT | | + +Index: `idx_tank_pressure_series_dive_tank` on `(dive_id, tank_id)`. + +The summary scalars are exactly the set the nine SQL consumers in section 9 +need. Nothing speculative is stored; anything else decodes. + +Both tables replace the two entries `idx_dive_profiles_dive_id` and +`idx_tank_pressure_dive_tank` in `kPerformanceIndexes` +(`lib/core/database/performance_indexes.dart`) with their own indexes. The +old entries must be removed, or the `beforeOpen` heal would try to index a +dropped table. + +## 5. Codec v1 + +Columnar, versioned, lossless. + +Layout: + +1. Header: one version byte (`1`), then `sample_count` as an unsigned + varint. +2. One block per field, in the fixed field-table order below (28 fields + for a dive profile: every `dive_profiles` column except `id`, + `dive_id`, `computer_id`, `source_id`, and `is_primary`). Each block + begins with a presence mode byte: `0` absent (every value null; no + payload), `1` all present, `2` bitmap (a presence bitmap of + `ceil(n / 8)` bytes precedes the payload; only present values are + written). +3. The concatenation of header and blocks is zlib-compressed with + `dart:io`'s `ZLibCodec` at level 6. No web build exists in CI, and + `dart:io` compression is already used by `track_point_codec.dart` and + `sync_envelope.dart`. + +Varints are canonical: every value has exactly one encoding, the shortest +one. LEB128 otherwise allows padding a value with continuation bytes that +carry no payload, which would give one series several valid byte forms and +make byte comparison of two blobs meaningless. The reader refuses any +varint whose terminating byte carries a zero payload, unless that byte is +the whole varint (the encoding of zero). + +Field table (dive profile), with encoding: + +| field | encoding | +|---|---| +| `timestamp` | delta from previous value, zigzag varint; the first delta is from 0 | +| `depth` | float64 little-endian | +| `pressure` (legacy per-sample column, still populated on older rows) | float64 | +| `temperature` | float64 | +| `heart_rate` | delta zigzag varint | +| `ascent_rate` | float64 | +| `ceiling` | float64 | +| `ndl` | delta zigzag varint | +| `setpoint` | float64 | +| `pp_o2` | float64 | +| `o2_sensor1` to `o2_sensor6` | float64 each | +| `cns` | float64 | +| `tts` | delta zigzag varint | +| `rbt` | delta zigzag varint | +| `deco_type` | delta zigzag varint | +| `heart_rate_source` | run-length over the present values: a run count varint, then per run a length varint, a UTF-8 byte-length varint, and the bytes | +| `heading` | float64 | +| `o2_sensor_mv1` to `o2_sensor_mv6` | delta zigzag varint each | + +Field table (tank pressure): `timestamp` (delta zigzag varint), `pressure` +(float64). + +Delta encoding of integer fields resets its "previous value" to 0 at the +start of each field block and skips null entries (the delta is from the last +present value). A zero timestamp delta is legal, so samples that share a +timestamp are representable in insertion order. + +Real fields are float64 rather than fixed point because the measured 74x +already exceeds the target and fixed point would be lossy for depths that +libdivecomputer reports as doubles. float32 was measured (101x) and rejected +for the same reason. + +Versioning: a later codec appends fields to the table under a new version +byte. A decoder for version N reads a version M < N blob with the missing +fields null. Decoders never guess: an unknown version byte, a truncated +payload, or a block count that disagrees with the field table throws +`ProfileSeriesCodecException` rather than returning a partial list. + +Summary scalars are computed by the encoder from the sample list it packs +and returned alongside the bytes, so the repository writes both from one +call. + +## 6. Read and write paths + +### Writes + +The thirteen sites that mint sample rows today reduce to this repository +API: + +- `insertSeries(ProfileSeriesDraft)`: encode, compute summaries, mint a v4 + id, insert one row. +- `replaceSeriesOwnedBy(diveId, {computerId, sourceId}, samples)`: delete + the matching series rows, insert the replacement. Used by reparse, + re-download, and consolidation. +- `demoteAll(diveId)` and `promote(diveId, {computerId, sourceId})`: the + `is_primary` flips, each a single-row `UPDATE`. +- `deleteSeriesOwnedBy(diveId, {computerId, sourceId})` and + `deleteSeriesForDive(diveId)`. +- The tank-pressure equivalents keyed by `(diveId, tankId, computerId)`. + +Semantics preserved exactly: + +- Manual edit (`saveEditedProfile`): demote every series for the dive, then + insert a new primary series with `computer_id` null and the primary + source's `source_id`. +- Restore (`restoreOriginalProfile`): delete series where `is_primary` and + `computer_id IS NULL`, then re-promote the primary computer's series (or + every remaining series for a single-computer dive). +- Split and merge: encode their rebased sample lists into new series rows; + originals are deleted as today. +- Consolidation undo: `DiveMergeSnapshot` captures series rows instead of + sample rows and re-inserts them by series id with `insertOrReplace`. +- Primary-source swap: `markRecordPending` once per series instead of once + per sample. + +Exact-duplicate dedupe (`_dropDuplicateSamples`, whole-row equality) moves +from read time to encode time in the repository and into the migration +packer. Samples that share a timestamp but differ are kept in insertion +order. + +### Reads + +Every existing read method selects series rows for the dive and decodes. +Merge order across sources and the promote tiebreaker are reproduced in Dart +over decoded lists. `getBatchProfileSummaries` decodes one series per dive +and downsamples, replacing the load-every-row path. + +Decoding one series (at most a few thousand samples) is on the order of a +millisecond and runs on the isolate that receives the row. Whole-library +aggregations (section 9) decode on a worker isolate via `compute`. + +### Domain + +`DiveProfilePoint` is unchanged. `TankPressurePoint.id` is removed: it +appears only in Equatable `props`, no widget or exporter reads it, and +`estimated_tank_pressure_synthesizer.dart` already fabricates non-uuid +values for it. + +## 7. Sync + +`diveProfileSeries` and `tankPressureSeries` register in +`SyncDataSerializer._baseTables` with `blob: true`, export by their own +`hlc > since` (the `_exportGpsTracks` pattern, `samples` riding as base64 +through `_syncBlobSerializer`), and register as HLC-bearing with +`updated_at` in `SyncService`. Apply is `insertOnConflictUpdate` by series +id with last-writer-wins by HLC. `parentRefs` lists `dives`, +`dive_computers`, `dive_data_sources`, and (for tank series) `dive_tanks`, so +`sync_parent_refs_completeness_test` stays green. + +`diveProfiles` and `tankPressureProfiles` leave `_baseTables`, `SyncData`, +`hlcBearing`, `parentRefs`, and the `fetchRecord`, `upsertRecord`, +`upsertRecords`, `deleteRecord`, and `recordIdsFor` switches. + +Deleting a series logs one `deletion_log` row for the series id. The three +per-sample tombstone amplifiers named in #1375 (tank-pressure delete, split, +safety-review recompute over samples) disappear as a consequence. + +`minimumCompatibleSchemaVersion` rises to the new rung: the migration +removes two synced entities and replaces them, which the floor's own rules +classify as breaking. The floor is one-directional, so receiving-side +tolerance for older peers' payloads is added in the serializer's established +pattern: legacy `diveProfiles` and `tankPressureProfiles` arrays that still +arrive are grouped by identity tuple and packed into series on apply, using +the same packer the migration uses. This is a read-side shim, not a +dual-write; older peers hold until they upgrade and then migrate their own +rows. + +The `cross_version_roundtrip_test` projection is extended to cover the new +boundary, as the floor's doc comment requires. + +## 8. Migration and one-time compaction + +### The rung + +One new ladder rung at the next free schema version. At the time of +writing `origin/main` is at 179 and 180 is the next free rung, but at least +one sibling worktree also needs to move onto it; the number is claimed at +implementation time and re-checked against `origin/main` immediately before +the PR opens. + +Steps, in order: + +1. Create both series tables and their indexes. +2. Pack profiles. For each distinct `dive_id`, select that dive's rows + ordered by `(computer_id, source_id, is_primary, timestamp, rowid)`, + group by the identity tuple in Dart, drop exact duplicates, encode, and + insert one series row per group. Memory is bounded by one dive's samples + (the largest observed series is 4,631 samples), never by the table. +3. Pack tank pressures the same way, grouped by + `(dive_id, tank_id, computer_id)`. +4. Drop `dive_profiles` and `tank_pressure_profiles`. +5. Purge `deletion_log` and `sync_records` rows whose entity type is + `diveProfiles` or `tankPressureProfiles`. + +Migrated series rows take `created_at` and `updated_at` from the migration +time and an `hlc` minted by the local clock, so the first sync after +upgrade publishes them. + +### Fleet convergence + +Every device runs this migration independently. A random series id per +device would let sync union two primary series per dive, the exact shape of +the duplicate dive-types bug (#1360). Migrated series ids are therefore +deterministic: uuid v5 over the identity tuple +`dive_id | computer_id | source_id | is_primary` (with the literal `null` +for absent members). Two devices holding the same synced sample rows +produce the same series id and converge on upsert. Only the migration +derives ids this way; writes after it mint v4, because a fresh download or +edit genuinely is a new series. + +### Safety net + +The existing pre-migration backup (`_runPreMigrationBackup` in +`startup_page.dart`, retain 3) runs before the ladder as it does today. The +rung runs under Drift's migration handling like every other rung; a failure +leaves the old tables intact for the retry. + +### Compaction + +Dropping the tables returns roughly 90% of the pages to the freelist, but +the file does not shrink until `VACUUM`. The seam is +`DatabaseService._runUpgradeLadder`: it reads `PRAGMA user_version` before +constructing the migrator, and if that value was below the pack rung, it +executes `VACUUM` on the same synchronous connection after the ladder +completes (the forcing `SELECT 1` has returned) and before `close()`. That +point is outside any migration transaction, on the one exclusive +main-isolate connection, before the background executor opens the file, and +works under SQLCipher. + +Non-fatal by design: a `VACUUM` that fails (for example `SQLITE_BUSY` from +another connection) logs and moves on with a correct but large file. No +marker is persisted; the decision comes from the pre-ladder `user_version`. + +## 9. The nine SQL consumers + +| consumer | today | after | +|---|---|---| +| deco classification signals (`statistics_repository.dart`) | `MAX(deco_type IS NOT NULL)`, `MAX(deco_type = 2)`, `MAX(ceiling > 0)` over rows | `has_deco_type`, `has_deco_stop`, `has_positive_ceiling`; stays SQL | +| `decoSignalCondition` (`statistics/data/dive_filter_sql.dart`) and `getDiveIdsWithDecoSignal` | `EXISTS` over rows plus `dive_profile_events` | same flags plus events; stays SQL | +| `had_deco` in the recent-dives query (`dive_repository_impl.dart`) | `EXISTS(deco_type = 2 OR ceiling > 0)` | `has_deco_stop OR has_positive_ceiling`; stays SQL | +| profile span in `_effectiveRuntimeSql` and `_diveTimesSelect` | `MAX(ts) - MIN(ts)` over rows | `MAX(end_timestamp) - MIN(start_timestamp)` over the dive's series; stays SQL | +| quality neighbour first and last depth (`quality_context_builder.dart`) | two correlated `ORDER BY ts LIMIT 1` subqueries | `first_depth`, `last_depth`; stays SQL | +| quality prefilters (`quality_prefilters.dart`) | `EXISTS` on both sample tables | `EXISTS` on both series tables; stays SQL | +| source ownership and promote-by-predicate (`_sourceOwnsProfiles` and neighbours) | row predicates with a bound-variable-limit workaround | series-row predicates; the workaround becomes unnecessary | +| sustained ascent and descent rates (`statistics_repository.dart`) | windowed `AVG(depth)` and `LAG` over every primary row in scope | decode primary series in scope on a worker isolate; same window and threshold constants | +| time-at-depth buckets (`statistics_repository.dart`) | `LEAD` intervals with a cadence cap, tiebreak `ORDER BY at, sample_id` | same algorithm in Dart over decoded series; tiebreak is stored order | + +Only the last two leave SQL, and both are whole-library aggregations that +the statistics page already awaits asynchronously. The two Dart aggregations +carry a benchmark gate (section 10). + +Historical backfills in older rungs (v132, v146, v177) run before the pack +rung and are untouched. Any future backfill that needs samples decodes. + +## 10. Testing and benchmark gates + +Codec: + +- Round-trip property tests over generated sample lists: every field null, + every field present, mixed presence, duplicate timestamps, negative + deltas, the largest realistic series (20,000 samples at 1 s), float64 + bit-exactness. +- A version-1 blob decoded under a hypothetical later field table yields + the new fields null. +- An unknown version, a truncated payload, a malformed block, and a + non-canonical (padded) varint each throw `ProfileSeriesCodecException`. + +Summary scalars: + +- Equivalence tests run today's SQL expressions over a row fixture, pack + it, and assert the scalars match, proving the section 9 substitutions. + +Migration (following the `test/core/database/migration_v*` precedent): + +- A pre-rung fixture with exact duplicates, null `source_id`, null + `computer_id`, a multi-source dive, and a manual-edit series. +- Asserts: decoded samples equal the originals minus exact duplicates; the + old tables are gone; the retired tombstones and pending records are + purged; two independently migrated copies of the same fixture produce + identical series ids (the #1360 test). +- `query_plan_test` and `performance_indexes_test` updated for the removed + and added indexes. + +Sync: + +- Export-to-JSON-to-apply round trips for both entities on a second + database. +- The legacy-array tolerance shim applied to a v179-shaped payload. +- `sync_parent_refs_completeness_test`; the `cross_version_roundtrip_test` + projection extended. +- One tombstone per deleted series. + +Behaviour: + +- Existing tests for manual edit, restore, primary swap, split, merge, and + consolidation undo run unchanged against the new storage. Of the 53 test + files that reference the old tables, those asserting on raw rows are + ported. +- The two statistics aggregations that move to Dart get golden values + captured from the SQL versions before the change. + +Benchmarks, gated in the second PR, on a synthesized 1,000-dive fixture (a +`tools/` script replicates the 40-dive development database with fresh ids; +the 2026-07-10 real fixture is no longer on this machine): + +- per-dive detail hydrate +- `getBatchProfileSummaries` for 50 dives +- both statistics aggregations +- migration wall time +- post-`VACUUM` file size and `VACUUM` time + +Gate: nothing slower than today; numbers reported in the PR description. + +## 11. Delivery + +Two stacked PRs on `worktree-profile-sample-storage`: + +1. Codec only: `ProfileSeriesCodec`, its exception type, the summary + computation, and the codec tests. Pure Dart, no schema change, no + behaviour change. +2. Schema, `ProfileSeriesRepository`, the repository ports, the migration + rung with compaction, the sync changes and tolerance shim, the SQL + consumer substitutions, the fixture script, and the benchmark results. + +## 12. Risks and mitigations + +- Migration time on a large phone database: bounded per-dive memory and a + benchmark on the 1,000-dive fixture; the startup screen already shows + migration progress. +- Statistics regressions from moving two aggregations to Dart: golden-value + tests plus the benchmark gate. +- Schema rung collision with sibling worktrees: claim at implementation + time, re-check before the PR opens. +- A sample field the field table forgot: the codec tests enumerate the + field table against the Drift column list for `DiveProfiles` so a missing + column fails the test, not the user. +- Deterministic migrated ids colliding with a v4 id: uuid v5 and v4 occupy + disjoint version nibbles, so a collision is impossible by construction. diff --git a/docs/superpowers/specs/2026-08-29-diver-buddy-profile-photos-design.md b/docs/superpowers/specs/2026-08-29-diver-buddy-profile-photos-design.md new file mode 100644 index 0000000000..d49ba130fd --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-diver-buddy-profile-photos-design.md @@ -0,0 +1,514 @@ +# Diver and buddy profile photos + +Status: approved design, ready for an implementation plan +Date: 2026-08-29 +Branch: `worktree-profile-photos` (rebased onto `origin/main` at schema v180; this feature claims v181) + +## Goal + +Let divers and buddies have a profile photo. Store the image bytes in the +database so it travels with the row across devices, and bound the size so the +database and the sync changeset do not grow without limit. + +## Background: what already exists + +Three findings shaped this design. + +**A half-built path-based implementation is already in the tree.** `Divers`, +`Buddies`, and `Species` each carry a `photoPath` TEXT column, fully wired +through the entities, repositories, and merge repositories. Nothing writes it +for divers or buddies. Two places read it, and they disagree: +`buddy_list_tile.dart:293` correctly uses `FileImage` guarded by +`existsSync()`, while `buddy_detail_page.dart:417` uses +`AssetImage(buddy.photoPath!)`, which resolves against the bundled asset +manifest and could never load a filesystem path. `buddy_edit_page.dart:307` is +a stub: a camera badge over the initials avatar and the string +`buddies_label_photoComingSoon` ("Photo support coming in v2.0"). + +A device-local file path is the wrong storage for a synced app. It does not +survive sync to a second device, does not survive an iOS app-container UUID +change between installs, and on Android SAF a picked file frequently has no +usable path at all. + +**Storing image bytes in a synced row is established practice here.** Four +tables already do it and are marked `blob: true` in the sync serializer's +`_baseTables`: `media.imageData` (signatures), `certifications.photoFront` and +`photoBack`, `gpsTracks.points`, and `diveDataSources.rawData`. Sync encodes +these as base64 rather than the default JSON array of byte ints, via +`_SyncBlobValueSerializer` (`sync_data_serializer.dart:32`). + +`Certifications` in particular has already performed exactly the migration +this feature needs: `photoFrontPath` and `photoBackPath` are marked +"deprecated, kept for migration" and superseded by the `photoFront` and +`photoBack` blobs. + +**The existing size limiting is broken on desktop.** Certification photos are +bounded by passing `maxWidth: 2000, maxHeight: 2000, imageQuality: 85` to +`ImagePicker.pickImage` (`certification_edit_page.dart:205`). Both +`image_picker_macos` and `image_picker_windows` state verbatim that these +arguments "are not currently supported. If any of these arguments are supplied, +they will be silently ignored." On macOS, Windows, and Linux a full-size camera +JPEG therefore enters SQLite whole and rides into every sync changeset at +roughly 1.33x as base64. Android and iOS honor the caps, which is why this has +stayed invisible. + +The lesson that drives this design: resizing must happen in Dart on the write +path, never as a picker argument. + +## Decisions + +| Decision | Choice | +| --- | --- | +| Storage | `BlobColumn` directly on `divers` and `buddies` | +| Stored format | 512x512 square JPEG, quality 85, roughly 50-80 KB | +| Crop | Pan and zoom crop surface, not automatic center-crop | +| Contacts | Per-buddy via the native picker, no address-book enumeration | +| Surfaces | All in-app avatars, table rows, and picker chips. Not PDF export | +| Certifications | Rerouted through the new codec so their cap becomes real | + +### Why a blob on the row rather than a side table + +A separate `profile_photos` table would ship photo bytes only when the photo +changes, whereas a column on `buddies` re-ships them whenever any field on that +buddy changes, because `_exportBuddies` selects whole rows where `hlc > since`. + +The column still wins. A new synced entity costs roughly 14 registration sites +plus the FK parent map, conflict-reference resolver, merge ordering, HLC +targets, and the structural tests that lock all of them, and it adds a join or +second query to every buddy and diver read. That is a large permanent cost to +avoid re-shipping about 60 KB on an edit to an entity that is edited rarely. +The column also matches four existing precedents, so the pattern is +recognizable. + +Reusing the `media` table was rejected. Media rows are entangled with the media +store's upload pipeline, library grid filtering, resolvers, and thumbnails. +Buddy signatures already leak into the media library grid because +`kSignatureFileTypes` omits `'buddy_signature'`, and they pass the upload +pipeline's eligibility check. Adding another pseudo-media type repeats a +mistake this codebase has already made. + +### Why 512px + +The largest avatar in the app is `radius: 50`, so 100 logical pixels across, +which is 300 physical pixels at 3x. 512 stores deliberate headroom for a +possible future tap-to-enlarge view, because the source is discarded at pick +time and re-encoding upward later is impossible. + +## Section 1: Data layer and sync + +### Schema + +```dart +// Divers (database.dart:19) and Buddies (database.dart:1966) +TextColumn get photoPath => text().nullable()(); // deprecated, superseded by photo +BlobColumn get photo => blob().nullable()(); // 512px square JPEG +``` + +`photoPath` is deprecated in place rather than dropped, following the comment +style `Certifications` uses. Nothing writes it for divers or buddies, so there +is no data to migrate and no backfill rung. + +### Migration + +Follows the repo's idempotent assert idiom, modeled on +`_assertBuddyFavoriteColumn` (`database.dart:5494`): + +```dart +Future _assertProfilePhotoColumns() async { + for (final table in const ['divers', 'buddies']) { + final cols = await customSelect("PRAGMA table_info('$table')").get(); + if (cols.isEmpty) continue; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('photo')) { + await customStatement('ALTER TABLE $table ADD COLUMN photo BLOB'); + } + } +} +``` + +Wired in five places: the `if (from < 181)` rung and its `reportProgress()`, an +appended `181` in `migrationVersions` with a comment naming the claim, the +`currentSchemaVersion` bump, and a `beforeOpen` backstop call so databases +arriving by restore or sync-adopt, which never run `onUpgrade`, still get the +column. + +`minimumCompatibleSchemaVersion` stays at 170. A nullable column is additive +and Drift's generated `fromJson` ignores unknown keys, so a v180 peer keeps +syncing with a v181 peer and simply ignores `photo`. + +The rung number must be re-verified against `origin/main` at implementation +time. It already moved once: PR #1374 landed and took 180 while this design +was being written, so the claim is now 181. A rung at or below +main's scalar merges with no conflict marker and its step then never runs. + +### Entities + +`Uint8List? photo` on both `Diver` and `Buddy`. + +`Diver.copyWith` uses the sentinel pattern, so `photo: null` genuinely clears +the field. `Buddy.copyWith` is the plain `??` style, where `photo: null` falls +through to `this.photo` and is a silent no-op. Wiring the two identically would +make "Remove photo" work for a diver and silently fail for a buddy. + +`Buddy` therefore gains a separate `Buddy clearPhoto()` method, mirroring +`Certification.clearPhotos({bool clearFront, bool clearBack})` +(`certification.dart:125-146`), which rebuilds through the constructor rather +than routing a flag through `copyWith`. That is this codebase's established +idiom for clearing a photo blob. Copying it avoids converting `Buddy` to the +sentinel pattern, which would touch every existing caller. + +Note that `buddy_edit_page.dart` constructs a `Buddy(...)` directly rather than +going through `copyWith`, so the primary user-facing "Remove photo" path does +not depend on this method. It exists so that no future caller reaching for +`copyWith(photo: null)` gets a silent no-op. + +### Repositories + +Every site that reads a row into a `Buddy`/`Diver` or writes a companion must +carry `photo`, or it silently fails to persist on that path. The complete list, +against the worktree base: + +`buddy_repository.dart` +- `_mapRowToBuddy` `:1100` +- inline row-to-`Buddy` construction feeding `_withPrimaryCerts`, `:140-162` +- `createBuddy` companion `:165-206` +- `updateBuddy` companion `:277-312` +- two further `BuddiesCompanion` writes at `:887` and `:918` + +`diver_repository.dart` +- `_mapRowToDiver` `:660` +- `createDiver` companion `:128-194` +- `updateDiver` companion `:196-239` +- (`:551` writes only `isDefault` and `updatedAt`; it needs no change) + +`buddy_merge_repository.dart` carries `photoPath` at `:115`, `:446`, and `:594` +and needs the same treatment, as does `buddy_merge_form_controller.dart:81`, so +a merge picks a winning photo the way it picks other fields. + +Existing `markRecordPending` calls already stamp the HLC, so no new sync +bookkeeping is needed in the repositories. + +### Sync + +Six edits per entity, following the `certifications` pattern. Line numbers are +against the worktree base (`origin/main` at v180, after the rebase of 2026-08-29): + +| Site | divers | buddies | Change | +| --- | --- | --- | --- | +| `_baseTables` | `:654` | `:718` | `blob: false` becomes `blob: true` | +| delta exporter | `_exportDivers` `:4535` | `_exportBuddies` `:4803` | `r.toJson(serializer: _syncBlobSerializer)` | +| `fetchRecord` | `:1555` | `:1650` | `row?.toJson(serializer: _syncBlobSerializer)` | +| `fetchRecords` (batch) | `:1971` | `:2026` | same serializer in the map comprehension | +| `upsertRecord` | `:2444` | `:2549` | `fromJson(data, serializer: _syncBlobSerializer)` | +| `upsertRecords` (batch) | `:2951` | `:3115` | same serializer | + +Note this is six sites, not five. The batch `fetchRecords` path +(`sync_data_serializer.dart:1951`) is easy to miss because it sits between the +single-record fetch and the upserts, and it has its own switch. + +Missing any one of these fails silently: the blob still round-trips correctly, +just as a JSON byte array at roughly 3x the size. `sync_blob_base64_test.dart` +exists to lock this and gains diver and buddy cases. + +## Section 2: The image pipeline + +New shared file: `lib/core/services/images/profile_photo_codec.dart`. + +```dart +class ImageEncodeSpec { + final int maxDimension; + final int jpegQuality; + final bool square; + + /// 512px square avatar, roughly 50-80 KB. + static const avatar = + ImageEncodeSpec(maxDimension: 512, jpegQuality: 85, square: true); + + /// Certification card face. Card text must stay readable, so the ceiling is + /// higher and the source aspect ratio is preserved. + static const certificationCard = + ImageEncodeSpec(maxDimension: 2000, jpegQuality: 85, square: false); +} +``` + +The entry point mirrors `image_resize_job.dart`: a `compute`-dispatched wrapper +plus a `@visibleForTesting` top-level isolate body tests can call directly +without spawning an isolate. + +```dart +Future encodeStoredImage(ImageEncodeRequest r) => + compute(runImageEncodeRequest, r); + +@visibleForTesting +ImageEncodeResult runImageEncodeRequest(ImageEncodeRequest r); +``` + +The isolate body, in strict order: + +1. Decode: `decodeNamedImage` when the source name is known, else + `decodeImage`. Null yields `undecodable`. +2. `img.bakeOrientation`. +3. Crop: the caller's explicit `cropRect` if given, else the largest centered + square when `spec.square`. +4. `copyResize` to `maxDimension`, skipped when the source is already smaller + so it never upscales. +5. `encodeJpg(quality: spec.jpegQuality)`. + +Step 2 is mandatory and its position matters. The `image` package's JPEG +decoder performs no orientation handling, so a portrait phone photo decodes as +a sideways buffer carrying `exif.orientation = 6`. Baking after the crop would +apply a rectangle chosen in the upright preview to a rotated buffer and select +the wrong region. + +Re-encoding also strips EXIF, so GPS coordinates embedded in a gallery or +contact photo cannot ride into a blob that syncs to every device and cloud +provider. + +`resizeToJpegFile` was not reused because it only writes a JPEG to a +destination path, has no crop step, and has no orientation step. Reusing it +would mean inventing a temp file and reading it straight back for something +whose destination is a blob. + +The request carries a `maxSourcePixels` ceiling returning a distinct `tooLarge` +outcome, because decoding is what allocates and nothing caps a desktop pick. + +### Certification reroute + +`_pickPhoto` in `certification_edit_page.dart` drops `maxWidth`, `maxHeight`, +and `imageQuality` from its `pickImage` call and pipes the bytes through +`encodeStoredImage(spec: certificationCard)`. The 2000px cap becomes real on +macOS, Windows, and Linux for the first time. Existing oversized rows are not +backfilled. + +## Section 3: The crop sheet + +Placement: `lib/shared/widgets/profile_photo/`. + +| File | Role | +| --- | --- | +| `profile_photo_source_sheet.dart` | Camera (mobile only), Photo Library / Choose File, Contacts (buddies on mobile only), Remove Photo (only when a photo exists) | +| `profile_photo_crop_dialog.dart` | The pan and zoom crop surface | +| `profile_photo_crop_geometry.dart` | Pure viewport-to-source-pixel math | +| `profile_avatar.dart` | Shared display widget | + +The crop surface is a `Dialog.fullscreen` shown via `showDialog`, +not a bottom sheet. A crop wants maximum area, and a full-screen dialog +sidesteps the `isScrollControlled` sheet-ceiling problem from issue #1188 +rather than re-solving it. + +Inside sits an `InteractiveViewer` over the decoded image with a square +viewport, a circular mask, and dimmed surroundings. `minScale` is the cover +scale, so zooming out far enough to expose a gap is not representable. + +Confirm computes geometry rather than rasterizing: + +```dart +@visibleForTesting +Rect cropRectInSourcePixels({ + required Matrix4 transform, // TransformationController.value + required Size viewport, // the square crop window, logical px + required Size childSize, // the image as laid out in the viewer + required Size sourceSize, // decoded pixel dimensions +}); +``` + +Invert the transform, map the viewport corners into child space, scale by +`sourceSize / childSize`, clamp. The result feeds `ImageEncodeRequest.cropRect`. + +Returning a rect in source pixels makes the output independent of device pixel +ratio and dialog size, so the same gesture yields the same stored image on a 1x +Linux window and a 3x iPhone, and the whole thing unit-tests as a matrix and +four numbers with no widget tree and no golden files. + +`InteractiveViewer` owns its recognizer and consumes trackpad pan-zoom +internally, so there is no `Listener` to pair with it and no way to reproduce +the double-apply bug from issue #1188. `trackpadScrollCausesScale` stays at its +default, so two-finger scroll pans and pinch zooms. + +Save and Cancel pop from inside the dialog's own `builder` context. `showDialog` +already defaults to `useRootNavigator: true`, so this is the correct-by- +construction case and specifically not the bare `Navigator.of(context).pop()` +from a page `State` that blanked master-detail in PR #1312. + +Flow: the source sheet returns a source, bytes are obtained, the crop dialog +opens, Save computes the rect and runs `encodeStoredImage` behind a progress +state, and the dialog pops with ready-to-store bytes. The caller never holds +unbounded bytes. + +### When the crop dialog is shown + +One rule, applied consistently: the crop dialog opens whenever the user +explicitly chose a photo, and does not open when a photo arrives as a side +effect of another action. Concretely, every source in the source sheet opens +the dialog, while the photo that comes along with a contact during buddy import +is centered automatically. The imported photo can be adjusted afterwards from +the buddy edit page, so nothing is unreachable. + +## Section 4: Contacts and entry points + +`flutter_contacts: ^2.0.2` is already a dependency, +`NSContactsUsageDescription` already reads "This app uses your contacts to +import dive buddies", and the Podfile already compiles in `PERMISSION_CONTACTS`. +This feature adds no new permission surface. + +`FlutterContacts.native.showPicker()` returns only a contact ID, so the +follow-up `FlutterContacts.get()` is what reads data and does require contacts +permission. The existing permission check therefore stays. + +### The existing import gains a photo + +In `buddy_list_content.dart:365`: + +```dart +final fullContact = await FlutterContacts.get( + contactId, + properties: { + ContactProperty.name, ContactProperty.email, ContactProperty.phone, + ContactProperty.photoFullRes, ContactProperty.photoThumbnail, + }, +); +final raw = fullContact.photo?.fullSize ?? fullContact.photo?.thumbnail; +``` + +Full resolution first, thumbnail as fallback, since thumbnails are only 96x96 +or 150x150. The bytes go through `encodeStoredImage(spec: avatar)` with an +automatic centered crop and no dialog, because the user is mid-import and did +not ask to frame a photo. They can adjust it afterwards from the edit page. + +The route builder gains `initialPhoto: extra?['photo'] as Uint8List?` and +`BuddyEditPage` gains a matching `initialPhoto` parameter. + +### Master-detail data loss, fixed here + +The current code branches: + +```dart +if (ResponsiveBreakpoints.isMasterDetail(context)) { + final state = GoRouterState.of(context); + context.go('${state.uri.path}?mode=new'); // carries nothing +} else { + context.push('/buddies/new', extra: {'name': name, 'email': email, 'phone': phone}); +} +``` + +The comment above it says data is passed via query params, but nothing is +passed. Master-detail means viewports at or above 1100pt and contact import is +enabled on iOS, so an iPad in landscape imports a contact and lands on a blank +form, discarding the name, email, and phone just read. A photo would inherit +the same loss. + +The fix is deletion. `/buddies/new` already declares +`parentNavigatorKey: rootNavigatorKey` so it renders in the foreground rather +than under a shell or open dialog, which means the plain `context.push` branch +is already correct on every layout and the master-detail special case is +unnecessary. + +### Contacts as a photo source for existing buddies + +The source sheet gains "Choose from Contacts", shown only for buddies and only +on iOS and Android. The platform guard currently lives as the private +`_isContactImportSupported` getter in `buddy_list_content.dart:80` and is +lifted to a shared helper so both call sites agree by construction rather than +by duplication. It +runs the same permission check and native picker, requests only the two photo +properties, and routes the result through the crop dialog, because here the +user is explicitly choosing a photo. + +The three existing snackbar strings cover unsupported platform, denied +permission, and failed contact load. One new string is needed for a contact +that has no photo, which is common and must not read as an error. + +## Section 5: The shared widget + +```dart +class ProfileAvatar extends StatelessWidget { + const ProfileAvatar({ + super.key, + required this.photo, // Uint8List? + required this.initials, + this.radius = 20, + this.backgroundColor, + this.foregroundColor, + this.ringColor, // buddy_list_tile draws a role ring + }); +} +``` + +Photo present renders it, photo absent falls back to `initials`, preserving +today's behavior. Both entities already document their `initials` getter as +being for avatar display. + +The widget wraps in `ResizeImage(MemoryImage(bytes), width: target, height: +target)` with the target computed from `radius * 2 * devicePixelRatio`. Flutter +decodes to intrinsic size, so a bare `MemoryImage` would hold a 512x512x4 byte +bitmap, about 1 MB, for an avatar drawn at 40 logical pixels. A list of 200 +buddies would hold roughly 200 MB of decoded bitmaps. + +The bytes must not be copied on the way through. `MemoryImage` equality is +identity-based on the `Uint8List`, and that is what keys Flutter's image cache, +so a defensive copy would mint a fresh cache key each rebuild and re-decode +every avatar every frame. + +### Rollout + +All 13 `CircleAvatar` sites: `buddy_summary_widget`, `buddy_edit_page` (x2), +`buddy_list_tile`, `buddy_detail_page` (x2), `buddy_picker` (x3), +`diver_switcher_sheet`, `diver_summary_widget` (x2), `diver_profile_hub_page`. + +Three are substantive rather than mechanical: + +- `buddy_detail_page.dart:417` loses the `AssetImage(photoPath)` bug entirely. +- `buddy_list_tile.dart:293` loses its `File` / `existsSync` / `FileImage` + block, because `photoPath` is dead. +- `buddy_edit_page.dart:307` turns the stub into a real tappable avatar with + the camera badge it already draws. `buddies_label_photoComingSoon` is deleted + from all 11 locale files. + +Table rows need one opt-in addition: an optional `leadingBuilder` on +`EntityTableView`, null for every other entity so no existing table changes. A +photo is not a sortable text value, so it does not join `BuddyField`, which +also avoids the saved-layout breakage that renaming that enum causes. + +The diver entry point is the active-diver card at +`diver_profile_hub_page.dart:130`, which becomes tappable. + +## Section 6: Testing + +| Layer | Coverage | +| --- | --- | +| Pure geometry | `cropRectInSourcePixels` at identity, zoomed, panned, clamped; same matrix at two surface sizes proves DPR independence | +| Codec | Square guarantee, never upscales, EXIF orientation 6 fixture comes out upright, `undecodable`, `tooLarge`, cert spec preserves aspect ratio | +| Migration | `migration_v180_profile_photo_test.dart`, plus a `beforeOpen` backstop case for a database that never ran `onUpgrade` | +| Sync | `sync_blob_base64_test.dart` gains diver and buddy round-trips asserting the wire value is a base64 string, not a byte array | +| Repository | Create, update, read round-trip, and that `Buddy.copyWith(clearPhoto: true)` actually clears | +| Widget | Initials fallback, crop dialog Save returns bytes, source sheet hides Contacts on desktop | +| Contacts | Import with a fake contact, the no-photo case, and an iPad-landscape regression test via `testAppInShell` | + +Progress-state tests pump manually rather than using `pumpAndSettle`. +Multi-pointer gesture tests end with a trailing 500ms pump to drain the +double-tap recognizer timer. + +## Localization + +New strings for the source sheet, crop dialog, remove-photo confirmation, and +the contact-has-no-photo case, across all 11 locales. +`buddies_label_photoComingSoon` is removed. The ARB parity test enforces both. + +## Out of scope + +To be filed rather than built: + +- PDF export embedding of profile photos. +- Bulk address-book matching of existing buddies. +- `Species.photoPath`, which is genuinely reference data. +- Backfilling already-oversized certification photos. +- The missing `bakeOrientation` in `image_resize_job.dart`, which leaves + media-store thumbnails of EXIF-rotated photos rendering rotated. + +## Risks + +- PR #1374 landed and took v180, so this feature claims v181. Re-verify against + `origin/main` again before claiming it: the ladder moved twice while this + design was being written. +- Decode memory on desktop, mitigated by the `maxSourcePixels` ceiling. +- Image cache pressure, mitigated by `ResizeImage`. diff --git a/docs/superpowers/specs/2026-08-29-storage-reclamation-design.md b/docs/superpowers/specs/2026-08-29-storage-reclamation-design.md new file mode 100644 index 0000000000..ec4763546e --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-storage-reclamation-design.md @@ -0,0 +1,497 @@ +# Storage reclamation: inventory, caps, and sweeps + +Design for issue #1375, "Nothing reclaims on-device caches or temp files". + +Date: 2026-08-29 + +## Problem + +The app never reclaims disk space on any schedule. Several caches are uncapped +and never evicted, some temp directories are write-only, the sync tombstone log +grows forever for local-only users, and there is no surface anywhere that tells +a user how much space the app is holding or where it went. + +A 40-dive development install measures 133 MB of container, of which the +database is 28 MB. A stale 73 MB Sparkle update `.dmg` in `Library/Caches` was +larger than the entire database. + +Much of this is code the project already wrote and then never called: the +eviction functions exist, the cap constants exist, and neither is reached. + +## What was verified in the tree + +The issue is an audit. Before designing against it, each claim was checked. Most +held. Three findings changed the shape of the work. + +### There is no startup-maintenance host, and one was deliberately abandoned + +`lib/core/services/maintenance/` does not exist. A generic +`StartupMaintenanceRunner` was built in PR #516 and then removed, and the +decision not to revive it is recorded twice: + +- `docs/superpowers/specs/2026-07-17-data-quality-assistant-design.md:85` +- `docs/superpowers/plans/2026-08-06-media-section-phase5.md:589` + +`lib/features/media/presentation/providers/media_watcher_providers.dart:196` +works around its absence explicitly. + +The live precedent is narrower: `MediaOrphanBacklogSweep` +(`lib/features/media_store/data/media_orphan_backlog_sweep.dart`, 42 lines) is +constructed inline and fired `unawaited` inside a swallow-and-log wrapper at +`lib/core/presentation/pages/startup_page.dart:686`. Any new sweep follows that +shape. This design does not reintroduce a task runner. + +### Cadence guards exist in three unshared flavours + +- Prefs done-flag: `media_origin_republish_v1` + (`lib/features/media_store/data/media_origin_republish_sweep.dart:53`) +- Prefs version int: `builtin_species_seed_version` + (`lib/features/marine_life/data/services/builtin_species_seed_version_store.dart:17`) +- Synced DB column with a fleet-wide 30-day cadence: `media_stores.last_sweep_at` + (`lib/core/database/database.dart:1637`) + +`MediaOrphanBacklogSweep` uses none of them. It can run unguarded on every launch +because its probe is one indexed `SELECT` +(`MediaRepository.getSweepableOrphanIds`, +`lib/features/media/data/repositories/media_repository.dart:1866`). A filesystem +walk is not that cheap, so a filesystem sweep needs a guard. A prefs timestamp is +the correct flavour: the state being tracked is per-device by nature and must not +sync. + +### Tombstone GC already has its floor; what it lacks is reachability + +The issue proposes giving `deletion_log` "a time-based floor for local-only +libraries". A 30-day floor already exists +(`SyncLiveness.gcFloorMillis`, +`lib/core/services/sync/changeset_log/sync_liveness.dart:15`), and +`TombstoneGcDecision` already offers an `unbounded()` constructor +(`lib/core/services/sync/changeset_log/tombstone_horizon.dart`). + +The actual defect is that `SyncRepository.clearAcknowledgedDeletions` +(`lib/core/data/repositories/sync_repository.dart:1119`) is reached from exactly +one place: the tail of a successful cloud sync +(`lib/core/services/sync/sync_service.dart:744`). A device that has never +configured sync never arrives there, so its tombstones are immortal. + +This reframes the per-sample tombstone concern. `TankPressureRepository` +(`lib/features/dive_log/data/repositories/tank_pressure_repository.dart:114`), +`DiveSplitService` +(`lib/features/dive_log/data/services/dive_split_service.dart:337`) and +`SafetyFindingsRepository` +(`lib/features/dive_log/data/repositories/safety_findings_repository.dart:57`) +do each mint one tombstone per row, and a tank-pressure re-import of a 60-minute +dive at 10-second sampling mints roughly 360 of them per tank. At around 100 +bytes a row that is tens of KB, which a working GC reclaims on the 30-day floor. +The unbounded growth is the absence of GC, not the minting rate. + +## Scope decisions + +### The audit splits five ways, not seven + +| Slice | Scope | Audit items | Risk | +| --- | --- | --- | --- | +| A | Storage inventory and a read-only Storage usage page | 7 | Very low, reads only | +| B | Wire the caps that already exist | 1, 2, 4 | Low | +| C | Scratch-file sweep | 3, minus the unsafe targets | Medium, deletes files | +| D | Orphaned backup files | 5 | Medium | +| E | Tombstone GC for local-only libraries | 6a | Medium, sync semantics | + +Slice A is built first, and is the only slice this worktree delivers. Measuring +before optimizing is the house rule, and a shipped measurement surface makes +B through E verifiable rather than merely assertable. + +### Item 6b moves to its own issue + +"Stop minting per-sample tombstones where a per-dive one would do" is a +wire-format change, not a refactor. `deletion_log` is +`(id, entityType, recordId, deletedAt, hlc)` +(`lib/core/database/database.dart:2934`) with a v114 uniqueness index on +`(entityType, recordId)` and no scope or parent column. A "delete every +`tankPressureProfiles` row for dive X" tombstone requires either a new column +plus a main-database schema bump and a matching wire change, or a synthetic +`entityType` convention that every apply-side consumer must learn to expand into +a `WHERE dive_id = ?` delete. Older peers can interpret neither, so it needs a +schema floor gate of the kind PR #1348 established. + +The one existing precedent, `clearReviewForDive` +(`safety_findings_repository.dart:262`), works only because +`diveSafetyReviews` is itself keyed on `diveId`. It does not generalise. + +With slice E landed, the remaining cost is write amplification rather than +storage: `logDeletion` (`sync_repository.dart:980`) issues an HLC and opens a +transaction per row. A batching `logDeletions` would fix that without touching +the wire format, and belongs with 6b. + +### Two categories are never swept, in any slice + +- **Documents-root exports.** `saveAndShareFile` and `saveAndShareFileBytes` + (`lib/core/services/export/shared/file_export_utils.dart:24,49`) leave a file + permanently in the root of `getApplicationDocumentsDirectory()`. That + directory also holds the database + (`lib/core/services/database_location_service.dart:133`) and, on iOS, is + visible to the user in the Files app. Deleting a user's exported logbook + because it looked like debris is not acceptable. +- **Backup files.** Every one is a full copy of the database. Slice D surfaces + orphans and offers an action; it never deletes on its own. + +Both are measured and shown. Neither is ever reclaimed without a user action. + +### `kDiskCacheCapBytes` is deleted rather than implemented + +`lib/features/media/data/network_cache_config.dart:35` declares a 500 MB disk +cap, has a unit test asserting its own value +(`test/features/media/data/network_cache_config_test.dart`), and is wired to +nothing. `flutter_cache_manager` 3.4.1 exposes `maxNrOfCacheObjects` and +`stalePeriod` but no byte cap, so honouring the constant means a custom +`BaseCacheManager` with hand-rolled eviction. A named cache manager also +relocates the cache directory, orphaning the existing `DefaultCacheManager` +folder. + +Decision: in slice B, delete the constant and its self-referential test, keep +`DefaultCacheManager`, and let the Storage usage page report the directory's +real size next to the clear button that already works +(`lib/features/media/data/services/cached_network_image_diagnostics.dart:71`). +This states honestly what is enforced instead of shipping a number that is not. + +## Slice A design + +### Architecture + +The sweep in slice C and the page in slice A need the same knowledge: for each +place bytes accumulate, where it is, how large it is, and how it can be +reclaimed. Written twice, the two drift the first time a fourteenth cache is +added. + +`StorageCategory` is therefore a plain descriptor, not a task. Slice A ships the +measurement half only. Slice C extends the same descriptors with a reclaim +policy. Nothing speculative is built now. + +This is deliberately not the abandoned `StartupMaintenanceRunner`. That was a +scheduler with a convergence ledger and splash progress UI. This is a list of +places bytes live, consumed by a page. The distinction is worth keeping in mind +for anyone who reads the abandonment note and wonders whether it was reversed. + +### Why measurement is per-category rather than one directory walk + +Measurement cost is uneven by an order of magnitude, and the existing code +already says which is which: + +| Strategy | Cost | +| --- | --- | +| `MediaCacheStore.totalBytes(kind)` (`lib/features/media_store/data/media_cache_store.dart:240`) | `SUM` over an index, instant | +| `TileCacheService.getTotalCacheSize()` (`lib/features/maps/data/services/tile_cache_service.dart:387`) | FMTC-native | +| `CachedNetworkImageDiagnostics.cacheSize()` (`cached_network_image_diagnostics.dart:41`) | Recursive `dart:io` walk | +| Thumbnails, `staging/`, `transcode/`, temp, backups | Recursive walk | +| Database files | `File.length()` | + +A single blocking "compute total storage" call would be paced by its slowest +member. Each category brings its own strategy, and the page renders each result +as it arrives. + +### The `StorageCategory` contract + +```dart +/// One place on disk where the app accumulates bytes. +class StorageCategory { + const StorageCategory({ + required this.id, + required this.group, + required this.measure, + }); + + /// Stable across releases: it keys the l10n label, the size provider, and + /// the reclaim policy slice C adds. Never derive it from a directory name. + final String id; + + final StorageGroup group; + + /// Null means genuinely unmeasurable, which is not the same as zero. + final Future Function() measure; +} +``` + +`measure` returns `Future`. The nullable return carries real information +in exactly one case today: on Android SAF the backup target is a `content://` +tree URI with no `Directory` to enumerate +(`lib/features/backup/data/services/backup_service.dart:1258`). Reporting +"0 bytes of backups" there would be a lie, and a user acting on that lie would +conclude their backups had vanished. + +### Categories + +| Group | id | Location | Measurement | +| --- | --- | --- | --- | +| App data | `database` | Main database plus `-wal` and `-shm` | `File.length()` | +| App data | `localCache` | `submersion_local.db` (`lib/core/services/local_cache_database_service.dart:49`) | `File.length()` | +| Media cache | `mediaCacheOriginals` | `media_cache/originals` | `totalBytes(original)` | +| Media cache | `mediaCacheThumbs` | `media_cache/thumbs` | `totalBytes(thumb)` | +| Media cache | `mediaCacheRenditions` | `media_cache/renditions` | `totalBytes(rendition)` | +| Media cache | `mediaCacheStaging` | `media_cache/staging` | Walk | +| Media cache | `mediaCacheTranscode` | `media_cache/transcode` | Walk | +| Caches | `mapTiles` | FMTC store | `getTotalCacheSize()` | +| Caches | `networkImages` | `DefaultCacheManager` directory | `cacheSize()` | +| Caches | `videoThumbnails` | `Submersion/video_thumbnails` | Walk | +| Caches | `pdfThumbnails` | `Submersion/pdf_thumbnails` | Walk | +| Backups | `backups` | Resolved backups directory | Walk, or null on SAF | +| Temporary | `temporary` | `/picked` and loose share files | Walk | +| Exports | `exports` | Loose files in the Documents root | Non-recursive listing | + +Fourteen categories. `staging` and `transcode` are listed separately from the +three indexed media-cache pools on purpose: the LRU caps in `MediaCacheStore` +(2 GiB, 256 MiB, 1 GiB) are enforced only over rows in `media_cache_entries`, +and neither subdirectory is indexed. Their bytes are invisible to the 3.25 GiB +budget today, and showing them as their own rows is what makes that visible. + +`exports` excludes the database files and the `Submersion/` subtree. It is a +non-recursive listing because the Documents root is the only level where the +loose export files land. + +### Providers + +```dart +/// The descriptor list. Plain Provider: construction is pure. +final storageCategoriesProvider = Provider>(...); + +/// One future per category, keyed by StorageCategory.id. +final storageCategorySizeProvider = + FutureProvider.family((ref, id) => ...); +``` + +A family keyed by id rather than a single future over the whole list is what +gives each row independent loading, and what stops one throwing category from +blanking the page. A category whose measurement fails renders an error in its +own row. + +The total is computed from the categories that have resolved. A partial total is +labelled as such rather than presented as final. + +### UI + +A new page at +`lib/features/settings/presentation/pages/storage_usage_page.dart`, routed in +`lib/core/router/app_router.dart` and reached from a tile on the existing +Storage settings page. + +It is not appended to `storage_settings_page.dart`. That file is 754 lines +against the project's 800-line ceiling, and it has a single coherent +responsibility today (where the database file lives) that a disk-usage report +does not share. + +Contents: grouped rows of label and size, a total, and a refresh action. The +page is entirely read-only. Nothing on it deletes anything. + +An earlier draft of this section put trailing clear buttons on the two +categories that have working clear paths today. That was dropped during +implementation, for one concrete reason: `clearAllCache()` +(`lib/features/maps/presentation/providers/offline_map_providers.dart:285`) +resets the FMTC store **and** deletes every `cached_regions` row, so it is not +merely a cache clear. The offline maps page guards it with a confirmation +dialog; putting the same call behind a bare button on a usage page would be a +safety regression. Rather than ship one clear action with a confirmation and +one without, the slice ships neither and stays genuinely read-only. + +Both actions remain reachable from the pages that own them: + +- Map tiles: the "Clear cache" action on the offline maps page +- Network images: `NetworkCacheCard` + (`lib/features/media/presentation/widgets/network_cache_card.dart`), mounted + on the network sources page + +A later slice may add reclaim actions here, with the confirmations each one +needs. That is a deliberate follow-up, not an oversight. + +`_formatBytes` already exists privately at +`lib/features/settings/presentation/pages/sync_devices_page.dart:342`. It is +extracted to a shared formatter and both call sites use it, rather than a second +copy being written. Bytes are not a diver-facing unit, so the project rule about +respecting the active diver's unit settings does not apply. + +### Localisation + +Roughly twenty new keys: fourteen category labels, five group headings, and the +page title, plus strings for the unavailable and error states. All eleven ARB +files under `lib/l10n/arb/` get them, not only `app_en.arb`. + +### Testing + +Test-driven, tests before implementation. + +**Measurement strategies** get plain `test()` cases against real temporary +directories, deliberately not `testWidgets`. Awaiting `dart:io` inside +`testWidgets` parks forever with no output and no timeout firing. Cases per +strategy: a populated directory, an empty directory, a directory that does not +exist, and for the SAF backup case, the null return. + +**The page** gets widget tests with `storageCategorySizeProvider` overridden, so +no real I/O runs. States covered: all rows loading, all resolved, one row +unavailable, one row in error while its siblings resolve, and the partial-total +label. + +Two known traps are designed against rather than discovered: + +- The test font renders one em per glyph, so a `Row` of label plus size + overflows at a 360px surface without `Flexible`. Every row is built with the + label flexible from the start. +- The eleven-locale ARB fanout must be complete or `arb_parity_test` fails + against every locale at once. + +### Out of scope for slice A + +No file is deleted that is not already deletable through existing UI. No cap is +changed. No sweep is scheduled. No startup code is touched at all. + +## Slices B through E + +Designed here so each can be executed without re-deriving the analysis. Each +gets its own worktree and its own PR. + +### Slice B: wire the caps that already exist + +1. **`TileCacheService.removeOldTiles(Duration)`** + (`tile_cache_service.dart:373`) has zero callers in `lib/` or `test/`. Call + it, and give the FMTC store the `maxLength` that `_store!.manage.create()` + (`:131`) currently omits. Note that `FMTCTileProvider` also receives no + `cachedValidDuration`, so browsed tiles have neither a cap nor a TTL. +2. **`kDiskCacheCapBytes`**: delete the constant and its test per the decision + above. +3. **`MediaCacheStore.evictIfNeeded()`** (`media_cache_store.dart:249`) is + public but called only from inside `put()` (`:161`). A store that is over cap + and idle stays over cap. Call it once per launch. The natural seam is the + media store runtime rather than `startup_page.dart`, since building + `mediaStoreRuntimeProvider` is already the drain trigger and already awaits a + once-per-process reclaim (`mediaTransferQueueReclaimProvider`, + `lib/features/media_store/presentation/providers/media_store_providers.dart:107`). + +Also found during exploration and not in the audit: deleting an offline region +removed the `cached_regions` row but no tiles, and the per-region `sizeBytes` +shown in the UI was a fabricated estimate, `downloadedTiles * 20 * 1024`, not a +measurement. Both moved to issue #1403 and were fixed there rather than in this +program, because neither could be corrected in place: FMTC 10 deletes a whole +store or tiles older than a date and nothing in between, so a region only +becomes deletable, and measurable, by being a store of its own. Regions +downloaded before that change keep the old behaviour, since there is no way to +tell after the fact which tile in `submersion_tiles` belonged to which region. + +### Slice C: scratch-file sweep + +A `StorageScratchSweep` class following the `MediaOrphanBacklogSweep` shape +exactly: small, self-contained, constructed inline, fired `unawaited` in a +swallow-and-log wrapper next to the existing sweep at `startup_page.dart:686`. +Guarded by a prefs timestamp, because a filesystem walk is not a cheap indexed +`SELECT`. A daily cadence, following `shouldAutoScan` +(`lib/features/media/data/services/repair/watched_folder_scanner.dart:16`), +which is the closest existing analogue: a filesystem traversal gated by a +per-device stamp. Copy its future-clock defence as well, so a device whose clock +jumps forward does not stop sweeping until the clock catches up. + +Targets, all of which are debris with no user value: + +- `/picked/*` (`lib/core/services/files/picked_file_materializer.dart`): + full copies of SAF-picked files including large ZIP archives. The only + reference to `'picked'` in the tree is the write site. +- `media_cache/staging/*` older than a safety age: any staged file whose `put()` + never completed. +- `media_cache/transcode/*.tmp`, and `.mp4` renditions with no live queue row. + `deleteTranscodeArtifacts()` runs only on video uploads that reach `markDone` + (`lib/features/media_store/data/media_upload_pipeline.dart:239,293`), so an + abandoned upload leaves a full-size video forever. +- Orphaned video and PDF thumbnails whose source file no longer exists. Both + services key on content plus mtime + (`video_thumbnail_service.dart:139`, `pdf_thumbnail_service.dart:139`), so + editing or moving a file orphans its old thumbnail permanently, and the only + existing `delete()` is a corrupt-entry self-heal. +- Loose share temp files (`media_share_temp_file.dart:17` writes flat into the + temp root and nothing removes it; `certification_share_sheet.dart:113,155` + likewise, though its deterministic filenames make it bounded). + +Not targeted: Documents-root exports and backup files, per the scope decision. + +The age guard matters more here than in the media sweep, because a file being +written concurrently must never be swept mid-flight. + +### Slice D: orphaned backup files + +`pruneOldBackups` (`backup_service.dart:1076`) and the pre-migration +`_retainN = 3` prune (`pre_migration_backup_service.dart:250`) are both driven +off the SharedPreferences history list. +`getValidatedBackupHistory()` (`:986`) drops records whose file has vanished, +but nothing removes a file whose record vanished. Prefs reset, reinstall over a +preserved Documents directory, or a crash between write and `addRecord` all +produce a `.db` or `.sbe` that is invisible to every prune path and permanent, +each one a full copy of the database. + +The only directory listing in the whole feature is `_sweepTempFiles` +(`pre_migration_backup_service.dart:265`), which matches hidden `.db.tmp` files +only. + +Slice D adds a listing that cross-references the directory against the history +list and surfaces orphans in the Storage usage page with an explicit prune +action. It does not delete on its own. Two platform cases need care: Android SAF +has no `Directory` to list, and custom locations on Apple platforms need the +security-scoped bookmark lease (`resolveBackupsDirectoryLeased`, `:1166`). + +Backup retention counts are out of scope here; they belong to issue #1376. + +### Slice E: tombstone GC for local-only libraries + +Reach `clearAcknowledgedDeletions` when sync is not configured, using the +existing `SyncLiveness.gcFloorMillis` 30-day floor and a null horizon, which +is the `TombstoneGcDecision.unbounded()` branch the cloud path already takes +when no live peer constrains GC. A never-synced device is that case with an +empty fleet, so no new deletion logic is needed, only a second caller. + +The gate must be precise. A device that has never synced can safely purge +tombstones, because a peer joining later adopts a full base publish rather than +replaying our log. A device that *has* synced and then went local-only must not +purge, because peers still need its tombstones to converge. So the condition is +"no provider configured and no prior sync has ever occurred", not merely "sync +is currently off". + +"No prior sync" is evidenced by the absence of four durable signals, any one +of which blocks GC: + +| Signal | Why it is read | +| --- | --- | +| `sync_metadata.lastSyncTimestamp` | Set at the tail of every successful sync; sign-out does not clear it | +| any `sync_peer_cursors` row | This device consumed a peer's log | +| any `local_publish_states` row | This device published or adopted a base. A single-device cloud library has no peers and so no peer cursor; this row is the only transport-side trace it ever synced | +| `sync_metadata.lastAcceptedEpochId` | Survives the user-facing Reset Sync State, which wipes the other three | + +Peer cursors alone, as first drafted, were too weak: a single-device cloud +library leaves none, so a signed-out device could have purged tombstones its +cloud log still owed a later joiner. + +Implementation: `LocalOnlyTombstoneGc` in +`lib/core/services/sync/changeset_log/`, in the `MediaOrphanBacklogSweep` +shape (constructed inline in `startup_page.dart`, `unawaited`, swallow and +log). Runs every launch; the probe is one metadata read and two indexed +existence checks. `SyncHistoryEvidence` separates the signals from the +decision so the gate is a pure function under test. + +Known residual, accepted: a user who runs Reset Sync State and then signs out +erases all four signals, so a reconnect more than 30 days later could +resurrect rows deleted in between. That pair of actions is already a deliberate +cold start of the sync transport, and the status quo for every other user is a +log that never shrinks. + +This is the whole of item 6. Item 6b is a separate issue. + +## Rejected + +- **Reviving `StartupMaintenanceRunner`.** Abandoned twice on record. This + problem needs a description of where bytes live, not a scheduler with a + convergence ledger. +- **Periodic `VACUUM` of the main database.** Measured on the development + database: `freelist_count` is 0 and `VACUUM INTO` recovers 1.21 MB, 4.3%. Not + worth an exclusive lock contended by the UI isolate and the Workmanager + headless one. The real database cost is the profile sample schema, tracked + separately. +- **A custom `BaseCacheManager` for the network image byte cap.** See the + `kDiskCacheCapBytes` decision above. +- **Sweeping Documents-root exports or backup files.** See the scope decision. + +## Related + +- #1376: backup retention counts and raw dive computer data discard. +- Profile sample storage, the majority of database bytes, is a separate schema + change tracked on its own. diff --git a/docs/superpowers/specs/2026-08-29-usb-hid-transport-design.md b/docs/superpowers/specs/2026-08-29-usb-hid-transport-design.md new file mode 100644 index 0000000000..a812338420 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-usb-hid-transport-design.md @@ -0,0 +1,213 @@ +# USB HID Transport Design + +**Issue:** [#1271](https://github.com/submersion-app/submersion/issues/1271) ScubaPro G2 TEK USB Import + +## Problem + +A Scubapro G2 TEK can be downloaded over Bluetooth on a phone, but a Windows +desktop without Bluetooth has no way to reach it. The USB tab of the dive +computer wizard lists no Scubapro model at all, even though the same hardware +imports fine in Subsurface over USB. + +## Why the device is missing + +libdivecomputer already knows the G2 family speaks USB HID +(`third_party/libdivecomputer/src/descriptor.c:171-180`): + +```c +{"Scubapro", "Aladin Square", DC_FAMILY_UWATEC_SMART, 0x22, DC_TRANSPORT_USBHID, dc_filter_uwatec}, +{"Scubapro", "G2 TEK", DC_FAMILY_UWATEC_SMART, 0x31, DC_TRANSPORT_USBHID | DC_TRANSPORT_BLE, dc_filter_uwatec}, +{"Scubapro", "G2", DC_FAMILY_UWATEC_SMART, 0x32, DC_TRANSPORT_USBHID | DC_TRANSPORT_BLE, dc_filter_uwatec}, +{"Scubapro", "G2 Console", DC_FAMILY_UWATEC_SMART, 0x32, DC_TRANSPORT_USBHID | DC_TRANSPORT_BLE, dc_filter_uwatec}, +{"Scubapro", "G3", DC_FAMILY_UWATEC_SMART, 0x34, DC_TRANSPORT_USBHID | DC_TRANSPORT_BLE, dc_filter_uwatec}, +{"Scubapro", "G2 HUD", DC_FAMILY_UWATEC_SMART, 0x42, DC_TRANSPORT_USBHID | DC_TRANSPORT_BLE, dc_filter_uwatec}, +``` + +Every native backend then drops that bit on the floor when it converts +libdivecomputer's transport bitmask into the Pigeon `TransportType` list. The +suppression is deliberate and carries the same comment on all four platforms, +for example +`packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift:77-81`: + +> USBHID is deliberately NOT surfaced as USB: no platform build implements a USB +> HID transport (HAVE_HIDAPI is off), so advertising it sent HID-only devices +> (Suunto EON Steel family) into the serial path's "No USB serial ports found" +> dead end (#143). BLE is the working path for those devices. + +That is accurate. Commit `73ea1433bf2` added it because simply advertising USB +made the device selectable and then failed at download time. So the fix is not +to flip the filter; it is to build the transport the comment says is missing, +and then flip the filter. + +## Key architectural fact + +**The plugin never uses libdivecomputer's own transport backends.** Every +download opens `dc_custom_open` with a plugin-owned eleven-slot callback table +(`packages/libdivecomputer_plugin/macos/Classes/libdc_download.c:849-869`), and +the bytes move through platform code: `BleIoStream`, `SerialIoStream`, +`FtdiUsbIoStream` on darwin, and their C/C++/Kotlin equivalents elsewhere. + +Two consequences: + +1. `HAVE_HIDAPI` being undefined in every `config/config.h` does not matter. + Nothing calls `dc_usbhid_open`. There is no need to vendor hidapi or libusb, + and no build-system risk from doing so. +2. `dc_custom_open` takes the transport as an argument and stores it on the + iostream (`third_party/libdivecomputer/src/custom.c:74-84`). Passing + `DC_TRANSPORT_USBHID` is enough to make the Uwatec Smart driver use its HID + code paths. + +That second point carries most of the protocol work. `uwatec_smart.c` branches +purely on `dc_iostream_get_transport()`: + +| Location | Behaviour under `DC_TRANSPORT_USBHID` | +| --- | --- | +| `uwatec_smart.c:279-285` | TX packet size `PACKETSIZE_USBHID_TX + 1` = 33 bytes | +| `uwatec_smart.c:300-303` | writes the full 33-byte buffer, leading report id included | +| `uwatec_smart.c:318-327` | RX packet size `PACKETSIZE_USBHID_RX` = 64 bytes | +| `uwatec_smart.c:377-379` | clamps the payload length to the packet's first byte | +| `uwatec_smart.c:502-518` | `DC_TRANSPORT_USBHID` is an accepted, fully supported case | + +So the plugin has to supply a byte pipe with exactly the semantics +libdivecomputer's own `usbhid.c` provides, and nothing more. + +## The contract a HID byte pipe must honour + +Taken from `third_party/libdivecomputer/src/usbhid.c:707-790`, which is the +reference implementation these drivers were written against: + +**Write.** The first byte of the buffer is the HID report id. A report id of +zero is not part of the wire format, so it is stripped and the remaining +`size - 1` bytes are sent as the output report; `actual` still reports the full +`size` the caller passed. A non-zero report id is transmitted. + +**Read.** One input report is returned per call, up to `size` bytes, subject to +the timeout most recently set by `set_timeout`. A timeout yields zero bytes and +`DC_STATUS_SUCCESS`, not an error; `uwatec_smart_usbhid_receive` treats a short +read as a protocol error itself. + +Everything else in the callback table is inert for HID: `configure`, `set_dtr` +and `set_rts` are serial line control, and `purge` has no meaning for a report +pipe. + +## Design + +### Where HID devices enter the download + +The macOS backend already probes an ordered candidate list for the USB and +serial transports, because hardware can be reachable in more than one way +(`DiveComputerHostApiImpl.swift:400-424`): a `/dev/cu.*` node first, then a raw +FTDI cable the operating system never claimed (issue #732). USB HID is a third +kind of candidate on the same list, and Windows and Linux gain the equivalent. + +Two changes to the candidate machinery: + +1. **Transport becomes per-candidate.** Today the whole probe runs with a single + `transportValue` of `LIBDC_TRANSPORT_SERIAL`. A HID candidate must be run + with `LIBDC_TRANSPORT_USBHID` or the Uwatec driver frames its packets as + serial and the handshake fails. Serial-port and FTDI candidates keep + `LIBDC_TRANSPORT_SERIAL`. +2. **HID candidates come first** when the selected model declares USBHID. + Nothing that works today changes: a model with no USBHID bit gets an + unchanged candidate list. + +Putting HID candidates on the existing probe list, rather than behind a new +Pigeon transport value, is what makes a *saved* computer keep working. The +`dive_computers.connection_type` column stores the string `"usb"` +(`lib/features/import_wizard/data/adapters/dive_computer_adapter.dart:228-231`), +so a G2 registered today comes back as `DeviceConnectionType.usb` tomorrow. A +download path that discovers HID from the descriptor handles both the fresh +selection and the saved one; a path keyed on a new enum value would only handle +the fresh one. + +### Matching a plugged-in device to the selected model + +libdivecomputer keeps the VID/PID allowlist inside its filter functions +(`descriptor.c:666-671` for Uwatec, `:697-702` for Suunto) and exposes +`dc_descriptor_filter()` publicly (`include/libdivecomputer/descriptor.h:125`). +The shared C wrapper gains one function that asks libdivecomputer the question +directly: + +```c +int libdc_usbhid_match(const char *vendor, const char *product, + unsigned int model, + unsigned short vid, unsigned short pid); +``` + +No hand-maintained VID/PID table in plugin code. When libdivecomputer is next +updated, new HID hardware is picked up with no plugin change. This matters: +the descriptor list already contains a `G3` row whose USB ids are not yet in +`dc_filter_uwatec`, and that is upstream's business to fix, not ours to +second-guess. + +The wrapper also gains a transport query, so the download path can ask whether +the selected model is HID-capable before enumerating anything: + +```c +unsigned int libdc_descriptor_transports(const char *vendor, const char *product, + unsigned int model); +``` + +### Per-platform byte pipe + +| Platform | API | Notes | +| --- | --- | --- | +| macOS | `IOHIDManager` / `IOHIDDevice` (IOKit) | Swift. Input reports arrive on a run loop callback and queue into the existing `PacketReadBuffer`. Output via `IOHIDDeviceSetReport`. The podspec already declares `s.frameworks = 'IOKit'` and both sandboxed entitlement files already grant `com.apple.security.device.usb`. | +| Windows | `SetupDiGetClassDevs` + `HidD_*` + overlapped `ReadFile`/`WriteFile` | C++. `hid.lib` and `setupapi.lib` ship in the Windows SDK; `SetupAPI.lib` is already linked. | +| Linux | `/dev/hidraw*` + `HIDIOCGRAWINFO` | C. No libudev and no libusb: the ioctl reports VID/PID directly, so enumeration is a `readdir` over `/dev`. | + +iOS and Android keep USBHID suppressed. iOS has no USB host role at all, and +Android would need `UsbDeviceConnection` HID transfers, which is separate work +against separate hardware; both already reach these computers over BLE. + +Because darwin's `DiveComputerHostApiImpl.swift` is shared between macOS and +iOS, the mapping change there is guarded with `#if os(macOS)`. + +### Report sizes are read from the device, not assumed + +`uwatec_smart` asks for 64 bytes on read and writes 33. Suunto EON Steel uses +64 both ways. Rather than hard-code either, each backend asks the HID device +for its maximum input and output report lengths (`kIOHIDMaxInputReportSizeKey` +on macOS, `HidP_GetCaps` on Windows, the report descriptor size on Linux) and +sizes its buffers from that, clamped to what the caller asked for. A device +that reports nothing usable falls back to 64. + +## What becomes visible + +Surfacing the bit makes these selectable under USB for the first time, on +macOS, Windows and Linux: + +- Scubapro G2, G2 TEK, G2 Console, G2 HUD, G3 +- Scubapro Aladin Square, which is USBHID-only and therefore currently + unreachable in the app by any route +- Suunto EON Steel, EON Core, D5, EON Steel Black, which today are BLE-only in + the app and were the specific regression behind issue #143 + +## Failure messages + +The existing dead end the #143 comment describes is "No USB serial ports found. +Is the dive computer connected and powered on?". A HID-only model that finds no +matching HID device must not say that, because the user has no serial port to +go looking for. The probe reports, in order of specificity: + +- HID-capable model, no matching HID device, no serial candidates either: a + message naming the model and asking whether it is connected and powered on. +- Candidates existed but none opened: the existing `connect_failed` probe + report, now including HID candidates in the tried list. + +## Testing + +- **Native C** (`packages/libdivecomputer_plugin/test/native/`, run in CI by + `native-plugin-tests.yml`): `libdc_usbhid_match` accepts the four Uwatec and + four Suunto VID/PID pairs against their own descriptors, rejects a mismatched + pair, and rejects a VID/PID whose descriptor does not declare USBHID. + `libdc_descriptor_transports` reports the USBHID bit for a G2 TEK. +- **Swift** (`packages/libdivecomputer_plugin/darwin/run_native_tests.sh`): the + report framing unit, driven by vectors taken from `usbhid.c`, not from our + own implementation. +- **Dart**: `usbDeviceModelsProvider` includes a descriptor whose only + transport is USB, and the existing `DeviceModel.fromDescriptor` mapping tests + continue to pass unchanged. + +End-to-end verification needs the hardware. The issue reporter has a G2 TEK and +offered to test builds. diff --git a/docs/superpowers/specs/2026-09-02-blender-custom-mix-flat-fee-billing-analysis.md b/docs/superpowers/specs/2026-09-02-blender-custom-mix-flat-fee-billing-analysis.md new file mode 100644 index 0000000000..6e98577ee3 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-blender-custom-mix-flat-fee-billing-analysis.md @@ -0,0 +1,184 @@ +# Analysis: custom-mix billing line + per-gas flush flat fee + +Reference: submersion-app/submersion#1335. Analysis only, no implementation. +Branched from `upstream/main` at `60998f4e6`, independent of the still-open +trimix-mixer follow-up (submersion-app/submersion#1359 / +`claude/issue-1359-followup-20260829`), which has not merged to `main` yet. + +## 1. Current billing architecture + +### The "individual amount" line today + +`BlenderInvoiceCard` (`lib/features/gas_calculators/presentation/widgets/blender/blender_invoice_card.dart`) +keeps a running bill as `List` (`blenderBilledFillsProvider`). +Each `BilledFill` (`lib/features/gas_calculators/domain/blending/billed_fill.dart`) +is either: + +- a **computed** fill: `lines` holds one `BilledGasLine` per gas actually + blended (`gas` label, `addedBar`, `cost`), saved via + `BlenderBillingCard._saveFill`, or +- a **manual** entry: `lines` is empty (`BilledFill.isManual`), `label` and + `total` are free text/number, entered through `_LineEditDialog` in + `blender_invoice_card.dart` — a plain `AlertDialog` with exactly two + fields, description and amount. This dialog is the "individual amount" + option the task refers to; it has no notion of a gas mix or a cylinder at + all today. + +`BilledFill`s are stored oldest-first and only ever appended +(`appendCapped`), capped at `kMaxBilledFills = 100`. + +### Computed cost path + +`BlenderBillingCard` (same folder) owns the cylinder-volume field (free +text, or a preset picked from `blenderTankChoices()`), a currency picker, +and one price field per **configured bank** (`blenderGasPricesProvider`, +`List` of length 3). Prices are positional against +`blenderFillGas1/2/3Provider`, not tied to a gas's identity — a diver who +reassigns bank 2 from helium to something else is pricing that bank, not +"helium" (see the `PR #1215` comment on `computeBlendCost` in +`blend_billing.dart`). + +`computeBlendCost` only prices a `BlendResult` produced by `computeBlend()` +(`lib/features/gas_calculators/domain/gas_blender.dart`), which solves the +full partial-pressure fill procedure from a start mix/pressure to a target +mix/pressure through up to three banks. There is currently **no path** that +prices a mix without going through that solver — a manually typed mix has +no bank index to look its price up under. + +## 2. What the two requested features need + +### a) Custom mix on a manual bill line + +Requested: cylinder selection (default value or manual litre entry, same as +the costing card) plus O2/He/Luft shares, attached to what is today the +plain "individual amount" entry. + +- The cylinder picker is a straight lift of + `BlenderBillingCard._cylinderRow` (text field seeded from + `blenderCylinderLitersProvider`, plus a `PopupMenuButton` over + `blenderTankChoices()`). No new logic needed there. +- The domain model everywhere else in the app (`GasMix`, `MixTemplate`, + `mix_template_dialog.dart`'s `_numberField` row) is **O2 + He only**; Luft + is always the derived balance (`GasMix.n2 = 100 - o2 - he`, `isAir` when + `o2` is 20–22 and `he == 0`). A literal third "Luft %" field would add a + degree of freedom the rest of the app does not have and would need its + own `O2 + He + Luft == 100` validation. Recommend keeping the same + two-field (O2/He) entry as everywhere else rather than introducing a + third field. +- **Open question — how is the line priced?** `computeBlendCost` needs a + solved `BlendResult` keyed to bank order; a hand-typed mix was never + solved and has no bank index. Two ways to close that gap, both needing a + product decision before implementation: + 1. Keep pricing manual (today's free-typed total), and use the entered + mix only to build the label/record — closest to today's behaviour, + smallest change. + 2. Compute a price directly from `cylinderLiters × targetPressure × + (fraction of each gas)` against the existing bank prices, without + running `computeBlend` at all (a custom mix, by definition, was not + blended by this fill station's three banks in sequence, so the + solver's fill-order logic does not apply to it). + +### b) Per-gas flush/purge flat fee + +Requested: a flat fee per gas (O2, He, Luft), each with its own volume, +configured in settings, shown as a lump-sum line at the top of the invoice +(example: "O2, 20 L, 1.50 Fr."). + +- This is **identity**-keyed ("O2", "He", "Luft"), not bank-positional like + `gasPrices`. That is a real mismatch with the existing pricing model, + where a bank's identity is whatever `GasMix` the diver configured it + with. A flush fee tied to "the gas actually connected to fill from", + rather than to bank position, needs its own small model + (`{gas, volumeLiters, price}` × 3) rather than reusing `gasPrices`. +- `BilledGasLine` (the type computed fills are saved as) shows gas name + + `addedBar` (pressure) + cost — it has no litres field rendered anywhere + in `BlenderInvoiceCard._fillLine`. `GasCostLine.freeGasLiters` exists on + the *live* costing side but is dropped when a fill is frozen into a + `BilledGasLine` for storage. Showing "20 L" verbatim (as in the example) + is simplest as its own free-text manual-style entry (label == + `"O2, 20 L"`, `total == price`) rather than extending `BilledGasLine` + with an optional litres field used by only one caller. +- **Open question — once per invoice, or once per fill?** "Pauschalposition + am Anfang der Rechnung" (singular, at the *start* of the bill) reads as + once per gas per session, not once per cylinder — a hose only needs + purging once when a gas source is connected, not on every fill drawn from + it. But `blenderBilledFillsProvider`/`appendCapped` is an append-only, + oldest-first list; nothing in it is "first" by construction. Two ways to + reconcile that, needing a product decision: + 1. Treat the flush lines as ordinary `BilledFill` entries but *inserted* + at index 0 instead of appended, with de-duplication so re-saving a fill + does not add "O2, 20 L" a second time. + 2. Keep them entirely outside `blenderBilledFillsProvider`: render them + live in `BlenderInvoiceCard.build()`, ahead of the `for (final f in + fills)` loop, computed from settings × which gases were used this + session — the same "derived, not stored" shape `blenderBillingProvider` + already uses for the live cost preview. This sidesteps the + insert-at-0/de-dup problem entirely and reads closer to "once per + invoice" than option 1 does. + +## 3. Popup vs. alternative for the richer manual-line form + +Today's `_LineEditDialog` is a small, non-scrolling `AlertDialog` (two text +fields). Adding a cylinder row (text field + preset menu, ~56dp) and an +O2/He row roughly doubles its height, and `mix_template_dialog.dart` already +shows that even a much simpler two-field dialog caps its content at +`width: 320` to stay inside the app's narrowest supported phone — an +`AlertDialog` does not resize for the keyboard the way a scrollable sheet +does, so a taller fixed dialog risks overflow once the keyboard is up on a +short device (particularly landscape, or a phone with a large keyboard/ +predictive-text bar). + +The codebase's established pattern for a form richer than two or three +fields is a `showModalBottomSheet(isScrollControlled: true, ...)` wrapped in +a `SingleChildScrollView`/`Column` with +`Padding(bottom: MediaQuery.of(context).viewInsets.bottom + ...)` for the +keyboard — see `checklist_item_edit_sheet.dart` (`isScrollControlled: true`, +`viewInsets.bottom` padding) and the equivalent shape in +`dive_filter_sheet.dart`/`itinerary_day_edit_sheet.dart`. `TankEditor` +(`lib/features/dive_log/presentation/widgets/tank_editor.dart`) is the +closest existing analogue in complexity (preset + volume/material row + O2/ +He row), though it lives inline on a page rather than in an overlay. + +**Recommendation:** move the manual-line entry from `AlertDialog` to a +scrollable, `isScrollControlled` bottom sheet once the cylinder + mix +fields are added, following the `checklist_item_edit_sheet.dart` shape. +Open question for the product owner: whether to keep the plain +"amount only" path as today's compact `AlertDialog` and only grow into the +sheet when "enter a mix" is toggled on (two code paths, smaller change for +the common case), or unify on the sheet for both (one code path, but the +simple case loses its quick centered-dialog feel). No preference recorded +here — flagging both as viable pending a decision. + +## 4. Where a flush-fee settings UI would live + +`gasPrices`/currency/cylinder are currently edited inline inside +`BlenderBillingCard` on `main`, because the dedicated "Blender settings +page" (`blender_settings_page.dart`) that the trimix-mixer follow-up built +does not exist on `main` yet — it only exists on the unmerged +`claude/issue-1359-followup-20260829` branch. Adding three more +gas/volume/price rows for the flush fee would make `BlenderBillingCard` +noticeably more crowded if it lands before that settings page merges. +Worth flagging, not deciding here: this feature may read more naturally +once it can live in that dedicated settings page rather than growing the +billing card further — but that is a sequencing question for the two +efforts, not a blocker for building the feature on `main` now. + +## 5. Files a future implementation phase would touch + +- `lib/features/gas_calculators/domain/blending/billed_fill.dart` — line + modelling for a mix-carrying manual entry and/or the flush-fee line shape. +- `lib/features/gas_calculators/domain/blending/blend_billing.dart` — + possible direct-pricing path for a custom mix that bypasses + `computeBlend`. +- `lib/features/gas_calculators/domain/blending/blender_preferences.dart` — + new persisted field(s) for the per-gas flush fee (volume + price), plus + `copyWith`/`toJson`/`fromJson`. +- `lib/features/gas_calculators/presentation/widgets/blender/blender_invoice_card.dart` — + the manual-line entry point (dialog → sheet, mix fields). +- `lib/features/gas_calculators/presentation/widgets/blender/blender_billing_card.dart` — + cylinder-row logic to reuse; possible flush-fee settings fields. +- `lib/features/gas_calculators/presentation/providers/gas_blender_providers.dart` — + persistence load/save wiring. +- New l10n keys in all locale `.arb` files for the new fields/labels. + +No code, tests, or migrations were changed in this pass, per the task. diff --git a/docs/superpowers/specs/2026-09-02-blender-invoice-archive-view-analysis.md b/docs/superpowers/specs/2026-09-02-blender-invoice-archive-view-analysis.md new file mode 100644 index 0000000000..9e9bbcf201 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-blender-invoice-archive-view-analysis.md @@ -0,0 +1,242 @@ +# Trimix Blender Invoice Archive: List View, Filter, Total (issue #1335) — Analysis + +Date: 2026-09-02 +Issue: [#1335](https://github.com/submersion-app/submersion/issues/1335) +Branch: `fix/issue-1335-archiv-uebersicht` (based on `fix/issue-1335-abrechnung-erweiterung`) + +Analysis only. No code, migration, or UI changes in this phase. + +## Scope + +`fix/issue-1335-abrechnung-erweiterung` (PR #39) introduced a "Pay" action +that archives the running bill (`BlenderInvoiceCard._confirmPay()`) instead +of just clearing it. This phase designs the view that lists what gets +archived: every paid invoice, filterable (at least by date), with a total +over the filtered selection. The `ArchivedInvoice` doc comment already +anticipates this: + +> A minimal record rather than a full invoice history feature (that is +> issue #22) - just enough that archiving loses nothing, so a later history +> view has real data to build on instead of starting from zero. + +## 1. The existing `ArchivedInvoice` structure + +`lib/features/gas_calculators/domain/blending/billed_fill.dart:166-231`: + +```dart +const int kMaxArchivedInvoices = 50; + +class ArchivedInvoice { + final String id; + final DateTime date; + final String billedTo; + final List fills; + final double? total; // null when paid with an unpriced line still on it +} + +List appendArchivedCapped( + List invoices, + ArchivedInvoice invoice, +); +``` + +- **Storage:** part of `BlenderPreferences` (`archivedInvoices` field, + `blender_preferences.dart:88/134/189/205/246-253/270`), which is itself + one JSON blob under the `gas_blender_prefs` settings key + (`AppSettingsRepository.setBlenderPreferences`/`getBlenderPreferences`) — + confirmed as real DB persistence, not in-memory-only, in the verification + comment on PR #29 (2026-09-02 15:11 UTC). Adding fields to + `ArchivedInvoice` is additive JSON, no Drift migration. +- **Access provider:** `blenderArchivedInvoicesProvider` in + `gas_blender_providers.dart:92`, a plain + `StateProvider>`, oldest-first, capped client-side + at `kMaxArchivedInvoices = 50` by `appendArchivedCapped` (same + drop-oldest-on-append shape as `appendCapped` for `billedFills`, and the + same off-by-one bug class was already fixed once for that sibling + function per the PR #1215 review noted in the file — worth re-reading if + this cap logic gets touched again). +- **Written by:** `BlenderInvoiceCard._confirmPay()` + (`blender_invoice_card.dart:507-546`) only. No delete/edit path exists + yet for an archived entry. +- **Gap found:** `ArchivedInvoice` has no `currencyCode` field. `total` is + a bare `double`. The currency it was billed in is only ever the *global* + `blenderCurrencyProvider` (itself just a mirror of the diver's + `defaultCurrency`) at render time — not a snapshot at archive time. If + the diver's default currency changes between two "Pay" clicks, older + archived totals would silently render in the new currency label when the + archive view sums or displays them. This matters directly for "Gesamtsumme + über die gefilterte Auswahl": see §4. + +## 2. Existing list + filter + aggregate patterns to reuse + +Two established patterns in the codebase, both directly transplantable; +neither requires a new architectural idea. + +### Pattern A — `PreDiveSessionsPage` (closest match for the page shape) + +- `lib/features/pre_dive/domain/models/pre_dive_session_filter.dart`: + immutable `PreDiveSessionFilter` (`Equatable`), one field per facet, a + `dateRange` (`DateTimeRange?`), `hasActiveFilters` getter, + `apply(sessions, stats)` that filters, and `copyWith(..., clearDateRange: + bool)` using the clear-sentinel pattern the whole codebase uses for + nullable `copyWith` fields. +- `lib/features/pre_dive/presentation/providers/pre_dive_providers.dart:88-103`: + ```dart + final preDiveSessionFilterProvider = + StateProvider((ref) => const PreDiveSessionFilter()); + + final filteredPreDiveSessionsProvider = + Provider>>((ref) { + final sessionsAsync = ref.watch(preDiveSessionsProvider); + final filter = ref.watch(preDiveSessionFilterProvider); + return sessionsAsync.whenData((sessions) => filter.apply(sessions, stats)); + }); + ``` + Comment on the filter provider: *"Ephemeral by design, matching the + dive-list and trip-list filters: a filter is a view of the current + screen, not a stored preference."* — the archive filter should be the + same, a `StateProvider`, not persisted into `BlenderPreferences`. +- `lib/features/pre_dive/presentation/widgets/pre_dive_session_filter_sheet.dart`: + `showModalBottomSheet(isScrollControlled: true, ...)` → + `DraggableScrollableSheet`, edits held in local `State` and committed to + the provider only on "Apply" (so the list behind the sheet does not churn + while choosing). Date range picked via `showAppDateRangePicker` (already + used for exactly this kind of history filter in + `gps_track_date_filter_action.dart`, `media_library_filter_sheet.dart`, + and `pre_dive_session_filter_sheet.dart`). +- `lib/features/pre_dive/presentation/pages/pre_dive_sessions_page.dart`: + `AppBar` with a badge-on-filter-icon (`Badge(isLabelVisible: + filter.hasActiveFilters, ...)`) opening the sheet, a dismissible + `_ActiveFilterBar` (chips with `onDeleted` clearing one facet at a time), + and two distinct empty states — "nothing here at all" vs. "your filter + hides everything" (`_FilteredEmptyState`, with its own "clear all" + action). This distinction matters here too: an empty archive and an + over-narrow date range should not look the same. + +### Pattern B — Equipment `ServiceHistorySection` (closest match for the money total) + +- `lib/features/equipment/domain/entities/maintenance_history_filter.dart` + + `lib/features/equipment/presentation/widgets/service_history_section.dart`: + filter is local `State` (not a provider) since it does not need to + outlive the section; `records.where(_filter.matches).toList()` computed + inline in `build()`. +- The total, `service_history_section.dart:144-149`: + ```dart + final totals = sumByCurrency( + visible, // the already-filtered list, not the raw one — #1236 + amountOf: (r) => r.cost, + currencyOf: (r) => r.currency, + fallbackCode: ref.watch(defaultCurrencyProvider), + ).where((e) => e.value > 0).toList(); + ``` + `sumByCurrency` (`lib/core/utils/currency.dart:70-91`) is a generic + helper already built for exactly this: it groups by currency code so a + history spanning more than one currency never gets added into one + misleading figure, and is already used twice (equipment cost summary, + equipment service history). The doc comment on the equipment call site is + explicit that the total must be computed **from the filtered list**, so + the displayed sum always matches what is listed below it — the same rule + applies to the invoice archive's "Gesamtsumme über die gefilterte + Auswahl". +- Rendered with the same `formatMoney(amount, code)` helper the blender + cards already use for the running bill (`blender_invoice_card.dart:142`, + `blender_billing_card.dart`), so no new formatting code is needed. + +**Recommendation:** follow Pattern A for the page/sheet/provider shape +(`ArchivedInvoiceFilter` modeled on `PreDiveSessionFilter`, with at least a +`dateRange` facet, possibly `billedTo` as a free-text facet later), and +Pattern B for the total row, once §1's currency gap is closed (below). + +## 3. Where the list itself comes from + +Unlike `PreDiveSession` (DB-backed, `FutureProvider`) or `ServiceRecord` +(DB-backed, `AsyncNotifier`), `ArchivedInvoice` lives entirely in the +in-memory `blenderArchivedInvoicesProvider` `StateProvider`, seeded once by +`blenderPreferencesLoaderProvider`. That makes the filtered/total providers +simpler than either precedent — no `AsyncValue` unwrapping needed: + +```dart +final archivedInvoiceFilterProvider = + StateProvider((ref) => const ArchivedInvoiceFilter()); + +final filteredArchivedInvoicesProvider = Provider>((ref) { + final invoices = ref.watch(blenderArchivedInvoicesProvider); + final filter = ref.watch(archivedInvoiceFilterProvider); + return filter.apply(invoices); +}); +``` + +## 4. Currency gap and its effect on the total + +Since `ArchivedInvoice` carries no currency of its own, `sumByCurrency` +cannot actually distinguish invoices billed in different currencies — every +invoice's `total` would be attributed to whatever `fallbackCode` (today's +`blenderCurrencyProvider`) is passed in, even one paid months ago in a +different currency. Two options for the follow-up implementation phase: + +- **Minimal:** accept the limitation for now (the blender's currency is a + single global app setting today, so multi-currency history is already an + edge case, not the common path) and note it as a known simplification in + the archive view. +- **Correct:** add an optional `currencyCode` field to `ArchivedInvoice` + (additive JSON, no migration, same pattern as `freeGasLiters` on + `BilledGasLine` from the prior phase), snapshotted from + `blenderCurrencyProvider` at "Pay" time in `_confirmPay()`, with a + `?? currentCurrency` fallback on read for invoices archived before the + field existed (mirrors how `freeGasLiters` falls back to pressure display + for old rows). + +Recommend the "Correct" option since it is a small, additive, low-risk +change and makes the archive's total trustworthy from day one rather than +introducing a second known gap on top of the first. + +## 5. Routing and entry point + +- Gas calculators route the child tools generically: `app_router.dart:282-297`, + a `gas-calculators` parent `GoRoute` whose `routes:` list is built with + `for (final id in kGasCalculatorIds) GoRoute(path: id, ...)`. The blender + itself is a leaf (`gas-calculators/blender`), with no sub-routes today. +- The router already mixes generated and static children within one + `routes:` list elsewhere (see the comment at `app_router.dart:270`: + *"static children (compare/chart) still win route matching"* for + `dive-planner`), so adding one explicit sibling route beside the + generated loop is an established pattern, not a new one: + ```dart + routes: [ + for (final id in kGasCalculatorIds) GoRoute(path: id, ...), + GoRoute( + path: 'blender/invoices', + builder: (context, state) => const BlenderInvoiceArchivePage(), + ), + ], + ``` +- `_ManageSectionContent` in `settings_page.dart:2287` (Settings > Manage) + only lists catalog/configuration entries (dive types, tank presets, + service types, checklist templates, ...) — a transactional history view + does not fit that convention, matching how equipment's own history + sections live under the equipment feature, not under Settings. +- **Recommended entry point:** a small icon button (e.g. `Icons.history`) + next to the existing edit-date icon in `BlenderInvoiceCard._dateHeader` + (`blender_invoice_card.dart:197-224`), navigating to + `gas-calculators/blender/invoices`. That header already holds one + icon-button-next-to-title row, so a second icon fits the existing layout + without a new UI region. Also note: this branch (based on `upstream/main` + via PR #39) does **not** have the Trimix-Mixer settings-page + reorganisation from PR #29/#1359 (no gear icon, no + `/settings/trimix-mixer` route) — the entry point must be designed + against what exists on *this* branch, and reconciled later when the two + branches eventually merge. + +## Open questions for the implementation phase + +1. Confirm the "Correct" currency-snapshot option from §4, or explicitly + accept the "Minimal" simplification instead. +2. Filter facets beyond date range: `billedTo` (free text billed-to name) + is the only other field on `ArchivedInvoice` besides date/total, and a + free-text substring filter is a small addition to `apply()` if wanted + for this phase; can also be deferred. +3. Detail view: should tapping an archived invoice open a read-only + expansion of its `fills`/`BilledGasLine`s (reusing the existing + `_fillLine` rendering from `BlenderInvoiceCard`), or is a summary row + (date, billedTo, total) enough for this phase, with drill-down left to + the full issue #22 history feature? Not decided here. diff --git a/docs/superpowers/specs/2026-09-02-blender-invoice-volume-date-export-analysis.md b/docs/superpowers/specs/2026-09-02-blender-invoice-volume-date-export-analysis.md new file mode 100644 index 0000000000..54e02fc49d --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-blender-invoice-volume-date-export-analysis.md @@ -0,0 +1,197 @@ +# Trimix Blender Invoice: Volume, Date, Tariff, Export/Pay (issue #1335) — Analysis + +Date: 2026-09-02 +Issue: [#1335](https://github.com/submersion-app/submersion/issues/1335) +Branch: `fix/issue-1335-abrechnung-erweiterung` + +Analysis only. No code, migration, or UI changes in this phase. + +## Scope + +Four requests against the gas-blender "Kosten" (billing) and "Abgerechnet" +(invoice) cards: + +1. Saving a fill should record/show the delivered **volume**, not the + pressure. +2. The configured **gas tariff** (price per gas) should be visible at the + top of the invoice, so it can be checked at a glance. +3. The "Abgerechnet" heading should become an editable **invoice date** + ("Rechnung vom xy"). +4. **Export** (PDF/Image/Excel) and **Pay** actions at the end of the + section. + +All four touch `lib/features/gas_calculators/presentation/widgets/blender/ +blender_billing_card.dart` (the live "Kosten" card, where a fill is priced +and saved) and `blender_invoice_card.dart` (the "Abgerechnet" running bill), +plus the shared model in `lib/features/gas_calculators/domain/blending/ +billed_fill.dart` and `blender_preferences.dart`. + +## 1. Volume instead of pressure when a fill is saved + +**Current state:** + +- `blend_billing.dart::computeBlendCost()` already computes both figures per + line: `GasCostLine.addedBar` (bar delivered) and `GasCostLine.freeGasLiters` + (`waterLiters * addedBar`, the ideal free-gas volume — see the doc comment + there on why pressure is deliberately the *commercial* truth for pricing). +- The live "Kosten" card (`blender_billing_card.dart::_costLine`) already + shows **both** columns: `+X bar` and the litre figure + (`units.formatVolume(line.freeGasLiters)`). +- `BilledFill`/`BilledGasLine` (`billed_fill.dart`), the structure that + actually gets **persisted** when "Save fill" is pressed + (`blender_billing_card.dart::_saveFill`), only carries `gas`, `addedBar`, + `cost`. `freeGasLiters` is computed but dropped at save time. +- The invoice ("Abgerechnet") line (`blender_invoice_card.dart::_fillLine`) + therefore can only show pressure + (`units.formatPressure(line.addedBar, ...)`) — there is no volume to show + even if it wanted to. + +**Gap:** the volume is available at save time but never stored, so the +saved/invoiced fill line is pressure-only. + +**Straightforward fix path (no migration needed):** + +- Add `freeGasLiters` to `BilledGasLine` (`toJson`/`fromJson`, following the + existing per-field-independent-fallback pattern already used there and in + `BlenderPreferences`). Since this is a JSON blob in the `settings` KV + table (see §3 of `blender_preferences.dart`), adding a field is a pure + additive JSON change — no Drift schema version bump. +- `_saveFill` passes `freeGasLiters: line.freeGasLiters` through. +- `blender_invoice_card.dart::_fillLine` switches its display from + `units.formatPressure(line.addedBar, decimals: decimals)` to + `units.formatVolume(line.freeGasLiters)`. + +**What should NOT change:** the pricing formula itself +(`cost = freeGasLiters / 100 * unitPricePer100`) stays pressure-driven +internally — `blend_billing.dart` explains this is intentional (a fill +station meters and charges by gauge pressure drop, so bar is the "commercial +truth" even though it is not the physical one). The request reads as +changing what is **shown/recorded on the saved line**, not the underlying +cost math. `addedBar` should probably stay in the persisted structure too +(cheap, and the existing "Kosten" card still wants to show pressure live) — +only the invoice display needs to switch to volume. This is a judgment call +worth confirming before implementation. + +## 2. Gas tariff visible at the top of the invoice + +**Current state:** the per-gas price (`gasCalculators_blender_unitPrice`, +price per 100 L/cu ft) is only entered/shown in the "Kosten" card +(`_priceField`, one field per configured bank, labelled with the gas name +via `formatPreciseGasName`). `blender_invoice_card.dart` shows fill labels +and totals but never the tariff that produced them — there is no way to +audit "was this billed at the current price" from the invoice section +alone. + +**Data already available for this:** `blenderFillGas1/2/3Provider` (gas +identity per bank), `blenderGasPricesProvider` (`List`, positional +per bank), `blenderCurrencyProvider` — exactly what `_priceField` already +reads. A summary row/line such as "O₂ 1.20/100 L · He 4.50/100 L · Air +0.10/100 L" at the top of `BlenderInvoiceCard.build()` (before or beside the +"Billed to" field) can be assembled from the same three providers with +`formatPreciseGasName` + `formatMoney`/price formatting already used +elsewhere in this file's sibling. + +**Caveat:** prices are **positional per bank**, not per gas identity (see +`blend_billing.dart` doc comment, and the earlier PR #1215 review note about +air being billed at helium's rate when this was conflated). A tariff summary +must therefore be built the same way `_priceField` builds its labels — by +zipping bank index to the *currently configured* fill gas for that bank — +not by grouping "all O2 lines" from historical invoice lines, which may have +been filled from a bank that has since been reconfigured to a different gas. + +## 3. "Abgerechnet" → editable invoice date + +**Current state:** `BlenderSectionTitle(context.l10n.gasCalculators_blender_billed)` +("Abgerechnet") is a static heading +(`blender_invoice_card.dart:88`). There is no date field anywhere in +`BlenderPreferences`/`blenderBilledFillsProvider`; `billedFills` is a single +flat, append-only list with no concept of "session" or "invoice date" — it's +one running bill, not a history of dated invoices. + +**Existing conventions to reuse:** + +- `lib/shared/widgets/app_date_picker.dart::showAppDatePicker()` — the + app-wide date picker wrapper that honors the diver's + `DateFormatPreference` (already used by `course_edit_page.dart`, + `service_record_dialog.dart`, `equipment_attribute_form_section.dart`, + `download_step_widget.dart`). This should be reused rather than a bare + `showDatePicker`. +- `UnitFormatter.formatDate()` (`core/utils/unit_formatter.dart`) for + displaying the chosen date in the diver's preferred format. + +**Implementation shape:** add `billedDate` (`DateTime?`, defaulting to "now" +when first shown) to `BlenderPreferences` + a `blenderBilledDateProvider`, +persisted/loaded the same way `billedTo` already is (same JSON blob, no +migration). Replace the static heading with a row: "Rechnung vom +{formatted date}" plus an edit affordance (icon button) opening +`showAppDatePicker`, following the same `onEditingComplete`/ +`saveBlenderPreferences(ref)` save pattern used by every other field in +this card. + +**Open question:** since `billedFills` is one running list with no +"invoice" grouping, a single `billedDate` is a property of the *whole* +current bill, not of an individual fill line. That matches the request +("Rechnung vom xy" as one heading), but means "Clear billed" +(`_confirmClear`) is presumably also where a new invoice date would get +reset/re-seeded to today — worth confirming during implementation. + +## 4. Export (PDF/Image/Excel) and Pay, at the end of the section + +**Export — the app already has first-class services for all three formats,** +none of it blender-specific yet: + +- **PDF:** package `pdf` (+ `printing`), service class pattern in + `lib/core/services/export/pdf/pdf_export_service.dart` + (`pw.Document()`-based), with a swappable template system under + `lib/core/services/pdf_templates/`. An options sheet + (`lib/features/transfer/presentation/widgets/pdf_export_dialog.dart`, + shown via `showModalBottomSheet`, returns a `PdfExportOptions?`) is the UI + pattern to copy for "Export as PDF". +- **Image:** two established patterns — `certification_card_renderer.dart` + draws directly to a `ui.PictureRecorder`/`Canvas` (no live widget needed), + while `dive_profile_chart.dart` / `dive_detail_page.dart` wrap a widget in + `RepaintBoundary` and call `boundary.toImage(pixelRatio: 2.0)` to snapshot + what's already on screen — the latter is the simpler fit for "export this + card as it looks now". `certification_share_sheet.dart` is the matching + share-sheet UI (writes to a temp file, then `SharePlus.instance.share`). +- **Excel:** package `excel_community` (fork of `excel`), service class + pattern in `lib/core/services/export/excel/excel_export_service.dart`. +- All three are normally reached through the facade + `lib/core/services/export/export_service.dart`, and all file writes/shares + go through the shared helpers in + `lib/core/services/export/shared/file_export_utils.dart` + (`saveAndShareFile`, `exportImageAsPng`, `sharePdfBytes`, ...) on top of + `share_plus`. A blender-invoice export should follow the same + facade/helper split rather than talking to `share_plus` directly. + +**Pay — no existing precedent.** A repo-wide search for +`isPaid`/`paidAt`/`markPaid`/`PaymentStatus`/any "invoice" concept outside +the gas blender itself turned up nothing: no dive-center invoice, course +payment, or service-cost record has a paid/unpaid status anywhere in the +app. This means: + +- There is no boolean-toggle-on-an-entity pattern to copy for this + specifically (the closest structural analogue is an unrelated plain + checkbox in `pdf_export_dialog.dart`). +- "Pay" needs its own, new, small decision before implementation: is it (a) + a local status flag ("mark this running bill as paid", stored the same + way as `billedTo`/`billedDate`, with no external payment processing — + there is no indication anywhere in the app of a payment-gateway + integration), or (b) something that also clears/archives `billedFills` + once marked paid (closer to what `_confirmClear` already does)? Given + `billedFills` is a single running list rather than a history of past + invoices, (b) seems more consistent with the existing model, but this is + a product decision, not a technical constraint, and should be confirmed + before implementation. + +## Summary of implementation shape (for the follow-up task) + +No Drift migration needed anywhere in this scope — `BlenderPreferences` is +one JSON blob (`settings` KV table), so every new field (`freeGasLiters` per +line, `billedDate`, a future `paid` flag) is an additive JSON key with a +fallback default on read, exactly like `billedTo`/`gasPrices` already work. +The tariff summary (§2) needs no persistence at all — it's derived live from +providers that already exist. Export (§4) is the only piece that pulls in +genuinely new files, and only because nothing blender-specific exists yet — +the underlying PDF/Image/Excel/share machinery is all already in place and +should be reused rather than re-implemented. diff --git a/docs/superpowers/specs/2026-09-02-dashboard-urgent-chips-design.md b/docs/superpowers/specs/2026-09-02-dashboard-urgent-chips-design.md new file mode 100644 index 0000000000..9ade9f8618 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-dashboard-urgent-chips-design.md @@ -0,0 +1,170 @@ +# Dashboard: replace the urgent banner with hardened gauge chips + +Date: 2026-09-02 + +## Problem + +The dashboard opens with `UrgentBanner`, a full-width `errorContainer` slab +pinned above every other card. With four overdue service clocks it occupies +roughly 350px before the diver sees their own greeting: a title, up to four +rows each floored at a 48px tap target around a `bodySmall` label, and a +chevron per row. It reads as a stack of full-width buttons in a red box. + +It is also largely redundant. `GaugeStrip`, rendered a hundred pixels below, +already renders every due gear clock as a chip and uses the identical +localized string (`dashboard_gauges_gearOverdue`), so an overdue regulator +appears twice on first paint. + +## Why it is not a pure duplicate + +The two surfaces disagree about granularity, and that is the load-bearing +detail for this change. + +- `UrgentBanner` reads `DashboardAlerts.serviceClocksDue`, which is **per + item**. +- `GaugeStrip` reads `DashboardGauges.gearGauges`, built by `dueGearGauges`, + which runs `worstGaugePerType` first and collapses to **one gauge per + `EquipmentType`**. + +A diver with four lapsed regulators therefore sees four banner lines but one +chip. Deleting the banner without changing the provider would silently drop +three overdue items. The provider has to un-collapse overdue gear before the +banner can go. + +## Design + +### 1. Overdue gear is listed per item + +`dueGearGauges` returns a record instead of a bare list: + +```dart +({List gauges, int overdueOverflow}) dueGearGauges( + List clocks, { + int cap = 6, + int overdueCap = 4, +}); +``` + +- Overdue clocks are collected **per item** (each item contributing its own + worst clock), sorted by due date with undated last. +- Due-soon clocks keep the existing `worstGaugePerType` collapse, which keeps + the strip short in the common case where nothing is actually lapsed. An + item that is overdue makes its whole type overdue, so that type produces no + due-soon chip. +- Overdue chips claim slots from `cap` first; `overdueOverflow` reports how + many overdue items did not fit and renders as a single `+N more overdue` + chip routing to `/equipment`. + +The overflow count travels on `DashboardGauges.gearOverdueOverflow` so the +widget has one source of truth for both the chips and the overflow, rather +than a second function recomputing the same cap. + +### 2. Chips carry their severity to the render step + +`GaugeStrip` currently appends `Widget` values straight into a `List`, +discarding the `_Tone` that `_chip` was given. Nothing downstream can then +sort or filter by severity. + +The strip instead collects `_ChipSpec` records: + +```dart +typedef _ChipSpec = ({ + HomeChipType type, + _Tone tone, + IconData icon, + String label, + VoidCallback onTap, + VoidCallback? onLongPress, +}); +``` + +Specs are rendered through the unchanged `_chip` builder at the end, after an +index-tagged stable sort that moves `_Tone.alert` specs to the front and +leaves every other chip in today's source order. + +### 3. Safety-relevant alerts ignore the hidden-chips setting + +Three chip types are dive-safety facts rather than habit nags, and stay +visible even when the diver has hidden their type: + +```dart +static const _hardened = { + HomeChipType.gear, + HomeChipType.insurance, + HomeChipType.flightWindow, +}; + +bool _visible(_ChipSpec s, Set hidden) => + (s.tone == _Tone.alert && _hardened.contains(s.type)) || + !hidden.contains(s.type.name); +``` + +Only those three blocks build their specs unconditionally. Every other block +keeps its existing `_shown` guard: none of them can produce an alert tone, so +none can be forced, and leaving the sync block guarded keeps its +`isSyncingProvider` watch out of the tree when the chip is hidden. + +Last-dive currency and backup age can also reach `_Tone.alert`, and they stay +hideable on purpose. A diver who hid the "last dive 400d ago" chip made a +deliberate choice about a nag, not about a safety gate. + +### 4. The strip itself cannot be hidden out of a live safety alert + +`HomeCardType.gaugeStrip` is user-hideable, so hardening individual chips is +not enough on its own. `DashboardPage` forces `gaugeStrip` into `visibleCards` +when `dashboardGaugesProvider` reports overdue gear, expired insurance, or a +closed/conflicting flight window. This keys off the provider the strip already +reads, so there is no second source of truth for what counts as urgent. + +This preserves the guarantee that made the banner pinned in the first place. + +### 5. Removals + +- `urgent_banner.dart` and `urgent_banner_test.dart`. +- The `showUrgent` computation and both `UrgentBanner` render sites in + `dashboard_page.dart`; the all-cards-hidden branch collapses back to a bare + `_AllCardsHiddenState`. +- `dashboardAlertsProvider` and `DashboardAlerts`, whose only remaining + consumer was `showUrgent`, plus their tests. +- The stale "urgent banner is deliberately absent" comment in `home_cards.dart`. +- `dashboard_urgent_title` from all 11 ARB files. + +### 6. Localization + +One new key, translated in all 11 locales: + +```json +"dashboard_gauges_gearOverdueMore": "+{count} more overdue" +``` + +## Testing + +Provider (`gauge_providers_test.dart`): + +- overdue items are listed individually rather than collapsed per type +- due-soon still collapses to the worst clock per type +- an overdue item suppresses the due-soon chip for its own type +- an item with several clocks contributes its worst clock once +- overdue chips are capped and the overflow count is reported +- overdue chips claim cap slots ahead of due-soon chips + +Widget (`gauge_strip_test.dart`): + +- alert-tone chips render before every other chip +- gear, insurance, and flight-window alert chips render while their type is in + `hiddenHomeChips` +- a 400-day last-dive chip and a 45-day backup chip still hide when their type + is hidden +- the `+N more overdue` chip renders and routes to `/equipment` + +Page (`dashboard_page_test.dart`): + +- `HeroHeader` is the topmost widget when alerts are live (replaces the three + urgent-banner placement tests) +- the gauge strip renders when its card is hidden but a safety alert is live + +## Result + +The red slab is gone, the greeting is the first thing on the page, and four +overdue regulators become four red chips at the front of a strip that already +existed, instead of one chip plus a duplicated 350px banner. diff --git a/docs/superpowers/specs/2026-09-02-planner-derived-segments-tank-configs-design.md b/docs/superpowers/specs/2026-09-02-planner-derived-segments-tank-configs-design.md new file mode 100644 index 0000000000..8e6048be9f --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-planner-derived-segments-tank-configs-design.md @@ -0,0 +1,1418 @@ +# Dive Planner: Derived Segment Phases and Saved Tank Configurations + +**Status:** design approved in chat 2026-09-02; spec awaiting review. Parts A, C, D, E and F implemented on `worktree-planner-redesign`; part B not started +**Branch:** TBD (one worktree per part) +**Baseline:** `origin/main` at 6ba0f5cedc0 + +## Problem + +Two unrelated papercuts in the planner, both rooted in the planner asking the +diver to restate information it already has. + +### A. The diver declares a segment type the planner could infer + +`SegmentEditor` opens with a `DropdownButton` over all six values +of `SegmentType` (`segment_editor.dart:121-147`), and the diver must pick +Descent / Bottom / Ascent / Deco Stop / Gas Switch / Safety Stop *before* +entering depths. The choice is then largely ignored, and where it is not +ignored it can contradict the numbers: + +- A segment declared Descent with `startDepth: 30, endDepth: 12` is stored, + charted and decompressed as an ascent, but billed at the bottom SAC rate. +- Picking a type mutates the depth fields underneath the diver: + `_updateDefaultsForType` (`segment_editor.dart:273-314`) squashes Bottom to + flat by copying end depth into start depth, and forces Safety Stop to 5 m / + 3 min. +- Both depth fields are always visible for every type. Descent does not hide + start depth; Bottom does not hide end depth. The only per-type field + visibility in the whole editor is the rate box (`segment_editor.dart:95`). + +Meanwhile the chart already does the right thing: dragging a vertex re-derives +the type from the slope sign (`plan_chart_edit_controller.dart:89-103`). So the +app contains two contradictory models of what a segment type is - a declared +input in the editor and a derived label on the chart. + +### B. Tank setups cannot be saved, named or reused in the planner + +`PlanTankList`'s `_TankEditDialog` (`plan_tank_list.dart:168-370`) is five free +text fields, a role dropdown and a checkbox. There is no preset picker, no +saved-configuration picker, and no way to name and keep a tank setup. Every +plan starts from a hardcoded AL80 minted by +`DivePlanNotifier._createDefaultTank()` +(`dive_planner_providers.dart:144-155`), and a diver who plans the same +double-12 + AL40 deco rig every week retypes it every week. + +## Findings + +### The stored `SegmentType` is almost entirely redundant + +The decompression model never sees it. `plan_engine.dart:234` feeds +`DecoSegment(startDepth, endDepth, durationSeconds)` straight into +`BuhlmannGf`; ppO2/CNS/OTU use `avgDepth` and `deeperEnd` +(`plan_engine.dart:243-244`, `:718-722`); the chart profile +(`plan_canvas_providers.dart:145-151`), the bailout solver +(`bailout_solver.dart:139-152`) and the buoyancy twin +(`plan_buoyancy_twin_provider.dart:36`) all interpolate from +`startDepth`/`endDepth`/`durationSeconds` alone. The buoyancy twin explicitly +tests `seg.startDepth == seg.endDepth` rather than reading the type. + +Complete inventory of behaviour that depends on `segment.type`: + +| Site | Behaviour | Derivable from the numbers? | +| --- | --- | --- | +| `plan_engine.dart:588-589` | SAC selection: `bottom \| descent` -> `sacBottom`, else `sacDecoEffective` | Yes. Descending or level-at-depth -> bottom SAC; ascending or level-on-the-ascent -> deco SAC | +| `contingency_service.dart:180` | `deviatePlan` adds the time delta only to `bottom` segments | Yes. The same method already matches its *depth* delta by `startDepth/endDepth ~= plan.maxDepth` (`:164-168`) | +| `range_table_service.dart:122` | `_shortestBottomMinutes` skips non-`bottom` segments | Yes. Level leg at max depth | +| `plan_engine.dart:296` | Records `ndlAtBottom` / `ttsAtBottom` | Already `type == bottom \|\| endDepth >= maxDepth - 0.1` - the type half is redundant | +| `plan_calculator_service.dart:225` | Same capture in the legacy calculator | Same | +| `plan_calculator_service.dart:237` | Gates the `ndlExceeded` warning to `bottom` segments | Yes, and arguably should not be gated at all | +| `plan_chart_edit_controller.dart:48` | `draggable: type != gasSwitch` | See gas switches below | +| `plan_chart_edit_controller.dart:89-103` | Re-derives the type after a drag | Already derived: slope sign, with flat legs coerced to `bottom` | +| `plan_segment.dart:112-144`, `segment_list.dart:222-276`, `segment_editor.dart:316-405` | Icon, colour, label, dropdown text | Presentation only | + +Nothing else in `lib/` branches on the type. + +### Gas-switch segments are already vestigial + +A `gasSwitch` segment's only non-derivable payload is `switchToTankId`, and +`SegmentEditor._save` (`segment_editor.dart:333-367`) **never sets it** - so +every gas-switch segment created through the UI is a 60-second flat segment +with a null switch target. `plan_file_codec.dart:70-82` does not serialise +`switchToTankId` at all, so it is lost on export/import even when set. A gas +switch is already fully expressed by consecutive segments carrying different +`tankId` values, which is what the engine's per-segment `charge(segment.tankId, +...)` actually reads (`plan_engine.dart:591`). + +### `startDepth` is stored, and nothing keeps the chain intact + +`startDepth` is a stored per-segment column that every consumer reads +literally. It is chained only at authoring time, as a seed: the Add dialog +passes `initialStartDepth: segments.last.endDepth` +(`segment_list.dart:92-97`), and a chart drag explicitly writes the new depth +into the *next* segment's `startDepth` (`plan_chart_edit_controller.dart: +134-137`). The Edit path passes nothing. + +`reorderSegments`, `removeSegment` and `updateSegment` +(`dive_planner_providers.dart:241-291`) renumber `order` and never repair the +depth chain. So a plan where segment N ends at 30 m and segment N+1 starts at +12 m is representable, persists, syncs, and is computed as an instantaneous +teleport - `plan_canvas_providers.dart:146` even carries a +`profile.last.depth != segment.startDepth` guard to draw the discontinuity. +This is a latent correctness bug, not a cosmetic one: the Buhlmann integration +receives a leg that skips 18 m of ascent in zero time. + +### Per-segment `rate` is dead + +`PlanSegment.rate` is stored (`database.dart:632`), persisted +(`dive_plan_repository.dart:502`), serialised (`plan_file_codec.dart:80`) and +synced - and read by **zero** calculations. Its only consumer is seeding the +editor's rate text box (`segment_editor.dart:78`). All travel-leg maths uses +the plan-level `descentRate`/`ascentRate` (`dive_plan.dart:43-44`, +`plan_engine.dart:172-174`, `:474`, `:486`, `:603`, `:621`, `:696`, `:985`). +Its sign convention (`plan_segment.dart:56-57`: positive descent, negative +ascent) is upheld by `PlanSegment.ascent` (`:248`) and violated by the editor, +which always stores a positive value (`segment_editor.dart:361`). + +### The requested tank feature already exists, unwired + +`lib/features/cylinder_configs/` is a named, ordered, multi-cylinder +configuration store carrying exactly the fields the planner needs: +`CylinderConfigItem` has `label`, `tankRole`, `volumeL`, +`workingPressureBar`, `tankMaterial`, `o2Percent`, `hePercent`, +`defaultStartPressureBar` +(`cylinder_config_item.dart:10-109`). It persists to `cylinder_configs` / +`cylinder_config_items` (`database.dart:2429-2478`, added at schema v139) and +rides the sync layer (`sync_repository.dart:82-83`). + +Its only consumer is the dive edit page (`dive_edit_page.dart:2824`, +`:3104-3140`). Nothing under `lib/features/dive_planner/**` or +`lib/features/planner/**` references it. + +Three pieces are directly reusable: + +- `ApplyConfigurationMenu` (`apply_configuration_menu.dart:14-17`) takes only + `ValueChanged onSelected`, reads its own providers, and + renders `SizedBox.shrink()` when the diver has no configurations. Mountable + in the planner as-is. +- `DiveTankConfigAdapter._fromItem` + (`dive_tank_config_adapter.dart:32-42`) already maps a config item to a + `DiveTank` **including `gasMix`, `material` and `workingPressure`**. +- `CylinderConfigApplier.plan()` is pure, with an injected `newId` factory. + +One rule does not transfer. `FillTank` deliberately has no `o2Percent` / +`hePercent` fields (`cylinder_config_applier.dart:41-49`): for a logged dive, +`dive_tanks` defaults gas to air, so "unset" is indistinguishable from "air" +and overwriting a mix would be a safety-relevant falsehood in a logbook. That +reasoning is sound for the log and wrong for the planner, where the gas mix is +the primary thing being planned. + +Finally, the reverse direction does not exist anywhere in the app: there is no +"capture the current cylinders as a named configuration" flow on any screen. +`TankEditor._saveAsPreset` (`tank_editor.dart:215-266`) saves one cylinder's +hardware spec as a `TankPresetEntity` - no gas, no role, no set. + +## Decisions + +| Question | Decision | +| --- | --- | +| Derive the type, or remove the concept? | Remove `SegmentType` as *input*. A derived `SegmentPhase` remains, for labels and SAC selection only | +| Keep `startDepth` stored? | No. Full waypoint model: a segment is `(targetDepth, duration, tank)`; start depth is the previous segment's target, first segment starts at the surface | +| Where does the derived geometry live? | A new pure `SegmentChain` resolver producing `ResolvedLeg`s. Consumers take resolved legs, not raw segments | +| Gas-switch segments? | The type disappears. A switch is a `tankId` change between consecutive segments, which is what the engine already reads | +| Per-segment `rate`? | Dropped. Dead field; the effective rate is `(target - start) / duration` | +| Saved tank configurations | Reuse `cylinder_configs`. No fourth tank concept | +| Planner apply semantics | Replace, not merge. New mode in the adapter; the log's merge path is untouched | +| Schema migration | None required for part A. `end_depth` already holds the target depth | +| Authoring order | Tanks first, then segments, in every layout. A diver picks the gas they are carrying before drawing the profile that breathes it | +| Cylinder roles | Derived too (part D). `bailout` stays an explicit flag, offered only on a loop plan, because a 100% cylinder there is either the O2 supply or a bailout bottle and guessing wrong understates the bailout gas | +| Tank fields | Name, size, start pressure, mix, travel flag. End pressure is an output the engine computes, not an input | +| Segment runtime | Shown per row, derived on the chain like everything else | +| Deco ceiling semantics (part E) | The ceiling is a fixed point of the GF-vs-depth interpolation, and a property of tissue state alone. `currentDepth` leaves the ceiling API | +| Trial-ascent ordering (part E) | Tested on arrival at the stop, before any minute is loaded. The old order credited off-gassing the schedule never spent | +| Ascent rates (part F) | Four configurable rates on TDI's phases, not Subsurface's mean-depth-relative bands: bottom to first stop, between intermediate stops, between shallow stops, last stop to surface. Defaults 9 / 6 / 3 / 1 m/min | +| Delivery | Six independent PRs; no part depends on another. Part C is small enough to land on its own immediately. Part E is engine-only and touches no planner UI | + +### Frozen units + +The engine and all entities stay metric (metres, bar, litres, m/min). Every +depth, volume and pressure field in the editor and the tank dialog reads and +writes the active diver's unit via the existing conversion helpers, per the +project rule that anything displaying units respects the diver's settings. + +## Part A - Derived segment phases and the waypoint model + +### New: `lib/features/planner/domain/entities/segment_phase.dart` + +```dart +/// What a leg of the planned profile is doing, derived from its geometry. +enum SegmentPhase { descent, level, stop, ascent } +``` + +`level` and `stop` are both flat; they differ only in which SAC rate applies +and which icon is drawn. + +### New: `lib/features/planner/domain/services/segment_chain.dart` + +Pure, no Flutter, no `DateTime.now()`. Turns the authored waypoint list into +the resolved geometry every consumer needs. + +```dart +class ResolvedLeg { + final PlanSegment segment; + final double startDepth; // previous leg's target, or 0.0 for the first + final double endDepth; // == segment.targetDepth + final SegmentPhase phase; + final double? rate; // m/min, signed; null when duration == 0 + final int runtimeSeconds; // elapsed dive time at the END of this leg + + double get avgDepth => (startDepth + endDepth) / 2; + double get deeperEnd => math.max(startDepth, endDepth); + int get durationSeconds => segment.durationSeconds; +} + +class SegmentChain { + const SegmentChain(); + List resolve(List segments); +} +``` + +Phase derivation, in `resolve`: + +1. `endDepth > startDepth` -> `descent` +2. `endDepth < startDepth` -> `ascent` +3. equal -> `stop` if `endDepth < maxTargetDepth` **and** no later leg has a + target deeper than this one; otherwise `level` + +Rule 3 is what makes a 6 m flat leg after the bottom read as a stop while a +20 m flat leg in a multi-level dive that later drops to 25 m reads as a level. +It reproduces today's declared types for every profile a diver would plausibly +author. + +### Changed: `PlanSegment` + +Removed: `type`, `startDepth`, `rate`, `switchToTankId`. +Renamed: `endDepth` -> `targetDepth` (same semantics, same column). +Kept: `id`, `targetDepth`, `durationSeconds`, `tankId`, `gasMix`, +`setpointBar`, `diveModeOverride`, `order`. + +The six named factories (`plan_segment.dart:185-315`) collapse to one +`PlanSegment.waypoint({required targetDepth, required durationSeconds, +required tankId, required gasMix, ...})`. The `description` / `shortLabel` +getters (`:112-144`) are deleted rather than moved - both are already unused in +`lib/`; the UI localises through `segment_list.dart` instead. + +`avgDepth`, `isDepthChange` and `calculatedRate` move to `ResolvedLeg`, since +a segment in isolation no longer knows where it starts. + +### Changed: `PlanEngine` + +`plan_engine.dart` resolves once at the top of `compute()` and threads +`List` through the private helpers that currently take +`List`. + +- Line 234 (`DecoSegment`) reads `leg.startDepth` / `leg.endDepth` - identical + values for any continuous plan, repaired values for a discontinuous one. +- Lines 588-589 become: + + ```dart + final sac = switch (leg.phase) { + SegmentPhase.descent || SegmentPhase.level => plan.sacBottom, + SegmentPhase.ascent || SegmentPhase.stop => plan.sacDecoEffective, + }; + ``` + + This is behaviour-preserving: the loop at `:586` runs over authored segments + only, and the computed ascent is charged separately at `sacDecoEffective` + (`:600-628`). +- Line 296 drops the `type == bottom` disjunct and keeps the depth test. + +### Changed: `ContingencyService` and `RangeTableService` + +Both need "which leg is the bottom". Add one shared helper on `SegmentChain`: + +```dart +int? bottomLegIndex(List legs); // deepest level leg, last wins +``` + +`contingency_service.dart:180` applies its time delta to that index; +`range_table_service.dart:122` reads its duration. `contingency_service.dart: +164-168` already matches by depth and keeps doing so, except that it now +shifts only segment *targets* - the descent that feeds the bottom targets max +depth too, and every other leg follows the chain. + +This changes one behaviour on a multi-level plan: the old loop added the time +delta to *every* segment typed `bottom`, so a two-level dive got it twice. It +now buys bottom time once, on the deepest level leg, which is what "+5 min" is +meant to mean. + +### Changed: `SegmentEditor` + +The type dropdown, both depth fields and the rate field are replaced by: + +- **Depth** - the target depth, in the diver's unit. Labelled "Depth", not + "End Depth"; `divePlanner_segmentEditor_endDepth` is retired in favour of a + new `divePlanner_segmentEditor_depth`. +- **Duration** - minutes, unchanged. +- **Tank / gas** - unchanged. +- Setpoint and dive-mode override - unchanged, and now correctly preserved + (see below). + +A read-only line above the fields states the derived phase and the implied +rate: "Descent from 0 m at 18 m/min" / "Level at 30 m" / "Ascent to 6 m at +9 m/min". This is where the diver sees the inference, so it is not magic. + +`_updateDefaultsForType` (`:273-314`) is deleted outright - there is no type to +change, and nothing may rewrite a depth the diver typed. + +`_save` (`:333-367`) stops writing `rate` (gone), stops defaulting it to 10, +and **preserves `setpointBar` and `diveModeOverride`** by copying them from the +segment being edited. Today it drops both on every edit round-trip. + +### Changed: `SegmentList` + +Icon, colour and label switch on `leg.phase` instead of `segment.type`. Four +cases instead of six. The existing l10n strings map across: + +| Phase | l10n key | +| --- | --- | +| `descent` | `divePlanner_segmentList_descent` (existing) | +| `level` | `divePlanner_segmentList_bottom` (existing) | +| `stop` | `divePlanner_segmentList_deco` (existing) | +| `ascent` | `divePlanner_segmentList_ascent` (existing) | + +No new list strings; `divePlanner_segmentList_gasSwitch` and +`..._safetyStop` are retired. A leg whose `tankId` differs from the previous +leg's gets a gas-switch chip on its row, reusing the existing +`divePlanner_segmentList_gasSwitch` ("Gas switch to {gasName}") string. The +switch becomes an annotation on the leg that starts breathing the new mix, +which is also what the engine charges. + +### Changed: `PlanChartEditController` + +Simplifies substantially. `_retyped` (`:89-103`) is deleted - the phase is +never stored, so there is nothing to re-type. `draggable` (`:48`) becomes +unconditionally `true`. Writing the dragged depth into the next segment's +`startDepth` (`:134-137`) is deleted; the chain follows automatically. + +This also fixes the bug at `:99-111`, where the sloped branch rebuilt the +segment with a raw constructor and silently dropped `rate`, `setpointBar` and +`diveModeOverride` - only the first was documented as intentional (`:85-87`). + +### Changed: providers + +`dive_planner_providers.dart:241-291` (`reorderSegments`, `removeSegment`, +`updateSegment`) keep renumbering `order` and no longer have a depth chain to +corrupt: removing a middle segment now closes the gap by construction. + +`addSimplePlan` (`:311-326`) is rewritten in terms of waypoints and picks up +`state.descentRate` correctly - today it calls `PlanSegment.descent` without +`rate:` and silently uses the factory's hardcoded 18 m/min regardless of the +slider. + +### Persistence and compatibility + +**No schema migration.** `dive_plan_segments.end_depth` already holds exactly +the target depth, so `targetDepth: row.endDepth` reads every existing plan +correctly, and a continuous plan resolves to a byte-identical profile. + +- `type`, `start_depth` and `rate` are no longer *read* back, but they are + still *written*, filled from the resolved leg. Two reasons. `type` and + `start_depth` are NOT NULL with no default, so ceasing to write them would + need a destructive table rebuild for no gain. And filling them from the + chain keeps them true, so an older build or a sync peer reading the row + still sees the profile the diver authored rather than a placeholder. The + phase maps back as `level -> bottom` and `stop -> decoStop`; `safetyStop` + and `gasSwitch` are never written, since neither was ever distinguishable + from the geometry. `switch_to_tank_id` is nullable and is left null. +- A consequence worth noting: re-saving a plan that had a stored + discontinuity rewrites `start_depth` to the repaired value, so the repair is + durable rather than re-applied on every load. +- A later cleanup migration can drop all four columns once no released + version reads them. +- `dive_plan_repository.dart:594` currently does + `SegmentType.values.byName(s.type)`, which **throws** on an unrecognised + name. Deleting that line removes a live crash surface. +- Rows with `type == 'gasSwitch'` become 60-second flat legs at their stored + depth. Depth profile and deco loading are unchanged - that is exactly what + the engine computed before - and so is the SAC rate in the case that + matters: a switch held on the way up classifies as a stop, which is the deco + SAC it was already charged at. Only a switch authored at max depth would move + to the bottom SAC, and that is not a switch any real profile performs. +- `plan_file_codec.dart` writes format version `2`: `targetDepth` replaces + `endDepth`, and `type` / `startDepth` / `rate` are omitted. Decode accepts + both: a v1 file's `endDepth` is read as `targetDepth` and its `type` and + `startDepth` ignored. Version `1` files therefore keep importing, which + matters because `.subplan` is a sharing format. The existing silent fallback + at `:168-169` (`?? SegmentType.bottom`) disappears with the field. +- `sync_data_serializer.dart:2941` / `:3800` continue to round-trip the row; + the retired columns simply carry their defaults on new writes. + +### Out of scope for part A + +Per-segment setpoint and dive-mode override semantics (only their preservation +is fixed here). The plan-level rate sliders. `plan_calculator_service.dart` is +updated only where it references the removed fields - the legacy calculator is +not rewritten. + +## Part B - Saved tank configurations in the planner + +Independent of part A; can ship first or in parallel. + +### As built (2026-09-02) + +The diver asked for saved tanks as *selectable objects*: a bar above the +plan's tanks that opens into a box of every saved cylinder, each of which +joins the plan when tapped. That is a different shape from the +`ApplyConfigurationMenu` merge designed below, so the build diverged from +the design in these ways and kept the rest: + +- New `PlanSavedTanksBar` + (`lib/features/dive_planner/presentation/widgets/plan_saved_tanks_bar.dart`), + mounted in `PlanTankList` between the header and the tank chips. Closed by + default, titled "Saved tanks (n)"; open, it lists every saved cylinder + (every configuration's items, flattened) as its own chip. Tapping a chip + calls `DivePlanNotifier.addTank` with the cylinder converted by the new + `DiveTankConfigAdapter.tankFromItem`, gas included. Picking is a copy: the + saved cylinder stays offered. Open state is `savedTanksExpandedProvider`, + session-scoped like the rest of the planner's collapse state. +- Saving is one tank at a time, at the diver's request. "Save a tank" opens + a menu of the plan's tanks; picking one prompts for a tank name (the + `showPlanNameDialog` prompt gained a `fieldLabel` so it no longer says + "Plan Name" here), seeded with the tank's name. The tank is captured with + the new `TankConfigCapture.fromTanks` - the reverse adapter designed below + - and appended to a configuration named "Saved tanks" that is created for + the active diver on first use, so the equipment pages show everything saved + from the planner as one list rather than one configuration per tank. The + bar opens afterwards so the diver sees what they saved. The Manage link + (to `/equipment/cylinder-configs`) is where saved tanks get renamed or + removed. +- Not built: `replaceTanks`, the whole-configuration merge, the default-tank + preset resolver, and the tank-preset picker in `_TankEditDialog`. None was + asked for and the pick-one-cylinder shape does not need them. A tank saved + from the planner therefore carries whatever `workingPressure` and + `material` the plan had, which for a tank typed into the planner's dialog + is none; the volume, gas and start pressure a plan actually uses round-trip + exactly, and `tank_config_capture_test.dart` pins that. + +### Changed: `DiveTankConfigAdapter` - a replace mode + +`dive_tank_config_adapter.dart` gains one method beside `apply()`: + +```dart +/// Materialises [items] as a complete tank list, discarding what was there. +/// +/// The planner replaces rather than merges: a plan's cylinders are an input +/// the diver is choosing, not a record of a dive that happened, so filling +/// only null columns (as apply() does) would leave a half-applied rig. Gas +/// mix is carried here for the same reason it is refused by FillTank - in a +/// plan the mix is the thing being chosen, so silently keeping the old one +/// would be the falsehood. +List replace({ + required List items, + required String Function(int index) newId, +}); +``` + +The body is a `_fromItem` map over `items` - `_fromItem` +(`dive_tank_config_adapter.dart:32-42`) already carries `gasMix`, `material`, +`workingPressure`, `role`, `label` and `defaultStartPressureBar`. The log's +merge path and `CylinderConfigApplier` are untouched, so the safety reasoning +at `cylinder_config_applier.dart:41-49` still holds where it was written. + +### New: `lib/features/cylinder_configs/domain/services/tank_config_capture.dart` + +The reverse adapter, which the app currently lacks entirely. + +```dart +/// Builds config items from an in-memory tank list. +/// +/// Pure; [newId] mints item ids so this stays testable. +class TankConfigCapture { + const TankConfigCapture(); + + List fromTanks({ + required List tanks, + required String configId, + required String Function(int index) newId, + }); +} +``` + +Mapping is field-for-field: `name -> label`, `role -> tankRole`, +`volume -> volumeL`, `workingPressure -> workingPressureBar`, +`material -> tankMaterial`, `gasMix.o2 -> o2Percent`, +`gasMix.he -> hePercent`, `startPressure -> defaultStartPressureBar`, +`order -> sortOrder`. `endPressure`, `computerId`, `presetName`, +`decoSwitchDepth` and `isTravelGas` are not part of a configuration and are +dropped. Placing this in `cylinder_configs` rather than in the planner means +the dive editor can reuse it, which closes gap 3 of issue #800. + +### Changed: `PlanTankList` + +A "Saved tanks" row above the tank chips: + +- `ApplyConfigurationMenu(onSelected: ...)` mounted unchanged, calling + `DiveTankConfigAdapter.replace` and handing the result to a new + `DivePlanNotifier.replaceTanks(List)`. It self-hides when the + diver has none, so the row is never a dead end. +- A "Save tanks as..." action, enabled when at least one tank is present. It + prompts for a name (reusing the `showPlanNameDialog` pattern from + `plan_name_dialog.dart:10`, generalised into a small + `showNamePromptDialog`), then `TankConfigCapture.fromTanks` -> + `CylinderConfigRepository.createConfig` + `saveItems`. Saving with an + existing name offers to overwrite that configuration. +- A "Manage saved tanks" entry navigating to `/equipment/cylinder-configs` + (`app_router.dart:552`). + +### Changed: `DivePlanNotifier` + +New `replaceTanks(List tanks)`. It must do what `removeTank` +(`dive_planner_providers.dart:394`) already does for orphans: any segment whose +`tankId` is no longer present is reassigned to `tanks.first`, and its `gasMix` +is refreshed from that tank - the same invariant `updateTank` (`:372`) +maintains. Replacing with an empty list is refused, matching `removeTank`'s +refusal to remove the last tank. + +`_createDefaultTank()` (`:144-155`) additionally consults +`DefaultTankPresetResolver.resolve(presetName)` +(`default_tank_preset_resolver.dart:22`) so a new plan opens with the diver's +configured default cylinder instead of a hardcoded AL80. The resolver is +async and returns nullable, so the notifier keeps the current AL80 as the +synchronous fallback and refines the tank once the resolver settles - a new +plan must never block on I/O before its first render. + +### Changed: `_TankEditDialog` + +The planner's dialog leaves `workingPressure`, `material` and `presetName` null +(`plan_tank_list.dart:340-370`), so a save-then-apply round trip would lose +them. It gains the tank-preset picker the dive-log tank editor already has +(`tank_editor.dart:418+`): choosing a preset seeds volume, working pressure and +material, exactly as `CylinderConfigItemEditor._applyPreset` +(`cylinder_config_item_editor.dart:63-75`) does. Round-trip fidelity through a +saved configuration follows from that. + +### Out of scope for part B + +Linking planner tanks to owned `EquipmentType.tank` equipment (gap 1 of +issue #800). Rebreather-owned configurations are listed by +`ApplyConfigurationMenu` and apply normally, but the planner does not filter +them by plan mode. + +## Part C - Tanks before segments + +Independent of A and B, and by far the smallest change. + +### Rationale + +Both editing layouts currently put the profile first and the cylinders second, +which inverts the authoring order. A segment's tank dropdown +(`segment_editor.dart`, the Tank / Gas field) can only offer what already +exists, so a diver drawing the profile first is picking from a single +hardcoded AL80 (`dive_planner_providers.dart:144-155`) and must go back and +re-pick tanks on every segment after defining the real rig. Gas first, then +the profile that breathes it. + +This matters more after part A: with the type dropdown gone, the segment editor +is down to depth, duration and tank, so the tank choice becomes proportionally +the most significant thing in it. And it matters more after part B, because +applying a saved configuration is the natural first action when opening the +planner. + +Note on naming: the widget being promoted is `PlanTankList`, which is where gas +mixes actually live. `PlanGasSection` (`plan_gas_section.dart`) holds no gas +mixes at all despite its name - only the SAC/RMV slider and reserve pressure +(`:36-70`, `:113`) - so it stays in the Setup accordion where it is. + +### Changed: `PlanEditorPane` (desktop and wide) + +`plan_editor_pane.dart:51-53`: `PlanTankList` moves above `SegmentList`. The +Setup accordion stays the last child, which the pane's setup-focus scroll +depends on (`:35-45` animates to `maxScrollExtent` to materialize the lazily +built accordion), so that behaviour is unaffected. + +The class doc comment at `:11-12` ("segments and tanks always visible") is +reworded to match. + +### Changed: phone tab deck + +`plan_canvas_page.dart`: + +- The `tabs` list (`:421-426`) reorders to Tanks, Plan, Setup, Results - + `divePlanner_label_tanks` then `divePlanner_tab_plan`, both existing strings. +- `_phoneTabBody` (`:499-524`) swaps cases `0` and `1`: `0` returns + `PlanTankList`, `1` returns `SegmentList`. Cases `2` (Setup) and `3` + (Results) are untouched, so the two hardcoded index writes stay correct as + they are: `_focusSetup` -> `2` (`:308`) and the issues-chip handler -> `3` + (`:450`). +- The layout-mode doc comment at `:54` becomes + "Chart + Tab Deck (Tanks / Plan / Setup / Results)". + +`plannerPhoneTabProvider` defaults to `0` +(`planner_layout_providers.dart:19`) and is in-memory session state, so a +phone diver now lands on Tanks when opening the planner. That is the intent, +not a side effect. + +### Compatibility + +Nothing persisted encodes the tab order, so there is no migration and no stale +stored index to reconcile. + +### Out of scope for part C + +Reordering the Setup accordion's own sections +(`plan_setup_accordion.dart:81`), and the fullscreen chart page. + +## Part D - Derived cylinder roles + +Same argument as part A, applied to the tank editor. Independent of A, B +and C. + +### Problem + +The planner's tank dialog asked for a `TankRole` from a nine-value dropdown +(`plan_tank_list.dart:310-328`) - back gas, stage, deco, bailout, sidemount +left/right, pony, diluent, O2 supply. Two of those nine drive nothing at all +in a planner, and the choice can contradict the rest of the plan: a cylinder +labelled Back Gas that no segment breathes, or a Deco bottle leaner than the +bottom mix. + +Unlike the segment type, role is genuinely load-bearing - it is read in 14 +places and decides real numbers: + +| Consumer | What role decides | +| --- | --- | +| `bailout_solver.dart:74` | Which cylinders are bailout. No bailout-role tank and `solve()` returns null, so there is no bailout plan at all | +| `plan_engine.dart:510`, `:517` | CCR metabolic O2 charged to the O2 supply; loop-fill diluent charged to the diluent | +| `plan_engine.dart:666`, `:688` | Turn pressure and rock-bottom min gas - back gas only | +| `plan_engine.dart:553` | Bailout cylinders are exempt from the reserve-violation check | +| `plan_engine.dart:920` | The "CCR in deco with no bailout gas" issue | +| `_hasDecoGas` `:934`, `_tankForGas` `:1005` | Whether a deco gas is carried, and that deco stops bill the deco bottle rather than the back gas | +| `contingency_service.dart:79`, `:121` | Which cylinders lost-gas can lose, and the back-gas fallback | + +So it cannot simply be deleted. It can be *derived*, and derived more +reliably than the diver's dropdown, because the plan already records which +cylinder every segment breathes and what each mix's MOD is. + +### New: `lib/features/planner/domain/services/tank_role_resolver.dart` + +Pure. `apply(DivePlan) -> DivePlan` returns the plan with every cylinder's +role derived; `rolesFor(DivePlan)` exposes the map for display. + +- **back gas** - the cylinder the deepest leg breathes. Straight out of the + segment data, not a guess. With no segments yet, the first cylinder. +- **deco** - any other cylinder richer than the bottom mix, so a shallower + MOD. +- **stage** - same or leaner than the bottom mix but not the bottom cylinder: + a stage or pony. Travel-flagged cylinders are filed here explicitly, because + a travel gas must never become the back gas and drag turn pressure and + rock-bottom onto itself. +- **loop plans** (`mode != oc`) - pure O2 that no segment breathes is the + oxygen supply; the cylinder the segments breathe is the diluent; anything + else carried is open-circuit bailout. +- **bailout** - honoured as an explicit override and never re-derived. + +`stage` and `deco` are treated identically by every consumer, and +`sidemountLeft`, `sidemountRight` and `pony` drive nothing, so those five +values simply stop being reachable from the planner. The enum itself is +untouched - the dive log still uses all nine. + +### Why bailout stays a flag + +On a loop plan a 100% cylinder is either the oxygen supply or a bailout/deco +bottle, and no combination of mix, size and segment references separates them: +a CCR diver carrying pure O2 as a deco bottle has two 100% cylinders. Reading +a bailout bottle as the O2 supply silently removes it from the bailout +calculation, which is a safety-relevant understatement rather than a cosmetic +slip. Cylinder size is a hint (2-3 L supply against a 7-11 L bottle) and not a +basis for a bailout plan. + +So the dialog gains one checkbox, shown only when the plan mode is not `oc`. + +### Where the resolver runs + +At the consumer boundary, on a local copy, in four places: +`PlanEngine.compute`, `BailoutSolver.solve`, +`ContingencyService.lostGas` and `.lostGasFor`. All 14 role-reading call sites +are then correct with no change to any of them. + +Deliberately **not** applied to the stored plan. The dialog persists the +diver's raw input - `bailout` when ticked, `backGas` as a neutral "derive me" +placeholder otherwise - so a *derived* role can never be read back as an +override on a later pass. `ContingencyService.isLosable` reads the derived +role and is documented as requiring a resolved plan. + +No schema change: `dive_plan_tanks.role` already exists and keeps holding the +override. + +### Changed: `_TankEditDialog` + +Fields become **Name, Size, Start pressure, O2 %, He %**, a **Travel gas** +checkbox, and a **Bailout gas** checkbox on loop plans only. The role +dropdown is deleted. The dialog now takes the plan's `mode` so it knows +whether to offer the bailout flag. + +End pressure stays out: in a plan it is an output. The engine already computes +`PlanTankUsage.remainingPressure` per cylinder from the SAC and the profile, +and the results pane shows it. An editable end pressure would either be +ignored or fight the computed value. + +### Behaviour changes this causes + +Three, all surfaced by existing tests and all deliberate: + +1. **Every carried cylinder except the back gas is now losable.** A cylinder + can no longer be declared `pony` to exempt it from the lost-gas + contingency - a 32% bottle carried beside an 18/45 bottom mix is a + deco/stage bottle by the numbers. The planner now produces a lost-gas + variant where the declared-role version produced none. +2. **"My bottom segment breathes my deco bottle" is no longer expressible.** + Whichever cylinder the deepest leg breathes *is* the back gas. One + contingency test used that contrivance to exercise the segment remap; it is + rebuilt around a travel gas lost on the descent, which covers the same code + realistically. +3. **A plan always has a back gas**, so `lostGasFor`'s + `orElse: remaining.first` fallback is now defensive only. + +### Also in part D: segment runtime + +`ResolvedLeg` carries `runtimeSeconds` - elapsed dive time at the end of the +leg - accumulated in `resolve` beside the depths and the phase, with +`startRuntimeSeconds` derived from it. Each segment row's subtitle reads +`duration - RT n' - gas`, ceiled to whole minutes with the prime suffix so it +matches the computed deco schedule's existing RT column +(`plan_results_sheet.dart:367`) rather than the stat tiles' convention. It +reuses `plannerCanvas_table_runtime` ("RT") rather than adding a twelfth +translation of the same abbreviation. + +### Out of scope for part D + +The dive log's own tank editor and its role dropdown, which stay as they are - +for a logged dive the role is a record of how the diver actually rigged, not +something the numbers imply. Working pressure and material are still not +collected by the planner dialog; part B needs them for a lossless round trip +through a saved configuration. + +## Part E - Deco ceiling and schedule corrections + +Not part of the original request. Found while investigating a plan the diver +reported: 21 m for 30 min then 10 m for an hour on air, which showed a +decompression ceiling from runtime 23 where other planners showed 37. Three +independent defects, all in the shared deco core rather than the planner. + +### E1. The planner chart plotted an ungated ceiling + +`PlanEngine._ceilingTrace` asked for the ceiling at every sample and drew +whatever came back. A gradient-factor ceiling becomes non-zero the moment +supersaturation crosses the GF-low line, which on this profile is runtime 17 - +long before a direct ascent stops being permitted. The curve was drawing the +GF-low deep-stop *target*, not an obligation. + +`getDecoStatus` had always gated this correctly for dive details +(`buhlmann_algorithm.dart`, "only calculate ceiling/stops when actually in +deco"), so logged dives were never affected. The trace now applies the same +gate through the new `DecoModel.surfaceCeilingMeters`, and `PlanEngine`'s +per-segment `ceilingAtEnd` gates on `ndl < 0` to match +`PlanCalculatorService`, which reads it off a `DecoStatus` that was already +gated. + +### E2. Long depth ramps were loaded at a single mean depth + +`BuhlmannGf.applySegment` did one Schreiner step at `(start + end) / 2` +regardless of duration, so a 20-minute descent to 21 m was modelled as 20 +minutes at 10.5 m. Gas loading approaches the inspired pressure exponentially, +so the mean depth of a long ramp is not equivalent to travelling it. + +Depth-changing segments are now sliced at 10 s, each slice at its own mean +depth. Flat segments are untouched - their mean depth *is* their depth - and +dive-log replay feeds sample-to-sample segments a few seconds long, so that +path is unaffected either way. This is why the whole deco suite, golden +vectors included, was unmoved by E1 and E2. + +### E3. The ceiling contradicted the schedule + +The reported symptom, and the one that changes deco schedules. At runtime 110 +the plan showed a 4.17 m ceiling whose only stop was 3 m: the schedule was +telling the diver to ascend through their own ceiling. Two coupled causes. + +**The ceiling was evaluated at the diver's depth.** `calculateCeiling` took a +`currentDepth`, interpolated the GF there, and returned the tolerance at that +gradient factor. For anyone deeper than the anchor that is GF-low, so the +answer was the GF-low ceiling - a tolerance at a gradient factor that only +applies far shallower, and not a depth the diver has to respect. + +Because the GF is a function of depth, the ceiling is the depth at which the +tissues are tolerated *under the gradient factor that applies at that same +depth*: a fixed point. `calculateCeiling()` now solves it by iterating from +the GF-low ceiling, which is the deepest the answer can be and descends +monotonically onto the solution (the ceiling shallows as the GF rises, and the +GF rises as the depth shallows). It takes no `currentDepth` - the ceiling is a +property of tissue state and the anchor, not of where the diver is floating. + +The "may I surface?" question is genuinely different, and now has its own +name: `surfaceTargetCeiling()` on the algorithm, `surfaceCeilingMeters` on +`DecoModel`. Previously it was spelled `calculateCeiling(currentDepth: 0)`, +which worked only because `_interpolateGf(0)` returns GF-high. + +**The trial ascent was tested one minute late.** `_calculateStopTime` loaded a +minute at the stop, tested the ceiling, then restored the pre-minute state and +either committed the minute or left. So a stop that cleared inside its first +minute was recorded as zero minutes and dropped, while the off-gassing that +justified leaving was never spent. Every stop was under-reported by up to a +minute, and first stops vanished outright: on this profile the diver arrived +at 9 m with a 6.63 m ceiling and was sent to 6 m. + +The test now runs on arrival, before the minute is loaded - "may the diver +leave with the time spent so far?" That also removes the per-iteration +snapshot/restore, since no trial minute exists to undo. + +This defect was known. `tts_cleanroom_cross_check_test.dart` had reproduced it +deliberately, commenting that production "holds ~1 min less per stop than a +check-first loop; replicate that trial-then-commit ordering to match the +numbers." It was treated as a quirk to match rather than a bug to file. + +### What E3 changes + +Every schedule gains a deeper first stop and one to four minutes of TTS. The +regenerated golden vectors: + +| Case | Before | After | +| --- | --- | --- | +| air 30 m/25 min GF 50/80 | 6m/3 3m/8, TTS 14 | 9m/1 6m/4 3m/8, TTS 16 | +| air 40 m/20 min GF 30/70 | 15m/1 12m/2 9m/4 6m/7 3m/19, TTS 37 | 18m/1 15m/1 12m/3 9m/4 6m/8 3m/20, TTS 41 | +| EAN32 30 m/40 min GF 50/80 | 6m/4 3m/10, TTS 17 | 9m/1 6m/4 3m/11, TTS 19 | +| tx18/45 60 m/25 min GF 50/80 | 24m/2 ... 3m/16, TTS 52 | 27m/1 24m/2 ... 3m/17, TTS 55 | +| air 30 m/20 min altitude 2000 m | 6m/3 3m/6, TTS 12 | 9m/1 6m/3 3m/6, TTS 13 | +| air 30 m/25 min freshwater | 6m/3 3m/7, TTS 13 | 9m/1 6m/3 3m/7, TTS 14 | + +The direction is uniformly more conservative. + +**What the external references actually say.** Two fixtures in the suite carry +a hand-recorded Subsurface value, and they disagree about whether E3 is an +improvement. Measured, not recalled: + +| Reference | Subsurface | Before E3 | After E3 | +| --- | --- | --- | --- | +| Ceiling, fixture 004 @ 9 m, GF 50/75 | 7.4 m | 7.45 m | 7.07 m | +| TTS, fixture 003 @ min 40, GF 45/75 | 24.0 min | 22.9 min | 24.9 min | + +The ceiling moved *away* from Subsurface (0.05 m off, now 0.33 m off). The TTS +moved marginally toward it but overshot (1.1 min low, now 0.9 min high). Both +stay inside their tolerances, and the ceiling difference is a fifth of the 3 m +stop grid, so no decision a diver makes changes. But this is not the clean +corroboration it would be convenient to claim. + +The two halves of E3 are separable, and they earn their keep differently: + +- The **ordering** fix stands on its own without any reference. A schedule + cannot spend a minute of off-gassing it does not record; that is a defect by + inspection. It is also the half that moved TTS, and the only half that + changed the golden schedules at all - reverting it alone returns every + vector above to its old value. +- The **fixed-point ceiling** rests on coherence, not on matching Subsurface: + it makes the ceiling an actual depth limit instead of a tolerance evaluated + at a gradient factor that applies somewhere else. That is what the diver's + bug report was about. It costs 0.33 m of agreement on the one ceiling + reference we hold. + +A plausible reconciliation, worth investigating but not acted on here: our +GF-low anchor is the *deepest* ceiling of the dive, while Subsurface's +`first_ceiling_pressure` appears to latch the ceiling the first time one +exists, which is shallower. A shallower anchor makes the interpolated GF more +conservative at every depth and the fixed point deeper - toward 7.4. Changing +the anchor convention is a third semantic change with its own cascade, and it +should not be made from recall. The MultiDeco comparison in the golden README +is the right instrument for settling it. + +The 40 m/20 min air GF 30/70 vector is where an external planner would be most +useful, and we have no recorded value for it. + +The CCR loading vector's expected ceiling is unchanged, which is the sanity +check on the fixed point: at the deepest moment of a dive the tissues are +still on-gassing, so the GF-low ceiling equals the anchor and the fixed point +pins to it. E3 moves the ceiling only once off-gassing has begun and the +anchor has frozen above it. + +On the reported plan, ceiling onset now equals the deco obligation exactly: +runtime 31 at GF 30/70, 37 at GF 30/80, 40 at GF 30/85. The diver's 37 falls +out at GF-high 80. + +### Out of scope for part E + +`_simulateAscent` still loads a whole travel leg at its mean depth - the same +defect class as E2, but a leg between adjacent 3 m stops is 20 s and the +longest is around 100 s, so the error is far below the minute the schedule is +quantised to. Fixing it would move every schedule again for no operational +gain; it should be its own change, with its own golden regeneration. + +The `lastStopDepth = 6` policy path, air breaks and CCR bailout schedules all +inherit E3 through the same `_calculateStopTime`, and their tests pass, but +none of them has an external reference vector. + +## Part F - Configurable ascent rates + +Also not part of the original request. It came out of comparing a plan with +Subsurface: 50 m for 30 min on air with EAN50, GF 50/80 in both apps. + +| | Bottom | Travel | Stops | Runtime | +| --- | --- | --- | --- | --- | +| Subsurface | 35 | 14 | 42 | 91 | +| Submersion, before | 35 | 6 | 52 | 93 | +| Submersion, after | 35 | 13 | 48 | 96 | + +The total obligation always agreed to within a couple of minutes. What did +not agree was where the time sat: Subsurface's table shows 6 m to 3 m and 3 m +to the surface each taking three minutes, so it decompresses the diver *during* +the ascent, and its stops need less. We ascended at a flat 9 m/min and crossed +the last 6 m in forty seconds, so the stops had to provide all of it. The +schedules were not really disagreeing; they were bookkeeping the same +decompression differently, and only one of the two looks like what a diver +actually does. + +`SchedulePolicy` already had `ascentRateBands` and `ascentRateForDepth` for +Subsurface's four bands, defined as fractions of the dive's mean depth. +Nothing in `lib/` ever called them - the dead code was covered by tests, which +is how it survived. They are replaced rather than wired up: the model here is +TDI's, whose phases are named after where the diver is in the ascent rather +than computed from a mean depth the plan has not derived yet. + +### The rate model + +TDI's decompression procedures, metric column: + +| Phase | Rate | Field | +| --- | --- | --- | +| Bottom to first stop | 9-10 m/min | `ascentRate` (9) | +| Between intermediate stops | 6 m/min | `intermediateAscentRate` (6) | +| Between shallow stops | 3 m/min | `shallowAscentRate` (3) | +| Final stop to surface | 1 m/min | `finalAscentRate` (1) | + +These are **phases, not depth bands**, and that distinction is the whole +design. A diver leaving the bottom is doing a working ascent whether the first +stop is at 21 m or at 6 m; a diver leaving the last stop is doing the slow one +whether or not anything deeper was required. So `AscentPhase` names the three +kinds of leg a schedule contains - `toFirstStop`, `betweenStops`, +`fromLastStop` - and `ascentSeconds` takes the phase rather than inferring it +from depth. + +Only `betweenStops` needs a depth, to tell an intermediate stop from a shallow +one. `shallowStopDepth` (9 m) draws that line, so the 9/6/3 stops are the +shallow ones. It is a `SchedulePolicy` constant rather than a plan field, +because the standards that name these phases do not parameterise the boundary +either. + +The consequence that matters most: **a dive that owes no decompression has no +first stop to slow down for and no last stop to crawl off, so its whole ascent +is `toFirstStop` at the working rate.** Configuring the deco rates cannot +change a recreational profile at all. An earlier iteration of this banded the +ascent purely by depth, which made an 18 m no-deco dive take six minutes to +surface instead of two - correct arithmetic, wrong model. The phase enum is +what makes that error unrepresentable rather than merely fixed. + +`ascentTravelSeconds` sums a whole ascent given the stop depths, assigning the +phases in the order a schedule runs them and falling back to `toFirstStop` +throughout when there are no stops. The per-leg callers walk the same +sequence, and a test asserts the total equals the sum of its legs rather than +trusting that they agree. + +Both new rates **default to `ascentRate`**. That is the important safety +property of this change: only the planner configures them separately, so dive +details, profile analysis, VPM-B and every other consumer of the deco core +computes exactly what it did before. The full deco suite passed unchanged +after the wiring, which is the evidence for that. + +### Where the rates had to be applied + +The ascent legs of a computed schedule were being measured in seven places, +each with its own copy of `(from - to) / ascentRate * 60`: +`_ascendLeg`, `calculateTts` and the no-deco branch of `getDecoStatus` in the +algorithm; profile sampling, gas charging, stop arrival runtimes and +rock-bottom in `PlanEngine`; and `_ascentLiters` in `BailoutSolver`. Every one +of them has to agree with the legs the deco model actually loaded, or a stop's +displayed runtime drifts from the tissue state behind it. They now all route +through the policy, and `PlanEngine._policyFor` derives the policy from the +plan in one place. + +Threading a `betweenStops` flag through this also surfaced a live bug in +`_mapStops`: the flag was not being advanced inside the loop, so every stop +would have been costed as travel-to-first-stop. The analyzer's +`prefer_final_locals` hint on the never-reassigned variable is what caught it. + +### Persistence + +Schema 187 -> 188: `dive_plans.intermediate_ascent_rate`, +`shallow_ascent_rate` and `final_ascent_rate`, all NOT NULL with the standard +defaults, following the idempotent `_assertPlanAscentRateColumns` pattern +(PRAGMA-guarded helper called from both the ladder rung and the `beforeOpen` +backstop, as `_assertTravelGasColumn` does). `.subplan` gains all three keys +and defaults them when absent. + +An existing saved plan therefore picks up 6, 3 and 1 on next open and its +schedule redistributes. That is the intended behaviour, but it is a visible +change to plans a diver has already reviewed. + +While adding the Rates controls it turned out `DivePlanState` never carried +`lastStopDepth` at all, so a plan opened from a `.subplan` with a 6 m last +stop silently reset to 3 m on the way back out. The final-ascent label needs +the depth, so the field is now carried and mapped both ways - a small +pre-existing data-loss bug fixed in passing. + +### What is still different from Subsurface + +Measured on the reference plan (50 m / 30 min, air + EAN50, GF 50/80 in both +apps): + +| | Stops | Travel | Runtime | +| --- | --- | --- | --- | +| Subsurface | 42 | 14 | 91 | +| Before part F | 52 | 6 | 93 | +| After part F | 50 | 11 | 96 | + +Travel is close now, and the runtimes track stop for stop down to the 9 m +level (57 against 56). The divergence is all at the last two stops - 6 m: 12 +against 10; 3 m: 22 against 18 - and it is *not* the ascent rates. + +It is the stop-clearance criterion. Subsurface's `trial_ascent` simulates the +ascent to the next level, loading tissue at each step and checking the ceiling +as it goes, so the travel's own off-gassing counts toward clearing the stop +the diver is leaving. Ours asks whether the ceiling is already above the next +level before any travel happens. Part F actually *widens* this gap at the +last stop, because the leg it credits is now three minutes of off-gassing at +1 m/min rather than twenty seconds at 9 m/min: 18 min at 3 m plus a 3 min +crawl is close to our 22 min at 3 m plus 3 min. + +Adopting Subsurface's criterion closes most of the remaining five minutes, +and it is arguably the more faithful model - it is what the diver actually +does. It is a schedule-wide change in the *less* conservative direction, so it +was held for the diver's explicit decision rather than folded into an +ascent-rate feature. The diver gave it; part G does it. + +### Out of scope for part F + +The dive log's own TTS, which keeps a single ascent rate: these are planning +inputs, and a logged dive's ascent already happened at whatever rate the +samples record. + +Per-band rates relative to mean depth, Subsurface's actual model. TDI's named +phases cover the diver's intent without asking them to think in fractions of a +number the plan has not computed yet. + +Making `shallowStopDepth` configurable. It is 9 m by convention and the +standard treats it as fixed; a diver who wants to move it can say so and it +becomes a fifth control plus a fourth column. + +## Part G - Trial ascent, whole-minute stops, and the plan as a slate + +Three changes the diver asked for together after comparing the reference plan +against Subsurface's "Dive plan details" table line by line. + +### G1. Stop clearance by trial ascent + +`_calculateStopTime` in `lib/core/deco/buhlmann_algorithm.dart` no longer +asks whether the ceiling is already above the next level. It asks whether the +diver *could ascend there now*: `_trialAscentClears` simulates the leg to the +next level - at the rate the policy gives that phase, on the gas eligible at +each depth, in slices of at most 10 s (the same resolution `BuhlmannGf` +loads ramps at) - and requires the ceiling to stay at or below the diver after +every slice. The last slice lands exactly on the next level, so arrival is +checked too, which is what the ceiling/schedule consistency tests rely on. The +tissue state is restored before the answer is returned. + +This is Subsurface's `trial_ascent` for Buhlmann. The leg's own off-gassing +counts toward clearing the stop being left, because that is what happens to +the tissues in the water. The effect is largest at the last stop, where the +credited leg is three minutes at 1 m/min: on the reference plan it was worth +about four minutes at 3 m. + +The Python golden generator mirrors it (`trial_clears`, `semantics_version` +3) and the vectors were regenerated; the clean-room TTS reference does the +same in its own idiom. On the flat-rate golden cases the change is one to two +minutes of TTS, and two cases lose a first stop that cleared during the +20 s leg to the next level - the same thing Subsurface's planner does, since +it never pre-places a first stop at all. + +### G2. Stops end on whole minutes + +Travel between stops rarely takes a whole number of minutes (3 m at 6 m/min +is thirty seconds), so every stop used to start and end at odd seconds and a +slate could not be read against a watch. `SchedulePolicy.snapStopsToWholeMinutes` +extends each held stop so it ends on a whole minute of the ascent clock: the +stop absorbs the odd seconds of the leg that led to it, at most 59 s more per +stop, always in the conservative direction. Subsurface does the same (its +first stop chunk is `DECOTIMESTEP - clock % DECOTIMESTEP`). + +The flag is off by default, so live TTS in the dive log and on the profile +keep their exact values; `PlanEngine._policyFor` turns it on. Because the +snap lives inside `calculateDecoSchedule`, `calculateTts` inherits it and +the planner's TTS, runtime and table all agree. The one place this shows is +the legacy parity test, which now allows each stop to differ from +`PlanCalculatorService` by the rounding. + +### G3. The plan table is a slate + +`PlanOutcome.schedule` is a list of `PlanScheduleRow`: every authored leg, +then for the computed ascent a travel leg and a stop alternating, ending with +the leg to the surface. Each row carries its kind (descent, level, ascent, +stop), the depth it ends at, its duration, the runtime at its end, its gas and +tank, and whether the gas differs from the row before. Travel durations are +read off the stops' arrival times rather than recomputed, so a line can never +disagree with the stop it leads to, and `runtimeSeconds` on the outcome is the +last row's end so the headline can never disagree with the table. + +A travel leg breathes the gas eligible where it starts - the diver switches +at the stop - so the table marks the switch on the stop's line, as +Subsurface's does. Only switches print in the gas column, highlighted. + +Printing is the job of `scheduleLines` in +`lib/features/planner/domain/services/schedule_lines.dart`. The runtime is +rounded to the nearest minute and each printed duration is the difference +between consecutive printed runtimes, so the column always sums to the +runtime beside it. A line that takes any time prints at least one minute and +advances the runtime by at least one, so a thirty-second leg prints as 1 min +and the stop after it gives that minute back - a first version printed the +odd seconds, and the diver read 30 s as 30 min at a glance; a slate has one +unit. The results sheet and the PDF slate both use +it; the sheet prints Subsurface's glyphs (descent, level, ascent, stop) and +the PDF prints ASCII stand-ins because the embedded font has no arrows. + +The column header changed from "Stop" to "Duration" in all eleven locales +(`plannerCanvas_table_duration` replaces `plannerCanvas_table_stop`), and +`PlanSlateLabels.stop` became `duration`. + +### Reading Subsurface's own table + +Subsurface's stop column looked odd to the diver: 21 m for 1 min, 18 m for +1 min. Two things are going on. First, its clock snaps to a whole minute at +the first stop, so a 47 s hold prints as one minute and the leg after it +starts on the minute. Second, in the diver's Subsurface preferences the rate +between stops is 3 m/min and the "last 6 m" rate of 1 m/min applies from the +6 m stop, so every leg between 3 m stops is exactly one minute and the leg +from 6 m to 3 m is three. Neither is the TDI table this app ships as its +default (6 m/min between intermediate stops, 3 m/min between shallow ones, +1 m/min off the last stop only). To compare the two apps line for line, set +this app's rates to 9 / 3 / 3 / 1; the two-minute 6 m to 3 m difference +remains, because a "last 6 m" band is not expressible as a phase. + +## Part H - A last stop between 3 and 6 m + +`DivePlan.lastStopDepth` existed, persisted and flowed into the schedule +policy, but nothing in the planner set it: every plan held its last stop at +3 m. The diver asked to choose it between 6 and 4 m as well. + +The Decompression section gains a "Last stop" segmented control +(`PlanLastStopSelector`) offering 3, 4, 5 and 6 m (shown in the diver's depth +unit), under the gradient-factor sliders, wired to a new +`DivePlanNotifier.updateLastStopDepth` that clamps to that range. It first +sat in the Rates section next to the final-ascent slider; the diver asked for +it under Decompression, where the rest of the deco settings live. The +final-ascent slider's label still names the chosen depth. + +The engine had assumed the last stop lay on the 3 m grid. `calculateDecoSchedule` +and `_calculateStopTime` now share `_nextLevel`: from a stop the diver goes to +the next grid level, or to the last stop when the grid would step past it, or +to the surface from the last stop itself. A 4 m last stop therefore gives the +grid down to 6 m and then 4 m. Two consequences worth naming: the first stop is +never placed shallower than the last stop, so a lightly loaded dive with a 6 m +last stop owes a stop at 6 m rather than nothing because 3 m fell below the +grid (a latent gap the old code had); and the Python golden mirror follows +the same rule, which leaves the vectors byte-identical because they all use +3 m. + +## Localization + +New keys, English plus the existing ten locales: + +- `plannerCanvas_rates_intermediateAscent` - "Intermediate stop ascent rate" + (part F) +- `plannerCanvas_rates_shallowAscent` - "Shallow stop ascent rate" (part F) +- `plannerCanvas_rates_finalAscent` - "Final ascent rate (last {depth})", + the placeholder naming the last-stop depth the rate applies below (part F) +- `divePlanner_segmentEditor_depth` - "Depth ({unit})" +- `divePlanner_segmentEditor_derivedLevel` - "Level at {depth}" +- `divePlanner_segmentEditor_derivedDescent` - "Descent {from} -> {to} at + {rate}/min" +- `divePlanner_segmentEditor_derivedAscent` - "Ascent {from} -> {to} at + {rate}/min" +- `divePlanner_segmentEditor_derivedDescentNoRate` - "Descent {from} -> {to}" +- `divePlanner_segmentEditor_derivedAscentNoRate` - "Ascent {from} -> {to}" +- `divePlanner_tanks_savedSection` - "Saved tanks" +- `divePlanner_tanks_saveAsConfig` - "Save tanks as..." +- `divePlanner_tanks_saveAsConfigHint` - "Name this tank configuration" +- `divePlanner_tanks_configApplied` - "Applied {name}" +- `divePlanner_tanks_configSaved` - "Saved {name}" +- `divePlanner_tanks_configOverwrite` - "A configuration named {name} already + exists. Replace it?" +- `divePlanner_tanks_manageConfigs` - "Manage saved tanks" +- `divePlanner_field_bailoutGas` - "Bailout gas" +- `divePlanner_field_bailoutGasHint` - "Open-circuit gas carried in case the + loop fails" + +There is no `stop` variant of the derived-phase line: telling a deco stop +from the bottom needs the rest of the profile, which the dialog does not have, +so it says "Level" for any flat leg and the segment list makes the finer call. +The `NoRate` pair covers a zero-duration leg, where the direction is known but +a rate is not defined. + +Part C adds no strings: it reuses `divePlanner_label_tanks` and +`divePlanner_tab_plan` in their new positions. + +Reused unchanged: `divePlanner_segmentList_descent`, `..._bottom`, `..._deco`, +`..._ascent`, and `..._gasSwitch` ("Gas switch to {gasName}"), which becomes +the per-leg switch chip. + +Retired: `divePlanner_segmentEditor_segmentType`, `..._startDepth`, +`..._endDepth`, `..._descentRate`, `..._ascentRate`, `..._gasSwitchTime`, +`divePlanner_segmentList_safetyStop`, the six `divePlanner_segmentType_*` +labels the segment dropdown needed, and `divePlanner_field_role`. + +## Testing + +TDD; tests land before the change they describe. + +**Part A - new** + +Landed as `test/features/planner/segment_chain_test.dart` (21 cases), +`plan_engine_sac_phase_test.dart` and `dive_plan_legacy_rows_test.dart`. + +- `test/features/planner/segment_chain_test.dart` - phase derivation table: + descent, ascent, level at max depth, flat leg shallower than max with + nothing deeper after it (`stop`), flat leg in a multi-level profile that + later goes deeper (`level`), zero-duration leg (null rate), first leg starts + at 0, single-leg plan, empty list. +- `bottomLegIndex` for single-level, multi-level, equal deepest legs (last + wins), a profile with no level leg at all (null), and stops ignored. Folded + into the same file rather than a separate one. + +**Part A - behaviour-preservation, the load-bearing tests** + +- `test/features/planner/plan_engine_sac_phase_test.dart` - guards the + `plan_engine.dart:588` rewrite by perturbing one SAC rate at a time and + checking exactly which legs move: raising the bottom SAC by 1 L/min must add + precisely the descent and level legs' bar-minutes and nothing else, and + raising the deco SAC must move at least the authored ascent and stop legs. + Asserting deltas rather than a single total means an unrelated engine change + cannot make it fail spuriously. +- `test/features/planner/dive_plan_legacy_rows_test.dart` - inserts rows the + way the segment-type era wrote them and checks: a stored `start_depth` + discontinuity resolves to a continuous profile (an intentional behaviour + *change*); a row whose `type` is unrecognised loads instead of throwing; and + re-saving rewrites `type`/`start_depth`/`rate` from the chain, so the + retired columns stay truthful and the repair is durable. +- Existing suites that must keep passing unchanged: + `plan_engine_per_segment_test.dart`, `plan_engine_schedule_test.dart`, + `plan_engine_rates_test.dart`, `plan_engine_issues_test.dart`, + `plan_engine_ccr_test.dart`, `plan_compare_test.dart`, + `dive_plan_entity_test.dart`, `dive_plan_state_mapper_test.dart`, + `dive_plan_sync_round_trip_test.dart`, `plan_gas_consumption_test.dart`. +- `plan_file_codec` tests: v1 file decodes (type and `startDepth` ignored), v2 + round-trips, v2 preserves `setpointBar` and `diveModeOverride`. +- Widget: `segment_editor` shows one depth field and no type dropdown; editing + a segment preserves its setpoint; a chart drag preserves setpoint and mode + override (regression for + `plan_chart_edit_controller.dart:99-111`). + +**Part C** + +Nothing currently pins the order of these two widgets - `plan_panes_test.dart: +56-57` and `plan_canvas_page_test.dart:79-93` assert only that each is +present - so the swap must arrive with tests that would have caught it: + +- `plan_panes_test.dart` gains an order assertion on `PlanEditorPane`, + comparing the vertical offsets of `PlanTankList` and `SegmentList` rather + than their mere presence. +- `plan_canvas_page_test.dart:79` currently expects `SegmentList` on a + default-state phone load and **will fail** after the swap; it becomes + `PlanTankList`. This is the one existing test part C breaks. +- `plan_canvas_page_test.dart:82` ("phone tabs switch between plan, tanks, + setup, results") taps by label text, so it keeps passing; its name and the + order of its taps are updated to match the new deck. +- A new case asserts the deck's segment labels read Tanks, Plan, Setup, + Results left to right. +- Regression: tapping the issues chip still lands on Results, and a Setup + header chip deep-link still lands on Setup - the two hardcoded tab indices. + +**Part D** + +- `test/features/planner/tank_role_resolver_test.dart` - the derivation table: + deepest leg's cylinder is the back gas (including when it is not the last + leg), richer-than-bottom is deco, same-or-leaner is stage, a travel gas is + never the back gas, no segments falls back to the first cylinder; on a loop + plan pure O2 is the supply, the breathed cylinder the diluent, the rest + bailout; an explicit bailout flag beats the pure-O2 derivation and is + honoured on OC too; `apply` rewrites a copy and leaves the stored roles + alone. +- `contingency_service_test.dart` - three tests rewritten to the new + invariants rather than deleted: every cylinder but the back gas is losable, + the cylinder the bottom breathes is never offered as lost, and a plan always + has a back gas to fall back onto. +- `segment_chain_test.dart` - runtime accumulates across legs, + `startRuntimeSeconds` is the leg's start, and a zero-duration leg does not + advance the clock. +- `segment_list_selection_test.dart` - the rows render `RT 2'` and `RT 22'` + for a 30 m / 20 min quick plan. + +**Part B** + +- `test/features/cylinder_configs/dive_tank_config_adapter_replace_test.dart` - + `replace` carries gas mix, material and working pressure; discards prior + tanks; empty item list. +- `test/features/cylinder_configs/tank_config_capture_test.dart` - field + mapping, order preservation, null hardware fields, and a + capture -> replace round trip that is lossless for every configuration + field. +- `test/features/planner/plan_tank_list_configs_test.dart` - the saved-tanks + row is absent with no configurations; applying replaces the tank list; + applying reassigns orphaned segments and refreshes their gas; saving prompts + for a name and writes a config; duplicate name offers overwrite. + +**Part F - ascent rates** + +- `test/core/deco/schedule_policy_test.dart` - the band selection table: + the three banded rates defaulting to the single ascent rate (including + *across* boundaries, so 30 m at 9 m/min stays 30 m at 9 m/min), each of the + four phases in isolation, a leg spanning several bands being split, a + partial band prorated, a 6 m last stop moving the crawl boundary, a whole + ascent totalling exactly the sum of its legs, and level or descending legs + costing nothing. Replaces the `ascentRateForDepth` group, whose subject no + longer exists. +- `test/features/planner/plan_engine_ascent_rates_test.dart` - the engine + behaviour a diver would notice: travel between stops slowing from 30 s to + 60 s as the stops cross 9 m, a no-deco ascent still banded despite having no + stops to leave from, stop time falling when the ascent itself decompresses, + and setting all four bands equal reproducing the old single-rate arithmetic + exactly. +- `test/core/database/migration_v188_plan_ascent_rates_test.dart` - all three + columns on a fresh database, NOT NULL with the standard defaults pinned by + value (a wrong default silently rewrites every saved plan's schedule), the + `beforeOpen` backstop healing a database stranded before v188, and the + helper no-opping when the table is absent. Mirrors the v156 travel-gas + test. Renumbered from v185 (itself renumbered from v184): main landed the + dive_detail_layout, template-item-equipment and + session-item-overdue-services rungs at 185-187 while this branch was open. + +**Part E - the deco core** + +- `test/core/deco/` in full: 424 tests, including the golden vectors and the + VPM-B suite, all passing. +- `scripts/deco_golden/generate_vectors.py` carries the same two corrections + and `semantics_version` goes 1 -> 2. Regenerated per the README, never by + hand. Note what this suite does and does not prove: the generator is a + second implementation of the *same* semantics, so agreement is a + cross-implementation regression check, not independent validation of the new + behaviour. It caught nothing here because it was updated in lockstep. +- `tts_cleanroom_cross_check_test.dart` is the one place a genuinely + independent reference lives, and it had encoded the old ordering on purpose. + Corrected to check-first and to the fixed-point ceiling; it now agrees with + production to within its 60 s tolerance, having been 120 s out. +- The two Subsurface-referenced fixtures both still pass, but see the table + under "What E3 changes": the ceiling reference got worse and the TTS + reference got marginally better. Neither settles the question. The one + unambiguous external agreement is on E1: the diver's own planner puts deco + onset at runtime 37 for GF-high 80, which is now exactly what we report. +- `test/core/deco/ceiling_schedule_consistency_test.dart` pins the invariants + rather than the numbers: the diver never *arrives* at a level shallower than + the ceiling (arrival, not the ceiling standing when the schedule is + computed - the ascent legitimately off-gasses on the way), a stop that + clears inside its first minute is still recorded, and the surface target + stays distinguishable from the GF ceiling. Both of its headline assertions + fail against the old code. +- The release gate in the golden README - a hand comparison against MultiDeco - + is the right place to settle the ceiling convention before this ships. + +## Risks + +- **Every deco schedule changes (part E3).** Not a planner-only change: + `getDecoStatus` feeds dive details, so a logged deco dive reopened after + this lands shows a deeper first stop and a slightly longer TTS than it did + before. The direction is more conservative in every case measured, and the + old numbers were wrong in the unsafe direction - stops were dropped that the + tissues required. Needs a release note, and the MultiDeco comparison should + happen before it ships rather than after. +- **The ceiling number itself moves (part E3).** A diver on the bottom of a + deco dive previously saw the GF-low ceiling; they now see a shallower + figure. That will read as "less deco than before" at a glance, when in fact + the obligation grew. The stop schedule is the honest display and it is what + the plan results and the profile chart lead with, but the standalone ceiling + readouts in `deco_info_panel` and `compact_deco_status_card` will look + different. +- **Silent profile change on discontinuous saved plans.** Repairing the depth + chain alters the computed deco for any plan that had a gap. This is the + intended fix, but a diver could reopen a saved plan and see a different TTS. + Mitigation: the chart already draws the gap today, so the plan visibly looked + wrong; and `plan_engine_discontinuity_repair_test.dart` pins the new + behaviour. Worth a release note. +- **Hand-authored deco stops in the bottom portion.** A diver who today adds + an explicit Deco Stop segment gets `sacDecoEffective`; under phase derivation + a flat leg at max depth reads as `level` and gets `sacBottom`. Only affects a + flat leg *at* the deepest point, which is not a deco stop in any real + profile. Accepted. +- **`.subplan` forward compatibility.** A v2 file opened by an older build + hits `plan_file_codec.dart`'s version check and is rejected rather than + misread. Acceptable; the check exists for this reason. +- **Losing the explicit gas-switch row.** Divers who used gas-switch segments + as a visual marker lose the dedicated row. Mitigated by the per-leg switch + chip, which is strictly more accurate since it renders from the `tankId` the + engine actually charges. +- **Configuration name collisions.** `cylinder_configs.name` is not unique. + Handled by the overwrite prompt rather than a schema constraint. +- **Wider lost-gas output (part D).** Because every non-back-gas cylinder is + now losable, a plan carrying several bottles produces more contingency + variants than before, each of which runs the engine. Range tables already + run many more variants than this, so the cost is not new, but a plan with + five cylinders now computes four lost-gas schedules where it might have + computed one. +- **A stored role from before part D is ignored** except when it is + `bailout`. A plan saved with a hand-picked `oxygenSupply` or `deco` role + gets that role re-derived, which for any coherent plan reproduces the same + answer - but a plan whose declared roles contradicted its mixes will now + compute differently, and more correctly. + +## Out of scope + +- Deleting the legacy `lib/features/dive_planner/` module. It remains the home + of `PlanSegment`, `PlanResult` and `DivePlanNotifier`, all of which the + current planner imports. Consolidating the two modules is a separate change. +- Auto-generated ascent profiles and repetitive dives (issue #142). +- Planner total dive time display (issue #545). +- Unifying `tank_presets` with equipment cylinders (the rest of issue #800). +- Any change to `CylinderConfigApplier`'s merge rules or to the dive edit + page's apply flow. diff --git a/docs/superpowers/specs/2026-09-03-linux-packaging-design.md b/docs/superpowers/specs/2026-09-03-linux-packaging-design.md new file mode 100644 index 0000000000..601e7ddfe4 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-linux-packaging-design.md @@ -0,0 +1,402 @@ +# Linux Packaging Design + +**Problem:** the Linux download is a tar.gz of loose files. It installs nothing, +declares nothing, and resolves nothing, so getting Submersion running on a Linux +system is a research project. + +## The gap + +`.github/workflows/build-all.yml:620-623` is the entire packaging story: + +```yaml +- name: Create tarball + run: | + cd build/linux/x64/release/bundle + tar czf "$GITHUB_WORKSPACE/Submersion-${TAG_NAME}-Linux.tar.gz" . +``` + +The archive holds `submersion`, `data/`, and `lib/` (bundled native assets: +sqlcipher, pdfium, libdivecomputer, objectbox). A user who unpacks it does not +get: + +| Missing | Consequence | +| --- | --- | +| Any dependency declaration | GTK3, `libwebkit2gtk-4.1`, `libsoup-3.0`, `libsecret-1`, `liblzma`, `libstdc++` must already be present. Absent any of them, the loader emits a soname error, not a message. | +| `.desktop` file and icons | No menu entry, no dock icon, no `StartupWMClass` binding. Launch is `./submersion` from a terminal. | +| Device permissions | The Linux dive computer path opens `/dev/hidraw*` (`packages/libdivecomputer_plugin/linux/usbhid_enumerator.c`), `/dev/ttyUSB*` and `/dev/ttyACM*` (`serial_scanner.c`), and BlueZ over D-Bus (`ble_scanner.c`). `hidraw` is root-only by default, so USB dive computers simply never appear. | +| A stated distro requirement | Built on `ubuntu-latest`, and the shipped libraries require GLIBC_2.38, so the binary refuses to start on Debian 12 or Ubuntu 22.04. Nothing said so anywhere, which is the part this design fixes; the floor itself is kept (see Section 3). | +| A working updater | `lib/features/auto_update/presentation/providers/update_providers.dart:43` points Linux at the `Linux.tar.gz` asset, and `update_banner.dart:49` opens it in a browser. There is no install step to hand it to. | + +This was a deliberate deferral, not an oversight: +`docs/plans/2026-02-12-github-releases-design.md:173` records "Linux package +formats (Snap, Flatpak, AppImage) -- tar.gz only for now." + +## Decisions + +| Decision | Choice | +| --- | --- | +| Formats | `.deb` and `.rpm` as the primary path, plus an upgraded tarball for everything else. No Flatpak, AppImage, or Snap. | +| Compatibility floor | glibc 2.38, the floor the released tarball has always had. Covers Ubuntu 24.04+, Debian 13+, Fedora 39+, Mint 22, Arch and Tumbleweed. Excludes Debian 12, Ubuntu 22.04, and RHEL 9, which keep the tarball as today. | +| Updates | Self-hosted APT and DNF repositories, so `apt upgrade` and `dnf upgrade` carry Submersion along with everything else. | +| Repo hosting | A new `submersion-app/linux-packages` repo published to GitHub Pages, rebuilt statelessly on each release. | +| Channels | `stable` and `beta` suites from day one. | +| Self-enrollment | Packages downloaded directly from GitHub Releases install the repo definition and signing key, disclosed plainly. | +| Build mechanism | One Flutter build, one staging tree, two emitted packages. | + +`promote.yml:87` resolves a beta tag and reuses it as the stable tag, copying +assets without rebuilding, and tags carry no `-beta` suffix (`v1.7.6.7161`). +A package built once is therefore byte-identical in both channels, and +promotion stays a file copy. + +## Section 1: What the package installs + +`scripts/release/stage_linux_package.py` builds a canonical staging tree from +the untouched Flutter bundle. Both formats are emitted from that one tree, so +the `.deb` and the `.rpm` are provably the same binary. + +| Path | Source | +| --- | --- | +| `/usr/lib/submersion/` | `build/linux/x64/release/bundle/` verbatim | +| `/usr/bin/submersion` | generated wrapper: `exec /usr/lib/submersion/submersion "$@"` | +| `/usr/share/applications/app.submersion.desktop` | generated | +| `/usr/share/icons/hicolor/{16,32,48,64,128,256,512}x{same}/apps/app.submersion.png` | downscaled from `assets/icon/icon.png` | +| `/usr/share/metainfo/app.submersion.metainfo.xml` | generated, with release history | +| `/usr/lib/udev/rules.d/60-submersion-divecomputers.rules` | generated (below) | +| `/usr/lib/submersion/INSTALL_METHOD` | literal `deb` or `rpm` | + +Preserving the bundle layout verbatim is what keeps the runner's +`$ORIGIN/lib` rpath (`linux/CMakeLists.txt:17`) working with no build change. +`/usr/bin/submersion` is a wrapper rather than a symlink, not because `$ORIGIN` +needs it (it resolves through symlinks correctly), but because the wrapper is +where environment variables can be set later without re-cutting a release. + +The `.desktop` file sets `Categories=Science;Education;Utility;` and +`StartupWMClass=submersion`, without which the running window fails to bind to +its own icon in GNOME and KDE docks. The AppStream metainfo file is what makes +GNOME Software and KDE Discover show a name, a description, and screenshots +instead of an unnamed blob. + +### udev rules are generated, not copied + +libdivecomputer ships +`packages/libdivecomputer_plugin/third_party/libdivecomputer/contrib/udev/libdivecomputer.rules`, +but it is 35 lines covering roughly nine devices, and every rule reads +`GROUP="plugdev"`. That group does not exist on Fedora, so the file half-works +by construction. + +`scripts/gen_udev_rules.py` parses the descriptor table in the libdivecomputer +submodule and emits rules using `TAG+="uaccess"`, which grants access to the +user on the active seat through systemd-logind. No group membership, no +`usermod -aG`, no log out and back in, and identical behavior on Debian and +Fedora. Access follows the seat, so it is also revoked on fast user switching, +which group membership never does. + +Rules cover the three subsystems the Linux plugin actually opens: `hidraw`, +`tty` for USB-serial adapters, and the raw `usb` node. A fixture test asserts +the generator's output, so a submodule bump that changes the table's shape +fails loudly rather than silently emitting an empty file. + +BLE needs no rules. BlueZ mediates over the D-Bus system bus, which desktop +sessions may already use. + +## Section 2: Dependency declarations + +The failure mode to design against is a plugin's CMake fallback quietly +changing the runtime contract. `desktop_webview_window` is the live example +(`desktop_webview_window-0.3.0/linux/CMakeLists.txt:10-17`): + +```cmake +pkg_check_modules(WebKit IMPORTED_TARGET webkit2gtk-4.1) +if(NOT WebKit_FOUND) + pkg_check_modules(WebKit REQUIRED IMPORTED_TARGET webkit2gtk-4.0) +endif() +pkg_check_modules(LibSoup REQUIRED IMPORTED_TARGET libsoup-3.0) +``` + +Pin a runner, hand-write a dependency list, and you can ship a package that +declares 4.1 while linking 4.0. So the list is derived, not asserted. + +`scripts/linux_package_deps.py` reads `DT_NEEDED` out of the built `submersion` +binary and every `lib/*.so`, subtracts the libraries bundled inside the +package, and maps each remaining soname through an explicit table. **An +unmapped soname is a hard build failure.** This is deliberately the same guard +shape as `scripts/check_bundled_native_assets.py`, which exists because issue +#1129 proved a missing library stays invisible until a user launches the app. + +Starting content of the table, from what the plugins link today: + +| soname | apt | rpm | pacman | zypper | +| --- | --- | --- | --- | --- | +| `libgtk-3.so.0` | `libgtk-3-0` | `libgtk-3.so.0()(64bit)` | `gtk3` | `gtk3` | +| `libwebkit2gtk-4.1.so.0` | `libwebkit2gtk-4.1-0` | `libwebkit2gtk-4.1.so.0()(64bit)` | `webkit2gtk-4.1` | `webkit2gtk-4_1` | +| `libsoup-3.0.so.0` | `libsoup-3.0-0` | `libsoup-3.0.so.0()(64bit)` | `libsoup3` | `libsoup-3_0-0` | +| `libsecret-1.so.0` | `libsecret-1-0` | `libsecret-1.so.0()(64bit)` | `libsecret` | `libsecret-1-0` | +| `liblzma.so.5` | `liblzma5` | `liblzma.so.5()(64bit)` | `xz` | `liblzma5` | +| `libstdc++.so.6` | `libstdc++6` | `libstdc++.so.6()(64bit)` | `gcc-libs` | `libstdc++6` | + +The `pacman` and `zypper` columns are unused by the packages themselves and +exist for the tarball's preflight check (Section 6). + +Depending on **sonames** in the RPM rather than package names is what lets one +`.rpm` install on Fedora, openSUSE, and RHEL alike: Fedora names the package +`gtk3` while openSUSE names it `libgtk-3-0`, but both provide +`libgtk-3.so.0()(64bit)`. + +`printing` needs `gtk+-unix-print-3.0` (`printing-5.15.0/linux/CMakeLists.txt:50`), +which ships inside GTK3 itself and adds no new dependency. Its pdfium is +bundled. + +### Two weak dependencies + +Both `Recommends`, never `Requires`: + +- **`ffmpeg`.** `lib/features/media_store/data/platform_video_transcoder.dart:27` + looks it up on `PATH` and degrades gracefully, with a localized message + already shipped in all 11 languages ("Install ffmpeg to enable video + compression. Originals are uploaded until then."). It must stay weak because + stock Fedora has no ffmpeg at all: it lives in RPM Fusion, so a hard + `Requires` would make the package uninstallable on a default Fedora system. +- **`gnome-keyring`.** `flutter_secure_storage_linux` requires libsecret + (`flutter_secure_storage_linux-3.0.2/linux/CMakeLists.txt:15`) and therefore a + running Secret Service provider. Present on GNOME and KDE; absent on minimal + window managers, where sync encryption would otherwise fail at runtime with + no explanation. + +## Section 3: Build and CI wiring + +### The glibc floor is the runner's, and is left alone + +An earlier revision of this design built inside an `ubuntu:22.04` container to +pin a glibc 2.35 floor. That was reverted, for two reasons. + +The first is that it reversed a decision without saying so. The floor question +was asked and answered as "build on the ubuntu-22.04 runner"; the container +arrived later in the design, argued as a fresh recommendation rather than as a +change, on the premise that a retired runner label would raise the floor +silently. Retired labels fail loudly, so the premise was wrong. + +The second is that the reach was never real to begin with. Measured against +the shipped v1.7.6.7161 tarball, the released binaries already require +GLIBC_2.38: + +| File | Requires | +| --- | --- | +| `libflutter_secure_storage_linux_plugin.so` | GLIBC_2.38 | +| `liblibdivecomputer_plugin_plugin.so` | GLIBC_2.38 | +| `libsqlcipher.so` | GLIBC_2.38 | +| `submersion` | GLIBC_2.34 | + +The floor is set by three libraries, not by the app. Lowering it would have +extended Submersion to distros it has never supported, at the cost of a +container, a toolchain install on every run, and a class of missing-tool +failures that no pull request can catch, since `build-all.yml` is +`workflow_call`-only. Debian 12 and Ubuntu 22.04 have never been able to run a +release and gain no fallback here: the tarball bundles the same three libraries +and fails on those distros for the same reason. + +```yaml +build-linux: + runs-on: ubuntu-latest # glibc floor follows the runner image +``` + +The consequence to accept: when GitHub moves `ubuntu-latest` to the next +release, the floor rises with it and nothing announces that. The distro +requirement in the install docs is therefore a statement about the current +build environment rather than a guarantee. + +The existing `apt-get install` step (`build-all.yml:565`) moves inside the +container and gains `curl git unzip xz-utils ca-certificates`, which a bare +image lacks and `subosito/flutter-action` assumes. Two container traps to +handle explicitly: + +- `actions/checkout` inside a container trips git's "dubious ownership" check; + the job needs `git config --global --add safe.directory '*'`. +- The container runs as root. The build creates and uses an unprivileged user + so Flutter's tooling behaves as it does everywhere else. + +### New steps, after the existing native-asset guard + +1. `scripts/gen_udev_rules.py` +2. `scripts/release/stage_linux_package.py` +3. `scripts/linux_package_deps.py` +4. `fpm --input-type dir` once per format, fed the derived dependency list. + Ruby is already in the release pipeline (`release.yml:106`), so `fpm` costs + nothing new. +5. the existing tarball step, unchanged + +### Asset naming follows the existing six assets + +`Submersion-${TAG}-Linux-amd64.deb` and `Submersion-${TAG}-Linux-x86_64.rpm`, +not the Debian-conventional `submersion_1.7.6.7161_amd64.deb`. + +Conventional names are lowercase and would slip straight through the +`Submersion-*` globs that build the checksum file (`release.yml:335` and `:388`, +`beta.yml:230`), which is exactly the bug `beta.yml` already carries a comment +about, where `Submersion.pkg` was silently omitted from checksums. The +filename is cosmetic in any case: the repository indexer assigns pool paths +from package metadata, not from the downloaded name. + +`validate-release`'s `EXPECTED` array (`release.yml:434`) gains both files, so +a packaging step that fails without failing the build still blocks the release. + +### A new `verify-linux-packages` job + +Install the `.deb` in a clean `debian:12` container and the `.rpm` in a clean +`fedora:latest` container, using the real package manager, so an unsatisfiable +dependency is a test failure rather than a user's bug report. Then run +`submersion --version` and assert the output. + +That last check needs a small addition to `linux/runner/main.cc`, which today +is the stock six lines. A `--version` short-circuit that prints and exits +before `g_application_run` means the smoke test needs no display server at all. + +## Section 4: The APT and DNF repositories + +A new public repo, `submersion-app/linux-packages`, served from Pages at +`packages.submersion.app`. + +**Stateless by construction.** Each run downloads the packages it needs from +GitHub Releases, regenerates the whole site, and deploys via +`actions/upload-pages-artifact`. Nothing large is ever committed, so there is +no history to prune and no drift between git and what is served. The repo is +not the source of truth for packages; it is a view over GitHub Releases, which +is what makes a nightly reconcile able to repair it. + +``` +/submersion.gpg dearmored public key, for signed-by= +/apt/dists/{stable,beta}/main/binary-amd64/Packages{,.gz} +/apt/dists/{stable,beta}/{Release,Release.gpg,InRelease} +/apt/pool/main/s/submersion/*.deb +/rpm/{stable,beta}/{repodata/,*.rpm} +/submersion.repo drop-in for /etc/yum.repos.d/ +/setup.sh documented convenience script +``` + +APT metadata is `dpkg-scanpackages`, then `apt-ftparchive release`, then +`gpg --clearsign`. Neither `reprepro` nor `aptly` is used: both want a +persistent database that a stateless rebuild would have to fake. DNF metadata is +`createrepo_c`, with the RPMs signed by `rpmsign --addsign` and `repomd.xml` +detached-signed with the same key. + +**Retention: two versions per suite.** Two suites, two versions, two formats, +roughly 40 MB each puts the site near 320 MB against the 1 GB Pages cap. The +workflow fails if the assembled site exceeds 800 MB rather than discovering the +cap mid-deploy. Bandwidth is the softer constraint: the 100 GB/month soft limit +is roughly 2,500 package downloads, while the metadata every `apt update` +fetches is negligible. + +**Triggering is belt and braces**, because a missed publish means users silently +stop receiving updates: + +- `repository_dispatch` fired by `release.yml`, `beta.yml`, and `promote.yml` + on success +- a daily `schedule` that reconciles the published site against the latest + releases and republishes when they disagree, making a dropped dispatch + self-healing rather than permanent +- `workflow_dispatch` for manual repair + +**Signing key**: generated offline, stored passphrase-protected in that repo's +Actions secrets with the passphrase as a second secret. It signs both the +repository metadata and the RPMs themselves. + +### Self-enrollment + +Packages downloaded directly from GitHub Releases install the repo definition +and the signing key, so a one-time manual download keeps receiving updates. +This is what Chrome, VS Code, and Docker do, and without it most direct-download +users would quietly run one version forever. + +It is disclosed in three places: the package description, the release notes, +and the install documentation, each stating what is added and giving the +one-line removal for anyone who objects. + +## Section 5: The in-app updater on packaged installs + +Linux uses `GithubUpdateService` (`update_providers.dart:77`), matches the +`Linux.tar.gz` asset, and opens that URL in a browser +(`update_banner.dart:49`). On a dpkg-managed install that is actively wrong: it +hands the user a tarball that would shadow the packaged copy. + +A new `linuxInstallMethodProvider` reads the `INSTALL_METHOD` marker beside +`Platform.resolvedExecutable`, yielding `deb`, `rpm`, or `tarball` when the file +is absent. It follows the provider pattern established at +`lib/features/settings/presentation/providers/sync_providers.dart:86`, where +`isLinuxPlatformProvider` exists as a provider rather than an inline `Platform` +check precisely so widget tests can simulate the platform on a macOS CI host. + +| Install method | Behavior | +| --- | --- | +| `tarball` | Unchanged: download link to the new tarball. | +| `deb` / `rpm` | The check still runs, but the banner drops the Download button and shows `sudo apt upgrade submersion` or `sudo dnf upgrade submersion` with a copy affordance. No browser navigation. | + +Keeping the check while removing the download action is the honest split: the +app knows something the package manager has not told the user yet, but it is +not the thing that should install it. + +## Section 6: The tarball, upgraded + +The tarball remains the escape hatch for Arch, NixOS, and anyone who will not +use a package manager. It gains three files: + +- **`install.sh`**: installs to `~/.local/opt/submersion` by default with no + root, writes a `.desktop` file and icons into `~/.local/share`, symlinks + `~/.local/bin/submersion`, and offers to install the udev rules with `sudo` + or print the command for the user to run themselves. +- **`uninstall.sh`**: reverses exactly that, udev rules included. +- **`deps.json`**: the soname table from Section 2. + +The preflight check is what turns a loader error into an actionable message: +run `ldd` over the binary and every plugin `.so`, collect `not found` sonames, +map them through `deps.json`, detect which of `apt`, `dnf`, `pacman`, or +`zypper` is present, and print the exact command. One table, four package +managers, and the same table that produced the package dependencies. + +The tarball does not self-enroll in the repositories: there is no installed +package for `apt` to upgrade. + +## Section 7: Testing + +| Layer | Test | +| --- | --- | +| `gen_udev_rules.py` | Fixture-based: a captured descriptor-table slice in, expected rules out. Guards against a submodule bump silently emitting nothing. | +| `stage_linux_package.py` | Tree layout, desktop-file contents, marker file, icon sizes. | +| `linux_package_deps.py` | Mapping correctness, and that an unmapped soname raises rather than being dropped. | +| `install.sh` | Shell test in the style of `scripts/pre_push_hook_test.sh`, plus shellcheck. | +| Packages | `verify-linux-packages`: real install in clean `debian:12` and `fedora:latest` containers, then `submersion --version`. | +| Updater | Dart tests with `linuxInstallMethodProvider` overridden per install method. | +| Repo workflow | Assembled-site size guard, and a dry run that verifies `apt update` succeeds against the built site before deploying. | + +Python tests follow the existing `scripts/*_test.py` convention. + +## Section 8: Documentation + +- `README.md` and `docs/guide/installation.md`: three paths (add the repo, + direct `.deb`/`.rpm` download, tarball), with self-enrollment stated plainly + and the removal command given. +- `docs/developer/release-secrets-setup.md`: GPG key generation and storage, + and the dispatch token. +- Release notes for the version that ships this: the new formats and the + self-enrollment behavior. + +## Out of scope + +Listed so they are deliberate rather than forgotten: + +- Flatpak, AppImage, Snap +- arm64: the build is x86-64 only today. The asset naming leaves room, but no + aarch64 build exists. +- `.uddf` and `.ssrf` file associations (a MIME XML plus desktop-file + `MimeType=`). A natural follow-up. +- The website download page, which lives in the separate `submersion-website` + repo. + +## Risks + +| Risk | Mitigation | +| --- | --- | +| ~~`libwebkit2gtk-4.1` may be unavailable on Ubuntu 22.04~~ | Moot as of 2026-09-04: the 22.04 build environment was reverted, so the build uses the runner's own webkit 4.1. The fallback remains impossible to ship unnoticed either way, since it would put `libwebkit2gtk-4.0.so.37` in the derived dependency list and an unmapped soname fails the build. | +| A libdivecomputer submodule bump changes the descriptor table's shape and the udev generator emits nothing. | Fixture test on the generator's output. | +| A plugin update introduces a new shared-library dependency. | `linux_package_deps.py` fails the build on any unmapped soname. | +| Pages bandwidth or size limits are reached as the Linux audience grows. | Two-versions-per-suite retention, an 800 MB pre-deploy guard, and a documented migration path to a package registry if the limits bind. | +| The repository publish dispatch is dropped and users stop seeing updates. | Daily reconcile job republishes when the site and the latest releases disagree. | +| The glibc floor rises silently when GitHub moves `ubuntu-latest` to a newer release, invalidating the distro requirement in the install docs. | Accepted deliberately in exchange for build simplicity. The requirement is documented as a statement about the current build environment, not a guarantee. A CI guard asserting the maximum required GLIBC symbol version would make the drift visible; not implemented. | diff --git a/docs/superpowers/specs/2026-09-04-raw-dive-data-compression-design.md b/docs/superpowers/specs/2026-09-04-raw-dive-data-compression-design.md new file mode 100644 index 0000000000..2f67b3bb55 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-raw-dive-data-compression-design.md @@ -0,0 +1,364 @@ +# Compressing Raw Dive Computer Data at Rest + +**Issue:** [#227](https://github.com/submersion-app/submersion/issues/227) +**Date:** 2026-09-04 + +## Problem + +`dive_data_sources.raw_data` holds the raw per-dive bytes libdivecomputer +handed back at download time. Those bytes are what makes re-parse possible +after a libdivecomputer fix (#224, #226, #478), so they are kept forever and +are the sole recoverable copy of a download. They are stored uncompressed. + +The 2026-04-15 raw-data spec ruled compression out of scope with the +rationale "dive blobs are 1-50 KB; compression adds complexity for negligible +savings". Two things have changed since: + +1. That verdict was calibrated on small blobs. Measured against the repository's + own libdivecomputer fixtures, real dives compress well (see below). +2. The profile-series work (#1387, #1406) cut the row-per-sample tables from + 751 MB to 35 MB at 1,000 dives. `raw_data` was a rounding error next to + those tables. It is now a large share of what is left. + +## Measurement + +zlib level 6 over the raw fixtures already committed to this repository: + +| fixture | raw | zlib-6 | with 8-byte header | ratio | +|---|---:|---:|---:|---:| +| `packages/libdivecomputer_plugin/test/native/fixtures/dive1_raw.bin` | 400 B | 321 B | 329 B | 1.22x | +| `packages/libdivecomputer_plugin/test/native/fixtures/petrel3_ccr_o2_cells.bin` | 22,400 B | 8,903 B | 9,184 B | 2.44x | +| `packages/libdivecomputer_plugin/android/src/androidTest/assets/shearwater_teric_dive.bin` | 22,144 B | 4,655 B | 4,663 B | 4.75x | + +Level 9 was measured too and gains under 1.2% over level 6 on all three, so +level 6 is used. + +The "zlib-6" column is Python's zlib, used for the survey. The "with header" +column is what the shipped Dart encoder actually produces, measured after +implementation. They agree on two fixtures and differ by 3% on the Petrel 3 +one: the two zlib builds make slightly different choices at the same level. +The shipped numbers are the ones in the rightmost columns. + +The 400-byte fixture is a synthetic minimal dive. Ordinary recreational dives +from a Shearwater or Petrel are the 22 KB cases, at 2.4x to 4.7x. The old +"negligible" verdict came from calibrating on the small case. + +At 5,000 downloaded dives the design doc's own estimate of about 150 MB of raw +bytes becomes roughly 40 MB. #1376 documents that default backup retention +keeps up to 13 full database copies plus the live one, so bytes removed from +the live database are removed up to 14 times from disk. + +## Goal + +Store `raw_data` compressed on disk, with no change to what any caller sees, +no change to the sync wire format, and no possibility of losing a byte of an +irreplaceable download. + +## Scope + +- `dive_data_sources.raw_data` only. +- A migration rung that recompresses existing rows, so libraries that already + hold raw data benefit rather than only new downloads. + +## Out of Scope + +| Item | Rationale | +|---|---| +| `raw_fingerprint` | A handful of bytes per row, and it is hex-matched for download de-duplication. Nothing to gain, a live matching path to risk. | +| The "discard raw data" action and backup retention | #1376 owns both, and its body says the two issues coexist. | +| A Storage usage page row for raw bytes | #1375 owns the storage registry. Compression does not need it. | +| Compressing any other BLOB column | Media bytes are already compressed formats; profile series are already zlib. | +| Changing the sync wire format | Explicitly preserved. See "The sync invariant". | + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Where compression happens | A Drift `TypeConverter` on the column | Cannot be missed by a new write site, and it keeps sync transparent for free. | +| How a compressed blob is recognised | A magic header inside the blob | Self-describing. No sidecar column for `DiveMergeService` to carry, no state to consult on a partially migrated table. | +| What a non-matching blob means | Legacy uncompressed bytes, returned verbatim | One rule covers pre-migration rows, rows inbound from an older peer, and incompressible blobs. | +| Compression codec | `zlib`, level 6 | Already a dependency of the profile-series codecs. Level 9 measured under 1.2% better. | +| When to compress | Only when the encoded form is strictly smaller | A blob that does not compress is stored exactly as it arrived. | +| Sync wire format | Unchanged: peers keep exchanging uncompressed bytes | No `minimumCompatibleSchemaVersion` raise, so no older peer is refused. | +| Existing rows | Recompressed by a migration rung, then one VACUUM | Rewriting in place only moves pages to the freelist. VACUUM is what returns them. | +| Decode failure | Fall back to "legacy raw", never throw | A converter throwing would break ordinary row mapping for the whole table. | + +## Blob Format + +``` +offset size meaning +0 4 magic, ASCII "SRD1" +4 4 uint32 little-endian, length of the original bytes +8 n zlib stream (level 6) of the original bytes +``` + +A stored blob is decoded as compressed if and only if **all** of: + +1. It is at least 8 bytes long and begins with the magic. +2. The zlib stream inflates without error, bounded by the declared length. +3. The inflated length equals the declared length. + +Otherwise the stored bytes are returned unchanged. + +The residual risk is a legacy raw blob that satisfies all three conditions by +accident, which would read back as its own inflation instead of itself. For +that, arbitrary dive computer bytes would have to begin with the specific +four-byte magic (about 2^-32), and the bytes after offset 8 would have to form +a zlib stream whose adler32 trailer validates (about another 2^-32), inflating +to exactly the length the preceding four bytes happen to spell. Conditions 2 +and 3 are what push it there; the magic alone would not be enough. The +combination is not reachable in practice, and after the migration below every +stored blob was written by this encoder anyway, leaving only rows inbound from +an older peer in the legacy state. + +Encoding writes the compressed form only when both hold: + +- `8 + zlibLength < originalLength`. The 400-byte fixture above clears that + bar; a 20-byte blob would not, and is stored raw. +- `originalLength <= kMaxRawDiveBlobBytes` (see below). The encoder must never + produce a blob its own decoder's bound would refuse, so anything over the cap + is stored raw and stays readable. + +The version digit in the magic is the extension point. A future codec change +mints "SRD2" and the decoder keeps understanding "SRD1" forever, because the +blobs are the only copy of the download. + +## Where It Lives + +New file `lib/core/database/raw_dive_data_codec.dart`, holding the encode and +decode functions and the format constants, with no Flutter imports. The +existing `database_import_graph_test` walks `database.dart`'s import graph and +requires it to stay Flutter-free, so this file must stay so too. + +The converter is applied in `database.dart`: + +```dart +BlobColumn get rawData => + blob().map(const RawDiveDataConverter()).nullable()(); +``` + +Drift wraps a non-nullable converter on a nullable column with +`NullAwareTypeConverter`, so `null` maps to `null` in both directions and the +generated data-class field stays `Uint8List?`. No caller's type changes and +no call site needs editing. + +Decoding uses `inflateBounded` from `lib/core/utils/bounded_inflate.dart` with +`zlib.decoder`. That guard is chunked, so a crafted blob is abandoned at the +first oversized chunk rather than after the whole body is in memory. + +Both of its caps are derived from one constant, +`kMaxRawDiveBlobBytes = 8 MiB`, comfortably above any dive a computer records +and far below anything that threatens memory: + +- `maxBlobBytes` is `kMaxRawDiveBlobBytes`. +- `maxBytes` is `min(declaredLength, kMaxRawDiveBlobBytes)`. The declared + length is attacker-controlled up to 4 GiB, so it bounds the inflate only + when it is the smaller of the two. Clamping it is also what lets condition 3 + reject a blob that inflates past what it promised. + +### Why a converter rather than encoding at the call sites + +Only two sites write `raw_data` content today +(`dive_computer_repository_impl.dart:1348` and `reparse_service.dart:479`) and +only two read it (`ReparseService.reparseAllForComputer` and +`ReparseService.reparseDive`, both passing `source.rawData!` to the native +parser). Four sites is few enough to edit by hand. + +The converter is chosen anyway for two reasons that outlive the current site +count: + +1. A future write site that forgets to encode stores a raw blob, which still + reads back correctly, so the mistake is invisible. A future *read* site that + forgets to decode hands compressed bytes to libdivecomputer. The converter + removes both classes of mistake. +2. It is what makes the sync invariant below hold without touching sync at all. + +The cost is that every read of a `dive_data_sources` row inflates its blob, +including reads that never look at `rawData`. Reading the row already pulls the +blob off disk, and it now pulls a smaller one, so the net change on the read +path is a few tens of microseconds of inflate against less I/O. + +This would be the codebase's first `TypeConverter`. That is the one respect in +which the design cuts against the existing grain, and it is called out here so +a reviewer weighs it deliberately rather than discovering it. + +## The Sync Invariant + +Sync payloads are gzipped whole before encryption (`SyncEnvelope`, flag bit0), +so compressing this column buys nothing on the wire. The entire benefit is at +rest, and the wire is left alone. + +Every sync path that touches this table goes through +`table.map(row).toJson(serializer: _syncBlobSerializer)` or the matching +`fromJson`, both of which operate on the converted Dart value. With the +converter in place they therefore continue to emit and accept **uncompressed** +bytes, base64-encoded exactly as today. Consequences: + +- `minimumCompatibleSchemaVersion` is **not** raised. No peer on an older build + is refused. +- A row inbound from an older peer arrives uncompressed and is compressed on + write by the converter. +- The recompression migration is invisible to sync. It changes bytes on disk, + not the logical value, and touches no HLC, so no peer sees a change. +- Two devices compressing the same download to different bytes cannot cause a + flip-flop, because the compressed bytes never travel. + +Raw SQL that mentions this column was audited: `reparse_service.dart` lines +248, 255 and 271 and `database.dart:5790` use only `raw_data IS NULL` / +`IS NOT NULL` predicates, and `database.dart:8314` copies the column verbatim +during a table rebuild. None reads blob content, so the converter's absence on +those paths is correct. + +## Migration + +A new rung, **v190**. This was written against `origin/main` at schema version +187 and planned as v188, but by implementation time main had taken 188 (insurer +phone numbers, #1522) and 189 (media equipment link, #1517), so the branch +merged main and the rung moved up. The number is re-verified against +`origin/main` immediately before it is claimed, per the schema ladder +convention; that check is what caught this. + +The rung has no DDL. The column's SQL type is unchanged; only the stored bytes +move. It walks `dive_data_sources` rows where `raw_data IS NOT NULL` in +batches, encodes each blob, and writes back only the rows that got smaller. +Rows already carrying the magic are skipped, so the rung is idempotent and a +run interrupted part way costs nothing but the work already done. + +Encode cost is roughly 1 ms per 22 KB blob, so a 5,000-dive library is a few +seconds, comparable to the profile-series migration's measured 9.1 s. The rung +reports progress through the existing per-step callback. + +**The rung is guarded per row and cannot fail the ladder.** An unguarded pack +step in the v182 profile-series rung could leave a database that would not +open, which is the worst outcome available to any migration and the one this +rung is closest to repeating. A row whose encode throws is left exactly as it +is, counted, and logged; the rung moves on. Leaving a blob uncompressed costs +space, and the self-describing format means that row keeps reading correctly +forever. Nothing about a failed row justifies refusing to open the database +that holds the diver's log. + +### Reclaiming the pages + +Rewriting rows in place moves the old pages to the freelist; VACUUM returns +them to the filesystem. `DatabaseService._runUpgradeLadder` already owns +exactly one VACUUM per open, currently gated on `storedBefore < 183`, with a +second trigger for an unplanned reclaim discovered mid-ladder +(`AppDatabase.droppedLegacySampleTables`). + +The rung sets a parallel flag, `AppDatabase.recompressedRawBlobs`, set only +when at least one row actually shrank. `_runUpgradeLadder` treats it like the +existing unplanned-reclaim signal, so: + +- A file with no raw data takes no VACUUM. +- A file that recompressed rows takes the one VACUUM that already exists, + under the existing ticket, on the same exclusive main-isolate connection, + outside any migration transaction. +- The VACUUM stays non-fatal. A busy lock leaves a correct database that is + merely larger than it needs to be. + +The planned-versus-unplanned distinction in the progress reporting is kept as +it stands: reporting an unplanned reclaim as a step would push the bar past +its own total. + +## Error Handling + +| Situation | Behaviour | +|---|---| +| Blob without the magic | Returned verbatim as legacy raw bytes. | +| Magic present, stream corrupt or truncated | `BoundedInflateException` is caught and the stored bytes are returned verbatim. | +| Magic present, inflated length disagrees | Stored bytes returned verbatim. | +| Blob inflates past its declared length | Abandoned mid-stream by `inflateBounded`; stored bytes returned verbatim. | +| Encode throws on a normal write | Not caught. A failure to deflate in memory is a defect, not malformed input, and silently storing raw bytes would hide it. | +| Encode throws inside the migration | Caught per row. That row is left raw and counted; the rung continues. See below. | + +The fallback never throws out of row mapping, so a damaged blob degrades into +a re-parse failure. Re-parse already counts and surfaces failures +(`reparseAllForComputer` returns `succeeded`/`failed`; `reparseDive` returns +`errors`), so the degradation is reported rather than silent. + +## Testing + +**Codec unit tests** + +- Round trip over each of the three committed fixtures, asserting byte equality + and that the encoded form is smaller. +- A blob that does not compress is stored unchanged and reads back unchanged. +- Legacy raw bytes with no magic pass through both directions untouched. +- A crafted legacy blob beginning with the four magic bytes reads back as + itself. +- A truncated zlib stream behind a valid magic reads back as the stored bytes. +- A zlib bomb behind a valid magic is refused by the bound, not inflated. +- A blob larger than `kMaxRawDiveBlobBytes` is stored raw by the encoder and + reads back byte-identical, proving the encoder never mints something its own + decoder would refuse. +- Empty and single-byte blobs. + +**Migration test** + +A v187 raw-DDL fixture with several `dive_data_sources` rows carrying raw +blobs, stamped with `PRAGMA user_version`, run up the ladder. Assertions: the +on-disk blobs carry the magic, every decoded value equals the original bytes, +`recompressedRawBlobs` is set, and a second run is a no-op. A companion +fixture with no raw data asserts the flag stays clear. A third asserts the +ladder completes and the database opens when one row's encode throws, with +that row left raw and still readable. + +**Integration** + +`ReparseService` round trip: write a download through the repository, read it +back through `getSourcesForDiveReparse`, and assert the bytes handed to the +parse function are byte-identical to what was downloaded. + +**The load-bearing test** + +A sync round trip asserting the exported `diveDataSources` JSON carries the +base64 of the **uncompressed** bytes. This is what pins the wire contract. A +later refactor that moves encoding out of the converter and into the call +sites would leak compressed blobs to peers, and this test is the only thing +that would catch it. + +## Rejected Alternatives + +**A sidecar `raw_data_encoding` column.** Unambiguous, but every path that +copies a row has to carry it. `DiveMergeService` copies `dive_data_sources` +rows wholesale as provenance, deliberately, because each carried row is the +sole surviving copy of one download's raw bytes. A self-describing blob +survives that copy with nothing to remember. + +**Compressed bytes on the wire, with the sync floor raised to the new rung.** This is +the precedent the profile-series program set, and it is one line. It was +rejected because the failure it protects against is mild and self-healing (an +older peer's re-parse of a synced dive fails until that peer updates; no data +is lost or corrupted), while the protection is severe: raising the floor stops +older peers syncing at all. Disproportionate. + +**Decoding at the sync boundary while storing compressed.** Would also keep the +wire unchanged, but `sync_data_serializer.dart` has about ten +`case 'diveDataSources'` branches across export, paging, upsert and apply. +Getting nine of ten right leaks compressed blobs to peers silently, with no +floor raised to catch it. The converter makes the boundary singular. + +**Lazy recompression by a startup sweep** instead of a rung. The live +precedent for background maintenance is `MediaOrphanBacklogSweep`, and this +work would fit it. Rejected because a sweep with no VACUUM rewrites rows +without shrinking the file, and the one-VACUUM-per-open seam is tied to the +upgrade ladder. The rung reaches the reclaim; a sweep would not. + +**No migration at all**, letting only new downloads compress. Rejected because +the users with the most raw bytes are the ones who have been downloading +longest. + +## Files Touched + +| File | Change | +|---|---| +| `lib/core/database/raw_dive_data_codec.dart` | New. Format constants, encode, decode, converter. | +| `lib/core/database/database.dart` | `.map(...)` on `rawData`; the v190 rung; the `recompressedRawBlobs` and `hasUnreclaimedPages` flags. | +| `lib/core/database/database.g.dart` | Regenerated. Not committed: `.gitignore` excludes `*.g.dart`. | +| `lib/core/services/database_service.dart` | The VACUUM gate learns the new reclaim signal. | +| `test/core/database/raw_dive_data_codec_test.dart` | New. Codec unit tests. | +| `test/core/database/migration_v190_raw_data_compression_test.dart` | New. Migration fixtures. | +| `test/features/dive_computer/data/services/reparse_service_test.dart` | Round-trip assertion. | +| `test/core/services/sync/sync_serializer_round_trip_test.dart` | The wire-contract assertion. | + +No user-facing string changes, so no ARB work. diff --git a/docs/superpowers/specs/2026-09-04-uddf-raw-dive-data-design.md b/docs/superpowers/specs/2026-09-04-uddf-raw-dive-data-design.md new file mode 100644 index 0000000000..e4e390ed28 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-uddf-raw-dive-data-design.md @@ -0,0 +1,558 @@ +# Raw Dive Computer Data in UDDF Exports + +**Issue:** [#228](https://github.com/submersion-app/submersion/issues/228) +**Date:** 2026-09-04 +**Related:** [#227](https://github.com/submersion-app/submersion/issues/227), whose +spec is `2026-09-04-raw-dive-data-compression-design.md`. See "Relationship to +\#227" for the constraints it imposes on this work. + +## Problem + +`dive_data_sources.raw_data` holds the raw per-dive bytes libdivecomputer +handed back at download time. They are the only recoverable copy of a +download, and they are what makes re-parse work after a libdivecomputer fix. + +UDDF export does not include them. A UDDF file exported from Submersion is +therefore not a complete archive of the logbook: restore it on a new device and +the dives come back, but every dive's raw bytes are gone and "Re-parse raw +data" has nothing to work with for the rest of that library's life. + +## Goal + +A Submersion UDDF export can carry the raw bytes, and a Submersion UDDF import +restores them in a state where re-parse still works. The bytes are written in +the form the UDDF standard prescribes, so other dive software and dive computer +manufacturers can read them too. + +## Scope + +- Both UDDF export paths (`UddfExportService`, `UddfFullExportService`), behind + a toggle that is checked by default in both. +- The UDDF import path, restoring bytes and the metadata re-parse needs, + losslessly, including every source row of a multi-source dive. +- Compression and base64 encoding per the UDDF specification. + +## Out of Scope + +| Item | Rationale | +|---|---| +| Compressing `raw_data` at rest | #227 owns it. This design consumes its output transparently; see below. | +| Any other export format (CSV, PDF, KML, DiveCloud) | UDDF is the only one of them with a standard place for a device dump. | +| A new schema rung | Nothing in this design changes the schema. | +| Exporting source rows when the toggle is off | The provenance record exists to make the dumps restorable. With no dumps there is nothing to make restorable, and emitting it anyway would change every existing export. | +| Interpreting dumps written by other applications | We restore their bytes; we do not claim to know their parser. | + +## Background: what the UDDF standard provides + +UDDF 3.2.1 defines `` as the last section of the +document. It holds `` elements, whose children are: + +- ``: optional and **repeatable**, cross-referencing a declared id. +- ``: required, when the dump was captured. +- ``: required, the payload. + +The spec states of ``: "The memory dump data shall be compressed with +*bzip2* algorithm and encoded into ASCII using *Base64* encoder." bzip2 is +therefore not a choice this design makes; it is a requirement it satisfies. + +`` accepts **no attributes at all**, which is what drives the +pairing scheme below. + +Two mismatches between the standard and what Submersion stores are worth +naming plainly: + +1. The spec's `` means the whole binary contents transferred from a + device. Submersion stores per-dive slices from libdivecomputer. Emitting one + dump per dive is a defensible reading, and the explicit dive `` makes + the intent legible, but it is a reading rather than a literal match. +2. The spec has nowhere to record *which parser* a dump belongs to. Section + "Why a sidecar is unavoidable" covers the consequences. + +## Why a sidecar is unavoidable + +`ReparseService.reparseAllForComputer` (`reparse_service.dart:319`) skips any +source whose `descriptorVendor`, `descriptorProduct` or `descriptorModel` is +null, counting it as a failure. Those three fields are the libdivecomputer +descriptor that says which parser the bytes belong to. + +`` has no element for them. A restore that carries only what +the standard can express therefore produces rows whose bytes are present and +permanently unparseable, which is the opposite of the goal. The descriptor has +to travel somewhere, and ``, which this exporter +already writes (`uddf_export_builders.dart:895`), is where Submersion-specific +data already lives. + +The descriptor is only where it starts. A lossless restore needs the whole +`dive_data_sources` row, for two reasons the standard cannot address: + +1. A UDDF `` carries one set of values, the consolidated ones. The + per-source snapshots (`maxDepth`, `avgDepth`, `waterTemp`, `cns`, `otu`, + `decoAlgorithm`, the gradient factors, entry and exit times and positions, + ascent and descent rates, surface interval) live only in + `dive_data_sources`. Restoring bytes without them would restore blobs + losslessly and drop everything around them. +2. A dive can have a source row with no raw bytes at all, next to one that has + them. Emitting entries only for rows with dumps would restore such a dive + with fewer sources than it had. + +So the sidecar is a full per-source provenance record: one entry for **every** +source row of every exported dive, carrying every column except the blob. +Rows that have bytes additionally get a `` in the standard section. + +## Document Shape + +Both new pieces sit at the end of the document. `` goes +last, per the spec, after the existing `` that +`uddf_full_export_service.dart:405` already emits after `profiledata`. + +```xml + + + + + + + 0.9.0-devel + ... + A17F3C... + true + 0 + 0 + Perdix AI + ... + ... + ... + ... + 2019-06-02T18:41:07 + 2019-06-02T18:41:07 + 2026-03-11T09:02:00 + + + + + + + + + + + 2026-08-14T09:12:30 + QlpoOTFBWSZTW... + + +``` + +`` is hex, not base64. `dive_repository_impl.dart:6224` documents +that fingerprints are "hex-encoded the same way SQLite's `hex()` function" +does, and #227 leaves `raw_fingerprint` uncompressed and hex-matched for +download de-duplication. A restored fingerprint is therefore directly +comparable to a stored one. + +### Pairing + +`` cannot carry an id, so a sidecar entry cannot point at one +directly. `` entries are emitted grouped by dive in source-row order +and numbered from zero within each dive; dumps are emitted in that same order, +skipping rows with no bytes. A dump therefore pairs with the entry whose +`(diveref, ordinal)` it matches, and `hasdump` on the entry says whether to +expect one. An entry claiming a dump that is absent, or a dump with no matching +entry, is a malformed file: the bytes are still restored where present, and the +mismatch is counted and reported rather than guessed at. + +Every `ref` this design emits points at an id the standard itself declares: +`dive_` on ``, and the computer id on `` under +``. + +**The computer link is emitted only when the document declares that computer.** +Only the full export writes `` elements +(`uddf_full_export_service.dart:142`); the dives-only export writes none. A +dump in a dives-only export therefore carries the dive link alone. Teaching the +dives-only export to declare computers would be a larger change to a path whose +job is to stay lean, and the dive link already carries the attribution that +matters. Making the link conditional on declaration is what keeps the +invariant true in both paths. + +The UDDF documentation describes `ref` as the "unique +identifier of the data object to be cross-referenced" without naming its XML +type, and UDDF's schema conventionally pairs `id`/`ref` as `xs:ID`/`xs:IDREF`. +If it does, a `ref` pointing into `` could fail validation. +Referencing only standard-declared ids means nothing we write can dangle, at no +cost. + +The ordering is part of the contract, not incidental: it is what makes the +ordinal meaningful. If another tool rewrites the file and reorders or drops +dumps, pairing degrades to a wrong or absent descriptor, which the importer +detects (see "Importing a foreign file") rather than trusting. + +### Where the sidecar lives in each export + +The top-level `` block exists only +in the full export (`uddf_export_builders.dart:895`). The dives-only export's +only `` is a different element in a different place: a +per-dive inline one carrying custom fields +(`uddf_export_service.dart:525`), shaped `Submersion` plus +``. + +So `` is built by a small shared +`UddfExportBuilders.buildDataSources(builder, entries)`, called from two +places: + +- The full export calls it from inside the existing `buildApplicationData`, + alongside equipment, trips and courses. +- The dives-only export wraps it in a top-level + `` emitted only when dumps are + being written. The per-dive inline `` elements are + untouched; UDDF permits `` in both positions, and they do + not collide. + +The importer therefore has to accept a `` block containing nothing +but a ``, with none of the full export's other payload present. + +### `` + +The source row's `importedAt`. The spec means when the dump was captured, and +that is the closest thing recorded to the download moment. The dive's own +datetime would be friendlier to a reader that ignores our links, at the cost of +saying something the spec does not mean; the explicit dive `` serves that +reader better anyway. + +**This does not affect dive times.** The dive's own datetime lives at +`` and is read by a scoped lookup: +`diveElement.findElements('informationbeforedive')`, then +`getElementText(beforeElement, 'datetime')` +(`uddf_full_import_service.dart:1262`). The dump's datetime lives in a +different section entirely and is never in that element's scope. Neither UDDF +importer contains a single `findAllElements` call, so no document-wide scan can +stray across sections. + +The format already reuses the `` element name in an unrelated place, +equipment purchase dates (`uddf_full_import_service.dart:2474`), and it causes +no trouble for exactly this reason. A regression test pins it: a document +carrying both a dive datetime and a dump datetime that differ imports the dive +at its own time. That test is what fails if anyone later "simplifies" a lookup +to `findAllElements('datetime')`. + +## Export + +### Options + +A new `UddfExportOptions`, mirroring `PdfExportOptions` +(`lib/core/constants/pdf_templates.dart:95`): a const class with defaults, +whose only field for now is `includeRawData`, defaulting to **`true`**. It +threads through `ExportService`'s four UDDF entry points as a defaulted named +parameter, so every existing call site compiles unchanged. + +Every UI presents the checkbox pre-checked, in both the full backup and the +dives-only share paths, and the code-level default matches what the UI shows +rather than diverging from it. A caller that omits options therefore gets raw +data, which is the intended behaviour: the export is meant to be complete +unless the user says otherwise. + +The consequence worth stating plainly is that sharing a single dive from the +dive detail page now carries that dive's raw bytes by default, and costs a +bzip2 encode on a worker isolate. That is the point of the feature, and the +checkbox is how a user opts out for a particular share. + +### Fetching + +A new value type `DiveSourceExport` and one bulk repository method, +`getSourcesForExport(List diveIds)`: a single `diveId.isIn(ids)` query +ordered by dive, then `isPrimary`, then `createdAt`. + +It returns **every** source row, not only those with bytes, because the +provenance record has to cover blob-less rows for the restore to be lossless. +The `rawData` column being null is what sets `hasdump="false"` on that row's +entry. The ordering is part of the contract, not incidental: it defines the +ordinals that pair entries with dumps. + +**It must be a Drift-typed select, never `customSelect`.** Two independent +reasons: + +1. `getDataSources` (`dive_repository_impl.dart:6192`) runs rows through + `_canonicalDataSourceRows`, which deliberately collapses rows sharing a + merge slot into one *display* source. The `DiveDataSources` table comment + states each carried row "is the sole surviving copy of its half's rawData". + Exporting through that method would silently drop half of a combined dive's + bytes, so this query maps Drift rows directly and reuses neither that method + nor its collapsing. +2. Under #227, `raw_data` carries a `TypeConverter`. Drift-typed selects run + converters; `customSelect` does not. A raw-SQL version of this query would + put `SRD1`-framed zlib streams inside `` and no test would notice + until someone tried to re-parse a restored dive. + +`reparse_service.dart` already encodes the rule by example: `customSelect` for +the three counting queries (lines 246, 253, 269, all `IS NULL` / +`IS NOT NULL` predicates), and a typed `db.select(db.diveDataSources)` the +moment it needs bytes (line 292). + +When `includeRawData` is false the provider never calls this method, so a +shared single dive costs nothing extra. + +### Compression + +Compression happens before XML building, not inside the builder, so +`UddfExportBuilders.buildDiveComputerControl(...)` stays a pure synchronous +function over already-encoded strings like every other builder in that file. + +A new `UddfDumpCodec` holds `BZip2Encoder` plus base64, and an `Isolate.run` +hop shaped exactly like `backup_crypto.dart:54`, whose comment records the +reason: the hop is kept as its own method so the `Isolate.run` closure's +enclosing scope holds only sendable values, because Dart closures capture the +whole enclosing scope rather than only what they reference. The hop takes +`List` and +returns `List`. `Uint8List` crosses the isolate boundary cheaply. + +bzip2 is a block-sorting compressor and is markedly slower than zlib in pure +Dart, which is why this is on a worker isolate rather than inline. + +**Decode-then-recompress is mandatory.** At rest the bytes are zlib-framed +(#227); on the wire the spec requires bzip2. Passing the stored blob through +untouched would be faster and would produce a spec-violating file that no other +UDDF tool can read. This is recorded because it is exactly the optimization a +future reader reaches for. + +### Failure isolation + +This is a backup path, so one bad blob must not cost the user the whole file. +Each dump is compressed independently; a failure yields `null` for that entry, +its `` and its sidecar entry are both omitted, and the +failure is logged. The export completes. + +The export result reports how many dumps were written and how many were +skipped, so a shortfall is visible rather than inferred. + +### No export-side size cap + +The document is already assembled in memory today, raw dumps are single-digit +KB in practice (#227 measured 400 B to 22 KB against the repository's own +fixtures), and a cap only adds a failure mode nobody asked for. If real-world +file sizes prove to be a problem, that is a follow-up with a measurement behind +it. Import is a different matter; see below. + +## Import + +### Parsing + +`UddfFullImportService` gains two passes: one over `` producing a +source record per `` entry, and one over `` +producing decoded bytes per dump. The two are joined on `(diveref, ordinal)`, +so the parser emits, per dive, an ordered list of source records with bytes +attached where a dump was present. + +It stays a pure parser returning maps, matching the rest of that file. Records +whose dive ref resolves to no imported dive are dropped, as are dumps with no +matching entry, both counted. + +### Bounding untrusted input + +A `` in someone else's file is hostile input, and bzip2 expands far +more aggressively than zlib, so a small element can decode to a great deal. +`inflateBounded` is zlib-specific and does not apply. + +`BZip2Decoder.decodeStream(InputStream, OutputStream)` takes the abstract +`OutputStream` (`writeByte` / `writeBytes` / `length`), so decoding runs into a +bounded `OutputStream` subclass that throws once `length` exceeds the ceiling. +That aborts mid-stream rather than buffering the whole output and checking +afterwards, matching the posture #227's `inflateBounded` takes. `decodeBytes` +is not used. + +The ceiling is `kMaxRawDiveBlobBytes` (8 MiB), the same constant #227 +establishes. There must be exactly one definition of it; see "Relationship to +\#227". + +### Restore + +`uddf_entity_importer.dart:2078` currently builds exactly one +`DiveDataSourcesCompanion` per imported dive, synthesised from the dive's own +parsed fields, and writes it through `saveComputerReading` with +`isPrimary: true`. That single-row assumption is what has to change for the +restore to be lossless. + +The rule is deterministic: + +- **A dive with `` entries:** the entries define its source rows + completely. One `saveComputerReading` call per entry, populated from the + entry rather than from the dive's parsed fields, and the synthesised row is + **not** written. This is what restores a two-computer dive as two rows. +- **A dive with no entries:** unchanged. The synthesised row is written exactly + as today, so every existing import path and every foreign UDDF file behaves + as it does now. + +Restoring from the entry rather than synthesising also restores `importedAt`, +which is user-visible: the data sources panel renders it as "Imported" +(`data_sources_section.dart:631`). Today a restore relabels a 2019 dive's +source as imported at the restore moment. Carrying the row means the restored +library looks like the library that was backed up, which is the same fidelity +requirement that drives the rest of this section. + +**Exactly one row per dive must be primary.** Entries carry ``, but a +hand-edited or malformed file could claim zero or several. The importer takes +the first entry claiming primary and clears the flag on the rest; if none +claims it, the first entry is promoted. A dive silently ending up with no +primary source would break primary-source resolution +(`dive_repository_impl.dart:5028`) for that dive forever. + +**Computer resolution goes through model and serial, not the dump's link.** +`computerIdByKey` (`uddf_entity_importer.dart:1343`) is keyed by normalised +model plus serial via `_importedComputerKey`, not by the exported computer id. +Each restored source therefore resolves its computer from the +`computermodel` and `computerserial` it carries, through that same mechanism +and its existing best-effort failure handling. The `` +inside a dump is there for other tools; wiring the restore to it would couple +the restore to an id space the importer does not use. + +This is the one part of the design that is meaningfully more work than the +first draft, and it is where the implementation plan should expect the most +care. + +### Importing a foreign file + +A spec-shaped `` with no `` restores its bytes +with a null descriptor. Re-parse then reports that source as a failure, which +it already does for any source missing a descriptor +(`reparseAllForComputer` returns `succeeded`/`failed`). The bytes are kept +because they are not ours to discard, and because a later Submersion may learn +to identify them. + +## Relationship to #227 + +#227 compresses `raw_data` at rest with a Drift `TypeConverter`. Because the +converter operates below Drift's row mapping, this design reads plain +uncompressed bytes with no decompression step of its own. + +The two features are independent in either merge order. This design adds no +schema rung, so nothing races #227's v188 claim. + +### What is shared + +| Shared | Form | +|---|---| +| `kMaxRawDiveBlobBytes` (8 MiB) | One definition. Whichever PR lands second deletes its duplicate and imports the other's. Preferred long-term home is #227's `raw_dive_data_codec.dart`, the more foundational layer. | +| The three libdivecomputer raw fixtures | Already committed. Both specs cite the same evidence rather than inventing test data. | +| The bounded-decode posture | A principle: abort mid-stream, never buffer-then-check. Two implementations, because `inflateBounded` is zlib-specific. | + +### What is not shared, and why + +| | #227 (at rest) | #228 (UDDF) | +|---|---|---| +| Algorithm | zlib level 6 | bzip2, mandated by the spec | +| Framing | `SRD1` magic + uint32 length | bare bzip2 then base64; a custom header is forbidden | +| When | only when the result is smaller | always, even if it grows | +| On failure | return stored bytes verbatim | skip that dump, count it, keep exporting | +| Layer | `TypeConverter` under the database | isolate hop in the export path | + +Every row differs, and each for a reason belonging to a different master: #227 +answers to "never lose a byte and use less disk", #228 to "another vendor's +reader must be able to open this". A shared abstraction over those would be a +facade whose two implementations have no behavior in common. + +### Rejected: putting `SRD1` blobs inside `` + +The obvious way to make the two features share an encoder is to write #227's +`SRD1`-framed zlib blob straight into ``, since the export would then +be a byte copy of what is already on disk and neither feature would need a +second codec. + +It is rejected because it violates "shall be compressed with *bzip2*". The +resulting file would be opaque to every other UDDF reader, which destroys the +entire reason for using the standard `` section rather +than hiding the bytes in ``. Interop is the feature; the +shared encoder would be the thing that removed it. + +This is recorded because "share the compression code" is a reasonable instinct +that leads directly here. + +## Testing + +TDD throughout, per the project's rules. + +**Codec** + +- Round trip through base64(bzip2(...)) and back over the three committed + libdivecomputer fixtures, asserting byte equality. +- Empty and single-byte payloads. +- A decode bounded at `kMaxRawDiveBlobBytes` aborts mid-stream on a bzip2 bomb + rather than allocating the full output. + +**Export** + +- A dive with raw data produces one `` carrying the dive link, + the computer link, a datetime and a dcdump, plus a paired `` + entry with the descriptor triple. +- With `includeRawData` false, neither `` nor + `` appears at all, and the raw-dump query is never issued. +- A dump that fails to compress is skipped and counted; its siblings still + export and the document is still valid. +- A dive with two source rows carrying bytes exports two dumps with ordinals 0 + and 1, both paired. This is the test that fails if the query is ever routed + through `_canonicalDataSourceRows`. +- A dive with two source rows where only one has bytes exports two `` + entries, one `hasdump="true"` and one `hasdump="false"`, and one dump. This + is what makes a mixed multi-source dive restore losslessly. +- A dives-only export emits the dive link and **no** computer link, since that + path declares no ``, and hosts its `` in a + top-level `` alongside the untouched per-dive + inline `` custom-field elements. A full export of the same + dive emits both links. This pins the no-dangling-ref invariant in the path + where it is easiest to break. + +**The load-bearing test** + +Write a download through the repository, read it back through +`getSourcesForExport`, and assert the bytes are byte-identical to what went in. +Under #227 this fails loudly if anyone converts that query to `customSelect` +for speed. It is the only thing standing between a plausible refactor and a +corrupted backup. + +**Import** + +- Full round trip: export a logbook with raw data, import it, assert the + restored source row's bytes and descriptor triple are byte-identical, and + that `ReparseService` can consume the restored row. +- **The lossless test.** A dive with two source rows, only one carrying bytes, + round-trips to two source rows: same count, same order, same `isPrimary`, + same `mergeSourceSlot`, same per-source metrics, same `importedAt`, and the + bytes on the row that had them. This is the assertion the whole multi-source + design exists to satisfy. +- A dive with entries restores no synthesised row, so it ends up with exactly + as many sources as it was exported with and no phantom extra. +- A file claiming two primary sources, and a file claiming none, both restore a + dive with exactly one primary. +- Each restored source resolves its computer from its own model and serial, so + a two-computer dive restores two rows attributed to two different registered + computers. +- A spec-shaped dump with no `` restores bytes with a null + descriptor, and re-parse reports the failure rather than crashing. +- A `` block containing nothing but a `` imports + cleanly, which is what a dives-only export produces. +- A document whose dive datetime and dump datetime differ imports the dive at + its own time. This pins the section scoping described under ``. +- An oversized dump is refused without taking the import down. +- An entry claiming `hasdump="true"` whose dump is absent restores the row + without bytes and reports the mismatch, rather than pairing with the next + dump along. + +## Files Touched + +| File | Change | +|---|---| +| `lib/core/services/export/uddf/uddf_dump_codec.dart` | New. bzip2 + base64, the isolate hop, the bounded output stream. | +| `lib/core/services/export/models/uddf_export_options.dart` | New. `includeRawData`. | +| `lib/core/services/export/uddf/uddf_export_builders.dart` | `buildDiveComputerControl`; shared `buildDataSources`, called from `buildApplicationData`. | +| `lib/core/services/export/uddf/uddf_export_service.dart` | Accept options; emit `` and a top-level `` wrapper for the source records. | +| `lib/core/services/export/uddf/uddf_full_export_service.dart` | Same. | +| `lib/core/services/export/export_service.dart` | Thread options through four entry points. | +| `lib/core/services/export/uddf/uddf_full_import_service.dart` | Parse `` and ``. | +| `lib/features/dive_import/data/services/uddf_entity_importer.dart` | Replace the single synthesised provenance row with one row per `` entry when entries exist; primary-flag normalisation; per-source computer resolution. | +| `lib/features/dive_log/data/repositories/dive_repository_impl.dart` | `getSourcesForExport`. | +| `lib/features/dive_log/domain/entities/dive_source_export.dart` | New value type. | +| `lib/features/settings/presentation/providers/export_providers.dart` | Fetch sources when enabled; carry counts into export state. | +| UI: settings export, dive detail, dive list, buddy detail, transfer page | The toggle. | +| `lib/l10n/arb/app_en.arb` and every other locale | Toggle label, help text, and the counts of dumps skipped on export or unpaired on import. | + +New user-facing strings mean ARB work across all locales, and the generated +l10n must be regenerated and committed or CI's staleness gate fails. diff --git a/docs/superpowers/specs/2026-09-05-seascape-extent-and-detail-design.md b/docs/superpowers/specs/2026-09-05-seascape-extent-and-detail-design.md new file mode 100644 index 0000000000..f84e163d02 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-seascape-extent-and-detail-design.md @@ -0,0 +1,321 @@ +# 3D Seascape: Extent and Detail + +Status: proposed +Date: 2026-09-05 +Related: `2026-07-28-site-bathymetry-seascape-design.md`, +`2026-07-28-seascape-axes-and-extent-design.md`, +`2026-08-15-seascape-contours-chart-mode-design.md` + +## Problem + +Two complaints, reported together: + +1. The seascape shows too little area. It is "very constrained". +2. Dives close to shore look like they sit on the shoreline, with no real + detail around the dive itself. + +The current scene is a single 8 km square grid capped at 120x120 cells, so +67 m per cell at best. That square is the entire seascape: panning and +zooming reveal nothing beyond it, and zooming in past the cell size only +magnifies the same triangles. + +## Measured evidence + +All figures below come from the app's exact request shapes issued live on +2026-09-05. + +### Bonaire (12.0930 N, 68.2870 W), 8 km box + +| | EMODnet (tier 1, selected today) | GMRT (tier 2, never reached) | +| --- | --- | --- | +| Grid | 71 x 71 | 136 x 133 | +| Cell size | 115 m | 60 m | +| Known cells | 2,611 of 5,041 (48% nodata) | 18,088 of 18,088 (0% nodata) | +| Land cells | 0 | 45%, with a real coastline | +| Max depth in box | 668 m | 637 m | +| Wet cells shallower than 40 m | 5.6% | 2.3% | + +### NOAA NCEI DEM mosaic ImageServer + +`identify?returnCatalogItems=true&maxItemCount=25` returns every DEM in the +mosaic stack under a point with its `LowPS` cell size: + +| Site | Best catalog item | Cell size | +| --- | --- | --- | +| Florida Keys | `ncei19_n25x25_w080x50_2016v1` | 3.4 m | +| La Jolla | `san_diego_navd88` | 10.3 m | +| Bonaire | `ETOPO_2022_v1_15s_surface_elev` | 464 m | + +`maxItemCount` is load-bearing: the response truncates to three items by +default, which hides La Jolla's 10 m DEM behind ETOPO. + +`exportImage` with `format=tiff&pixelType=F32` returns an uncompressed, +**tiled** (128 x 128 tiles), little-endian float32 GeoTIFF at any requested +size up to 20000 x 20000. A 2 km box at Molasses Reef returned 256 x 256 at +7.8 m per cell, values -1.5 m to -39.2 m, zero nodata. A shoreline box +returned 53.7% land above zero. + +### Render budget + +GMRT holds ~60 m natively at any span, so a naive 30 km box would be +504 x 495 cells = 496,964 triangles. The painter is a CPU painter's +algorithm with a full per-frame triangle sort and no depth buffer, so +uniform scaling is not available. + +Measured end to end on 2026-09-05, after PR 1 landed, the cap is the +binding constraint well before the painter is: + +| Site | Fetched | Rendered after the 120 cap | Triangles | +| --- | --- | --- | --- | +| Bonaire | 133 x 136 @ 60 m | 67 x 68 @ 120 m | 8,844 | +| Florida Keys | 256 x 256 @ 31 m | 86 x 86 @ 94 m | 14,450 | +| Medes Islands | 70 x 94 @ 115 m | unchanged | 12,834 | + +Two consequences for PR 3. Real scenes run 8,800 to 14,500 triangles, not +the 28,322 a full 120 x 120 would produce, so there is more headroom than a +worst-case reading of the cap suggests. And `maxGridDim` currently discards +most of what the new source layer fetches: Bonaire renders at 120 m from a +60 m grid, which is no better than the 115 m EMODnet tile it replaced. PR 1 +delivers coverage and a real coastline there; the resolution win is still +locked behind the cap, and raising it is the single highest-leverage change +left. It stays gated on the budget measurement rather than guessed at. + +## Root causes + +1. **Tier order selects the worse source.** `BathymetryResolver.resolve` + accepts the first tier returning >= 10% wet cells. EMODnet's Caribbean + tile returns 99.96% wet, so it always wins at Bonaire despite having half + the resolution, half the cells missing, and no land at all. +2. **Nodata renders as flat shoreline.** `BathymetryTerrainBuilder + .surfaceDepth` maps `null` to depth 0.0, and the colour branch treats + `raw == null` as `landColor`. EMODnet's 48% nodata therefore becomes a + flat sand-coloured slab at the waterline. This is the literal cause of + "looks like it's basically on the shoreline". +3. **The dive zone is vertically crushed.** `SpatialProjection.maxDepth` + comes from the deepest cell in the box. At Bonaire that is 668 m, so the + 0-40 m band where diving happens occupies 6% of scene height. Widening + the box makes this strictly worse, because a wider box catches deeper + water. +4. **The cell cap discards fetched detail.** `downsampleTo(120)` uses an + integer stride, so GMRT's 136 columns collapse to 68: a 2x loss to clear + a cap exceeded by 13%. It also picks every Nth sample rather than + averaging, so the discarded cells contribute nothing. + +## Design + +### 1. Capability-driven source selection + +`BathymetrySource` gains: + +```dart +Future probe(GeoPoint center); + +class SourceCapability { + final double cellSizeMeters; // declared, best available at this point + final String detail; // provenance, e.g. the DEM's catalogue name +} +``` + +`probe` returns null for "I do not cover this point", replacing the +synchronous `covers`. EMODnet, GMRT and ETOPO answer from constants without +a network call. `NoaaDemSource` answers from the `identify` pre-flight and +returns null when the best `LowPS` is coarser than +`NoaaDemSource.usefulCellSizeMeters` (50 m), so the mosaic's ETOPO fallback +never masquerades as high-resolution data. + +`BathymetryResolver` probes every source concurrently, then orders them by +**materially better resolution only**: a source preempts the declared list +order when its declared cell size is more than `preemptionFactor` (2.0) +finer. Within that factor, the declared order stands, so a regional survey +still outranks a global grid of nominally similar resolution. + +This band matters. GMRT declares ~60 m against EMODnet's 115 m, a factor of +1.9, so a flat resolution sort would demote EMODnet across all of Europe on +a nominal number. Measured on 2026-09-05, GMRT's European grids are not +block-upsampled GEBCO (identical-neighbour fractions of 4.2% at the Medes +Islands and 0.1% at Zakynthos, against the ~87% a nearest-neighbour upsample +would show), but the Medes grid's 0.70 m median neighbour step is smooth +enough that its true information content is unproven. NOAA CUDEM at 3.4 m +clears the factor by 18x and rightly preempts everything. + +Bonaire is fixed by the floors rather than by the ordering: EMODnet stays +ahead of GMRT on declared resolution, then fails the known-cell floor at +48%, and the resolver falls through to GMRT. That is the intended division +of labour. Ordering promotes materially better data; the floors reject data +that is nominally fine but actually absent. + +The resolver fetches in the resulting order and accepts the first grid +passing **both** floors: + +- `minWetFraction` 0.10 (unchanged) +- `minKnownFraction` 0.60 (new): the fraction of non-nodata cells + +The known-cell floor is what rejects EMODnet's 48%-nodata Bonaire tile. A +source failing either floor falls through to the next; the definitive-versus +-transient distinction and its caching rules are unchanged. + +Changing selection changes which grid a coordinate resolves to, but the +cache key is `,@` and cached rows never expire, so every +already-visited site would keep serving its old EMODnet grid forever. The +key therefore gains a selection-generation token, `@8000v2`, bumped whenever +selection logic changes. Old rows go inert exactly as they did at the 4 km +to 8 km change. + +Declared resolution is a claim, not a measurement. GMRT in particular +returns a fine nominal grid even where the underlying data is upsampled +GEBCO. The known-cell floor plus the existing provenance caption are the +mitigation; this design does not attempt to measure information content. + +### 2. Nodata renders as a hole + +`surfaceDepth` stops inventing a waterline value for nodata. Grid quads +with any nodata corner are omitted from the terrain index buffer. The +translucent water plane already spans the full box, so a gap reads as water +with no data rather than as land. + +`TerrainCeiling`, `sampleGridDepth` and the hover picker already return null +for nodata cells and need no change. Contours, walls and the depth ramp +already skip them. + +### 3. Nested level of detail + +Two grids are fetched per site and merged into one terrain mesh. + +``` ++---------------------------------------+ +| | OUTER +| +-----------+ | outerSpanMeters (24 km default) +| | INNER | | outerMaxDim cells +| | * site| | +| +-----------+ | INNER +| | innerSpanMeters (2 km default) ++---------------------------------------+ innerMaxDim cells, native res +``` + +- **Inner grid**: `innerSpanMeters` centred on the site, requested at the + finest resolution the winning source offers, capped at `innerMaxDim`. +- **Outer grid**: `outerSpanMeters`, requested coarse, capped at + `outerMaxDim`. +- **Merge**: outer quads whose centre falls inside the inner box are + dropped, leaving a rectangular hole. The inner grid fills it. +- **Stitch band**: a ring of triangles bridges the outer hole's edge to the + inner grid's edge, with vertices welded to both. The two grids have + different cell sizes, so their edge vertices do not correspond; without + the band the seam is a see-through crack at every camera angle. + +Sources may differ between the two grids (CUDEM inner, GMRT outer). Vertical +datums then differ, which the original seascape design correctly warned +against mosaicking. The mitigation is explicit: the inner grid is offset by +the **median delta along its boundary ring**, sampled against the outer grid +at the same coordinates. The offset is recorded on the merged grid and +surfaced in the provenance caption. It is never applied silently, and it is +skipped entirely when both grids come from one source. + +Cache keys gain a role suffix: `,@in` and +`,@out`. Existing `@8000` rows go inert, as they did at the +4 km to 8 km change. + +### 4. Depth scale from the site's dives + +`SpatialProjection.maxDepth` stops being the box maximum. The new +`depthWindowMeters` is: + +1. the deepest recorded dive at this site multiplied by 1.25, floored at + 20 m and clamped to the grid's own maximum; or +2. with no dives on record, the 80th percentile of the box's wet cells. + +`siteSeascapeProvider` already gathers `atSite` dives for path +reconstruction, so the deepest dive is available with no new query. + +Terrain deeper than the window renders past the box floor and is clipped by +the viewport. `SceneProjector` fits to the declared `bounds.sceneMinY` and +`sceneMaxY` rather than to actual geometry extent, so this needs only that +`sceneMinY` stay pinned at `-SceneBounds.ySpan`. The current +`math.min(-SceneBounds.ySpan, proj.yOf(pinDepth))` grows the frame to keep a +deep site pin in view; that growth is removed, because it would re-crush the +scene and undo this change. A site pin deeper than the window is clamped to +the floor with its recorded depth still shown in the marker label. + +The depth axis is built from the same window, so its ticks stay linear and +truthful. The ramp's `rampMaxDepthMeters` appearance setting is unchanged +and still controls colour only. + +### 5. Averaging downsample + +`BathymetryGrid.downsampleTo` averages each stride block's known cells +instead of picking the block's first sample, and returns nodata only where a +block is entirely nodata. Striding discards up to 3/4 of a fetched grid; a +block mean keeps the information and suppresses single-cell noise. + +### 6. Render budget, measured first + +`innerMaxDim` and `outerMaxDim` are **not** fixed by this design. The first +implementation task instruments `Dive3dScenePainter` frame time against +triangle count on real hardware and sets both from the measurement. Today's +scene is ~28,300 triangles; the merged scene will be larger, and the budget +is the binding constraint on every dimension above. No dimension is chosen +by guess. + +`_isolateCellThreshold` (4000 cells) already routes large scenes through +`compute()`. The merged scene exceeds it in every case, so the isolate path +becomes the norm rather than the exception, and the synchronous path remains +only for widget tests. + +## Provenance + +The caption must name both grids: source, cell size and span for each, plus +the datum offset when one was applied. A scene whose inner and outer grids +come from different sources is a legitimate composite, and the UI says so. + +## Testing + +- `BathymetryResolver`: probe ordering, the known-cell floor rejecting a + 48%-nodata grid, fall-through on a failed floor, and the unchanged + definitive-versus-transient contract. +- `NoaaDemSource`: `identify` parsing including the `maxItemCount` + truncation trap, the 50 m usefulness threshold declining Bonaire, and the + tiled float32 GeoTIFF parser against a recorded fixture. +- `BathymetryGrid.downsampleTo`: block means, all-nodata blocks staying + nodata, and the resolution claim tracking the stride. +- Terrain builder: nodata quads absent from the index buffer, land and wet + cells unaffected. +- LOD merge: the outer hole matches the inner extent, the stitch band leaves + no gap (every boundary edge shared by exactly two triangles), and the + datum offset is the boundary-ring median. +- Depth window: deepest-dive derivation, the 20 m floor, the grid clamp, the + no-dives percentile fallback, and `sceneMinY` staying pinned when terrain + runs deeper than the window. +- A render regression extending the existing `map_frame_handedness_test` + approach: a synthetic asymmetric two-level grid rendered through the real + painter, asserting the seam produces no background pixels along the + boundary ring. + +## Delivery + +Three PRs, each independently shippable and each a visible improvement on +its own. Later phases do not silently depend on earlier ones landing. + +**PR 1: source layer.** `probe`/`SourceCapability`, capability-ordered +selection, the known-cell floor, `NoaaDemSource`, and the averaging +downsample. No change to the scene's architecture, but on its own it moves +Bonaire from 115 m EMODnet to 60 m GMRT with a real coastline, and gives US +coastal sites 3 m to 10 m CUDEM. + +**PR 2: honesty and vertical scale.** Nodata quads dropped, the depth window +derived from the site's dives, `sceneMinY` pinned, the site pin clamped, and +the depth axis rebuilt from the window. On its own this is what stops +near-shore dives reading as beached. + +**PR 3: nested level of detail.** The painter budget measurement first, then +the inner and outer grids, the merge with its hole, the stitch band, the +datum offset, the two-role cache keys, and the composite provenance caption. +The constants come from the measurement taken at the head of this PR. + +## Out of scope + +- Zoom-driven refetching. The two levels are fixed per site. +- More than two levels. +- Any new appearance setting. Spans and caps are constants until there is + evidence divers want to tune them. +- Measuring a source's true information content as opposed to its declared + resolution. diff --git a/hooks/pre-push b/hooks/pre-push index f295e4985e..c62cbd048f 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -1,7 +1,8 @@ #!/bin/bash # Pre-push hook for Submersion -# Runs dart format check, flutter analyze, and a SCOPED test run before pushing. +# Runs dart format check, flutter analyze, a generated-l10n staleness check, and +# a SCOPED test run before pushing. # # Why scoped tests: the full suite is ~1900 test files. Measured on the full # suite, 79% of local test wall-clock is `loading ` (isolate spawn + @@ -164,6 +165,97 @@ else range_resolved=0 fi +# --- Generated-l10n staleness ---------------------------------------------- +# +# lib/l10n/arb/app_localizations*.dart is produced by `flutter gen-l10n` and is +# CHECKED IN. It is the only generated output in the repo that can go stale: +# *.g.dart, *.freezed.dart and *.mocks.dart are gitignored and rebuilt per tree. +# Nothing regenerates the l10n on the way to a build either -- `flutter test` +# does not, despite `generate: true` in pubspec.yaml -- so what is committed is +# exactly what ships. An ARB edit that skipped gen-l10n, or a generated file +# hand-resolved during a merge (they conflict often, since every locale changes +# together), otherwise reaches main with the stale string intact. CI asserts the +# same thing in Analyze & Format; this hook is the cheaper second check that +# catches it before the push rather than after. +# +# Only runs when the push touches lib/l10n/. gen-l10n rewrites all 12 files +# unconditionally, even when the bytes are identical, and app_localizations.dart +# is imported by 378 test files -- the biggest hub in the repo -- so doing this +# on every push would bump those mtimes and force a pointless recompile. +# +# The verdict is HEAD-relative, like CI's, because HEAD is what gets pushed. +# Regenerate, then read `git status`, which tells these three states apart: +# +# generated dirty, ARBs clean -> HEAD's committed ARBs do not produce HEAD's +# committed output. Definitively stale: block. Covers both "forgot to run +# gen-l10n" and "ran it but committed only the ARB". +# generated dirty, ARBs dirty -> the output was regenerated from sources HEAD +# does not have, so it says nothing about the commits being pushed. Warn and +# let it through rather than block a developer mid-ARB-edit from pushing +# unrelated commits; CI still checks the pushed tree. +# nothing dirty -> HEAD is self-consistent. +# +# Runs BEFORE the parallel stage rather than inside it, because rewriting these +# files underneath a concurrent `flutter analyze` would let it read a +# half-written source. +l10n_touched=0 +if [ "$range_resolved" = "0" ]; then + # Push range unknown: check rather than guess. It costs about 3s. + l10n_touched=1 +else + while IFS= read -r f; do + case "$f" in + lib/l10n/*) l10n_touched=1; break ;; + esac + done <<< "$changed_files" +fi + +l10n_failed=0 +if [ "$l10n_touched" = "1" ]; then + echo "🌍 Verifying generated localizations match the ARB files..." + # Snapshot with -p so the mtimes can be put back when nothing drifted: + # gen-l10n rewrites all 12 files regardless, and letting that bump the + # mtime of the repo's biggest import hub would throw away an already-warm + # compile before this hook's own test stage runs. + l10n_before=$(mktemp -d) + l10n_log=$(mktemp) + set +e + cp -p lib/l10n/arb/app_localizations*.dart "$l10n_before/" 2>/dev/null + flutter gen-l10n > "$l10n_log" 2>&1 + l10n_status=$? + set -e + if [ "$l10n_status" -ne 0 ]; then + echo "❌ flutter gen-l10n failed!" + cat "$l10n_log" + l10n_failed=1 + else + # --porcelain, not `git diff`: a brand-new locale whose generated file + # was never added is untracked, which a diff cannot see at all. + stale=$(git status --porcelain -- 'lib/l10n/arb/app_localizations*.dart') + arbs_dirty=$(git status --porcelain -- 'lib/l10n/arb/*.arb') + if [ -z "$stale" ]; then + # Byte-identical to HEAD, so this only restores the mtimes. + cp -p "$l10n_before"/*.dart lib/l10n/arb/ 2>/dev/null || true + echo "✅ Generated localizations are up to date" + elif [ -n "$arbs_dirty" ]; then + echo "⚠️ Uncommitted ARB edits, so the pushed commits cannot be" + echo " verified here. Regenerating from the working tree changed:" + printf '%s\n' "$stale" | sed 's/^/ /' + echo " Commit these alongside the ARBs. CI checks the pushed tree." + else + echo "❌ Generated localizations are stale!" + printf '%s\n' "$stale" | sed 's/^/ /' + echo " The ARBs are committed, but the committed generated output" + echo " is not what they produce. gen-l10n has REGENERATED these in" + echo " place: review and commit them, then push again." + l10n_failed=1 + fi + fi + rm -rf "$l10n_before" + rm -f "$l10n_log" + echo "" +fi + # --- Static checks ---------------------------------------------------------- # # Format and analyze are independent, so run them concurrently and report both @@ -244,6 +336,12 @@ fi rm -f "$fmt_log" "$ana_log" echo "" +# Reported above, before the parallel stage; gated here so format, analyze and +# l10n all get a verdict on one run rather than stopping at the first failure. +if [ "$l10n_failed" -ne 0 ]; then + checks_failed=1 +fi + if [ "$checks_failed" -ne 0 ]; then echo "Fix the issues above before pushing." exit 1 diff --git a/integration_test/helpers/screenshot_test_data.dart b/integration_test/helpers/screenshot_test_data.dart index 6efa42b924..049c129810 100644 --- a/integration_test/helpers/screenshot_test_data.dart +++ b/integration_test/helpers/screenshot_test_data.dart @@ -3,6 +3,10 @@ import 'dart:math'; import 'package:drift/drift.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/profile_series_repository.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_series_repository.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; import 'package:uuid/uuid.dart'; /// Seeds the database with realistic test data for App Store screenshots. @@ -463,11 +467,12 @@ class ScreenshotTestDataSeeder { ); // Create tank entry + final tankId = _uuid.v4(); await db .into(db.diveTanks) .insert( DiveTanksCompanion.insert( - id: _uuid.v4(), + id: tankId, diveId: diveId, volume: const Value(11.1), workingPressure: const Value(207.0), @@ -483,6 +488,7 @@ class ScreenshotTestDataSeeder { durationSeconds * 60, // duration in seconds for profile maxDepth, waterTemp + random.nextDouble() * 2, + tankId, ); } } @@ -492,11 +498,19 @@ class ScreenshotTestDataSeeder { int duration, double maxDepth, double temp, + String tankId, ) async { final random = Random(); // Create profile points every 10 seconds final numPoints = duration ~/ 10; + final samples = []; + // Cylinder pressure is its own series (the app reads tank pressure from + // tank_pressure_series, and ProfileSample.pressure is a legacy + // per-sample column that the ProfileSample -> DiveProfilePoint + // conversion drops), so the consumption curve below is written there + // instead of into the profile blob. + final pressures = []; for (var i = 0; i < numPoints; i++) { final timestamp = i * 10; @@ -539,21 +553,26 @@ class ScreenshotTestDataSeeder { final pressure = (200 - depthAdjustedConsumption + breathingVariation) .clamp(40.0, 210.0); - await db - .into(db.diveProfiles) - .insert( - DiveProfilesCompanion.insert( - id: _uuid.v4(), - diveId: diveId, - timestamp: timestamp, - depth: depth, - temperature: Value( - temp - (depth * 0.1), - ), // Temp decreases with depth - pressure: Value(pressure), // Realistic gas consumption - ), - ); + samples.add( + ProfileSample( + timestamp: timestamp, + depth: depth, + // Temp decreases with depth + temperature: temp - (depth * 0.1), + ), + ); + pressures.add( + TankPressureSample(timestamp: timestamp, pressure: pressure), + ); } + + if (samples.isEmpty) return; + await ProfileSeriesRepository( + database: db, + ).insertSeries(diveId: diveId, samples: samples); + await TankPressureSeriesRepository( + database: db, + ).insertSeries(diveId: diveId, tankId: tankId, samples: pressures); } String _generateDiveNotes(String diveType, String siteName) { diff --git a/integration_test/screenshots_test.dart b/integration_test/screenshots_test.dart index 5da178a2d5..e26766f140 100644 --- a/integration_test/screenshots_test.dart +++ b/integration_test/screenshots_test.dart @@ -464,6 +464,7 @@ void main() { await _tapBottomNavItem(tester, Icons.more_horiz_outlined); await _settle(tester); final equipmentText = find.text('Equipment'); + await _scrollMoreSheetTo(tester, equipmentText); if (equipmentText.evaluate().isNotEmpty) { await tester.tap(equipmentText.first); await _settle(tester); @@ -485,6 +486,7 @@ void main() { await _tapBottomNavItem(tester, Icons.more_horiz_outlined); await _settle(tester); final statisticsText = find.text('Statistics'); + await _scrollMoreSheetTo(tester, statisticsText); if (statisticsText.evaluate().isNotEmpty) { await tester.tap(statisticsText.first); await _settle(tester); @@ -508,6 +510,7 @@ void main() { await _tapBottomNavItem(tester, Icons.more_horiz_outlined); await _settle(tester); final recordsInMore = find.text('Records'); + await _scrollMoreSheetTo(tester, recordsInMore); if (recordsInMore.evaluate().isNotEmpty) { await tester.tap(recordsInMore.first); await _settle(tester); @@ -539,11 +542,19 @@ void main() { Future openNavCustomization(WidgetTester tester) async { await tester.tap(find.widgetWithText(NavigationDestination, 'More')); await _settle(tester); - await tester.tap(find.widgetWithText(ListTile, 'Settings').first); + final settingsTile = find.widgetWithText(ListTile, 'Settings'); + await _scrollMoreSheetTo(tester, settingsTile); + await tester.tap(settingsTile.first); await _settle(tester); await tester.tap(find.widgetWithText(ListTile, 'Appearance').first); await _settle(tester); - await tester.tap(find.widgetWithText(ListTile, 'Navigation bar').first); + final navBarTile = find.widgetWithText(ListTile, 'Navigation layout'); + await tester.scrollUntilVisible( + navBarTile, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(navBarTile.first); await _settle(tester); } @@ -663,6 +674,25 @@ Future _enableProfileToggles(WidgetTester tester) async { } } +/// Scrolls the open "More" overflow sheet until [target] is on screen. +/// +/// The sheet is capped at a fraction of the screen height (issue #1480), so +/// destinations past the fold are not built until the list scrolls. Gives up +/// quietly: every caller already guards on whether the target turned up. +Future _scrollMoreSheetTo(WidgetTester tester, Finder target) async { + final sheetList = find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(Scrollable), + ); + if (sheetList.evaluate().isEmpty) return; + try { + await tester.scrollUntilVisible(target, 120, scrollable: sheetList.first); + } on StateError { + // Not in this sheet at all; the caller's isNotEmpty check handles it. + } + await _settle(tester); +} + /// Taps a bottom navigation item by its icon. /// Uses position filtering to ensure we tap the icon in the bottom navigation /// area, not icons with the same IconData elsewhere (e.g., app bar menus). diff --git a/ios/Runner/MetadataWriteHandler.swift b/ios/Runner/MetadataWriteHandler.swift index 3c225bab29..81244a53d9 100644 --- a/ios/Runner/MetadataWriteHandler.swift +++ b/ios/Runner/MetadataWriteHandler.swift @@ -5,11 +5,16 @@ import CoreLocation import ImageIO import MobileCoreServices -/// Handles writing dive metadata to photos and videos via platform channel. +/// Handles writing dive metadata to photos via platform channel. /// -/// Supports: -/// - JPEG/HEIC/HEIF photos via CGImageDestination -/// - MOV/MP4 videos via AVFoundation +/// Supports JPEG/HEIC/HEIF photos via CGImageDestination, edited in place so +/// the asset keeps its identity and the user keeps Revert to Original. +/// +/// Videos are refused. Writing to one cannot be done in place: it meant +/// exporting a copy, creating a new asset and deleting the original. +/// Submersion must never delete a user's original media (issue #1472), so the +/// path was removed rather than made non-destructive. Live Photos are refused +/// for a related reason (issue #795). class MetadataWriteHandler: NSObject { private let channel: FlutterMethodChannel @@ -34,14 +39,11 @@ class MetadataWriteHandler: NSObject { return } - let keepOriginal = args["keepOriginal"] as? Bool ?? false - writeMetadata( assetId: assetId, metadata: metadata, description: description, isVideo: isVideo, - keepOriginal: keepOriginal, result: result ) @@ -55,7 +57,6 @@ class MetadataWriteHandler: NSObject { metadata: [String: Any], description: String, isVideo: Bool, - keepOriginal: Bool, result: @escaping FlutterResult ) { // Check photo library authorization @@ -80,6 +81,23 @@ class MetadataWriteHandler: NSObject { return } + // Trust the library over the caller: a video mislabelled as a photo + // would otherwise fall through to the photo path. + if isVideo || asset.mediaType == .video { + // A video cannot be edited in place. The old path exported a copy, + // created a new asset and deleted the original, which Submersion + // must never do (issue #1472). Refuse instead; the UI does not + // offer the action for a video, so this is reached only by a + // mislabelled asset. + NSLog("[MetadataWriteHandler] Asset is a video; metadata writing is not supported") + result(FlutterError( + code: "VIDEO_UNSUPPORTED", + message: "Writing metadata to a video is not supported.", + details: nil + )) + return + } + // Check if asset is editable guard asset.canPerform(.properties) else { result(FlutterError( @@ -90,9 +108,7 @@ class MetadataWriteHandler: NSObject { return } - if isVideo { - writeVideoMetadata(asset: asset, metadata: metadata, description: description, keepOriginal: keepOriginal, result: result) - } else if asset.mediaSubtypes.contains(.photoLive) { + if asset.mediaSubtypes.contains(.photoLive) { // A Live Photo is a still paired with a short video. Photos' content // editing session expects the output to represent both resources, so // the plain-image round-trip in writePhotoMetadata is rejected with @@ -263,169 +279,4 @@ class MetadataWriteHandler: NSObject { return kUTTypeJPEG } } - - // MARK: - Video Metadata - - private func writeVideoMetadata( - asset: PHAsset, - metadata: [String: Any], - description: String, - keepOriginal: Bool, - result: @escaping FlutterResult - ) { - let options = PHVideoRequestOptions() - options.isNetworkAccessAllowed = true - options.version = .current - - PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { [weak self] avAsset, audioMix, info in - guard let self = self, let urlAsset = avAsset as? AVURLAsset else { - DispatchQueue.main.async { - result(FlutterError( - code: "WRITE_FAILED", - message: "Could not get video asset", - details: nil - )) - } - return - } - - self.writeVideoMetadataToAsset( - asset: asset, - videoURL: urlAsset.url, - metadata: metadata, - description: description, - keepOriginal: keepOriginal, - result: result - ) - } - } - - private func writeVideoMetadataToAsset( - asset: PHAsset, - videoURL: URL, - metadata: [String: Any], - description: String, - keepOriginal: Bool, - result: @escaping FlutterResult - ) { - // Create AVAsset for reading - let avAsset = AVAsset(url: videoURL) - - // Create export session - guard let exportSession = AVAssetExportSession(asset: avAsset, presetName: AVAssetExportPresetPassthrough) else { - DispatchQueue.main.async { - result(FlutterError( - code: "WRITE_FAILED", - message: "Could not create export session", - details: nil - )) - } - return - } - - // Create temporary output URL - let tempDir = FileManager.default.temporaryDirectory - let outputURL = tempDir.appendingPathComponent(UUID().uuidString + ".mov") - - exportSession.outputURL = outputURL - exportSession.outputFileType = .mov - - // Create metadata items - var metadataItems: [AVMutableMetadataItem] = [] - - // Add description - let descItem = AVMutableMetadataItem() - descItem.key = AVMetadataKey.commonKeyDescription as NSString - descItem.keySpace = .common - descItem.value = description as NSString - metadataItems.append(descItem) - - // Add location if available - if let lat = metadata["latitude"] as? Double, - let lon = metadata["longitude"] as? Double { - let locationItem = AVMutableMetadataItem() - locationItem.key = AVMetadataKey.commonKeyLocation as NSString - locationItem.keySpace = .common - locationItem.value = "\(lat >= 0 ? "+" : "")\(lat)\(lon >= 0 ? "+" : "")\(lon)/" as NSString - metadataItems.append(locationItem) - } - - // Add title with site name - if let siteName = metadata["siteName"] as? String { - let titleItem = AVMutableMetadataItem() - titleItem.key = AVMetadataKey.commonKeyTitle as NSString - titleItem.keySpace = .common - titleItem.value = siteName as NSString - metadataItems.append(titleItem) - } - - exportSession.metadata = metadataItems - - exportSession.exportAsynchronously { - switch exportSession.status { - case .completed: - // Import the modified video back to Photos - PHPhotoLibrary.shared().performChanges({ - let creationRequest = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: outputURL) - creationRequest?.creationDate = asset.creationDate - creationRequest?.location = asset.location - }) { success, error in - // Clean up temp file - try? FileManager.default.removeItem(at: outputURL) - - if success { - // Delete original if user chose not to keep it - if !keepOriginal { - PHPhotoLibrary.shared().performChanges({ - PHAssetChangeRequest.deleteAssets([asset] as NSArray) - }) { _, deleteError in - // Log but don't fail the operation if delete fails - if let deleteError = deleteError { - print("Warning: Could not delete original video: \(deleteError.localizedDescription)") - } - DispatchQueue.main.async { - result(true) - } - } - } else { - DispatchQueue.main.async { - result(true) - } - } - } else { - DispatchQueue.main.async { - result(FlutterError( - code: "WRITE_FAILED", - message: error?.localizedDescription ?? "Failed to save video", - details: nil - )) - } - } - } - - case .failed: - try? FileManager.default.removeItem(at: outputURL) - DispatchQueue.main.async { - result(FlutterError( - code: "WRITE_FAILED", - message: exportSession.error?.localizedDescription ?? "Export failed", - details: nil - )) - } - - case .cancelled: - try? FileManager.default.removeItem(at: outputURL) - DispatchQueue.main.async { - result(FlutterError( - code: "WRITE_FAILED", - message: "Export was cancelled", - details: nil - )) - } - - default: - break - } - } - } } diff --git a/lib/app.dart b/lib/app.dart index 220a07e7d8..5d73e031fa 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -20,6 +20,7 @@ import 'package:submersion/features/auto_update/presentation/providers/update_me import 'package:submersion/features/backup/presentation/pages/restore_complete_page.dart'; import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; import 'package:submersion/features/backup/presentation/widgets/restore_barrier.dart'; +import 'package:submersion/features/media_store/presentation/providers/media_origin_republish_provider.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; @@ -110,6 +111,7 @@ class _SubmersionAppState extends ConsumerState WidgetsBinding.instance.addPostFrameCallback((_) { _maybeSyncOnLaunch(); _resumeMediaTransfers(); + _republishOwnedMedia(); _fileShareHandler.initialize(); }); } @@ -176,6 +178,15 @@ class _SubmersionAppState extends ConsumerState unawaited(ref.read(mediaTransferResumeProvider)()); } + /// One-time repair of media rows whose cloud stamps a peer dropped before + /// the origin-device fix. Launch only: it is flagged after its first + /// success, and the provider checks the flag before building anything. + /// Same database timing argument as [_resumeMediaTransfers], and the + /// provider contains its own failures, so it is safe to leave unawaited. + void _republishOwnedMedia() { + unawaited(ref.read(mediaOriginRepublishProvider)()); + } + Future _maybeSyncOnLaunch() async { final settings = ref.read(syncBehaviorProvider); if (!settings.autoSyncEnabled || !settings.syncOnLaunch) return; diff --git a/lib/core/accessibility/accessibility.dart b/lib/core/accessibility/accessibility.dart deleted file mode 100644 index 855e3ab934..0000000000 --- a/lib/core/accessibility/accessibility.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'app_shortcuts.dart'; -export 'focus_helpers.dart'; -export 'semantic_helpers.dart'; -export 'shortcut_registry.dart'; -export 'shortcuts_help_dialog.dart'; diff --git a/lib/core/accessibility/focus_helpers.dart b/lib/core/accessibility/focus_helpers.dart deleted file mode 100644 index a4fc2e1daa..0000000000 --- a/lib/core/accessibility/focus_helpers.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Wraps a page's content with a [FocusTraversalGroup] using -/// [OrderedTraversalPolicy] for consistent keyboard tab order. -/// -/// Every page should use this at its root to isolate focus -/// cycling within the page's content area. -class AccessiblePage extends StatelessWidget { - const AccessiblePage({super.key, required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return FocusTraversalGroup(policy: OrderedTraversalPolicy(), child: child); - } -} - -/// A card that can receive keyboard focus and shows a visible focus indicator. -/// -/// Use this for any custom tappable card that uses [GestureDetector] or -/// [InkWell] instead of a standard Material button, to ensure keyboard -/// users can navigate to and activate it. -class FocusableCard extends StatefulWidget { - const FocusableCard({ - super.key, - required this.child, - required this.semanticLabel, - this.onTap, - this.borderRadius = 12.0, - }); - - final Widget child; - final String semanticLabel; - final VoidCallback? onTap; - final double borderRadius; - - @override - State createState() => _FocusableCardState(); -} - -class _FocusableCardState extends State { - bool _isFocused = false; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Semantics( - button: widget.onTap != null, - label: widget.semanticLabel, - child: Focus( - onFocusChange: (focused) { - setState(() => _isFocused = focused); - }, - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(widget.borderRadius), - border: _isFocused - ? Border.all(color: theme.colorScheme.primary, width: 2) - : null, - ), - child: widget.child, - ), - ), - ), - ); - } -} diff --git a/lib/core/buoyancy/body_composition.dart b/lib/core/buoyancy/body_composition.dart new file mode 100644 index 0000000000..6ed427d6df --- /dev/null +++ b/lib/core/buoyancy/body_composition.dart @@ -0,0 +1,63 @@ +/// Body-composition estimate for weight prediction: how much of the +/// diver's own buoyancy the mass-only rule of thumb misses once height is +/// known. +/// +/// Fat tissue (~0.9 kg/L) floats while lean tissue (~1.1 kg/L) sinks, so two +/// divers of equal mass can need noticeably different lead. Body mass index +/// is the cheapest proxy for the fat fraction the app can compute from the +/// profile, and the term is deliberately small: it refines the personal +/// baseline rather than replacing it, and logged history overrides both. +/// +/// Sign convention matches the rest of the engine: positive = more lead. +class BodyComposition { + /// Breakdown-term key shared by the engine, the twin assembler, and the + /// widgets that localize it. + static const String termLabel = 'bmi'; + + /// The height the mass-only personal prior implicitly assumes. At this + /// height the term is zero, so divers of average build see no change. + static const double referenceHeightCm = 175.0; + + /// Plausibility bounds for an adult diver's height; anything outside is + /// treated as unknown rather than fed into a squared denominator. + static const double minHeightCm = 100.0; + static const double maxHeightCm = 250.0; + + /// Body-fat fraction change per BMI unit (Deurenberg et al., 1991: about + /// 1.2 percentage points per unit for adults, sex and age aside). + static const double fatFractionPerBmiUnit = 0.012; + + /// Lead-equivalent buoyancy of one kilogram of fat in place of one + /// kilogram of lean tissue: 1/0.9 - 1/1.1 L, at roughly 1 kg/L of water. + static const double leadPerKgFatSwap = 0.2; + + /// Whether a height is within the adult plausibility bounds. + static bool isPlausibleHeight(double heightCm) => + heightCm >= minHeightCm && heightCm <= maxHeightCm; + + /// Body mass index (kg/m^2), or null when either input is missing or + /// implausible. + static double? bmi({required double weightKg, required double? heightCm}) { + if (heightCm == null || weightKg <= 0) return null; + if (!isPlausibleHeight(heightCm)) return null; + final heightM = heightCm / 100.0; + return weightKg / (heightM * heightM); + } + + /// Extra lead (kg) attributable to body composition, relative to the + /// build the mass rule assumes: zero at [referenceHeightCm], positive for + /// a shorter (higher-BMI) diver of the same mass, negative for a taller + /// one. Zero when BMI cannot be computed. + static double leadTermKg({ + required double bodyMassKg, + required double? heightCm, + }) { + final actual = bmi(weightKg: bodyMassKg, heightCm: heightCm); + if (actual == null) return 0.0; + final reference = bmi(weightKg: bodyMassKg, heightCm: referenceHeightCm)!; + return bodyMassKg * + fatFractionPerBmiUnit * + leadPerKgFatSwap * + (actual - reference); + } +} diff --git a/lib/core/buoyancy/buoyancy_physics.dart b/lib/core/buoyancy/buoyancy_physics.dart index a1755444d3..da7b90c34d 100644 --- a/lib/core/buoyancy/buoyancy_physics.dart +++ b/lib/core/buoyancy/buoyancy_physics.dart @@ -10,6 +10,7 @@ const Map kTankCatalog = { 'al40': (emptyBuoyancyKg: 0.9, dryMassKg: 6.8), 'al63': (emptyBuoyancyKg: 1.2, dryMassKg: 12.2), 'al80': (emptyBuoyancyKg: 1.7, dryMassKg: 14.2), + 'al100': (emptyBuoyancyKg: 1.6, dryMassKg: 18.4), 'hp80': (emptyBuoyancyKg: -1.3, dryMassKg: 13.0), 'hp100': (emptyBuoyancyKg: -1.0, dryMassKg: 15.0), 'hp120': (emptyBuoyancyKg: -1.4, dryMassKg: 18.0), @@ -17,8 +18,8 @@ const Map kTankCatalog = { 'steel10': (emptyBuoyancyKg: -1.2, dryMassKg: 12.5), 'steel12': (emptyBuoyancyKg: -1.4, dryMassKg: 14.5), 'steel15': (emptyBuoyancyKg: -1.8, dryMassKg: 17.5), - 'al30Stage': (emptyBuoyancyKg: 0.5, dryMassKg: 5.5), - 'al40Stage': (emptyBuoyancyKg: 0.9, dryMassKg: 6.8), + 'al30stage': (emptyBuoyancyKg: 0.5, dryMassKg: 5.5), + 'al40stage': (emptyBuoyancyKg: 0.9, dryMassKg: 6.8), }; /// Deterministic buoyancy terms for weight prediction: never learned, diff --git a/lib/core/buoyancy/gear_buoyancy_traits.dart b/lib/core/buoyancy/gear_buoyancy_traits.dart index 24a80afea3..ee82ed3722 100644 --- a/lib/core/buoyancy/gear_buoyancy_traits.dart +++ b/lib/core/buoyancy/gear_buoyancy_traits.dart @@ -14,6 +14,7 @@ class GearBuoyancyTraits extends Equatable { final String? bcdStyle; final double? liftCapacityKg; final String? gloveType; + final String? insulationLevel; const GearBuoyancyTraits({ this.primaryThicknessMm, @@ -23,6 +24,7 @@ class GearBuoyancyTraits extends Equatable { this.bcdStyle, this.liftCapacityKg, this.gloveType, + this.insulationLevel, }); static final RegExp _panelSeparators = RegExp(r'[/,\-]'); @@ -53,5 +55,6 @@ class GearBuoyancyTraits extends Equatable { bcdStyle, liftCapacityKg, gloveType, + insulationLevel, ]; } diff --git a/lib/core/buoyancy/gear_feature.dart b/lib/core/buoyancy/gear_feature.dart index 3063f593d3..7805204249 100644 --- a/lib/core/buoyancy/gear_feature.dart +++ b/lib/core/buoyancy/gear_feature.dart @@ -136,6 +136,30 @@ class GearFeature extends Equatable { case EquipmentType.boots: if (mm == null) return null; return (0.12 * mm).clamp(0.0, 2.0); + // The drysuit layers (#1537). Undergarments are rated by warmth rather + // than by millimetres, so the ladder reads insulationLevel instead of + // thickness. The undersuit range is the delta a drysuit diver actually + // feels between a summer and a winter garment; note the drysuit's own + // 10 kg default already assumes a mid-weight undersuit inside it, so + // these terms are deliberately modest rather than a second full suit. + case EquipmentType.undersuit: + return switch (t.insulationLevel) { + 'light' => 0.7, + 'mid' => 1.5, + 'heavy' => 2.5, + 'extreme' => 3.5, + _ => null, + }; + // A base layer traps far less air than the lofted garment over it: it + // shifts lead by a notch, not by a kilogram. + case EquipmentType.baselayer: + return switch (t.insulationLevel) { + 'light' => 0.1, + 'mid' => 0.2, + 'heavy' => 0.4, + 'extreme' => 0.5, + _ => null, + }; case EquipmentType.bcd: // Unknown/future style keys map to null (no signal) so they fall // through to the type default rather than claiming attribute-level @@ -196,9 +220,15 @@ class GearFeature extends Equatable { _ => 1.0, }; + // Relative to a five-finger wet glove of the same thickness. Coverage + // drives the wet variants; the dry, liner and utility styles are not + // closed-cell neoprene, so they displace far less for the same build. static double _gloveTypeFactor(String? gloveType) => switch (gloveType) { + 'three_finger' => 1.1, 'mitt' => 1.15, 'dry' => 0.5, + 'dry_liner' => 0.3, + 'utility' => 0.3, _ => 1.0, }; @@ -214,6 +244,10 @@ class GearFeature extends Equatable { static double _typeDefault(EquipmentType type) => switch (type) { EquipmentType.wetsuit => 4.0, EquipmentType.drysuit => 10.0, + // An unrated undergarment is assumed mid-weight, which is what the + // drysuit default above already assumes is inside it. + EquipmentType.undersuit => 1.5, + EquipmentType.baselayer => 0.2, EquipmentType.bcd => -0.5, EquipmentType.hood => 0.3, EquipmentType.gloves => 0.2, @@ -227,6 +261,8 @@ class GearFeature extends Equatable { static double _typeDryMass(EquipmentType type) => switch (type) { EquipmentType.wetsuit => 2.0, EquipmentType.drysuit => 3.0, + EquipmentType.undersuit => 1.5, + EquipmentType.baselayer => 0.4, EquipmentType.bcd => 3.5, // A wrist computer's dry mass is negligible against the rig, and gear // twins (v175) put one on every downloaded dive: the 0.5 kg fallthrough diff --git a/lib/core/buoyancy/weight_prediction_engine.dart b/lib/core/buoyancy/weight_prediction_engine.dart index 6131cc3176..4dc48ea1fd 100644 --- a/lib/core/buoyancy/weight_prediction_engine.dart +++ b/lib/core/buoyancy/weight_prediction_engine.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:equatable/equatable.dart'; +import 'package:submersion/core/buoyancy/body_composition.dart'; import 'package:submersion/core/buoyancy/buoyancy_physics.dart'; import 'package:submersion/core/buoyancy/gear_feature.dart'; import 'package:submersion/core/buoyancy/ridge_regression.dart'; @@ -38,19 +39,24 @@ class RigSpec extends Equatable { final WaterType? waterType; final double? bodyWeightKg; + /// Diver height for the body-composition term. Null falls back to the + /// height the model was fitted with, like [bodyWeightKg]. + final double? heightCm; + const RigSpec({ this.gear = const [], this.tanks = const [], this.waterType, this.bodyWeightKg, + this.heightCm, }); @override - List get props => [gear, tanks, waterType, bodyWeightKg]; + List get props => [gear, tanks, waterType, bodyWeightKg, heightCm]; } /// Where a breakdown term's value came from. -enum TermSource { measured, userSpec, typeDefault, physics } +enum TermSource { measured, userSpec, typeDefault, physics, bodyComposition } class PredictionTerm extends Equatable { final String label; @@ -111,14 +117,34 @@ class WeightPredictionEngine { /// returns null for ids that must not become features (lead/tank items, /// or per the caller's policy). Deleted gear should be given a weak /// zero-prior feature by the caller so its dives still inform the fit. + /// + /// [heightCm] enables the body-composition (BMI) term. It is treated like + /// a physics term: subtracted before the regression so the personal + /// intercept explains only what neither physics nor BMI can, then added + /// back by [FittedWeightModel.predict]. It needs a real [bodyWeightKg]; + /// the default body mass would fabricate a BMI. static FittedWeightModel fit({ required List observations, required GearFeature? Function(String equipmentId) gearById, double? bodyWeightKg, + double? heightCm, DateTime? now, }) { final effectiveNow = now ?? DateTime.now(); final bodyMass = bodyWeightKg ?? BuoyancyPhysics.defaultBodyMassKg; + // The stored height must mean "a term was subtracted": an implausible + // height, or a height without a real body weight, subtracts nothing and + // is forgotten so predict cannot add a term the fit never removed. + final subtractsBodyComposition = + bodyWeightKg != null && + BodyComposition.bmi(weightKg: bodyWeightKg, heightCm: heightCm) != null; + final fitHeightCm = subtractsBodyComposition ? heightCm : null; + final bodyCompositionKg = subtractsBodyComposition + ? BodyComposition.leadTermKg( + bodyMassKg: bodyWeightKg, + heightCm: fitHeightCm, + ) + : 0.0; // Collect the distinct gear features seen in history. final featuresById = {}; @@ -157,7 +183,7 @@ class WeightPredictionEngine { final corrected = observation.carriedKg + _feedbackAdjustment(observation); final physics = _observationPhysics(observation, bodyMass, featuresById); - y.add(corrected - physics); + y.add(corrected - physics - bodyCompositionKg); final row = List.filled(featureIds.length + 1, 0.0); row[0] = 1.0; @@ -219,6 +245,7 @@ class WeightPredictionEngine { supportingDives: supportingDives, residualStdKg: residualStd, bodyWeightKg: bodyWeightKg, + heightCm: fitHeightCm, ); } @@ -298,6 +325,19 @@ class FittedWeightModel { final double residualStdKg; final double? bodyWeightKg; + /// Height the model was fitted with, kept only when the fit actually + /// subtracted a body-composition term; the fallback for rigs that omit it. + final double? heightCm; + + /// Whether [predict] may add a body-composition term. True when the fit + /// subtracted one (fitted with a height) or had no observations to + /// subtract from, so the coefficients are still the priors. A model + /// calibrated on history WITHOUT a height has already absorbed the diver's + /// real body composition into the intercept; adding the term on top would + /// double count it. + bool get bodyCompositionCalibrated => + heightCm != null || supportingDives == 0; + const FittedWeightModel._({ required this.personalCoefficient, required this.coefficientsById, @@ -305,6 +345,7 @@ class FittedWeightModel { required this.supportingDives, required this.residualStdKg, required this.bodyWeightKg, + required this.heightCm, }); WeightPrediction predict(RigSpec rig) { @@ -322,6 +363,26 @@ class FittedWeightModel { ), ); + // Mirrors the subtraction in [WeightPredictionEngine.fit]; a rig may + // carry a different body than the calibration did. + final knownBodyMass = rig.bodyWeightKg ?? bodyWeightKg; + final height = rig.heightCm ?? heightCm; + if (bodyCompositionCalibrated && + knownBodyMass != null && + BodyComposition.bmi(weightKg: knownBodyMass, heightCm: height) != + null) { + terms.add( + PredictionTerm( + label: BodyComposition.termLabel, + kg: BodyComposition.leadTermKg( + bodyMassKg: knownBodyMass, + heightCm: height, + ), + source: TermSource.bodyComposition, + ), + ); + } + var gearDryMass = 0.0; for (final gear in rig.gear) { gearDryMass += gear.dryMassKg; diff --git a/lib/core/constants/dive_detail_layout.dart b/lib/core/constants/dive_detail_layout.dart new file mode 100644 index 0000000000..51db246ac2 --- /dev/null +++ b/lib/core/constants/dive_detail_layout.dart @@ -0,0 +1,60 @@ +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// How the Dive Details page arranges its sections. +/// +/// The layout never changes *which* sections render -- that stays the diver's +/// per-section visibility choice -- only how much room each one gets. +enum DiveDetailLayout { + /// Full-size cards, every visualization at its natural height. + detailed, + + /// Every section folded behind a single-row header, unfolded on tap. + list; + + /// Parse from a stored string, defaulting to [detailed]. + /// + /// Also covers the removed `compact` layout: a diver who selected it before + /// it was dropped reads back as [detailed]. + static DiveDetailLayout fromName(String? name) { + if (name == null) return DiveDetailLayout.detailed; + return DiveDetailLayout.values.firstWhere( + (e) => e.name == name, + orElse: () => DiveDetailLayout.detailed, + ); + } + + /// Localized name shown in the display-options menu. + String localizedName(AppLocalizations l10n) => switch (this) { + detailed => l10n.diveDetailLayout_detailed, + list => l10n.diveDetailLayout_list, + }; + + /// Vertical gap between two consecutive sections. + /// + /// [list] folds each section to a header row, so its sections sit close + /// together the way rows of a list do. + double get sectionGap => switch (this) { + detailed => 24, + list => 8, + }; + + /// Padding around the whole scrolling body. + double get pagePadding => switch (this) { + detailed => 16, + list => 8, + }; + + /// Gap between the header block and the first section. + double get headerGap => switch (this) { + detailed => 24, + list => 16, + }; + + /// Whether two sections that pair may render side by side. + /// + /// False in [list], where every section is one full-width folded row. + bool get pairsSections => this != DiveDetailLayout.list; + + /// Whether each section is wrapped in a fold that starts collapsed. + bool get foldsSections => this == DiveDetailLayout.list; +} diff --git a/lib/core/constants/dive_detail_section_pairs.dart b/lib/core/constants/dive_detail_section_pairs.dart index aaee40e664..184c3cbf8e 100644 --- a/lib/core/constants/dive_detail_section_pairs.dart +++ b/lib/core/constants/dive_detail_section_pairs.dart @@ -7,7 +7,12 @@ import 'package:submersion/core/constants/dive_detail_sections.dart'; /// each section sits in the diver's configured order, so a saved order that /// predates the pair still lays out the intended way. class DiveDetailSectionPair { - const DiveDetailSectionPair(this.left, this.right); + const DiveDetailSectionPair( + this.left, + this.right, { + this.minRowWidth = 700, + this.stretch = false, + }); /// The section shown in the left column (top card when stacked). final DiveDetailSectionId left; @@ -15,6 +20,16 @@ class DiveDetailSectionPair { /// The section shown in the right column (bottom card when stacked). final DiveDetailSectionId right; + /// At or above this available width the two cards sit side by side. + final double minRowWidth; + + /// Whether the two columns stretch to a shared height in row mode. + /// + /// True only where a card is built to fill the height it is given -- the + /// deco column pads itself against the taller tissue card so the pair reads + /// as one block. + final bool stretch; + /// The other half of the pair, or null when [id] is not part of it. DiveDetailSectionId? partnerOf(DiveDetailSectionId id) { if (id == left) return right; @@ -29,6 +44,15 @@ class DiveDetailSectionPair { /// that. The default section order in [DiveDetailSectionId] lists each pair's /// halves adjacently and in this same order. const List kDiveDetailSectionPairs = [ + // The deco column and the tissue heat map were one card until the diver + // could hide them separately; they pair back together at the 600px the + // combined panel used, and stretch the way it did. + DiveDetailSectionPair( + DiveDetailSectionId.decoStatus, + DiveDetailSectionId.tissueLoading, + minRowWidth: 600, + stretch: true, + ), DiveDetailSectionPair( DiveDetailSectionId.details, DiveDetailSectionId.environment, diff --git a/lib/core/constants/dive_detail_sections.dart b/lib/core/constants/dive_detail_sections.dart index 1666f8faee..a778ecedf9 100644 --- a/lib/core/constants/dive_detail_sections.dart +++ b/lib/core/constants/dive_detail_sections.dart @@ -1,17 +1,22 @@ import 'dart:convert'; +import 'package:flutter/material.dart'; + 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. +/// Declaration order defines the default display order. The header block is +/// the only fixed part of the page; every section below -- including the dive +/// profile chart -- can be hidden and reordered. /// /// Sections that pair side by side on a wide pane are declared adjacently, in /// left-then-right order (see `kDiveDetailSectionPairs`), so the default order /// already reads the way the paired layout renders. enum DiveDetailSectionId { - decoO2, + profile, + decoStatus, + tissueLoading, safetyReview, sacSegments, details, @@ -33,10 +38,19 @@ enum DiveDetailSectionId { customFields, dataSources; + /// The pre-split id that carried both [decoStatus] and [tissueLoading]. + /// + /// Saved orders written before the split still name it, so + /// [DiveDetailSectionConfig.sectionsFromJson] expands it into the two + /// sections that replaced it. + static const String legacyDecoO2Id = 'decoO2'; + /// Human-readable name shown in the settings UI (English fallback). String get displayName { return switch (this) { - decoO2 => 'Deco Status / Tissue Loading', + profile => 'Dive Profile', + decoStatus => 'Deco Status', + tissueLoading => 'Tissue Loading', safetyReview => 'Safety Review', sacSegments => 'Gas consumption by segment', details => 'Details', @@ -51,7 +65,7 @@ enum DiveDetailSectionId { buddies => 'Buddies', signatures => 'Signatures', equipment => 'Equipment', - sightings => 'Marine Life Sightings', + sightings => 'Species Sightings', media => 'Media', tags => 'Tags', notes => 'Notes', @@ -64,7 +78,9 @@ enum DiveDetailSectionId { /// (English fallback). String get description { return switch (this) { - decoO2 => 'NDL, ceiling, tissue heat map, O2 toxicity', + profile => 'Depth/time chart, playback, range selection', + decoStatus => 'NDL, ceiling, stops, O2 toxicity', + tissueLoading => 'Per-compartment saturation and heat map', safetyReview => 'Automatic post-dive profile observations', sacSegments => 'SAC and RMV by phase or time', details => 'Type, location, trip, dive center, interval', @@ -89,10 +105,42 @@ enum DiveDetailSectionId { }; } + /// Icon standing in for the section in the properties menu and, in the list + /// layout, on the section's folded header row. + IconData get icon { + return switch (this) { + profile => Icons.show_chart, + decoStatus => Icons.timer_outlined, + tissueLoading => Icons.grid_view, + safetyReview => Icons.health_and_safety_outlined, + sacSegments => Icons.speed, + details => Icons.info_outline, + environment => Icons.thermostat, + altitude => Icons.terrain, + tide => Icons.waves, + reefHealth => Icons.water, + surfaceGps => Icons.gps_fixed, + weights => Icons.fitness_center, + buoyancy => Icons.bubble_chart_outlined, + tanks => Icons.propane_tank_outlined, + buddies => Icons.group_outlined, + signatures => Icons.draw_outlined, + equipment => Icons.backpack_outlined, + sightings => Icons.pets, + media => Icons.photo_library_outlined, + tags => Icons.label_outline, + notes => Icons.notes, + customFields => Icons.tune, + dataSources => Icons.watch_outlined, + }; + } + /// Localized display name resolved via [AppLocalizations]. String localizedDisplayName(AppLocalizations l10n) { return switch (this) { - decoO2 => l10n.diveDetailSection_decoO2_name, + profile => l10n.diveDetailSection_profile_name, + decoStatus => l10n.diveDetailSection_decoStatus_name, + tissueLoading => l10n.diveDetailSection_tissueLoading_name, safetyReview => l10n.diveDetailSection_safetyReview_name, sacSegments => l10n.diveDetailSection_sacSegments_name, details => l10n.diveDetailSection_details_name, @@ -119,7 +167,9 @@ enum DiveDetailSectionId { /// Localized description resolved via [AppLocalizations]. String localizedDescription(AppLocalizations l10n) { return switch (this) { - decoO2 => l10n.diveDetailSection_decoO2_description, + profile => l10n.diveDetailSection_profile_description, + decoStatus => l10n.diveDetailSection_decoStatus_description, + tissueLoading => l10n.diveDetailSection_tissueLoading_description, safetyReview => l10n.diveDetailSection_safetyReview_description, sacSegments => l10n.diveDetailSection_sacSegments_description, details => l10n.diveDetailSection_details_description, @@ -145,8 +195,12 @@ enum DiveDetailSectionId { /// Whether this section is hidden for gauge (bottom-timer) dives, which log /// depth and time only -- no gas, decompression, or O2-toxicity data. + /// + /// The profile chart itself stays: depth over time is exactly what a gauge + /// dive does record. bool get hiddenInGaugeMode => - this == DiveDetailSectionId.decoO2 || + this == DiveDetailSectionId.decoStatus || + this == DiveDetailSectionId.tissueLoading || this == DiveDetailSectionId.sacSegments || this == DiveDetailSectionId.tanks; } @@ -156,13 +210,35 @@ class DiveDetailSectionConfig { final DiveDetailSectionId id; final bool visible; - const DiveDetailSectionConfig({required this.id, required this.visible}); + /// Whether the list layout shows this section unfolded. + /// + /// Only the list layout folds sections, so this is ignored elsewhere. It + /// lives here rather than in page state so a dive reopens the way the + /// diver left it. + final bool expanded; + + const DiveDetailSectionConfig({ + required this.id, + required this.visible, + this.expanded = false, + }); - DiveDetailSectionConfig copyWith({bool? visible}) { - return DiveDetailSectionConfig(id: id, visible: visible ?? this.visible); + DiveDetailSectionConfig copyWith({bool? visible, bool? expanded}) { + return DiveDetailSectionConfig( + id: id, + visible: visible ?? this.visible, + expanded: expanded ?? this.expanded, + ); } - Map toJson() => {'id': id.name, 'visible': visible}; + /// Folded is the default, so the flag is written only when set: an order + /// saved by a diver who never unfolds anything stays byte-identical, and + /// syncs no changeset it would otherwise have to carry. + Map toJson() => { + 'id': id.name, + 'visible': visible, + if (expanded) 'expanded': true, + }; factory DiveDetailSectionConfig.fromJson(Map json) { final idStr = json['id'] as String; @@ -170,6 +246,7 @@ class DiveDetailSectionConfig { return DiveDetailSectionConfig( id: id, visible: json['visible'] as bool? ?? true, + expanded: json['expanded'] as bool? ?? false, ); } @@ -182,7 +259,12 @@ class DiveDetailSectionConfig { } static const List defaultSections = [ - DiveDetailSectionConfig(id: DiveDetailSectionId.decoO2, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.profile, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.decoStatus, visible: true), + DiveDetailSectionConfig( + id: DiveDetailSectionId.tissueLoading, + visible: true, + ), DiveDetailSectionConfig( id: DiveDetailSectionId.safetyReview, visible: true, @@ -211,6 +293,67 @@ class DiveDetailSectionConfig { DiveDetailSectionConfig(id: DiveDetailSectionId.dataSources, visible: true), ]; + /// [sections] reordered as if [rendered] had its [oldIndex] entry dropped + /// at [newIndex]. + /// + /// The dive page renders only the sections that are visible and have + /// content, so a drop index in that subset is not an index into the saved + /// list. The moved section is re-anchored next to the rendered neighbour it + /// was dropped against, which leaves every section outside [rendered] -- + /// hidden ones included -- where the diver left it. + /// + /// Indices follow [ReorderableListView.onReorderItem]: [newIndex] is the + /// final resting index, already adjusted for the removal. + static List moveRenderedSection( + List sections, + List rendered, + int oldIndex, + int newIndex, + ) { + if (oldIndex == newIndex || + rendered.length < 2 || + oldIndex < 0 || + oldIndex >= rendered.length || + newIndex < 0 || + newIndex >= rendered.length) { + return sections; + } + + final movedId = rendered[oldIndex]; + final movedAt = sections.indexWhere((s) => s.id == movedId); + if (movedAt < 0) return sections; + final config = sections[movedAt]; + + final reordered = List.of(rendered)..removeAt(oldIndex); + reordered.insert(newIndex, movedId); + + final result = List.of(sections)..removeWhere((s) => s.id == movedId); + + // Anchor on the rendered section that now follows the moved one, so it + // lands immediately before it; at the end of the list, anchor on the one + // it now follows instead. + final followingId = newIndex + 1 < reordered.length + ? reordered[newIndex + 1] + : null; + if (followingId != null) { + final at = result.indexWhere((s) => s.id == followingId); + if (at >= 0) { + result.insert(at, config); + return result; + } + } + final precedingId = newIndex > 0 ? reordered[newIndex - 1] : null; + if (precedingId != null) { + final at = result.indexWhere((s) => s.id == precedingId); + if (at >= 0) { + result.insert(at + 1, config); + return result; + } + } + result.insert(0, config); + return result; + } + static String sectionsToJson(List sections) { return jsonEncode(sections.map((s) => s.toJson()).toList()); } @@ -220,8 +363,8 @@ class DiveDetailSectionConfig { try { final decoded = jsonDecode(json) as List; final sections = decoded - .map((e) => e is Map ? tryFromJson(e) : null) - .whereType() + .whereType>() + .expand(_configsForEntry) .toList(); if (sections.isEmpty) return List.of(defaultSections); return ensureAllSections(sections); @@ -230,14 +373,75 @@ class DiveDetailSectionConfig { } } + /// The configs one saved JSON entry stands for. + /// + /// One entry normally means one section. The retired `decoO2` id is the + /// exception: it covered the deco/tissue panel as a whole, so it expands + /// into both sections that replaced it, each inheriting the visibility and + /// fold state the diver had chosen for the combined panel. + static Iterable _configsForEntry( + Map json, + ) { + if (json['id'] == DiveDetailSectionId.legacyDecoO2Id) { + final visible = json['visible'] as bool? ?? true; + final expanded = json['expanded'] as bool? ?? false; + return [ + DiveDetailSectionConfig( + id: DiveDetailSectionId.decoStatus, + visible: visible, + expanded: expanded, + ), + DiveDetailSectionConfig( + id: DiveDetailSectionId.tissueLoading, + visible: visible, + expanded: expanded, + ), + ]; + } + final config = tryFromJson(json); + return config == null ? const [] : [config]; + } + + /// [sections] with every missing section added, visible, at the place the + /// default order puts it. + /// + /// A section added in a later release must not land at the bottom of an + /// order saved before it existed -- the dive profile chart, appended, would + /// follow the Data Sources card. Each missing id is instead inserted just + /// after its nearest preceding sibling in the default order, or at the top + /// when none of them is present. 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]; + if (presentIds.length == DiveDetailSectionId.values.length) { + return sections; + } + + final result = List.of(sections); + for (final id in DiveDetailSectionId.values) { + if (presentIds.contains(id)) continue; + result.insert( + _insertionIndex(result, id), + DiveDetailSectionConfig(id: id, visible: true), + ); + presentIds.add(id); + } + return result; + } + + /// Where [id] belongs in [sections], by default-order adjacency. + static int _insertionIndex( + List sections, + DiveDetailSectionId id, + ) { + final defaultIndex = DiveDetailSectionId.values.indexOf(id); + for (var i = defaultIndex - 1; i >= 0; i--) { + final position = sections.indexWhere( + (s) => s.id == DiveDetailSectionId.values[i], + ); + if (position >= 0) return position + 1; + } + return 0; } } diff --git a/lib/core/constants/dive_field.dart b/lib/core/constants/dive_field.dart index 73436c6659..a4dfef9d86 100644 --- a/lib/core/constants/dive_field.dart +++ b/lib/core/constants/dive_field.dart @@ -138,6 +138,30 @@ enum DiveField { } } +/// Field names that saved layouts may still carry, mapped to the field that +/// replaced them. +/// +/// Saved layouts persist [DiveField.name] verbatim. The v170 migration +/// rewrites the layouts already on the device, but a layout synced from an +/// older build arrives after that migration has run, so these aliases are +/// permanent rather than a migration-window convenience. +const Map _legacyDiveFieldNames = { + // Split into sac and rmv (discussions #354, #803). + 'sacRate': DiveField.sac, +}; + +/// Resolves a persisted [DiveField.name], honouring names that were renamed +/// after a layout was saved. Returns null when the name is not recognized at +/// all, leaving the fallback to the caller. +DiveField? diveFieldFromName(String name) { + final legacy = _legacyDiveFieldNames[name]; + if (legacy != null) return legacy; + for (final field in DiveField.values) { + if (field.name == name) return field; + } + return null; +} + extension DiveFieldMetadata on DiveField { /// The category this field belongs to. DiveFieldCategory get category { diff --git a/lib/core/constants/enums.dart b/lib/core/constants/enums.dart index 4d2ecc6a13..8981725abc 100644 --- a/lib/core/constants/enums.dart +++ b/lib/core/constants/enums.dart @@ -1,15 +1,35 @@ import 'dart:ui' show Color; -/// Types of diving equipment +/// A category of gear a diver owns. +/// +/// Persisted by [name] (the `equipment.type` column is text), so the order of +/// the values is free to change and new values need no migration: an older +/// build reading a newer library falls back to [other] rather than failing. +/// The declaration order is the order the type dropdown and the filter chips +/// offer, so related gear is grouped rather than alphabetised. enum EquipmentType { regulator('Regulator'), bcd('BCD'), wetsuit('Wetsuit'), drysuit('Drysuit'), + // The two drysuit layers, requested in #1537. They sit next to the suits + // because that is where a diver looks for them, and they are separate types + // rather than `other` because their thermal rating is what moves a drysuit + // diver's lead between a summer and a winter dive. + undersuit('Undersuit'), + baselayer('Base Layer'), + // Requested in #1518 alongside the two above. It is neither of them: a rash + // guard is worn for sun and abrasion in warm water, so it is rated in UPF + // rather than in warmth, and a diver owns one for the reason they own a + // baselayer for the opposite one. + rashGuard('Rash Guard'), fins('Fins'), mask('Mask'), + snorkel('Snorkel'), computer('Dive Computer'), transmitter('Transmitter'), + instrument('Instrument / Gauge'), + compass('Compass'), tank('Tank'), rebreather('Rebreather'), weights('Weights'), @@ -18,6 +38,7 @@ enum EquipmentType { smb('SMB'), reel('Reel'), knife('Knife'), + tool('Tool'), hood('Hood'), gloves('Gloves'), boots('Boots'), diff --git a/lib/core/constants/pdf_templates.dart b/lib/core/constants/pdf_templates.dart index b2eb7d3c45..5bcf4a0753 100644 --- a/lib/core/constants/pdf_templates.dart +++ b/lib/core/constants/pdf_templates.dart @@ -2,11 +2,10 @@ /// /// Each template provides a different layout and style for the exported PDF: /// - [simple]: High-density table format, 15-20 dives per page -/// - [detailed]: Card-style entries with full information, 3 dives per page -/// - [professional]: Formal layout with signature/stamp areas, 2 dives per page +/// - [detailed]: One dive per page with the full field set and a profile chart /// - [padiStyle]: Layout mimicking PADI paper logbook format /// - [nauiStyle]: Layout mimicking NAUI paper logbook format -enum PdfTemplate { simple, detailed, professional, padiStyle, nauiStyle } +enum PdfTemplate { simple, detailed, padiStyle, nauiStyle } /// Extension methods for [PdfTemplate]. extension PdfTemplateExtension on PdfTemplate { @@ -17,8 +16,6 @@ extension PdfTemplateExtension on PdfTemplate { return 'Simple'; case PdfTemplate.detailed: return 'Detailed'; - case PdfTemplate.professional: - return 'Professional'; case PdfTemplate.padiStyle: return 'PADI Style'; case PdfTemplate.nauiStyle: @@ -32,9 +29,7 @@ extension PdfTemplateExtension on PdfTemplate { case PdfTemplate.simple: return 'Compact table format, many dives per page'; case PdfTemplate.detailed: - return 'Full dive information with notes and ratings'; - case PdfTemplate.professional: - return 'Signature and stamp areas for verification'; + return 'One dive per page with the profile chart and every field'; case PdfTemplate.padiStyle: return 'Layout matching PADI logbook format'; case PdfTemplate.nauiStyle: @@ -48,9 +43,7 @@ extension PdfTemplateExtension on PdfTemplate { case PdfTemplate.simple: return 18; case PdfTemplate.detailed: - return 3; - case PdfTemplate.professional: - return 2; + return 1; case PdfTemplate.padiStyle: return 4; case PdfTemplate.nauiStyle: @@ -62,9 +55,7 @@ extension PdfTemplateExtension on PdfTemplate { bool get supportsCertificationCards { switch (this) { case PdfTemplate.simple: - return false; case PdfTemplate.detailed: - case PdfTemplate.professional: case PdfTemplate.padiStyle: case PdfTemplate.nauiStyle: return true; @@ -108,6 +99,12 @@ class PdfExportOptions { /// The page size for the PDF. final PdfPageSize pageSize; + /// Whether to add an official stamp box and large signature blocks. + /// + /// For a logbook presented to a training agency for verification. Only the + /// detailed template renders these. + final bool includeVerificationAreas; + /// Whether to include certification card images in the PDF. /// /// Only applicable for templates that support certification cards. @@ -123,6 +120,7 @@ class PdfExportOptions { this.template = PdfTemplate.detailed, this.pageSize = PdfPageSize.a4, this.includeCertificationCards = false, + this.includeVerificationAreas = false, }); /// Creates a copy of this options object with the given fields replaced. @@ -130,12 +128,15 @@ class PdfExportOptions { PdfTemplate? template, PdfPageSize? pageSize, bool? includeCertificationCards, + bool? includeVerificationAreas, }) { return PdfExportOptions( template: template ?? this.template, pageSize: pageSize ?? this.pageSize, includeCertificationCards: includeCertificationCards ?? this.includeCertificationCards, + includeVerificationAreas: + includeVerificationAreas ?? this.includeVerificationAreas, ); } @@ -145,12 +146,18 @@ class PdfExportOptions { return other is PdfExportOptions && other.template == template && other.pageSize == pageSize && - other.includeCertificationCards == includeCertificationCards; + other.includeCertificationCards == includeCertificationCards && + other.includeVerificationAreas == includeVerificationAreas; } @override int get hashCode { - return Object.hash(template, pageSize, includeCertificationCards); + return Object.hash( + template, + pageSize, + includeCertificationCards, + includeVerificationAreas, + ); } @override @@ -158,6 +165,7 @@ class PdfExportOptions { return 'PdfExportOptions(' 'template: $template, ' 'pageSize: $pageSize, ' - 'includeCertificationCards: $includeCertificationCards)'; + 'includeCertificationCards: $includeCertificationCards, ' + 'includeVerificationAreas: $includeVerificationAreas)'; } } diff --git a/lib/core/constants/profile_metrics.dart b/lib/core/constants/profile_metrics.dart index 20b41f8e40..4b85dd66fd 100644 --- a/lib/core/constants/profile_metrics.dart +++ b/lib/core/constants/profile_metrics.dart @@ -72,7 +72,7 @@ enum ProfileRightAxisMetric { gasDensity( displayName: 'Gas Density', shortName: 'Density', - color: Colors.brown, + color: Color(0xFF827717), unitSuffix: 'g/L', category: ProfileMetricCategory.gasAnalysis, ), @@ -125,6 +125,15 @@ enum ProfileRightAxisMetric { color: Color(0xFF00838F), unitSuffix: 'mV', category: ProfileMetricCategory.gasAnalysis, + ), + gtr( + displayName: 'GTR', + shortName: 'GTR', + // Green 800 - apart from NDL's yellow-green and SAC's teal, the two + // lines it is most likely to share a chart with. + color: Color(0xFF2E7D32), + unitSuffix: 'min', + category: ProfileMetricCategory.gasAnalysis, ); final String displayName; @@ -202,4 +211,5 @@ typedef MetricSourceInfo = ({ MetricDataSource ttsActual, MetricDataSource cnsActual, MetricDataSource decoStopActual, + MetricDataSource gtrActual, }); diff --git a/lib/core/constants/sort_options.dart b/lib/core/constants/sort_options.dart index 2229bdc8e6..c7863d6d07 100644 --- a/lib/core/constants/sort_options.dart +++ b/lib/core/constants/sort_options.dart @@ -33,7 +33,8 @@ enum SiteSortField { rating('Rating', Icons.star), difficulty('Difficulty', Icons.trending_up), depth('Max Depth', Icons.vertical_align_bottom), - diveCount('Dive Count', Icons.scuba_diving); + diveCount('Dive Count', Icons.scuba_diving), + lastDived('Last Dived', Icons.history); final String displayName; final IconData icon; @@ -67,7 +68,8 @@ enum EquipmentSortField { /// Sort fields for Buddies enum BuddySortField { name('Name', Icons.sort_by_alpha), - diveCount('Dive Count', Icons.scuba_diving); + diveCount('Dive Count', Icons.scuba_diving), + lastDive('Last Dive', Icons.history); final String displayName; final IconData icon; diff --git a/lib/core/constants/sort_options_display.dart b/lib/core/constants/sort_options_display.dart index 2cde9cc465..faccf126bb 100644 --- a/lib/core/constants/sort_options_display.dart +++ b/lib/core/constants/sort_options_display.dart @@ -35,6 +35,7 @@ extension SiteSortFieldDisplay on SiteSortField { SiteSortField.difficulty => l10n.enum_sortField_difficulty, SiteSortField.depth => l10n.enum_sortField_maxDepth, SiteSortField.diveCount => l10n.enum_sortField_diveCount, + SiteSortField.lastDived => l10n.enum_siteField_lastDived, }; } @@ -60,6 +61,7 @@ extension BuddySortFieldDisplay on BuddySortField { String localizedName(AppLocalizations l10n) => switch (this) { BuddySortField.name => l10n.enum_sortField_name, BuddySortField.diveCount => l10n.enum_sortField_diveCount, + BuddySortField.lastDive => l10n.enum_sortField_lastDive, }; } diff --git a/lib/core/constants/tank_preset_display.dart b/lib/core/constants/tank_preset_display.dart index fa23a010ef..c9593827bc 100644 --- a/lib/core/constants/tank_preset_display.dart +++ b/lib/core/constants/tank_preset_display.dart @@ -22,6 +22,7 @@ String? builtInTankPresetName(AppLocalizations l10n, String slug) => 'al40' => l10n.tank_al40_displayName, 'al63' => l10n.tank_al63_displayName, 'al80' => l10n.tank_al80_displayName, + 'al100' => l10n.tank_al100_displayName, 'hp80' => l10n.tank_hp80_displayName, 'hp100' => l10n.tank_hp100_displayName, 'hp120' => l10n.tank_hp120_displayName, @@ -40,6 +41,7 @@ String? builtInTankPresetDescription(AppLocalizations l10n, String slug) => 'al40' => l10n.tank_al40_description, 'al63' => l10n.tank_al63_description, 'al80' => l10n.tank_al80_description, + 'al100' => l10n.tank_al100_description, 'hp80' => l10n.tank_hp80_description, 'hp100' => l10n.tank_hp100_description, 'hp120' => l10n.tank_hp120_description, diff --git a/lib/core/constants/tank_presets.dart b/lib/core/constants/tank_presets.dart index 593ac6586d..ddacd58bba 100644 --- a/lib/core/constants/tank_presets.dart +++ b/lib/core/constants/tank_presets.dart @@ -66,6 +66,16 @@ class TankPresets { ratedCapacityCuft: 77.4, ); + static const al100 = TankPreset( + name: 'al100', + displayName: 'AL100', + volumeLiters: 13.1, + workingPressureBar: 227.527, // 3300 psi + material: TankMaterial.aluminum, + description: 'Aluminum 100 cu ft', + ratedCapacityCuft: 100.0, + ); + // High pressure steel tanks static const hp80 = TankPreset( name: 'hp80', @@ -158,7 +168,7 @@ class TankPresets { ); /// All available presets grouped by category - static const List aluminum = [al80, al63, al40]; + static const List aluminum = [al100, al80, al63, al40]; static const List hpSteel = [hp120, hp100, hp80]; static const List lpSteel = [lp85]; static const List metric = [steel15, steel12, steel10]; @@ -166,6 +176,7 @@ class TankPresets { /// All available presets static const List all = [ + al100, al80, al63, al40, @@ -191,6 +202,18 @@ class TankPresets { /// Match a preset by volume and working pressure within tolerance. /// Returns the matching preset (with ratedCapacityCuft) or null. + /// + /// Specs alone cannot always identify one preset: an AL40 and an AL40 Stage + /// are both 5.7 L at 206.843 bar with a 40 cuft rating, differing only in + /// how they are rigged. Ties resolve to the first entry in [all], so the + /// plain cylinder wins over its stage variant. Callers that know which one + /// they hold should pass the slug instead of relying on this. + /// + /// That tie-break is only safe while indistinguishable presets agree on + /// everything a caller reads from the result. Two invariants hold it: + /// `tank_presets_test.dart` pins the AL40 tie, and `tank_catalog_test.dart` + /// asserts that any two presets this method cannot tell apart share the same + /// `kTankCatalog` row, so buoyancy never depends on list order. static TankPreset? matchBySpecs( double volumeLiters, double workingPressureBar, diff --git a/lib/core/data/repositories/sync_repository.dart b/lib/core/data/repositories/sync_repository.dart index 2ff9115bdc..b26e53e25b 100644 --- a/lib/core/data/repositories/sync_repository.dart +++ b/lib/core/data/repositories/sync_repository.dart @@ -18,7 +18,10 @@ enum CloudProviderType { icloud, googledrive, s3, dropbox } /// Repository for managing sync metadata and tracking class SyncRepository { - AppDatabase get _db => DatabaseService.instance.database; + SyncRepository({AppDatabase? database}) : _database = database; + + final AppDatabase? _database; + AppDatabase get _db => _database ?? DatabaseService.instance.database; final _uuid = const Uuid(); final _log = LoggerService.forClass(SyncRepository); @@ -48,6 +51,8 @@ class SyncRepository { 'liveaboardDetails': (table: 'liveaboard_detail_records', pk: 'id'), 'itineraryDays': (table: 'trip_itinerary_days', pk: 'id'), 'tripDayWeather': (table: 'trip_day_weather', pk: 'id'), + 'diveProfileSeries': (table: 'dive_profile_series', pk: 'id'), + 'tankPressureSeries': (table: 'tank_pressure_series', pk: 'id'), 'checklistTemplates': (table: 'checklist_templates', pk: 'id'), 'checklistTemplateItems': (table: 'checklist_template_items', pk: 'id'), 'tripChecklistItems': (table: 'trip_checklist_items', pk: 'id'), @@ -226,6 +231,42 @@ class SyncRepository { return metadata.deviceId; } + /// Whether this database has ever taken part in a sync. + /// + /// True as soon as anything sync-related has been recorded: a baseline + /// timestamp, an accepted library epoch, a remote file reference, a sync + /// record, a tombstone, a peer cursor or a publish state. + /// + /// False only for a database that has never synced. On a launch whose + /// out-of-database anchors name some other install, that is the signature of + /// a fresh install rather than a restore; see + /// [SyncInitializer.reconcileDeviceIdentity]. + /// + /// Deliberately does NOT count [SyncMetadataData.syncProvider], which merely + /// names a chosen backend and is set before any sync has run. + Future hasSyncHistory() async { + final metadata = await getOrCreateMetadata(); + if (metadata.lastSyncTimestamp != null) return true; + // Accepting a library epoch, or holding a remote file reference, both mean + // this database has taken part in a sync even when no baseline survives. + if (metadata.lastAcceptedEpochId != null) return true; + if (metadata.remoteFileId != null) return true; + if (await _hasAnyRow(_db.syncRecords)) return true; + if (await _hasAnyRow(_db.deletionLog)) return true; + if (await _hasAnyRow(_db.syncPeerCursors)) return true; + if (await _hasAnyRow(_db.localPublishStates)) return true; + return false; + } + + /// True if [table] holds at least one row. Uses a LIMIT 1 select rather than + /// a COUNT so the check stops at the first row on a large table. + Future _hasAnyRow( + ResultSetImplementation table, + ) async { + final row = await (_db.select(table)..limit(1)).getSingleOrNull(); + return row != null; + } + /// Get this database's instance token, or null if none has been set yet /// (rows predating the column, or a freshly created/restored database). Future getInstanceToken() async { @@ -549,6 +590,24 @@ class SyncRepository { timestamp: 'updated_at', filter: null, ), + // The packed sample series (schema v182). A row can reach these tables + // unstamped two ways: the v182 pack runs on a device that had no clock + // to advance yet, and any series write whose sync bookkeeping did not + // land. Without an entry here such a row is invisible to the + // incremental export forever, because NULL never passes the strict + // watermark comparison. + ( + entityType: 'diveProfileSeries', + table: 'dive_profile_series', + timestamp: 'updated_at', + filter: null, + ), + ( + entityType: 'tankPressureSeries', + table: 'tank_pressure_series', + timestamp: 'updated_at', + filter: null, + ), ]; /// One-time self-heal for rows that carry `hlc IS NULL`. Such rows are @@ -560,32 +619,53 @@ class SyncRepository { /// /// Self-limiting: every write path for these tables now stamps an HLC, so /// once the legacy rows are done this finds nothing. + /// Per table, and never fatal: a database can carry a table this scan + /// cannot read (a v183 pack that failed leaves a differently shaped + /// series table behind, which is the state the drop guard deliberately + /// preserves for a later retry). This runs at the start of every sync, so + /// one unreadable table must not stop the other tables from healing, let + /// alone fail the sync. Future backfillMissingHlc() async { for (final target in _hlcBackfillTargets) { - final filter = target.filter == null ? '' : ' AND ${target.filter}'; - final rows = await _db - .customSelect( - 'SELECT id, "${target.timestamp}" AS ts FROM "${target.table}" ' - 'WHERE hlc IS NULL$filter', - ) - .get(); - if (rows.isEmpty) continue; - // One transaction per table: markRecordPending's own per-row transaction - // nests as a savepoint, so a library with many affected rows commits once - // instead of once per row (the per-row fsync was the sync-start cost - // flagged in review). - await _db.transaction(() async { - for (final row in rows) { - await markRecordPending( - entityType: target.entityType, - recordId: row.read('id'), - localUpdatedAt: row.read('ts'), - ); - } - }); + try { + await _backfillTable(target); + } catch (e, stackTrace) { + _log.warning( + 'HLC backfill skipped for ${target.table}', + error: e, + stackTrace: stackTrace, + ); + } } } + Future _backfillTable( + ({String entityType, String table, String timestamp, String? filter}) + target, + ) async { + final filter = target.filter == null ? '' : ' AND ${target.filter}'; + final rows = await _db + .customSelect( + 'SELECT id, "${target.timestamp}" AS ts FROM "${target.table}" ' + 'WHERE hlc IS NULL$filter', + ) + .get(); + if (rows.isEmpty) return; + // One transaction per table: markRecordPending's own per-row transaction + // nests as a savepoint, so a library with many affected rows commits once + // instead of once per row (the per-row fsync was the sync-start cost + // flagged in review). + await _db.transaction(() async { + for (final row in rows) { + await markRecordPending( + entityType: target.entityType, + recordId: row.read('id'), + localUpdatedAt: row.read('ts'), + ); + } + }); + } + /// Stamp a fresh Hybrid Logical Clock onto the just-written entity row, if /// the entity is conflict-capable and the clock is configured. Centralised /// here (the write choke point) rather than in every repository companion. @@ -628,8 +708,28 @@ class SyncRepository { /// none has one yet. Lexically comparable because the packed format zero-pads /// physical time and counter. Future _maxRowHlc() async { - final union = hlcTargets.values - .map((t) => 'SELECT MAX(hlc) AS h FROM "${t.table}"') + // Only the tables that exist. The series tables are created by + // _assertProfileSeriesSchema, which deliberately waits for their foreign + // key parents, so a partially built database (a migration fixture, or + // one caught mid-ladder) can reach here without them and a UNION naming + // a missing table takes the whole clock seed down. backfillMissingHlc + // already guards per table for exactly this. + final present = { + for (final r + in await _db + .customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table'", + ) + .get()) + r.read('name'), + }; + final tables = [ + for (final t in hlcTargets.values) + if (present.contains(t.table)) t.table, + ]; + if (tables.isEmpty) return null; + final union = tables + .map((t) => 'SELECT MAX(hlc) AS h FROM "$t"') .join(' UNION ALL '); final row = await _db .customSelect('SELECT MAX(h) AS m FROM ($union)') diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 504d37541c..4bb828057d 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -4,8 +4,12 @@ import 'dart:developer' as developer; import 'package:drift/drift.dart'; import 'package:submersion/core/database/dive_computer_gear_backfill.dart'; +import 'package:submersion/core/database/dive_type_uniqueness.dart'; import 'package:submersion/core/database/imported_computer_backfill.dart'; import 'package:submersion/core/database/performance_indexes.dart'; +import 'package:submersion/core/database/profile_series_pack_coverage.dart'; +import 'package:submersion/core/database/profile_series_pack.dart'; +import 'package:submersion/core/database/raw_dive_data_codec.dart'; import 'package:submersion/core/database/tag_uniqueness.dart'; import 'package:submersion/core/constants/enums.dart'; @@ -21,7 +25,15 @@ class Divers extends Table { TextColumn get name => text()(); TextColumn get email => text().nullable()(); TextColumn get phone => text().nullable()(); + + /// Deprecated, superseded by [photo]. Never written for divers; kept so a + /// database that predates v181 still maps. TextColumn get photoPath => text().nullable()(); + + /// Profile photo: a 512x512 square JPEG produced by + /// `lib/core/services/images/profile_photo_codec.dart`. Stored on the row so + /// it syncs with the diver rather than depending on a device-local path. + BlobColumn get photo => blob().nullable()(); // Emergency contact TextColumn get emergencyContactName => text().nullable()(); TextColumn get emergencyContactPhone => text().nullable()(); @@ -41,6 +53,13 @@ class Divers extends Table { TextColumn get insuranceProvider => text().nullable()(); TextColumn get insurancePolicyNumber => text().nullable()(); IntColumn get insuranceExpiryDate => integer().nullable()(); // Unix timestamp + + /// The insurer's 24-hour dive emergency assistance line, and its general or + /// office line (issue #1522). Without these the emergency card can only lead + /// with the regional diver hotline, which is the wrong first call for a + /// diver insured by anyone else. + TextColumn get insuranceEmergencyPhone => text().nullable()(); + TextColumn get insurancePhone => text().nullable()(); // General TextColumn get notes => text().withDefault(const Constant(''))(); BoolColumn get isDefault => boolean().withDefault(const Constant(false))(); @@ -309,7 +328,7 @@ class PreDiveChecklistTemplateItems extends Table { TextColumn get notes => text().withDefault(const Constant(''))(); IntColumn get sortOrder => integer().withDefault(const Constant(0))(); - /// 'check' | 'value' | 'equipmentSet' (PreDiveItemType.name). + /// 'check' | 'value' | 'equipmentSet' | 'equipment' (PreDiveItemType.name). TextColumn get itemType => text().withDefault(const Constant('check'))(); TextColumn get valueLabel => text().nullable()(); TextColumn get valueUnit => text().nullable()(); @@ -320,6 +339,18 @@ class PreDiveChecklistTemplateItems extends Table { /// Required items must end Done or Flagged (never Skipped). BoolColumn get isRequired => boolean().withDefault(const Constant(false))(); + + /// Remembered equipment for an 'equipment'-typed item. Chosen at session + /// start (not in the template editor, mirroring the equipmentSet flow) + /// and persisted here so later sessions pre-fill the same device. Issue + /// #814. + /// + /// Deliberately not a SQL-level FK: template items are (re-)seeded + /// independently of the equipment table in isolated schema fixtures (and + /// at every app start for builtin templates), so a REFERENCES clause would + /// require the equipment table to exist wherever this table does. + /// Referential integrity is enforced at the application layer instead. + TextColumn get equipmentId => text().nullable()(); IntColumn get createdAt => integer()(); IntColumn get updatedAt => integer()(); @@ -412,6 +443,12 @@ class PreDiveSessionItems extends Table { #id, onDelete: KeyAction.setNull, )(); + + /// JSON-encoded list of overdue-service entries, frozen the moment the + /// diver last moved this item away from pending. Null while pending (the + /// runner computes the live overdue list from equipmentId instead) and + /// cleared back to null on reset. Issue #814 phase 2. + TextColumn get overdueServices => text().nullable()(); IntColumn get createdAt => integer()(); IntColumn get updatedAt => integer()(); @@ -516,6 +553,17 @@ class DivePlans extends Table { IntColumn get gfHigh => integer()(); RealColumn get descentRate => real().withDefault(const Constant(18.0))(); RealColumn get ascentRate => real().withDefault(const Constant(9.0))(); + + /// Ascent rate between intermediate (deeper than 9 m) stops, m/min. + RealColumn get intermediateAscentRate => + real().withDefault(const Constant(6.0))(); + + /// Ascent rate between shallow (9 m and above) stops, m/min. + RealColumn get shallowAscentRate => real().withDefault(const Constant(3.0))(); + + /// Ascent rate from the last stop to the surface, m/min. + RealColumn get finalAscentRate => real().withDefault(const Constant(1.0))(); + RealColumn get lastStopDepth => real().withDefault(const Constant(3.0))(); IntColumn get gasSwitchStopSeconds => integer().withDefault(const Constant(0))(); @@ -729,6 +777,18 @@ class Dives extends Table { RealColumn get weightingFeedbackKg => real().nullable()(); // Favorite flag (v1.1) BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); + // Statistics exclusion (schema v180, issues #526 and #1272). + // excludedFromStats is the master flag: the dive stays in the logbook but + // contributes to no descriptive aggregate, its count included. + // excludedFromGasStats drops the dive from SAC/RMV and gas-mix aggregates + // only, for an otherwise ordinary dive whose gas number is unrepresentative + // (for example purging the tank for an end-of-dive weight check). + // The master flag implies the gas flag; the implication is applied in SQL by + // DiveStatsScope, not stored on the row. + BoolColumn get excludedFromStats => + boolean().withDefault(const Constant(false))(); + BoolColumn get excludedFromGasStats => + boolean().withDefault(const Constant(false))(); // Dive mode for CCR/SCR (v1.5) TextColumn get diveMode => text().withDefault(const Constant('oc'))(); // oc, ccr, scr @@ -823,88 +883,9 @@ class Dives extends Table { /// (nullable: rows written before HLC rollout fall back to updatedAt). TextColumn get hlc => text().nullable()(); - @override - Set get primaryKey => {id}; -} - -/// Time-series dive profile data points -class DiveProfiles extends Table { - TextColumn get id => text()(); - TextColumn get diveId => - text().references(Dives, #id, onDelete: KeyAction.cascade)(); - TextColumn get computerId => - text().nullable().references(DiveComputers, #id)(); - - /// Owning [DiveDataSources] row (issue #1149). - /// - /// [computerId] cannot identify the owner on its own: file imports and - /// manual entries leave it null on both the source row and the profile - /// rows, so two file-imported sources on one dive are indistinguishable. - /// This FK names the owner outright, which is what `setPrimaryDataSource` - /// promotes on. - /// - /// Nullable, and consumers must tolerate null: rows written before v158, - /// and rows synced from a peer running an older schema, carry none. The - /// fallback is the legacy convention (match on [computerId]; null belongs - /// to the primary source). `onDelete: setNull` because samples must - /// outlive their metadata row -- dropping a source must never destroy the - /// profile it describes. - TextColumn get sourceId => text().nullable().references( - DiveDataSources, - #id, - onDelete: KeyAction.setNull, - )(); - BoolColumn get isPrimary => boolean().withDefault( - const Constant(true), - )(); // Primary profile for stats - IntColumn get timestamp => integer()(); // seconds from dive start - RealColumn get depth => real()(); - // Deprecated: use tank_pressure_profiles table. Column retained for schema compat. - RealColumn get pressure => real().nullable()(); - RealColumn get temperature => real().nullable()(); - IntColumn get heartRate => integer().nullable()(); - // Compass heading in degrees (0-359) from DC_SAMPLE_BEARING; null when the - // computer does not report bearing samples. - RealColumn get heading => real().nullable()(); - // Computed decompression data (optional, can be calculated on-the-fly) - RealColumn get ascentRate => real().nullable()(); // m/min - RealColumn get ceiling => real().nullable()(); // deco ceiling in meters - IntColumn get ndl => integer().nullable()(); // no-deco limit in seconds - - // CCR/SCR rebreather data (v1.5) - RealColumn get setpoint => - real().nullable()(); // Current setpoint at sample (bar) - RealColumn get ppO2 => real().nullable()(); // Measured/calculated ppO2 (bar) - - // Individual CCR O2 cell readings (bar). Subsurface exports up to 6 - // (sensor1..sensor6); rebreathers run 3 (e.g. JJ-CCR) to 5 (e.g. rEvo). - // Stored raw exactly as the source reports them; null when absent. - RealColumn get o2Sensor1 => real().nullable()(); - RealColumn get o2Sensor2 => real().nullable()(); - RealColumn get o2Sensor3 => real().nullable()(); - RealColumn get o2Sensor4 => real().nullable()(); - RealColumn get o2Sensor5 => real().nullable()(); - RealColumn get o2Sensor6 => real().nullable()(); - // Raw O2 cell output in millivolts (issue #810). Reported even when the - // matching o2SensorN is null because the logged calibration was untrusted. - IntColumn get o2SensorMv1 => integer().nullable()(); - IntColumn get o2SensorMv2 => integer().nullable()(); - IntColumn get o2SensorMv3 => integer().nullable()(); - IntColumn get o2SensorMv4 => integer().nullable()(); - IntColumn get o2SensorMv5 => integer().nullable()(); - IntColumn get o2SensorMv6 => integer().nullable()(); - - // Per-sample decompression data (v1.5) - RealColumn get cns => real().nullable()(); // CNS percentage 0-100 - IntColumn get tts => integer().nullable()(); // Time to surface in seconds - IntColumn get rbt => - integer().nullable()(); // Remaining bottom time in seconds - IntColumn get decoType => - integer().nullable()(); // 0=NDL, 1=safety, 2=deco, 3=deep - - // Wearable integration (v2.0) - tracks source of heart rate data - TextColumn get heartRateSource => - text().nullable()(); // 'diveComputer', 'appleWatch', 'garmin', 'manual' + /// When the diver dismissed the site suggestion for this dive (photo GPS or + /// dive-computer GPS). Null = never dismissed. Synced with the row. + IntColumn get siteSuggestionDismissedAt => integer().nullable()(); @override Set get primaryKey => {id}; @@ -1399,6 +1380,17 @@ class Media extends Table { // taken_at. Lives on the media row, not on media_enrichment, so it syncs // with the row and survives every enrichment recompute. IntColumn get manualElapsedSeconds => integer().nullable()(); + // v189: equipment attachment (issue #1517). Invoices, receipts and warranty + // paperwork linked to a piece of gear, so an insurance claim after lost + // luggage, theft or fire has the proof attached to the item it covers. + // Same SET NULL semantics as [siteId]: the repository's deletion partition + // decides whether a leftover row dies or survives, and it stamps the HLC a + // silent FK never would. + TextColumn get equipmentId => text().nullable().references( + Equipment, + #id, + onDelete: KeyAction.setNull, + )(); // coverage:ignore-end IntColumn get createdAt => integer()(); IntColumn get updatedAt => integer()(); @@ -1779,6 +1771,11 @@ class DiverSettings extends Table { integer().withDefault(const Constant(1))(); IntColumn get defaultTtsSource => integer().withDefault(const Constant(1))(); IntColumn get defaultCnsSource => integer().withDefault(const Constant(1))(); + // Gas time remaining on the profile chart (v177). Source is a + // MetricDataSource index: 0 = computer, 1 = calculated. Reserve is bar. + IntColumn get defaultGtrSource => integer().withDefault(const Constant(1))(); + RealColumn get gtrReservePressure => + real().withDefault(const Constant(50.0))(); // CNS calculation method: 'classic' | 'shearwater' | 'subsurface' (v113) TextColumn get cnsCalculationMethod => text().withDefault(const Constant('shearwater'))(); @@ -1877,6 +1874,8 @@ class DiverSettings extends Table { boolean().withDefault(const Constant(false))(); BoolColumn get defaultShowTts => boolean().withDefault(const Constant(false))(); + BoolColumn get defaultShowGtr => + boolean().withDefault(const Constant(false))(); BoolColumn get defaultShowCns => boolean().withDefault(const Constant(false))(); BoolColumn get defaultShowOtu => @@ -1924,6 +1923,9 @@ class DiverSettings extends Table { boolean().withDefault(const Constant(true))(); // Dive detail section order and visibility (v56) — JSON array TextColumn get diveDetailSections => text().nullable()(); + // Dive detail page layout: detailed | list (v185). A stored "compact", + // from before that layout was dropped, reads back as detailed. + TextColumn get diveDetailLayout => text().nullable()(); // Table view profile panel default visibility (v61) BoolColumn get showProfilePanelInTableView => boolean().withDefault(const Constant(true))(); @@ -1962,7 +1964,16 @@ class Buddies extends Table { TextColumn get name => text()(); TextColumn get email => text().nullable()(); TextColumn get phone => text().nullable()(); + + /// Deprecated, superseded by [photo]. Two readers disagreed about how to + /// load it and nothing ever wrote it; kept so a database that predates v181 + /// still maps. TextColumn get photoPath => text().nullable()(); + + /// Profile photo: a 512x512 square JPEG produced by + /// `lib/core/services/images/profile_photo_codec.dart`. Stored on the row so + /// it syncs with the buddy rather than depending on a device-local path. + BlobColumn get photo => blob().nullable()(); TextColumn get notes => text().withDefault(const Constant(''))(); BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); IntColumn get createdAt => integer()(); @@ -2183,6 +2194,17 @@ class DiveDiveTypes extends Table { /// Seeds one junction row per existing dive from its representative dive_type /// slug. Used by the v92 migration and asserted directly in tests. +/// +/// The `NOT EXISTS` guard makes a second run a no-op, which its sibling +/// [kSeedBuiltInDiveTypesSql] has always had via `INSERT OR IGNORE` on stable +/// slug ids. This one mints a RANDOM id per row, so it had nothing to conflict +/// with and a re-run simply doubled every dive's types -- issue #1360. Because +/// the sync merge keys junction rows on that id, each device's own seed pass +/// produced rows the fleet then unioned rather than deduplicated. +/// +/// The guard is keyed on the dive having ANY junction row, not on the exact +/// pair: this seed's job is to give a dive its first type, so a dive that +/// already has one (a synced peer's, or a later edit) needs nothing. const String kSeedDiveDiveTypesSql = ''' INSERT INTO dive_dive_types (id, dive_id, dive_type_id, created_at) SELECT @@ -2191,6 +2213,9 @@ const String kSeedDiveDiveTypesSql = ''' COALESCE(NULLIF(dive_type, ''), 'recreational'), CAST(strftime('%s','now') AS INTEGER) * 1000 FROM dives + WHERE NOT EXISTS ( + SELECT 1 FROM dive_dive_types j WHERE j.dive_id = dives.id + ) '''; /// Seeds the built-in dive types. Used by BOTH [onCreate] (fresh installs) and @@ -2612,7 +2637,13 @@ class DiveDataSources extends Table { IntColumn get gradientFactorHigh => integer().nullable()(); DateTimeColumn get importedAt => dateTime()(); DateTimeColumn get createdAt => dateTime()(); - BlobColumn get rawData => blob().nullable()(); + + /// The raw bytes libdivecomputer returned for this download, zlib-compressed + /// at rest behind a self-describing header (issue #227). The converter runs + /// on every read and write, so callers see the original bytes and the sync + /// layer keeps exchanging them uncompressed. See [RawDiveDataConverter]. + BlobColumn get rawData => + blob().map(const RawDiveDataConverter()).nullable()(); BlobColumn get rawFingerprint => blob().nullable()(); TextColumn get sourceUuid => text().nullable()(); TextColumn get descriptorVendor => text().nullable()(); @@ -2630,6 +2661,27 @@ class DiveDataSources extends Table { /// time base", which is every source that was never consolidated. IntColumn get timeOffsetSeconds => integer().nullable()(); + /// This row's position among its own original dive's data sources at the + /// moment a sequential Combine carried it here (issue #1451). Null on every + /// row a merge never carried, which is every row on an ordinary dive. + /// + /// `DiveMergeService.apply` copies each combined segment's + /// `dive_data_sources` rows onto the merged dive as provenance, because + /// each is the sole surviving copy of its half's rawData / rawFingerprint / + /// sourceUuid. Two halves of one physical dive therefore arrive as two + /// rows, and the display used to offer them as two switchable sources: the + /// chart then drew only the active half. The rows are the same strand seen + /// in two consecutive slices, not two competing recordings, so + /// `_canonicalDataSourceRows` collapses rows sharing a slot into one + /// display source. The rows themselves stay in the table untouched. + /// + /// A slot rather than a plain flag so a dive that was consolidated (two + /// computers, slots 0 and 1 in each segment) and only then combined still + /// shows one chip per computer instead of flattening both strands into one. + /// Segments whose sources carry no computerId have nothing else to + /// distinguish their strands by, which is exactly the case that was broken. + IntColumn get mergeSourceSlot => integer().nullable()(); + @override Set get primaryKey => {id}; } @@ -2758,27 +2810,97 @@ class GasSwitches extends Table { Set get primaryKey => {id}; } -/// Per-tank time-series pressure data for multi-tank dives -/// Enables visualization of pressure curves for each tank (AI transmitters) -class TankPressureProfiles extends Table { +/// One packed series of profile samples: every sample a +/// (dive, computer, source, is_primary) group holds, encoded by +/// `ProfileSeriesCodec` (spec 2026-08-28-profile-sample-storage). Replaced +/// row-per-sample `dive_profiles`, which v183 dropped. +/// +/// The identity columns mirror the ones `dive_profiles` carried, so every +/// ownership predicate ported one for one. The summary scalars are the values the SQL +/// consumers read instead of decoding the blob; they are computed from the +/// same samples the blob packs, so they can never disagree with it. +@DataClassName('DiveProfileSeriesRow') +class DiveProfileSeries extends Table { + // coverage:ignore-start + TextColumn get id => text()(); + TextColumn get diveId => + text().references(Dives, #id, onDelete: KeyAction.cascade)(); + TextColumn get computerId => text().nullable().references( + DiveComputers, + #id, + onDelete: KeyAction.setNull, + )(); + TextColumn get sourceId => text().nullable().references( + DiveDataSources, + #id, + onDelete: KeyAction.setNull, + )(); + BoolColumn get isPrimary => boolean().withDefault(const Constant(true))(); + IntColumn get sampleCount => integer()(); + + /// Seconds from dive start of the first and last sample. + IntColumn get startTimestamp => integer()(); + IntColumn get endTimestamp => integer()(); + + /// Metres. + RealColumn get maxDepth => real()(); + RealColumn get firstDepth => real()(); + RealColumn get lastDepth => real()(); + + /// Any sample carries deco_type; any carries deco_type = 2; any carries + /// ceiling > 0. The deco classification and deco-signal predicates read + /// these instead of scanning samples. + BoolColumn get hasDecoType => boolean().withDefault(const Constant(false))(); + BoolColumn get hasDecoStop => boolean().withDefault(const Constant(false))(); + BoolColumn get hasPositiveCeiling => + boolean().withDefault(const Constant(false))(); + IntColumn get codecVersion => integer()(); + + /// `ProfileSeriesCodec` output. + BlobColumn get samples => blob()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + + /// Hybrid Logical Clock for cross-device conflict resolution. + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; + // coverage:ignore-end +} + +/// One packed series of tank pressure readings for a (dive, tank, computer) +/// group, encoded by `TankPressureSeriesCodec`. Replaced row-per-sample +/// `tank_pressure_profiles`, which v183 dropped. +/// +/// Not the domain entity of the same name +/// (lib/features/dive_log/domain/entities/profile_series.dart); consumers +/// import that one as domain. +@DataClassName('TankPressureSeriesRow') +class TankPressureSeries extends Table { + // coverage:ignore-start TextColumn get id => text()(); TextColumn get diveId => text().references(Dives, #id, onDelete: KeyAction.cascade)(); TextColumn get tankId => text().references(DiveTanks, #id, onDelete: KeyAction.cascade)(); - IntColumn get timestamp => integer()(); // seconds from dive start - RealColumn get pressure => real()(); // bar - // Which computer contributed this pressure sample (null = primary source / - // manual). Same null-means-primary semantics as dive_profiles.computerId; - // deletes set null. TextColumn get computerId => text().nullable().references( DiveComputers, #id, onDelete: KeyAction.setNull, )(); + IntColumn get sampleCount => integer()(); + IntColumn get startTimestamp => integer()(); + IntColumn get endTimestamp => integer()(); + IntColumn get codecVersion => integer()(); + BlobColumn get samples => blob()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + TextColumn get hlc => text().nullable()(); @override Set get primaryKey => {id}; + // coverage:ignore-end } /// Tide data recorded with a dive for historical reference. @@ -3182,7 +3304,7 @@ String legacyDataSourceId(String diveId) => '$kLegacyDataSourceIdPrefix$diveId'; DiverSettings, Trips, Dives, - DiveProfiles, + DiveProfileSeries, DiveSites, DiveTanks, Equipment, @@ -3223,7 +3345,7 @@ String legacyDataSourceId(String diveId) => '$kLegacyDataSourceIdPrefix$diveId'; EmergencyChambers, Incidents, GasSwitches, - TankPressureProfiles, + TankPressureSeries, TideRecords, // Site-species junction SiteSpecies, @@ -3288,7 +3410,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 = 175; + static const int currentSchemaVersion = 191; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3323,7 +3445,25 @@ class AppDatabase extends _$AppDatabase { /// breaking. Peers below 170 are held until they update. Their payloads /// still arrive here; _renamedWireKeys plus the value map in /// _applyDiverSettingDefaults carry the receiving-side tolerance. - static const int minimumCompatibleSchemaVersion = 170; + /// + /// Raised 170 -> 183 by the packed profile series: v182 replaces the synced + /// entities diveProfiles and tankPressureProfiles with diveProfileSeries and + /// tankPressureSeries, which the first rule above classifies as breaking. + /// Peers below 183 are held until they update. Their payloads still arrive + /// here; SyncData keeps the two legacy keys inbound-only and + /// SyncDataSerializer.packLegacySamples packs them into series on apply. + /// + /// 183 rather than 182, even though 182 is the rung that made the change: + /// no released build was ever stamped 182. Shipped devices are at 180, the + /// 181 rung shipped in PR #1390, and 182 and 183 land in the same release, + /// so nothing in the fleet is held by 183 that 182 did not already hold. + /// The extra step records that v183, not v182, is the rung that drops the + /// legacy tables and purges their `deletion_log` rows. The floor is stamped + /// on this device's own payloads and only holds readers below it; the gate + /// is one-directional and does nothing to inbound payloads from an older + /// peer. See [_purgeLegacySampleBookkeeping] for why those inbound legacy + /// rows stay safe without the purged tombstones. + static const int minimumCompatibleSchemaVersion = 183; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -3649,6 +3789,113 @@ class AppDatabase extends _$AppDatabase { // comment already reserves for it. 169 is now permanently skipped, as are // 162 and 167. 175, + // v177: GTR settings on diver_settings and the dive_profiles.rbt + // minutes-to-seconds repair. + 177, + // v178: one dive_dive_types row per (dive, type), collapsing the + // duplicates the unguarded v92 seed minted on every device and the sync + // merge then unioned by row id. Issue #1360. (That comment originally + // recorded PR #1328 as holding 176; #1328 has since moved to 179, the + // rung below.) + 178, + // v179: dives.site_suggestion_dismissed_at, the synced per-dive dismissal + // of the photo / dive-computer site suggestion. Renumbered twice while + // this branch was open -- from 172 when main landed 173-175, then from + // 176 when main landed 177 (GTR) -- because a rung below the shipped + // version never runs its onUpgrade step: a database already at the + // shipped version gains the column only through the beforeOpen backstop. + // 178 shipped with the dive-type uniqueness work while this branch was + // open, so this sits above it. 162, 167, 169 and 176 are skipped; the + // ladder is non-contiguous by design. + 179, + // v180 (statistics exclusion): dives.excluded_from_stats and + // dives.excluded_from_gas_stats, letting a diver keep a dive in the + // logbook while removing it from statistics. Issues #526 and #1272. + // Renumbered from 178: main landed 178 (dive-type uniqueness) and 179 + // (site-suggestion dismissal) while this branch was open, and a rung + // at or below the shipped version never runs its onUpgrade step. + // Column-only rung with no backfill, so the beforeOpen backstop is + // safe to re-run. + 180, + // v181: divers.photo and buddies.photo, the profile photo blobs. Claimed + // against origin/main at 180, having been renumbered from 180 when PR + // #1374 (statistics exclusion) landed and took that rung while this + // branch held only its design docs. A rung at or below the shipped + // version merges with no conflict marker and its onUpgrade step then + // never runs, so re-verify this number if this branch sits open while + // main advances again. + 181, + // v182 (packed profile series, spec 2026-08-28-profile-sample-storage): + // dive_profile_series and tank_pressure_series, one zlib columnar blob + // per (dive, computer, source, is_primary) group and per (dive, tank, + // computer) group, packed from the row-per-sample tables by + // packLegacyProfileRows with ids derived from the identity tuple so every + // device converges (the #1360 lesson). The legacy tables stay until the + // consumers move; the same PR retires them in a later plan. 176 remains + // skipped; the ladder is non-contiguous by design. Numbered 182 because + // PR #1390 (profile photos) took 181. + 182, + // v183 (packed profile series, plan 2e): drop the row-per-sample + // dive_profiles and tank_pressure_profiles tables and purge the sync + // bookkeeping that named them. Every reader moved to the series tables + // in plans 2b to 2d, so the rows have no consumer left. The rung packs + // once more before it drops, because a device that reached 182 through + // a parallel branch's rung never ran ours and the beforeOpen backstop + // only runs AFTER onUpgrade: by then the rows would be gone. + 183, + // v184 (issue #1451): dive_data_sources.merge_source_slot, the marker a + // sequential Combine stamps on the provenance rows it carries so the + // display can collapse the halves of one dive back into one source. + // Backfilled for dives combined before this rung shipped. + 184, + // v185 (issue #1476): diver_settings.dive_detail_layout, the dive detail + // page's layout choice. Column-only rung, no backfill: a null reads back + // as the detailed layout, which is what every existing diver was already + // getting. Numbered 185 because PR #1451 took 184 while this branch was + // open; the column is nullable and additive either way, so the + // compatibility floor stays at 183. + 185, + // v186: pre_dive_checklist_template_items.equipment_id, the remembered + // single-equipment link for an 'equipment'-typed template item. Chosen + // at session start (not in the template editor), mirroring the + // equipmentSet flow. Issue #814. Column-only rung, no backfill, so the + // beforeOpen backstop is safe to re-run. Renumbered from 181: main + // landed 181 through 185 while this branch was open, and a rung at or + // below the shipped version never runs its onUpgrade step. + 186, + // v187: pre_dive_session_items.overdue_services, the frozen snapshot of + // overdue-service entries for a resolved checklist item (issue #814 + // phase 2). Column-only rung, no backfill: every pre-existing row + // correctly reads back as null (no frozen snapshot), which the UI + // already treats as "nothing known" for a resolved legacy row. + // Renumbered from 182 for the same reason as 186 above. + 187, + // v188: divers.insurance_emergency_phone and divers.insurance_phone, the + // insurer's 24h assistance line and office line (issue #1522). Column-only + // rung, no backfill: nothing in an existing database can tell us an + // insurer's hotline, so every pre-existing row correctly reads back as + // "not recorded" and the card keeps leading with the regional hotline. + 188, + // v189: media.equipment_id plus idx_media_equipment_id (issue #1517). + // The link that files an invoice, receipt or warranty document against a + // piece of gear. Column-and-index rung, no backfill, so the beforeOpen + // backstop is safe to re-run. Renumbered from 188: main took that step + // for the insurance phone columns while this branch was open, and a rung + // at or below the shipped version never runs its onUpgrade step. + 189, + // v190: recompress dive_data_sources.raw_data in place (issue #227). + // No DDL; the column's SQL type is unchanged and only the stored bytes + // move. Guarded per row: the self-describing header means a row this + // rung skips keeps reading correctly forever, so a blob left + // uncompressed costs space and nothing else. Numbered 190 because main + // took 188 and 189 while this branch was open. + 190, + // v191: per-band planner ascent rates (9/6/3/1 m/min TDI phases). + // Renumbered from 188, which was itself renumbered from 185 and 184: + // main landed the insurance-phone, media-equipment-link and raw-data + // recompression rungs (188-190) while this branch was open, and a rung + // at or below the shipped version never runs its onUpgrade step. + 191, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4137,6 +4384,265 @@ class AppDatabase extends _$AppDatabase { ); } + /// v182: the packed profile series tables. + /// + /// Raw idempotent DDL so it doubles as the beforeOpen backstop for a + /// database stranded at 182 by a parallel branch. The DDL must agree with + /// the Drift declarations column for column; the v182 migration test + /// compares the two on a fresh database. + /// + /// Each table waits for its own foreign-key parents. A child table whose + /// parent is absent poisons the parents that ARE present: SQLite resolves + /// the child's references when a cascade fires, so `DELETE FROM dives` + /// would fail with "no such table: main.dive_tanks" on a partial schema + /// (the older migration-test fixtures, and a database caught mid-upgrade). + /// Every real database has carried all four parents for many versions, and + /// the beforeOpen backstop creates whatever was skipped on the next open. + Future _assertProfileSeriesSchema() async { + final tables = await customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table'", + ).get(); + final present = tables.map((r) => r.read('name')).toSet(); + if (present.containsAll(const { + 'dives', + 'dive_computers', + 'dive_data_sources', + })) { + await _assertDiveProfileSeriesTable(); + } + if (present.containsAll(const {'dives', 'dive_computers', 'dive_tanks'})) { + await _assertTankPressureSeriesTable(); + } + } + + Future _assertDiveProfileSeriesTable() async { + await customStatement(''' + CREATE TABLE IF NOT EXISTS dive_profile_series ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL REFERENCES dives (id) ON DELETE CASCADE, + computer_id TEXT REFERENCES dive_computers (id) ON DELETE SET NULL, + source_id TEXT REFERENCES dive_data_sources (id) ON DELETE SET NULL, + is_primary INTEGER NOT NULL DEFAULT 1 CHECK (is_primary IN (0, 1)), + sample_count INTEGER NOT NULL, + start_timestamp INTEGER NOT NULL, + end_timestamp INTEGER NOT NULL, + max_depth REAL NOT NULL, + first_depth REAL NOT NULL, + last_depth REAL NOT NULL, + has_deco_type INTEGER NOT NULL DEFAULT 0 + CHECK (has_deco_type IN (0, 1)), + has_deco_stop INTEGER NOT NULL DEFAULT 0 + CHECK (has_deco_stop IN (0, 1)), + has_positive_ceiling INTEGER NOT NULL DEFAULT 0 + CHECK (has_positive_ceiling IN (0, 1)), + codec_version INTEGER NOT NULL, + samples BLOB NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc TEXT + ) + '''); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_dive_profile_series_dive_primary ' + 'ON dive_profile_series (dive_id, is_primary)', + ); + } + + /// v183: drops the row-per-sample profile table. + /// + /// Called from the v183 rung and from the `beforeOpen` backstop, and in + /// both places only once the pack has returned normally AND + /// `dive_profile_series` exists. Both conditions are load-bearing. A pack + /// that threw packed nothing, and a pack that ran with no series table to + /// pack into ALSO packed nothing: `_assertProfileSeriesSchema` skips a + /// series table whose foreign-key parents are absent, and the packer's + /// unpacked-dive scan then reports no work rather than failing. Dropping + /// on either would destroy the only copy of those samples. + /// + /// Idempotent (`IF EXISTS` throughout), so a ladder that failed later and + /// retried from the top runs this again harmlessly. + /// + /// Split from [_purgeLegacySampleBookkeeping] because the two have + /// different preconditions: the bookkeeping purge is always correct, while + /// dropping the table is only safe once the samples in it are packed. + /// Split from [_dropLegacyTankTable] because the two legacy tables have + /// different parents, so one can be packable on a database where the other + /// is not. + Future _dropLegacyProfileTable() async { + final present = await _tableExists('dive_profiles'); + await customStatement('DROP INDEX IF EXISTS idx_dive_profiles_dive_id'); + await customStatement('DROP TABLE IF EXISTS dive_profiles'); + if (present) _droppedLegacySampleTables = true; + } + + /// v183: drops the row-per-sample tank pressure table. The mirror of + /// [_dropLegacyProfileTable], gated on `tank_pressure_series` existing + /// (that table waits for `dive_tanks`, which `dive_profile_series` does + /// not need). + Future _dropLegacyTankTable() async { + final present = await _tableExists('tank_pressure_profiles'); + await customStatement('DROP INDEX IF EXISTS idx_tank_pressure_dive_tank'); + await customStatement('DROP TABLE IF EXISTS tank_pressure_profiles'); + if (present) _droppedLegacySampleTables = true; + } + + bool _droppedLegacySampleTables = false; + + /// True once this connection has actually dropped a row-per-sample legacy + /// table, whether from the v183 rung or from the beforeOpen backstop. + /// + /// The pages those tables held are most of an older file, and only a + /// VACUUM returns them to the filesystem. Which open performs the drop is + /// not something the stored schema version can answer: the rung is allowed + /// to skip it (its pack threw, the series table's foreign-key parents were + /// absent, or the residue count found rows no series covered), and the + /// backstop then drops on the first later open whose pack succeeds, by + /// which time the file is long since stamped 183. So the reclamation keys + /// off this, the event itself. See [DatabaseService]. + bool get droppedLegacySampleTables => _droppedLegacySampleTables; + + /// True when [table] exists in this database right now. + Future _tableExists(String table) async { + final rows = await customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(table)], + ).get(); + return rows.isNotEmpty; + } + + /// Drops whichever legacy sample table the pack has provably moved into + /// its series table. Never both unconditionally: on a database missing one + /// side's foreign-key parents only one series table exists, and the other + /// legacy table is still the only copy of its samples. + /// + /// Two gates per table, and both are load-bearing. The series table must + /// exist, and [countLegacyRowsAwaitingPack] must find no legacy row that a + /// series row does not cover. A pack that returned normally is not proof + /// the rows moved: an orphaned pressure row is skipped, a dive that + /// already had a series row is never revisited so a second computer's rows + /// stay behind, and `INSERT OR IGNORE` can pack nothing at all into a + /// series table a parallel branch shaped differently. Dropping on any of + /// those destroys the only copy. + Future _dropPackedLegacySampleTables() async { + final residue = await countLegacyRowsAwaitingPack(this); + if (await _tableExists('dive_profile_series')) { + if (residue.profiles == 0) { + await _dropLegacyProfileTable(); + } else { + developer.log( + 'Keeping dive_profiles: ${residue.profiles} row(s) no series row ' + 'covers. A later open retries the pack.', + name: 'AppDatabase', + ); + } + } + if (await _tableExists('tank_pressure_series')) { + if (residue.tanks == 0) { + await _dropLegacyTankTable(); + } else { + developer.log( + 'Keeping tank_pressure_profiles: ${residue.tanks} row(s) no series ' + 'row covers. A later open retries the pack.', + name: 'AppDatabase', + ); + } + } + } + + /// v183: deletes the sync bookkeeping of the retired sample entities. + /// + /// The `sync_records` rows are this device's pending outbound work for + /// entity types this build no longer exports. Left behind they would be + /// published forever and never acknowledged. + /// + /// The `deletion_log` rows did double duty, and the second job is the one + /// worth naming: outbound they are tombstones peers apply, and INBOUND they + /// were the resurrection guard for these two entity types, the rows + /// SyncService's merge consults to keep a peer's copy of a sample this + /// device deleted from coming back. Purging them retires that guard, which + /// is safe because there is nothing left for it to guard. A `diveProfiles` + /// or `tankPressureProfiles` row from an older peer no longer reaches a + /// live table at all: those entity types are inbound-only + /// (SyncService.inboundOnlyLegacyEntities), their rows land in the TEMP + /// staging tables of `legacy_sample_staging.dart`, and the packer builds a + /// series only for a dive that has none, so a stale legacy row cannot + /// overwrite or revive a series this device holds. Deleting samples in this + /// build tombstones `diveProfileSeries` / `tankPressureSeries` instead, and + /// those tombstones are untouched here. + /// + /// The compatibility floor (183) is NOT what makes this safe. That gate is + /// one-directional: it stops readers below 183 from applying our payloads, + /// not older peers' payloads from reaching us. The staging shim above is + /// what handles those, and it is what has to be retired before the floor + /// argument would ever apply. + /// + /// UNCONDITIONAL in the rung: this is bookkeeping about rows nothing + /// exports any more, so it is correct whether or not the pack that guards + /// the table drop succeeded. + /// + /// Guarded per table like every other migration helper: a minimal + /// old-schema fixture (and a database that reached this rung through a + /// guarded path) can lack the sync bookkeeping entirely, and a DELETE + /// naming a missing table aborts the whole ladder. + /// sync_records ONLY. The deletion_log rows for these two entities stay, + /// because they are still load-bearing on the receive side: a peer below + /// the floor keeps publishing row-per-sample rows, and _mergeEntity's + /// local-deletion guard is what stops one this device already deleted + /// from being staged and packed back into a series. Purging them removed + /// that guard while the inbound shim still exists. (Nothing is at risk on + /// the send side either way: peers below 183 are held and apply none of + /// this device's payloads, so those tombstones were never reaching them.) + /// They can go with the shim. + Future _purgeLegacySampleBookkeeping() async { + final exists = await customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = 'sync_records'", + ).get(); + if (exists.isEmpty) return; + await customStatement( + "DELETE FROM sync_records WHERE entity_type IN ('diveProfiles', " + "'tankPressureProfiles')", + ); + } + + /// The `user_version` this database carries on disk right now. + Future _storedSchemaVersion() async { + final row = await customSelect('PRAGMA user_version').getSingle(); + return (row.data.values.first as int?) ?? 0; + } + + /// True when either retired row-per-sample table is still present. + Future _legacySampleTablesPresent() async { + final rows = await customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name IN ('dive_profiles', 'tank_pressure_profiles')", + ).get(); + return rows.isNotEmpty; + } + + Future _assertTankPressureSeriesTable() async { + await customStatement(''' + CREATE TABLE IF NOT EXISTS tank_pressure_series ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL REFERENCES dives (id) ON DELETE CASCADE, + tank_id TEXT NOT NULL REFERENCES dive_tanks (id) ON DELETE CASCADE, + computer_id TEXT REFERENCES dive_computers (id) ON DELETE SET NULL, + sample_count INTEGER NOT NULL, + start_timestamp INTEGER NOT NULL, + end_timestamp INTEGER NOT NULL, + codec_version INTEGER NOT NULL, + samples BLOB NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc TEXT + ) + '''); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_tank_pressure_series_dive_tank ' + 'ON tank_pressure_series (dive_id, tank_id)', + ); + } + Future _assertQualityFindingsSchema() async { await customStatement(''' CREATE TABLE IF NOT EXISTS quality_findings ( @@ -4341,6 +4847,22 @@ class AppDatabase extends _$AppDatabase { } } + /// v185: diver_settings.dive_detail_layout, the dive detail page's layout + /// choice (detailed/list; a stored "compact" from before that layout was + /// dropped reads back as detailed). Idempotent so it is safe to call from + /// both onUpgrade and the beforeOpen backstop. + Future _assertDiveDetailLayoutColumn() async { + final cols = await customSelect( + "PRAGMA table_info('diver_settings')", + ).get(); + final names = cols.map((c) => c.read('name')).toSet(); + if (cols.isNotEmpty && !names.contains('dive_detail_layout')) { + await customStatement( + 'ALTER TABLE diver_settings ADD COLUMN dive_detail_layout TEXT', + ); + } + } + /// v126: emergency_chambers table + emergency card settings columns. /// Idempotent so it is safe to call from both onUpgrade and the /// beforeOpen backstop. @@ -4664,11 +5186,17 @@ class AppDatabase extends _$AppDatabase { /// Data self-heal: synthesize a primary [DiveDataSources] row for any dive /// that has profile samples but no data-source row. Older file imports (and - /// any import path predating dive_data_sources) wrote dive_profiles without - /// the metadata row, which stranded the grouped-by-source view that the 3D + /// any import path predating dive_data_sources) wrote samples without the + /// metadata row, which stranded the grouped-by-source view that the 3D /// scene, spatial map, and computer-compare all read -- they spun forever on /// a null scene. The 2D chart survived because it reads dive.profile directly. /// + /// Reads `dive_profile_series`, not the retired row-per-sample + /// `dive_profiles`: v183 dropped that table, so the pre-183 guard and + /// predicate would have made this helper a permanent no-op. The rung packs + /// before it drops, so a dive that had primary legacy rows has a primary + /// series row and is still healed. + /// /// Runs on every open; a cheap no-op once healed (the NOT EXISTS guard leaves /// nothing to insert). Local-only by design: the id is deterministic /// (`legacy-src-`), so every device heals to the identical row @@ -4684,10 +5212,50 @@ class AppDatabase extends _$AppDatabase { // exist yet. final tables = await customSelect( "SELECT name FROM sqlite_master WHERE type='table' " - "AND name IN ('dives', 'dive_profiles', 'dive_data_sources')", + "AND name IN ('dives', 'dive_profile_series', 'dive_data_sources')", ).get(); final present = tables.map((r) => r.read('name')).toSet(); - if (!present.containsAll({'dives', 'dive_profiles', 'dive_data_sources'})) { + if (!present.containsAll({ + 'dives', + 'dive_profile_series', + 'dive_data_sources', + })) { + return; + } + // Column guard as well as table guard. A minimal fixture (and a database + // caught mid-upgrade) can carry a dive_data_sources that predates these + // columns, and beforeOpen's own _assertProfileSeriesSchema will have + // created dive_profile_series for it, so the table check alone is not + // enough: the INSERT below would abort the open. + final sourceCols = await customSelect( + "PRAGMA table_info('dive_data_sources')", + ).get(); + final sourceColNames = sourceCols + .map((c) => c.read('name')) + .toSet(); + if (!sourceColNames.containsAll(const { + 'id', + 'dive_id', + 'is_primary', + 'imported_at', + 'created_at', + })) { + return; + } + // The same column guard on the OTHER side of the statement. A + // dive_profile_series a parallel branch shaped differently, or a fixture + // that stands one up by hand, survives beforeOpen's IF NOT EXISTS DDL + // untouched, so its presence says nothing about its shape. These are the + // two columns the EXISTS predicate below reads; the rest of the series + // row (source_id, computer_id, the summary scalars, the blob) this + // helper never names. + final seriesCols = await customSelect( + "PRAGMA table_info('dive_profile_series')", + ).get(); + final seriesColNames = seriesCols + .map((c) => c.read('name')) + .toSet(); + if (!seriesColNames.containsAll(const {'dive_id', 'is_primary'})) { return; } await customStatement(''' @@ -4699,8 +5267,8 @@ class AppDatabase extends _$AppDatabase { SELECT CAST(strftime('%s','now') AS INTEGER) AS now_s ) n WHERE EXISTS ( - SELECT 1 FROM dive_profiles p - WHERE p.dive_id = d.id AND p.is_primary = 1 + SELECT 1 FROM dive_profile_series s + WHERE s.dive_id = d.id AND s.is_primary = 1 ) AND NOT EXISTS ( SELECT 1 FROM dive_data_sources s WHERE s.dive_id = d.id @@ -5200,6 +5768,19 @@ class AppDatabase extends _$AppDatabase { } } + /// v179: site_suggestion_dismissed_at on dives. Null means the site + /// suggestion (from photo GPS or dive-computer GPS) was never dismissed. + Future _assertSiteSuggestionDismissedAtColumn() async { + final cols = await customSelect("PRAGMA table_info('dives')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('site_suggestion_dismissed_at')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN site_suggestion_dismissed_at INTEGER', + ); + } + } + /// v161: default_show_o2_cell_mv on diver_settings (issue #1235). The /// per-cell O2 mV toggle previously had no persisted default; this lets a /// diver make it visible by default on the profile chart. @@ -5218,6 +5799,60 @@ class AppDatabase extends _$AppDatabase { } } + /// v177: the GTR (gas time remaining) settings on diver_settings: default + /// visibility, computer-vs-calculated source, and the reserve pressure + /// (bar) the calculated value counts down to. + Future _assertGtrSettingsColumns() async { + final cols = await customSelect( + "PRAGMA table_info('diver_settings')", + ).get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('default_show_gtr')) { + await customStatement( + 'ALTER TABLE diver_settings ADD COLUMN default_show_gtr ' + 'INTEGER NOT NULL DEFAULT 0 ' + 'CHECK (default_show_gtr IN (0, 1))', + ); + } + if (!names.contains('default_gtr_source')) { + await customStatement( + 'ALTER TABLE diver_settings ADD COLUMN default_gtr_source ' + 'INTEGER NOT NULL DEFAULT 1', + ); + } + if (!names.contains('gtr_reserve_pressure')) { + await customStatement( + 'ALTER TABLE diver_settings ADD COLUMN gtr_reserve_pressure ' + 'REAL NOT NULL DEFAULT 50.0', + ); + } + } + + /// v177: dive_profiles.rbt is documented in seconds, and the Subsurface and + /// UDDF importers store seconds, but libdivecomputer reports RBT/GTR in + /// minutes and every libdc path (download, reparse, raw-log import) wrote + /// the raw value. Rows that came through libdc on a download, reparse or + /// raw-log import carry raw bytes on their data source, so scale only + /// them. Shearwater Cloud and MacDive imports also parse through libdc but + /// persist no raw bytes and no format marker, so their existing rbt rows + /// cannot be told apart from file imports here; re-importing them writes + /// seconds. + Future _scaleLibdcRbtMinutesToSeconds() async { + final tables = await customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name IN ('dive_profiles', 'dive_data_sources')", + ).get(); + if (tables.length < 2) return; + final cols = await customSelect("PRAGMA table_info('dive_profiles')").get(); + if (!cols.any((c) => c.read('name') == 'rbt')) return; + await customStatement( + 'UPDATE dive_profiles SET rbt = rbt * 60 ' + 'WHERE rbt IS NOT NULL AND dive_id IN ' + '(SELECT dive_id FROM dive_data_sources WHERE raw_data IS NOT NULL)', + ); + } + /// v163: default_show_estimated_tank_pressure on diver_settings (issue /// #731). Synthesized "(est.)" pressure lines previously had no off switch. /// Defaults to 1 so existing databases keep drawing them. @@ -5362,9 +5997,9 @@ class AppDatabase extends _$AppDatabase { /// diver's layout at the lane they were seeing. Runs after /// [_assertGasConsumptionDisplayColumn] so the lane names are final. /// - /// Never called from beforeOpen: DiveFieldAdapter.fieldFromName aliases - /// sacRate to sac for layouts that arrive later by sync, and re-running - /// this on every open would rewrite rows the diver has since changed. No + /// Never called from beforeOpen: diveFieldFromName aliases sacRate to sac + /// for layouts that arrive later by sync, and re-running this on every + /// open would rewrite rows the diver has since changed. No /// HLC bump: every device applies the same deterministic rewrite to its /// own rows, so there is nothing to push. Future _rewriteLegacySacRateLayouts() async { @@ -5409,6 +6044,115 @@ class AppDatabase extends _$AppDatabase { /// letting frequently-dived buddies be pinned to the top of the "Add /// buddy" picker regardless of sort. Self-guards on the table existing, and /// defaults every pre-existing row to not-favorited. + /// Idempotent DDL for the v180 dives.excluded_from_stats and + /// dives.excluded_from_gas_stats columns (issues #526 and #1272), letting a + /// diver keep a dive in the logbook while removing it from statistics. + /// Self-guards on the table existing, and defaults every pre-existing row to + /// included. Same dual-call contract (onUpgrade + beforeOpen backstop) as + /// the other column-assert helpers. + Future _assertDiveStatsExclusionColumns() async { + final cols = await customSelect("PRAGMA table_info('dives')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('excluded_from_stats')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN excluded_from_stats ' + 'INTEGER NOT NULL DEFAULT 0', + ); + } + if (!names.contains('excluded_from_gas_stats')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN excluded_from_gas_stats ' + 'INTEGER NOT NULL DEFAULT 0', + ); + } + } + + /// Idempotent DDL for the v181 pre_dive_checklist_template_items + /// equipment_id column (issue #814): the remembered single-equipment link + /// for an 'equipment'-typed template item, chosen at session start (not in + /// the template editor) and persisted so later sessions pre-fill the same + /// device. Self-guards on the table existing. Same dual-call contract + /// (onUpgrade + beforeOpen backstop) as the other column-assert helpers. + /// + /// No SQL-level REFERENCES clause: template items are (re-)seeded + /// independently of the equipment table (isolated schema fixtures, builtin + /// template reseeding on every app start), so referential integrity is + /// enforced at the application layer instead of via SQLite FK. + Future _assertTemplateItemEquipmentIdColumn() async { + final cols = await customSelect( + "PRAGMA table_info('pre_dive_checklist_template_items')", + ).get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (names.contains('equipment_id')) return; + await customStatement( + 'ALTER TABLE pre_dive_checklist_template_items ADD COLUMN equipment_id ' + 'TEXT', + ); + } + + /// Idempotent DDL for the v182 pre_dive_session_items.overdue_services + /// column (issue #814 phase 2): the frozen snapshot of overdue-service + /// entries for a resolved checklist item, written by the repository the + /// moment an item leaves pending and cleared on reset. Self-guards on the + /// table existing. Same dual-call contract (onUpgrade + beforeOpen + /// backstop) as the other column-assert helpers. + Future _assertSessionItemOverdueServicesColumn() async { + final cols = await customSelect( + "PRAGMA table_info('pre_dive_session_items')", + ).get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (names.contains('overdue_services')) return; + await customStatement( + 'ALTER TABLE pre_dive_session_items ADD COLUMN overdue_services TEXT', + ); + } + + /// v188: the two insurer phone numbers the emergency card leads with. + /// Column-only and independently guarded, so an interrupted upgrade that + /// added one of the two still gets the other. + Future _assertInsurancePhoneColumns() async { + final cols = await customSelect("PRAGMA table_info('divers')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('insurance_emergency_phone')) { + await customStatement( + 'ALTER TABLE divers ADD COLUMN insurance_emergency_phone TEXT', + ); + } + if (!names.contains('insurance_phone')) { + await customStatement( + 'ALTER TABLE divers ADD COLUMN insurance_phone TEXT', + ); + } + } + + /// Idempotent DDL for the v189 media.equipment_id column plus its lookup + /// index (issue #1517): the link that makes an invoice or receipt an + /// attachment of a piece of gear. Self-guards on the media table existing, + /// so a partial migration-test fixture passes through untouched. Same + /// dual-call contract (onUpgrade + beforeOpen backstop) as the other + /// column-assert helpers. + /// + /// No REFERENCES clause: SQLite cannot add a foreign key with ALTER TABLE, + /// so a migrated database enforces the equipment link at the repository + /// layer only -- exactly what media.site_id has always done for the rows + /// that predate it. + Future _assertMediaEquipmentIdColumn() async { + final cols = await customSelect("PRAGMA table_info('media')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('equipment_id')) { + await customStatement('ALTER TABLE media ADD COLUMN equipment_id TEXT'); + } + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_media_equipment_id ' + 'ON media(equipment_id)', + ); + } + Future _assertBuddyFavoriteColumn() async { final cols = await customSelect("PRAGMA table_info('buddies')").get(); if (cols.isEmpty) return; @@ -5420,6 +6164,21 @@ class AppDatabase extends _$AppDatabase { } } + /// Idempotent DDL for the v181 divers.photo and buddies.photo columns. + /// Holds a 512x512 square JPEG, so it is nullable with no default. Self- + /// guards on each table existing, which is what makes it safe to call from + /// both the ladder and beforeOpen. + Future _assertProfilePhotoColumns() async { + for (final table in const ['divers', 'buddies']) { + final cols = await customSelect("PRAGMA table_info('$table')").get(); + if (cols.isEmpty) continue; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('photo')) { + await customStatement('ALTER TABLE $table ADD COLUMN photo BLOB'); + } + } + } + /// Idempotent DDL for the v159 dive_data_sources.time_offset_seconds /// column (issue #1177). Same dual-call contract (onUpgrade + beforeOpen /// backstop) as the other column-assert helpers. Nullable with no default, @@ -5437,6 +6196,219 @@ class AppDatabase extends _$AppDatabase { } } + /// Idempotent DDL for the v184 dive_data_sources.merge_source_slot column + /// (issue #1451). Same dual-call contract (onUpgrade + beforeOpen backstop) + /// as the other column-assert helpers. Nullable with no default, so every + /// pre-existing row reads back as "not carried by a merge". + Future _assertDataSourceMergeSlotColumn() async { + final cols = await customSelect( + "PRAGMA table_info('dive_data_sources')", + ).get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('merge_source_slot')) { + await customStatement( + 'ALTER TABLE dive_data_sources ADD COLUMN merge_source_slot INTEGER', + ); + } + } + + /// Stamp `merge_source_slot = 0` on the provenance rows of dives that were + /// combined before v184 shipped, so their halves collapse to one display + /// source the way a post-v184 combine does (issue #1451). + /// + /// Nothing recorded the marker at the time, so the rows have to be + /// recognized by shape. A dive qualifies only when all three hold: + /// + /// - it has two or more `dive_data_sources` rows, and + /// - none of them is primary. Every importer writes its own row with + /// `is_primary = 1` (dive_import_service, uddf_entity_importer, + /// saveComputerReading) and a consolidation leaves the target's primary + /// row alone, so "no primary at all" is the signature of + /// `DiveMergeService.apply`, which writes every carried row + /// `isPrimary: false`, and + /// - every row has an entry and an exit time, and no two of those spans + /// overlap, and + /// - no row carries a non-zero `time_offset_seconds`. + /// + /// The span test is what makes this safe. Combined halves are consecutive + /// slices of one timeline, so their spans are disjoint; two computers + /// recording one dive cover the same minutes, so theirs overlap. Without + /// it, a consolidation whose target row was never marked primary would + /// collapse to a single chip and the chart would go back to drawing the + /// interleaved union of both computers (issue #543). A dive whose rows + /// carry no entry/exit times cannot be classified either way and is left + /// alone: it keeps exactly today's behavior. + /// + /// The offset test closes the one hole in that reasoning. + /// `DiveConsolidationService` shifts a folded-in dive's SAMPLES by + /// `time_offset_seconds` but copies its `entry_time`/`exit_time` over + /// verbatim, so a secondary whose clock was badly out has a stored span + /// that misses the target's even though the two cover the same minutes. + /// The span test would read that as a Combine. A non-zero offset is the + /// trace consolidation leaves and a Combine never writes, so requiring + /// zero across the dive rules that shape out. It costs a few false + /// negatives -- a pre-v184 dive that was consolidated and then combined is + /// now skipped -- and a skipped dive simply keeps today's display, which + /// is the safe direction to be wrong in for a one-shot write over user + /// data. + /// + /// Runs once on the v184 rung, not from the beforeOpen backstop: it writes + /// rows rather than asserting DDL, and every merge performed after the + /// upgrade stamps its own slots. + /// + /// Guarded on the columns it reads, like every other migration helper here. + /// A database whose `dive_data_sources` a parallel branch shaped without + /// entry/exit times must still open: the classification has no input there, + /// so skipping is the same answer as running. + Future _backfillMergeSourceSlots() async { + final cols = await customSelect( + "PRAGMA table_info('dive_data_sources')", + ).get(); + final names = cols.map((c) => c.read('name')).toSet(); + const required = { + 'dive_id', + 'is_primary', + 'entry_time', + 'exit_time', + 'time_offset_seconds', + 'merge_source_slot', + }; + if (!names.containsAll(required)) return; + await customStatement( + 'UPDATE dive_data_sources SET merge_source_slot = 0 ' + 'WHERE merge_source_slot IS NULL ' + 'AND dive_id IN (' + ' SELECT dive_id FROM dive_data_sources' + ' GROUP BY dive_id' + ' HAVING COUNT(*) >= 2' + ' AND SUM(CASE WHEN is_primary THEN 1 ELSE 0 END) = 0' + ' AND SUM(CASE WHEN entry_time IS NULL OR exit_time IS NULL' + ' THEN 1 ELSE 0 END) = 0' + ' AND SUM(CASE WHEN COALESCE(time_offset_seconds, 0) <> 0' + ' THEN 1 ELSE 0 END) = 0' + ') ' + 'AND dive_id NOT IN (' + ' SELECT a.dive_id FROM dive_data_sources a' + ' JOIN dive_data_sources b' + ' ON b.dive_id = a.dive_id AND b.id <> a.id' + ' WHERE a.entry_time < b.exit_time AND b.entry_time < a.exit_time' + ')', + ); + } + + /// v190: rewrite `dive_data_sources.raw_data` in its compressed at-rest + /// form (issue #227). + /// + /// PRAGMA-guarded like every other data rung, so a partial schema no-ops + /// rather than throwing. Rows already carrying the magic are skipped, which + /// is what makes a second run free and an interrupted run cost only the + /// work it already did. + /// + /// Every row is guarded on its own. An unguarded pack step in the v182 + /// profile-series rung could leave a database that would not open, which is + /// the worst outcome available to a migration and the one this rung is + /// closest to repeating. A row that will not pack is left exactly as it is + /// and logged; nothing about it justifies refusing to open the diver's log. + /// + /// Paged with a keyset cursor rather than read whole: a large library holds + /// thousands of blobs, and loading every one into memory to save space + /// would be a strange way to go about it. + Future _recompressRawDiveData() async { + final cols = await customSelect( + "PRAGMA table_info('dive_data_sources')", + ).get(); + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('id') || !names.contains('raw_data')) return; + + const pageSize = 200; + String? cursor; + while (true) { + final page = await customSelect( + 'SELECT id, raw_data FROM dive_data_sources ' + 'WHERE raw_data IS NOT NULL${cursor == null ? '' : ' AND id > ?'} ' + 'ORDER BY id LIMIT $pageSize', + variables: [if (cursor != null) Variable(cursor)], + ).get(); + if (page.isEmpty) break; + cursor = page.last.read('id'); + + for (final row in page) { + final id = row.read('id'); + final stored = row.read('raw_data'); + if (isCompressedRawDiveData(stored)) continue; + try { + final packed = encodeRawDiveData(stored); + if (packed.length >= stored.length) continue; + await customStatement( + 'UPDATE dive_data_sources SET raw_data = ? WHERE id = ?', + [packed, id], + ); + _recompressedRawBlobs = true; + } catch (e, stackTrace) { + _rawBlobsLeftUncompressed++; + developer.log( + 'v190 left raw_data on dive_data_sources row $id uncompressed; ' + 'the bytes are intact and still readable', + name: 'AppDatabase', + error: e, + stackTrace: stackTrace, + ); + } + } + if (page.length < pageSize) break; + } + } + + bool _recompressedRawBlobs = false; + int _rawBlobsLeftUncompressed = 0; + + /// How many rows the v190 rung could not pack on this connection. + /// + /// Counted rather than only logged: a swallowed exception with nothing but + /// a log line is invisible to any test, and the one thing worth proving + /// about this rung is that a row it cannot pack changes nothing else. + int get rawBlobsLeftUncompressed => _rawBlobsLeftUncompressed; + + /// True once this connection's v190 rung has actually shrunk at least one + /// `raw_data` blob. + /// + /// The rewritten pages go to the freelist, and only a VACUUM returns them + /// to the filesystem. Keyed off the event rather than the stored version + /// for the same reason as [droppedLegacySampleTables]: a file with no raw + /// data crosses this rung without earning a reclaim, and rewriting it would + /// cost a diver a full-file VACUUM for nothing. + bool get recompressedRawBlobs => _recompressedRawBlobs; + + /// True when this connection did something whose freed pages are still held + /// by the file. The single signal [DatabaseService] reads to decide whether + /// its one VACUUM is worth taking. + bool get hasUnreclaimedPages => + droppedLegacySampleTables || recompressedRawBlobs; + + /// What earned this connection's pending reclaim, for a log line that has + /// to name a cause. + /// + /// Both causes can be true of one upgrade, and neither has to be: a file + /// old enough to plan a VACUUM gets one even when its v183 rung skipped the + /// drop, and a message naming a step that did not run is worse than one + /// saying so. Kept beside [hasUnreclaimedPages] so a future reclaiming rung + /// that adds itself to the gate is looking straight at the string it also + /// has to extend. + String get unreclaimedPagesReason { + final causes = [ + if (droppedLegacySampleTables) 'the legacy sample tables were dropped', + if (recompressedRawBlobs) 'raw dive data was recompressed', + ]; + if (causes.isEmpty) return 'no reclaiming step reported on this connection'; + return causes.join(' and '); + } + + /// Test hook: run the v190 recompression on demand so tests can assert it + /// is idempotent. Not used in production; the migration calls the private + /// method. + Future recompressRawDiveDataForTest() => _recompressRawDiveData(); + /// Site-level entry/exit method columns on dive_sites (issue #1104). /// PRAGMA-guarded so a healthy database no-ops and a partial schema does /// not throw. @@ -5473,6 +6445,30 @@ class AppDatabase extends _$AppDatabase { } } + /// The v191 dive_plans per-band ascent rate columns: the ascent slows in + /// stages between intermediate stops, between shallow stops, and over the + /// final stretch to the surface. PRAGMA-guarded so a healthy database + /// no-ops and a partial schema does not throw. Called from the v191 + /// onUpgrade step and the beforeOpen backstop, matching the other additive + /// column helpers. + Future _assertPlanAscentRateColumns() async { + final cols = await customSelect("PRAGMA table_info('dive_plans')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + const defaults = { + 'intermediate_ascent_rate': '6.0', + 'shallow_ascent_rate': '3.0', + 'final_ascent_rate': '1.0', + }; + for (final entry in defaults.entries) { + if (names.contains(entry.key)) continue; + await customStatement( + 'ALTER TABLE dive_plans ADD COLUMN ${entry.key} ' + 'REAL NOT NULL DEFAULT ${entry.value}', + ); + } + } + /// Owning-source FK on dive_profiles (issue #1149). PRAGMA-guarded so a /// healthy database no-ops and a partial schema does not throw. Future _assertProfileSourceIdColumn() async { @@ -5829,6 +6825,11 @@ class AppDatabase extends _$AppDatabase { // raw-SQL indexes, so a fresh install would otherwise be the one // device in the library without them. await assertTagUniqueness(this); + + // Dive-type junction uniqueness index (v178, issue #1360): same + // reason as the tag indexes above -- createAll() does not build + // raw-SQL indexes. + await assertDiveTypeUniqueness(this); }, onUpgrade: (Migrator m, int from, int to) async { int completedSteps = 0; @@ -9072,6 +10073,233 @@ class AppDatabase extends _$AppDatabase { await backfillDiveComputerGearTwins(this); } if (from < 175) await reportProgress(); + // v177: GTR (gas time remaining) settings on diver_settings, and a + // one-time repair of dive_profiles.rbt for rows that came through + // libdivecomputer, which reports minutes into a seconds column. + if (from < 177) { + await _assertGtrSettingsColumns(); + await _scaleLibdcRbtMinutesToSeconds(); + } + if (from < 177) await reportProgress(); + if (from < 178) { + // Duplicate dive types (issue #1360). The helper dedupes BEFORE + // creating the unique index and its dedupe is total (`id` + // tie-breaks), so no tie can survive to abort the index creation -- + // the failure mode v148 documents. Self-guarding on the tables + // existing. + await assertDiveTypeUniqueness(this); + } + if (from < 178) await reportProgress(); + // v179: dives.site_suggestion_dismissed_at (site suggestion dismissal). + if (from < 179) { + await _assertSiteSuggestionDismissedAtColumn(); + } + if (from < 179) await reportProgress(); + // v180: dives.excluded_from_stats and dives.excluded_from_gas_stats + // (issues #526 and #1272). Column-only rung, no backfill: every + // pre-existing row correctly defaults to included. + if (from < 180) { + await _assertDiveStatsExclusionColumns(); + } + if (from < 180) await reportProgress(); + // v181: divers.photo and buddies.photo, the profile photo blobs. + if (from < 181) { + await _assertProfilePhotoColumns(); + } + if (from < 181) await reportProgress(); + // v182: packed profile series tables, then pack every legacy + // row-per-sample row into them. Both steps are idempotent (IF NOT + // EXISTS DDL; INSERT OR IGNORE on ids derived from the identity + // tuple), so a retry after a failed ladder, or a collision re-run, + // is safe. v183 below drops the legacy tables. + // + // Wrapped the way the v183 rung below is, and for the same reason: + // `profileSampleOf` casts unchecked, so one malformed legacy row + // (a text timestamp, say) would otherwise throw out of onUpgrade, + // `_runUpgradeLadder` would rethrow, and the database could not be + // opened on any relaunch. Nothing is dropped here, so continuing + // costs nothing: the legacy tables stay, and the v183 rung below or + // the beforeOpen backstop packs them later. The schema assert is + // inside the try for the same reason it is in v183: both the v183 + // rung and the backstop re-assert it, so swallowing it here cannot + // leave the ladder with a schema no later step rebuilds. + // Deliberately redundant with the v183 rung below: `from < 182` + // implies `from < 183`, so on a single upgrade both packs run and + // the second finds everything covered. Kept because the v182 SCHEMA + // has to be correct on its own (a rung inserted between the two + // later, or a ladder interrupted between them, would otherwise + // leave a 182 database with legacy rows and no series), and because + // the cost is now one pass per distinct identity rather than per + // sample: see legacyCoverageIdentityColumns. + if (from < 182) { + try { + await _assertProfileSeriesSchema(); + final report = await packLegacyProfileRows(this); + if (report.failedDives > 0) { + // Per-dive isolation means the pass as a whole succeeded, so + // this is the only place the skipped dives are visible. The + // residue count keeps their legacy table for a later open. + developer.log( + 'v182: ${report.failedDives} dive(s) could not be packed and ' + 'stay in the legacy tables for a later open', + name: 'AppDatabase', + ); + } + } catch (e, stackTrace) { + developer.log( + 'v182: packing legacy profile rows failed; keeping the legacy ' + 'tables so no samples are lost', + name: 'AppDatabase', + error: e, + stackTrace: stackTrace, + ); + } + } + if (from < 182) await reportProgress(); + // v183: the legacy row-per-sample tables are gone. Their sync + // bookkeeping goes with them: pending records and tombstones for + // entity types no peer exports. Idempotent (IF NOT EXISTS DDL, + // INSERT OR IGNORE, IF EXISTS, DELETE), so a retried ladder is safe. + // The pages come back at the one VACUUM in + // DatabaseService._runUpgradeLadder. + // + // The pack repeats here rather than relying on the v182 rung above. + // A device that reached 182 through a PARALLEL BRANCH's rung of the + // same number never ran ours, so `from < 182` is false for it and + // nothing has packed its rows; the beforeOpen backstop cannot save + // it either, because drift runs beforeOpen AFTER onUpgrade and the + // rows would already be dropped. Packing here is a no-op once + // packed (one indexed NOT EXISTS per legacy dive). + // + // Two steps with different preconditions: + // - The bookkeeping purge is UNCONDITIONAL. Those rows describe + // entities nothing exports any more, so deleting them is correct + // whether or not the pack succeeded. + // - The table drop is CONDITIONAL, per table, on that pack having + // actually moved the samples. A series table a parallel branch + // shaped differently makes every packer INSERT fail; dropping + // anyway would destroy the only copy of those samples, and + // letting the exception out would leave a database that cannot + // open at all (backstop_resilience_test.dart pins that). A pack + // that returned normally is not enough on its own either: when a + // series table's foreign-key parents are absent + // (`dive_data_sources` for profiles, `dive_tanks` for pressures) + // `_assertProfileSeriesSchema` creates no series table, the + // packer finds nothing to pack into and returns having packed + // nothing, so `_dropPackedLegacySampleTables` requires the + // matching series table as well. Skipping a drop leaves a correct + // database that merely still carries the old table, and the + // beforeOpen backstop below drops it on the first later open + // whose pack succeeds. + if (from < 183) { + var packed = true; + try { + await _assertProfileSeriesSchema(); + final report = await packLegacyProfileRows(this); + if (report.failedDives > 0) { + // Per-dive isolation means the pass as a whole succeeded, so + // this is the only place the skipped dives are visible. The + // residue count keeps their legacy table for a later open. + developer.log( + 'v183: ${report.failedDives} dive(s) could not be packed and ' + 'stay in the legacy tables for a later open', + name: 'AppDatabase', + ); + } + } catch (e, stackTrace) { + packed = false; + developer.log( + 'v183: packing legacy profile rows failed; keeping the legacy ' + 'tables so no samples are lost', + name: 'AppDatabase', + error: e, + stackTrace: stackTrace, + ); + } + // Guarded like the beforeOpen backstop's own copy of these two + // steps, and for the same reason: a busy lock from the second + // isolate, or a legacy table shape the residue count cannot read, + // must not turn into a database that cannot open. onUpgrade + // rethrows, so an escape here replays on every relaunch, while + // skipping the drop costs only a table the backstop retires on a + // later open. + try { + await _purgeLegacySampleBookkeeping(); + if (packed) { + await _dropPackedLegacySampleTables(); + } + } catch (e, stackTrace) { + developer.log( + 'v183: purging or dropping the legacy sample tables failed; ' + 'the backstop retries on a later open', + name: 'AppDatabase', + error: e, + stackTrace: stackTrace, + ); + } + } + if (from < 183) await reportProgress(); + // v184: the marker a sequential Combine stamps on the provenance + // rows it carries, plus a backfill for dives combined before it + // existed (issue #1451). + if (from < 184) { + await _assertDataSourceMergeSlotColumn(); + await _backfillMergeSourceSlots(); + } + if (from < 184) await reportProgress(); + // v185: diver_settings.dive_detail_layout. Column-only rung, no + // backfill: a null reads back as the detailed layout, which is what + // every existing diver was already getting. + if (from < 185) { + await _assertDiveDetailLayoutColumn(); + } + if (from < 185) await reportProgress(); + // v186: pre_dive_checklist_template_items.equipment_id (issue #814). + // Column-only rung, no backfill: every pre-existing item correctly + // defaults to unlinked. + if (from < 186) { + await _assertTemplateItemEquipmentIdColumn(); + } + if (from < 186) await reportProgress(); + // v187: pre_dive_session_items.overdue_services (issue #814 phase 2). + // Column-only rung, no backfill: every pre-existing resolved item + // correctly reads back as "nothing known" until it is next resolved. + if (from < 187) { + await _assertSessionItemOverdueServicesColumn(); + } + if (from < 187) await reportProgress(); + // v188: divers.insurance_emergency_phone + divers.insurance_phone + // (issue #1522). Column-only rung, no backfill. + if (from < 188) { + await _assertInsurancePhoneColumns(); + } + if (from < 188) await reportProgress(); + // v189: media.equipment_id (issue #1517). Column-and-index rung, no + // backfill: every pre-existing media row correctly reads back as + // unattached to any gear. + if (from < 189) { + await _assertMediaEquipmentIdColumn(); + } + if (from < 189) await reportProgress(); + // v190: recompress dive_data_sources.raw_data in place (issue #227). + // No DDL. Guarded per row, so a blob that will not pack is left as it + // is rather than failing the ladder. No beforeOpen backstop: the + // backstops re-assert schema a partial upgrade may have missed, and + // this rung changes none. + if (from < 190) { + await _recompressRawDiveData(); + } + if (from < 190) await reportProgress(); + // v191: per-band planner ascent rates. Additive columns with + // defaults, so an existing plan picks up the standard 6/3/1 m/min + // ascent bands and its computed schedule redistributes time from the + // stops into the ascent. Renumbered from 188: main landed the + // insurance-phone, media-equipment-link and raw-data recompression + // rungs at 188-190 while this branch was open. + if (from < 191) { + await _assertPlanAscentRateColumns(); + } + if (from < 191) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -9242,6 +10470,11 @@ class AppDatabase extends _$AppDatabase { // (same parallel-branch version-collision self-heal). await _assertTravelGasColumn(); + // v191 backstop: re-assert the dive_plans per-band ascent rate + // columns. A database that arrives by restore or sync-adopt never + // runs onUpgrade, and reading a plan without them throws. + await _assertPlanAscentRateColumns(); + // v157 backstop: re-assert the default service price columns (issue // #829; same parallel-branch version-collision self-heal). await _assertServiceCostColumns(); @@ -9251,6 +10484,12 @@ class AppDatabase extends _$AppDatabase { // Reading a consolidated dive's sources throws without it. await _assertDataSourceTimeOffsetColumn(); + // v184 backstop: re-assert the merge provenance marker (issue + // #1451; same parallel-branch version-collision self-heal). + // Reading any dive's sources throws without it. Only the column is + // re-asserted here; the one-shot backfill belongs to the rung. + await _assertDataSourceMergeSlotColumn(); + // v160 backstop: re-assert service_kinds.default_category. A device // that reached 160 or higher through a parallel branch never enters // the `from < 160` block above, and the seed SQL below is @@ -9266,11 +10505,20 @@ class AppDatabase extends _$AppDatabase { // (issue #1235; same parallel-branch version-collision self-heal). await _assertO2CellMvDefaultColumn(); + // v177 backstop: re-assert the GTR settings columns (same + // parallel-branch version-collision self-heal). The rbt repair is + // deliberately NOT re-run here: it is a one-shot data fix. + await _assertGtrSettingsColumns(); + // v163 backstop: re-assert // diver_settings.default_show_estimated_tank_pressure (issue #731; // same parallel-branch version-collision self-heal). await _assertEstimatedTankPressureDefaultColumn(); + // v179 backstop: re-assert dives.site_suggestion_dismissed_at (same + // parallel-branch version-collision self-heal). + await _assertSiteSuggestionDismissedAtColumn(); + // v164 backstop: re-assert media.manual_elapsed_seconds (issue // #1090; same parallel-branch version-collision self-heal). The // media row mapper reads it on every hydration. @@ -9309,6 +10557,128 @@ class AppDatabase extends _$AppDatabase { // item the user deleted. await _assertDiveComputerEquipmentColumn(); + // v180 backstop: re-assert the dives statistics-exclusion columns + // (same parallel-branch version-collision self-heal). Safe to re-run + // on every open: the helper is column-only with no backfill, so it + // cannot resurrect or overwrite diver data. + await _assertDiveStatsExclusionColumns(); + + // v181 backstop: re-assert divers.photo and buddies.photo. A database + // that arrives by restore or sync-adopt never runs onUpgrade, and + // every read of a diver or buddy row would throw without the column. + await _assertProfilePhotoColumns(); + + // v185 backstop: re-assert diver_settings.dive_detail_layout. Every + // settings read selects the whole row, so a database that skipped the + // rung would throw on the first read instead of falling back to the + // default layout. + await _assertDiveDetailLayoutColumn(); + // v182 backstop: re-assert the packed profile series tables, then + // pack any dive that still has legacy rows and no series row. A + // schema-version collision with a parallel branch skips the rung on + // devices that took the other branch's number first, so this is the + // self-heal for series tables a device would otherwise never build. + // Cheap once packed (an indexed NOT EXISTS per legacy dive). v183 + // drops the legacy tables, and the packer no-ops once they are gone + // (a missing table reports no columns, so neither side is packable), + // which is what makes this safe to keep running afterwards. Note the + // v183 rung packs for itself: beforeOpen runs after onUpgrade, so on + // the upgrading open this call comes too late to feed the drop. + // Best effort: the ladder's own call is where a packing failure is + // visible and retried. Here a malformed legacy table, a series table + // a parallel branch shaped differently, or a busy lock from the + // second isolate must not turn into a database that cannot open. + // + // The schema assert is INSIDE the try for that reason, the way the + // v182 and v183 rungs already place it. CREATE TABLE IF NOT EXISTS + // is a no-op against an existing table of any shape, so the assert + // goes on to CREATE INDEX ... (dive_id, is_primary): against a + // series table lacking that column SQLite raises "no such column", + // and outside the guard that throw failed the open on every launch + // rather than the one self-heal it belongs to. + try { + await _assertProfileSeriesSchema(); + final report = await packLegacyProfileRows(this); + if (report.failedDives > 0) { + developer.log( + 'beforeOpen: ${report.failedDives} dive(s) could not be packed ' + 'and stay in the legacy tables for a later open', + name: 'AppDatabase', + ); + } + // v183 convergence: the rung skips its table drop when its own + // pack threw, and a rung never runs twice, so without this the + // legacy tables would survive forever on that database. Once a + // later open's pack succeeds the samples are all in the series and + // the tables can go. Gated on the stored version so a database + // still below 183 (a migration fixture, or one caught mid-ladder) + // keeps its tables until its own rung has run, and gated per table + // on the matching series table existing, for the same reason the + // rung is: a series table whose foreign-key parents are absent is + // never created, and the pack over it moves nothing. + // + // Nested try so the log names the step that threw. A drop or purge + // failure here is its own event, and it must not be reported as a + // packing failure; a pack failure, by contrast, has to keep the + // drop from running at all, which is why this sits INSIDE the + // pack's try rather than beside it. + try { + if (await _storedSchemaVersion() >= 183) { + // The purge is unconditional at 183, matching its own doc: + // those rows describe two entities this build never exports + // again, so they are dead whether or not the legacy tables + // are still here. Gating it on the tables tied it to + // something unrelated, and a device that crossed 183 through + // a parallel branch's rung of the same number (which dropped + // the tables itself) then kept them forever: sync_records + // that can never be acknowledged, and tombstones riding + // every base publish. + if (await _legacySampleTablesPresent()) { + await _dropPackedLegacySampleTables(); + } + await _purgeLegacySampleBookkeeping(); + } + } catch (e, stackTrace) { + developer.log( + 'Backstop drop of the legacy sample tables failed; continuing', + name: 'AppDatabase', + error: e, + stackTrace: stackTrace, + ); + } + } catch (e, stackTrace) { + developer.log( + 'Backstop pack of legacy profile rows failed; continuing', + name: 'AppDatabase', + error: e, + stackTrace: stackTrace, + ); + } + + // v186 backstop: re-assert pre_dive_checklist_template_items. + // equipment_id (same parallel-branch version-collision self-heal). + // Safe to re-run on every open: the helper is column-only with no + // backfill, so it cannot resurrect or overwrite diver data. + await _assertTemplateItemEquipmentIdColumn(); + + // v187 backstop: re-assert pre_dive_session_items.overdue_services + // (same parallel-branch version-collision self-heal). Safe to re-run + // on every open: the helper is column-only with no backfill, so it + // cannot resurrect or overwrite diver data. + await _assertSessionItemOverdueServicesColumn(); + + // v188 backstop: re-assert the divers insurance phone columns. Every + // diver read selects the whole row, so a database that arrives by + // restore or sync-adopt without the rung would throw on the first read + // rather than merely lack the numbers. + await _assertInsurancePhoneColumns(); + + // v189 backstop: re-assert media.equipment_id and its index (same + // parallel-branch version-collision self-heal). Safe to re-run on + // every open: column-and-index only, no backfill, so it cannot + // resurrect or overwrite diver data. + await _assertMediaEquipmentIdColumn(); + // v145 backstop: re-assert the gps_tracks provenance and trim columns. await _assertGpsTrackColumns(); @@ -9464,13 +10834,21 @@ class AppDatabase extends _$AppDatabase { // exactly the second device the duplicate tags came from. await assertTagUniqueness(this); + // v178 backstop (issue #1360): re-assert the dive-type junction + // uniqueness index, deduping first so the creation cannot abort. Same + // reasoning as the tag backstop above -- a database that arrives by + // restore or sync-adopt never runs onUpgrade, and that is exactly the + // second device the duplicate types came from. + await assertDiveTypeUniqueness(this); + // Data self-heal: backfill a primary dive_data_sources row for dives // that have profile samples but no source row (legacy file imports). // Without it, the 3D/spatial/compare views spin forever on those dives. // Idempotent and local-only (deterministic ids, no HLC bump). Runs // AFTER ensurePerformanceIndexes so its per-dive EXISTS/NOT EXISTS - // subqueries hit idx_dive_profiles_dive_id / idx_dive_data_sources_dive_id - // instead of full-scanning million-row tables on a fresh/restored DB. + // subqueries hit idx_dive_profile_series_dive_primary / + // idx_dive_data_sources_dive_id instead of full-scanning on a + // fresh/restored DB. await _backfillMissingDataSources(); // Data self-heal (issue #1064): adopt dives.computer_id from the diff --git a/lib/core/database/dive_stats_scope.dart b/lib/core/database/dive_stats_scope.dart new file mode 100644 index 0000000000..8e307477b1 --- /dev/null +++ b/lib/core/database/dive_stats_scope.dart @@ -0,0 +1,69 @@ +/// The canonical SQL predicate deciding which dives contribute to +/// *descriptive* statistics. +/// +/// Every term is written in the affirmative: it states what a dive must be in +/// order to *count*. Two always apply: +/// +/// - `excluded_from_stats = 0` keeps dives the diver has NOT ticked "exclude +/// from statistics" on (issue #526). A dive they did tick, say a 90 minute +/// session at 12 ft that is not an official dive by most agency standards, +/// has the column set to 1 and is dropped here. +/// - `is_planned = 0` keeps dives that actually happened, dropping entries the +/// planner created for a dive that was never made. Nothing filtered on this +/// before, so a planned dive inflated every total; that is a behaviour fix +/// carried by this predicate, not by the migration, which only adds columns. +/// +/// The [gas] variant adds two more, for SAC/RMV and gas-mix aggregates: +/// +/// - `excluded_from_gas_stats = 0` keeps dives the diver has NOT ticked +/// "exclude from gas statistics" on (issue #1272). They tick it when the +/// gas figure is unrepresentative, say after purging the tank down to +/// 500 psi for an end-of-dive weight check. +/// - `dive_mode <> 'gauge'` keeps dives that are not gauge-mode, which carry +/// no usable gas data. This rule predates the exclusion flags and was +/// hand-copied into seven queries in StatisticsRepository; it now lives +/// here, which is the point. +/// +/// **This is deliberately NOT folded into `buildFilteredDiveIdSubquery`.** +/// That function implements the diver's transient *view* filter and correctly +/// returns an empty no-op when no axis is active. This scope is a persistent +/// property of the dive and must apply unconditionally. Merging the two would +/// either make the exclusion evaporate for every diver who never opens the +/// filter sheet, or silently scope the deliberately-unfiltered surfaces +/// (dashboard quick stats, dive-log summary, species detail page). +/// +/// **Operational counts deliberately ignore this scope.** Equipment service +/// intervals, course-requirement progress, and the logbook list header all +/// count excluded dives on purpose; each carries a doc comment saying so. A +/// practice dive still cycled the regulator, and a dive the diver linked to a +/// course requirement was linked on purpose. +/// +/// See +/// `docs/superpowers/specs/2026-08-28-exclude-dive-from-statistics-design.md`. +class DiveStatsScope { + const DiveStatsScope._(); + + /// The bare predicate, with no leading conjunction. Use when the query has + /// no WHERE clause yet: `WHERE ${DiveStatsScope.predicate()}`. + /// + /// [alias] must match the alias the query already gives the `dives` table + /// (or `dives` itself when the query does not alias it). Queries that join + /// `dives` to itself need one call per alias. + static String predicate({String alias = 'd', bool gas = false}) { + final parts = [ + '$alias.excluded_from_stats = 0', + '$alias.is_planned = 0', + ]; + if (gas) { + parts.add('$alias.excluded_from_gas_stats = 0'); + parts.add("$alias.dive_mode <> 'gauge'"); + } + return parts.join(' AND '); + } + + /// The predicate prefixed with ` AND `, for appending into an existing + /// WHERE clause. Emits no bind placeholders, so callers never thread params + /// for the scope. + static String and({String alias = 'd', bool gas = false}) => + ' AND ${predicate(alias: alias, gas: gas)}'; +} diff --git a/lib/core/database/dive_type_uniqueness.dart b/lib/core/database/dive_type_uniqueness.dart new file mode 100644 index 0000000000..bde73e07f5 --- /dev/null +++ b/lib/core/database/dive_type_uniqueness.dart @@ -0,0 +1,154 @@ +/// Dive-type junction identity: one `dive_dive_types` row per (dive, type) +/// -- issue #1360. +/// +/// `dive_dive_types` is the structural twin of `dive_tags`: a surrogate uuid +/// primary key, deliberately chosen so a re-inserted row never collides with +/// the tombstone of the row it replaced (the composite-key sync data-loss trap +/// of #347), and no constraint at all on the pair the row actually means. The +/// junction is a SET semantically and a BAG structurally, and nothing closed +/// that gap. +/// +/// Two producers exploited it: +/// +/// * The v92 seed (`kSeedDiveDiveTypesSql`) mints a random id per row and had +/// no re-run guard, unlike its `INSERT OR IGNORE` sibling +/// `kSeedBuiltInDiveTypesSql`. Every device that upgraded through v92 minted +/// its OWN ids for the same (dive, type) pairs. +/// * The sync merge keys junction rows on that id, so those independently +/// minted rows are distinct records and the merge keeps all of them. A +/// fleet of N devices that each upgraded through v92 converged on N badges +/// per dive. +/// +/// `dive_tags` got exactly this repair in v149 for issue #1032; this is the +/// same treatment applied to the junction that was left behind. With the index +/// in place a peer's duplicate physically cannot be inserted, so the collapse +/// below needs no tombstones to stick -- and deliberately writes none, since a +/// tombstone racing ahead of the survivor would leave a device holding neither +/// row. +/// +/// Every writer must therefore be conflict-safe: with an index in place an +/// unguarded duplicate insert THROWS, and a throw inside the sync merge is far +/// worse than the duplicate it replaces. +library; + +import 'package:drift/drift.dart'; + +/// Unique index over the `dive_dive_types` junction, keyed on (dive, type). +const String kDiveDiveTypesUniqueIndexName = + 'idx_dive_dive_types_dive_type_unique'; + +/// One junction row per (dive, dive type). The surrogate `id` stays the +/// primary key so a re-inserted row never collides with the tombstone of the +/// row it replaced (#347). +const String kCreateDiveDiveTypesUniqueIndexSql = + 'CREATE UNIQUE INDEX IF NOT EXISTS $kDiveDiveTypesUniqueIndexName ' + 'ON dive_dive_types(dive_id, dive_type_id)'; + +/// Keeps one junction row per (dive, type): the oldest, `id` breaking ties. +/// +/// The survivor's `created_at` is load-bearing rather than arbitrary. A dive's +/// types read back ordered by it, and the FIRST is the dive's representative +/// type, so keeping the oldest row of a duplicated pair leaves the badge order +/// the diver already sees untouched. A plain `MIN(rowid)` would instead keep +/// whichever copy this device happened to insert first, which for a row that +/// arrived by sync is unrelated to when the type was actually put on the dive. +/// +/// The tie-break is `id` and NOT `rowid` because it has to be a property of the +/// ROWS rather than of this device, so every device that runs this lands on the +/// same survivor. `rowid` is local insertion order: two devices holding the +/// same two rows can have received them in opposite orders, and each would keep +/// a different id. Both would then show one badge and look repaired -- but sync +/// deletions are keyed on `id`, so a later "remove this type" would tombstone +/// an id the peer does not have and the removal would never propagate. This is +/// the same reasoning `tag_uniqueness.dart` gives for collapsing `tags` onto +/// the lexically lowest id. +/// +/// `id` is the primary key, so it is unique and the ordering is a total one: +/// no tie can survive. That matters beyond determinism. Ordering on +/// `created_at` alone leaves ties, and ties here are the norm, not an edge +/// case: the column is epoch MILLISECONDS and the v92 seed writes all of its +/// rows inside a single `strftime('now')` second. Every tied row would survive +/// and the unique index created straight afterwards would abort the whole +/// migration, leaving the database unopenable -- the v148 lesson. +/// +/// Both partition columns are NOT NULL, so the inner query covers every row +/// and the bare `rowid NOT IN (...)` cannot sweep away rows it never +/// considered. +const String _collapseDuplicateDiveTypesSql = ''' + DELETE FROM dive_dive_types WHERE rowid NOT IN ( + SELECT rowid FROM ( + SELECT rowid, ROW_NUMBER() OVER ( + PARTITION BY dive_id, dive_type_id + ORDER BY created_at ASC, id ASC + ) AS rn FROM dive_dive_types + ) WHERE rn = 1 + ) +'''; + +Future _tableExists(DatabaseConnectionUser db, String name) async { + final rows = await db + .customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(name)], + ) + .get(); + return rows.isNotEmpty; +} + +/// Collapses duplicate junction rows. Idempotent: a no-op on clean data. +Future collapseDuplicateDiveTypes(DatabaseConnectionUser db) async { + await db.customStatement(_collapseDuplicateDiveTypesSql); +} + +/// Asserts the junction uniqueness index exists, deduping first so creating it +/// cannot abort. +/// +/// Called from the v178 migration, from `onCreate` (a fresh install never runs +/// onUpgrade, and `createAll()` does not build raw-SQL indexes) and from +/// `beforeOpen` as a backstop. When the index is already present this costs one +/// `sqlite_master` lookup and does no table scans, so the dedupe is paid once +/// rather than on every open. +/// +/// Self-guarding on the tables existing so partial migration-test fixture +/// databases pass through unharmed. `dives` is checked as well as the junction +/// itself: `dive_dive_types.dive_id` is a foreign key into it, and with +/// `PRAGMA foreign_keys = ON` any DML on a table whose FK parent is absent +/// fails with `no such table: main.dives`. The v92 seed guards on the same +/// table for the same reason -- a minimal fixture gets the junction from +/// `createTable` without ever having `dives`. +Future assertDiveTypeUniqueness(DatabaseConnectionUser db) async { + if (!await _tableExists(db, 'dive_dive_types')) return; + if (!await _tableExists(db, 'dives')) return; + + // Compare the stored DDL, not just the index NAME. `CREATE UNIQUE INDEX IF + // NOT EXISTS` is a no-op against an index of the same name whatever its + // definition, so a name-only check would silently keep an older keying -- + // which is exactly what a database that ran a pre-release build of this + // version holds. Dropping and rebuilding is cheap next to a wrong index. + final rows = await db + .customSelect( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", + variables: const [Variable(kDiveDiveTypesUniqueIndexName)], + ) + .get(); + final actual = rows.isEmpty ? null : rows.first.readNullable('sql'); + final expected = kCreateDiveDiveTypesUniqueIndexSql.replaceAll( + 'IF NOT EXISTS ', + '', + ); + if (actual != null && _normalizeSql(actual) == _normalizeSql(expected)) { + return; + } + + await db.customStatement( + 'DROP INDEX IF EXISTS $kDiveDiveTypesUniqueIndexName', + ); + await collapseDuplicateDiveTypes(db); + await db.customStatement(kCreateDiveDiveTypesUniqueIndexSql); +} + +/// Collapses whitespace and case so two spellings of the same DDL compare +/// equal. SQLite stores `sqlite_master.sql` as written, so the comparison has +/// to tolerate formatting rather than demand a byte match. +String _normalizeSql(String sql) => + sql.replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase(); diff --git a/lib/core/database/legacy_sample_staging.dart b/lib/core/database/legacy_sample_staging.dart new file mode 100644 index 0000000000..704a66c248 --- /dev/null +++ b/lib/core/database/legacy_sample_staging.dart @@ -0,0 +1,329 @@ +import 'package:drift/drift.dart'; + +import 'package:submersion/core/database/profile_series_pack_coverage.dart'; +import 'package:submersion/core/database/profile_series_pack.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_field_table.dart'; + +/// Where an older peer's row-per-sample arrays land now that the legacy +/// tables are gone (v183). Ordinary tables, created on demand; the packer +/// reads them like it read `dive_profiles`, and each pack clears the rows it +/// has finished with (see [packStagedLegacyRows]). Retire with the receive-side shim (plan 2d's +/// SyncService.inboundOnlyLegacyEntities) once no peer below 182 can publish. +const String kLegacyProfileStagingTable = 'dive_profiles_inbound'; +const String kLegacyTankStagingTable = 'tank_pressure_profiles_inbound'; + +String _sqlType(ProfileFieldKind kind) => switch (kind) { + ProfileFieldKind.deltaInt => 'INTEGER', + ProfileFieldKind.float64 => 'REAL', + ProfileFieldKind.runLengthString => 'TEXT', +}; + +/// The legacy `dive_profiles` columns: identity plus every codec field. +final List _legacyProfileColumns = [ + 'id', + 'dive_id', + 'computer_id', + 'source_id', + 'is_primary', + for (final f in kProfileFieldTableV1) f.name, +]; + +const List _legacyTankColumns = [ + 'id', + 'dive_id', + 'tank_id', + 'computer_id', + 'timestamp', + 'pressure', +]; + +String _profileDdl() => + 'CREATE TABLE IF NOT EXISTS $kLegacyProfileStagingTable (' + 'id TEXT NOT NULL PRIMARY KEY, dive_id TEXT NOT NULL, computer_id TEXT, ' + 'source_id TEXT, is_primary INTEGER NOT NULL DEFAULT 1, ' + '${[for (final f in kProfileFieldTableV1) '${f.name} ${_sqlType(f.kind)}'].join(', ')})'; + +const String _tankDdl = + 'CREATE TABLE IF NOT EXISTS $kLegacyTankStagingTable (' + 'id TEXT NOT NULL PRIMARY KEY, dive_id TEXT NOT NULL, tank_id TEXT NOT NULL, ' + 'computer_id TEXT, timestamp INTEGER NOT NULL, pressure REAL NOT NULL)'; + +Future ensureLegacyStagingTables(DatabaseConnectionUser db) async { + await db.customStatement(_profileDdl()); + await db.customStatement(_tankDdl); + // The packer reads these one dive at a time (`WHERE dive_id = ?`, once per + // unpacked dive) and scans them whole to group by dive. A base restore + // from a peer below the floor stages the library row-per-sample, so + // without this the pack is a full table scan per dive over millions of + // rows. The PRIMARY KEY is on id, which answers none of that. + await db.customStatement( + 'CREATE INDEX IF NOT EXISTS idx_${kLegacyProfileStagingTable}_dive ' + 'ON $kLegacyProfileStagingTable (dive_id)', + ); + await db.customStatement( + 'CREATE INDEX IF NOT EXISTS idx_${kLegacyTankStagingTable}_dive ' + 'ON $kLegacyTankStagingTable (dive_id)', + ); +} + +/// `diveId` -> `dive_id`, `ppO2` -> `pp_o2`, `o2SensorMv1` -> `o2_sensor_mv1`: +/// Drift's default column naming, which is what the wire keys were made from. +String legacyColumnFor(String wireKey) => + wireKey.replaceAllMapped(RegExp('[A-Z]'), (m) => '_${m[0]!.toLowerCase()}'); + +/// Columns whose value the staging DDL supplies when the wire row omits the +/// key. Only `is_primary` has a DDL default; binding null for it would fail +/// its NOT NULL constraint, which is what the old per-row column list +/// avoided by leaving the column out of the statement altogether. +const Map _legacyProfileDefaults = {'is_primary': 1}; + +/// Rows per multi-row INSERT. SQLite's default SQLITE_MAX_VARIABLE_NUMBER is +/// 32,766. The wider of the two staging tables is the profile one, five +/// identity columns plus every codec field, so 200 rows binds well under +/// 7,000 values; [_maxStagedVariables] keeps that true if the field table +/// grows. +const int _maxStagedRowsPerStatement = 200; +const int _maxStagedVariables = 30000; + +Future stageLegacyProfileRows( + DatabaseConnectionUser db, + List> jsonRows, +) => _stage( + db, + kLegacyProfileStagingTable, + _legacyProfileColumns, + jsonRows, + defaults: _legacyProfileDefaults, +); + +Future stageLegacyTankRows( + DatabaseConnectionUser db, + List> jsonRows, +) => _stage(db, kLegacyTankStagingTable, _legacyTankColumns, jsonRows); + +/// Stages [jsonRows] into [table], returning how many rows were written. +/// +/// Every row binds the SAME full [columns] list, so one statement shape +/// serves a whole chunk rather than a fresh SQL string per row. A key the +/// wire row omits binds its [defaults] entry, or null. Values are always +/// bound, never interpolated; only the identifiers come from the fixed +/// column and table constants above. +/// +/// Chunked at [_maxStagedRowsPerStatement] rows per statement. A throw part +/// way through therefore leaves the earlier chunks staged, which is harmless: +/// the pack that reads the staging tables is idempotent, +/// so the retry restages the same ids over the same rows +/// (`INSERT OR REPLACE`) and packs once. +/// +/// [jsonRows] is only read; the caller's list and maps are never mutated. +Future _stage( + DatabaseConnectionUser db, + String table, + List columns, + List> jsonRows, { + Map defaults = const {}, +}) async { + final staged = >[]; + for (final row in jsonRows) { + final values = {}; + for (final entry in row.entries) { + final column = legacyColumnFor(entry.key); + if (!columns.contains(column)) continue; + final v = entry.value; + values[column] = v is bool ? (v ? 1 : 0) : v; + } + if (values['id'] == null || values['dive_id'] == null) continue; + staged.add([ + for (final column in columns) values[column] ?? defaults[column], + ]); + } + if (staged.isEmpty) return 0; + + final perStatement = _rowsPerStatement(columns.length); + final tuple = '(${List.filled(columns.length, '?').join(', ')})'; + final prefix = + 'INSERT OR REPLACE INTO $table (${columns.join(', ')}) VALUES '; + for (var start = 0; start < staged.length; start += perStatement) { + final end = start + perStatement < staged.length + ? start + perStatement + : staged.length; + final chunk = staged.sublist(start, end); + await db.customStatement( + prefix + List.filled(chunk.length, tuple).join(', '), + [for (final row in chunk) ...row], + ); + } + return staged.length; +} + +int _rowsPerStatement(int columnCount) { + final byVariables = _maxStagedVariables ~/ columnCount; + if (byVariables < 1) return 1; + return byVariables < _maxStagedRowsPerStatement + ? byVariables + : _maxStagedRowsPerStatement; +} + +/// The staging tables are ordinary tables, not TEMP. +/// +/// A row the pack cannot place yet is kept for the next apply, and the +/// changeset cursor that would offer it again is committed durably: with +/// TEMP tables the promised retry ended at the next app launch with the row +/// gone and the peer convinced it had been delivered. They are created on +/// demand by [ensureLegacyStagingTables] and stay behind empty once drained, +/// which costs one sqlite_master row and keeps every "is it empty" check +/// meaning what it says. They go with the shim. + +/// Removes [recordId] from whichever staging table holds it. +/// +/// A peer below the floor still deletes its own row-per-sample rows and +/// publishes tombstones for them. Nothing local answers those: the tables +/// they name are gone at v183. But a copy of that row can be sitting in +/// staging, waiting for a dive that has not arrived, and packing it later +/// would resurrect exactly what the peer deleted. +/// +/// No-op when the staging tables were never created, which is the common +/// case once every peer has upgraded. +Future deleteStagedLegacyRow( + DatabaseConnectionUser db, + String entityType, + String recordId, +) async { + final table = switch (entityType) { + 'diveProfiles' => kLegacyProfileStagingTable, + 'tankPressureProfiles' => kLegacyTankStagingTable, + _ => null, + }; + if (table == null) return; + final present = await db + .customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(table)], + ) + .get(); + if (present.isEmpty) return; + await db.customStatement('DELETE FROM $table WHERE id = ?', [recordId]); +} + +/// True when either staging table exists and still holds a row. +/// +/// The pack is otherwise gated on the payload in hand carrying legacy rows, +/// which misses the retry the shim promises: rows whose dive had not +/// arrived are kept, and the payload that finally brings the dive need not +/// carry any legacy rows of its own. Two indexed existence probes, and only +/// when the tables exist at all, so the common case (every peer upgraded, +/// nothing ever staged) costs one sqlite_master lookup. +Future hasStagedLegacyRows(DatabaseConnectionUser db) async { + final present = await db + .customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table' " + 'AND name IN (?, ?)', + variables: [ + const Variable(kLegacyProfileStagingTable), + const Variable(kLegacyTankStagingTable), + ], + ) + .get(); + for (final row in present) { + final table = row.read('name'); + final any = await db.customSelect('SELECT 1 FROM $table LIMIT 1').get(); + if (any.isNotEmpty) return true; + } + return false; +} + +/// Packs whatever is staged into series (dives that already have a series +/// are left alone, exactly as the migration packer does) and clears the +/// staged rows the pack is done with. +/// +/// The staging tables are cleared only after a successful pack, and then +/// only of the rows whose dive now has a series row. Those are exactly the +/// rows the pack has finished with: either it just packed them, or the dive +/// already held a series and the staged copy lost to it, which is the +/// intended precedence. Every other staged row is KEPT. The staging table +/// is the only copy of a peer's row once staged (the real `dive_profiles` / +/// `tank_pressure_profiles` tables are gone) and the peer never re-sends, so +/// a row the pack could not move yet, most importantly one whose dive has +/// not arrived in this restore, has to survive to the next apply rather than +/// be discarded. The next apply in this session (another changeset, base +/// file, or adopt) calls this again and retries it. +/// +/// If [packLegacyProfileRows] throws, nothing is cleared at all. The +/// staging tables are ordinary tables, so whatever is still staged survives +/// an app restart and the next apply that brings a missing parent retries +/// it. +Future packStagedLegacyRows( + DatabaseConnectionUser db, +) async { + await ensureLegacyStagingTables(db); + // byGroupIdentity: these rows are a PEER's, not this device's. The + // migration path can ask coverage at (dive, computer) because a finer + // difference there could only come from an ignored insert of its own + // rows; here a peer can legitimately hold two groups of one computer (a + // saved profile edit and the original it demoted), and the coarse + // question calls both of them done and then deletes them. + final report = await packLegacyProfileRows( + db, + profileTable: kLegacyProfileStagingTable, + tankTable: kLegacyTankStagingTable, + byGroupIdentity: true, + ); + await _clearStagedRowsAlreadyPacked( + db, + staging: kLegacyProfileStagingTable, + series: 'dive_profile_series', + byTank: false, + byGroupIdentity: true, + ); + // byGroupIdentity here too, for the reason the pack above carries. The + // pressure side has no source or primary flag, so all it buys is the + // null-computer wildcard, and that is exactly the case that matters: this + // device stamps a computer onto its own series after packing while a peer + // below the floor still holds the same readings unattributed. Without it + // the clear demands an exact match the pack never made, so the staged + // rows are never deleted and every later apply re-runs the whole pack. + await _clearStagedRowsAlreadyPacked( + db, + staging: kLegacyTankStagingTable, + series: 'tank_pressure_series', + byTank: true, + byGroupIdentity: true, + ); + return report; +} + +/// Deletes the [staging] rows a [series] row of the same identity covers. +/// +/// Identity, not dive: [legacyRowCoveredSql] carries the reasoning. A dive +/// can hold a series for one computer while the staged rows are the only +/// copy of another's, and clearing per dive discarded those outright. +/// +/// A missing series table means nothing was packed, so nothing is cleared. +Future _clearStagedRowsAlreadyPacked( + DatabaseConnectionUser db, { + required String staging, + required String series, + required bool byTank, + bool byGroupIdentity = false, +}) async { + final exists = await db + .customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(series)], + ) + .get(); + if (exists.isEmpty) return; + final covered = await legacyRowCoveredSql( + db, + legacyTable: staging, + seriesTable: series, + byTank: byTank, + byGroupIdentity: byGroupIdentity, + ); + // The predicate aliases the legacy table `p`, and SQLite takes no alias in + // DELETE FROM, so the rows are selected in a subquery that can. + await db.customStatement( + 'DELETE FROM $staging WHERE rowid IN (' + 'SELECT p.rowid FROM $staging p WHERE $covered)', + ); +} diff --git a/lib/core/database/legacy_tank_orphan_adoption.dart b/lib/core/database/legacy_tank_orphan_adoption.dart new file mode 100644 index 0000000000..be644d088f --- /dev/null +++ b/lib/core/database/legacy_tank_orphan_adoption.dart @@ -0,0 +1,116 @@ +/// The pressure-row orphan adoption the packer runs before it packs. +/// +/// Split from `profile_series_pack.dart` to keep that file under the +/// project's 800-line limit. It is a pre-pass over the legacy table rather +/// than part of the pack: it rewrites `tank_id` in place so the rows the +/// packer then reads already name a tank the dive still has. +library; + +import 'package:drift/drift.dart'; +import 'package:submersion/core/database/profile_series_pack_coverage.dart'; + +/// Re-points pressure rows whose tank id is no longer one of the dive's +/// tanks at a tank that is, in place, before anything is packed. +/// +/// `tank_pressure_series.tank_id` is a NOT NULL foreign key, so an orphan +/// cannot be packed as it stands, and after v183 every reader reads series: +/// leaving it behind drops that dive's pressure curve and per-cylinder SAC, +/// and its rows keep the legacy table alive forever, since the residue +/// count can never cover a tank that does not exist. +/// +/// The rule is the v102 rung's ([AppDatabase] `_relinkStrandedTankPressures`) +/// and `GasAnalysisService`'s read-time resolver, so a dive reads the same +/// before and after packing: exact id matches are left alone, and the +/// remaining orphans, in first-sample order, are paired with the dive's +/// still-unmatched tanks in tank order. Both sides are totally ordered +/// (ties broken by id) and every device runs it over the same rows, so two +/// devices packing the same logbook reach the same tank ids and the derived +/// series ids still converge. +/// +/// Healing the rows rather than remapping in memory keeps everything +/// downstream, the coverage predicate and residue count included, working +/// on ids that exist. Idempotent: a second run finds no orphans. +Future adoptStrandedTankPressures( + DatabaseConnectionUser db, + String table, +) async { + if (!await legacyTableExists(db, 'dive_tanks')) return; + final tankColumns = await legacyColumnNames(db, 'dive_tanks'); + if (!tankColumns.containsAll(const {'id', 'dive_id'})) return; + final hasOrder = tankColumns.contains('tank_order'); + final tankRows = await db + .customSelect( + 'SELECT dive_id, id${hasOrder ? ', tank_order' : ''} FROM dive_tanks', + ) + .get(); + final tanksByDive = >{}; + for (final r in tankRows) { + (tanksByDive[r.read('dive_id')] ??= []).add(( + id: r.read('id'), + order: hasOrder ? (r.readNullable('tank_order') ?? 0) : 0, + )); + } + if (tanksByDive.isEmpty) return; + + // Filtered on storage class, not just on NULL. This is a prologue: it + // runs before the first dive's transaction and OUTSIDE the per-dive + // try/catch the rest of the design rests on, so a throw here costs every + // dive rather than one, on this open and on every later one. And SQLite + // orders storage classes, so MIN over a group whose values are all text + // returns text; drift's `read` converts by parsing, which throws a + // FormatException on anything but a numeric string. A row this filter + // excludes holds no readable sample anyway, and the packer's own loops + // step over it for exactly the same reason. + final keyRows = await db + .customSelect( + 'SELECT dive_id, tank_id, MIN(timestamp) AS first_ts FROM $table ' + 'WHERE ${readableTextSql('tank_id')} ' + 'AND ${readableNumberSql('timestamp')} ' + 'GROUP BY dive_id, tank_id', + ) + .get(); + final keysByDive = >{}; + for (final r in keyRows) { + (keysByDive[r.read('dive_id')] ??= []).add(( + tankId: r.read('tank_id'), + firstTs: r.read('first_ts'), + )); + } + + for (final entry in keysByDive.entries) { + final diveId = entry.key; + final tanks = tanksByDive[diveId]; + if (tanks == null || tanks.isEmpty) continue; + final currentIds = {for (final t in tanks) t.id}; + final matchedIds = { + for (final k in entry.value) + if (currentIds.contains(k.tankId)) k.tankId, + }; + final orphans = + [ + for (final k in entry.value) + if (!currentIds.contains(k.tankId)) k, + ]..sort((a, b) { + final byTime = a.firstTs.compareTo(b.firstTs); + return byTime != 0 ? byTime : a.tankId.compareTo(b.tankId); + }); + if (orphans.isEmpty) continue; + final free = + [ + for (final t in tanks) + if (!matchedIds.contains(t.id)) t, + ]..sort((a, b) { + // id tie-break: tanks sharing the default order must still pair + // deterministically, and Dart's sort is not stable. + final byOrder = a.order.compareTo(b.order); + return byOrder != 0 ? byOrder : a.id.compareTo(b.id); + }); + final pairs = orphans.length < free.length ? orphans.length : free.length; + for (var i = 0; i < pairs; i++) { + await db.customStatement( + 'UPDATE $table SET tank_id = ? WHERE dive_id = ? AND tank_id = ?', + [free[i].id, diveId, orphans[i].tankId], + ); + } + } +} diff --git a/lib/core/database/local_cache_database.dart b/lib/core/database/local_cache_database.dart index df688eb9e6..314a35b6e5 100644 --- a/lib/core/database/local_cache_database.dart +++ b/lib/core/database/local_cache_database.dart @@ -82,6 +82,47 @@ class BathymetryCache extends Table { Set get primaryKey => {cacheKey}; } +/// Cached swissBATHY3D tiles, keyed by the LV95 1-km tile index (e.g. +/// "2600_1200"). Never synced, never backed up: re-derivable from the +/// public OGD/STAC source. This sits BELOW [BathymetryCache] in the cache +/// stack — one physical swissBATHY3D tile can be reused by several +/// [BathymetryCache] quantized cells, so caching at tile granularity is what +/// actually guarantees "every tile is downloaded only once" (the task's OGD +/// fair-use requirement), independent of the coarser 0.02 degree cache grid. +/// status semantics: 'ok' = usable grid in gridJson; 'empty' = the STAC +/// lookup for this tile definitively found no covering asset. Transient +/// failures (network error, STAC error) write NO row. +class SwissBathyTileCache extends Table { + TextColumn get tileKey => text()(); + TextColumn get status => text()(); + TextColumn get gridJson => text().nullable()(); + IntColumn get fetchedAt => integer()(); + + /// The STAC item's `datetime` (or `updated`/`created` fallback) at the + /// time this tile was last downloaded — the version token the periodic + /// freshness check compares against. Null for 'empty' rows and rows + /// written before this field existed (v14). + TextColumn get sourceDatetime => text().nullable()(); + + /// When this row was last confirmed current: set to [fetchedAt] on + /// download, bumped without a re-download when a freshness check finds no + /// version change. Null means "never checked" and is treated as due for a + /// check immediately, which covers rows written before this field existed. + IntColumn get checkedAt => integer().nullable()(); + + /// The href of the STAC asset [sourceDatetime] was read from. A freshness + /// check matches the current STAC response back to this exact asset by + /// href before comparing datetimes, rather than assuming the first + /// bbox-overlapping candidate is the one that actually covered this tile + /// (it is not necessarily -- see [SwissBathy3dSource._firstOverlappingCandidate]). + /// Null for 'empty' rows and rows written before this field existed + /// (v15), which fall back to one full re-resolution on their next check. + TextColumn get sourceHref => text().nullable()(); + + @override + Set get primaryKey => {tileKey}; +} + /// Cached third-party reef data, keyed by quantized coordinate. Never synced /// and never backed up: any device can re-derive this from a site's /// coordinates, so a restored database re-fetches rather than carrying @@ -217,6 +258,7 @@ class DecoClassificationCache extends Table { MediaTransferQueue, MediaCacheEntries, BathymetryCache, + SwissBathyTileCache, ReefDataCache, NoaaTideStations, GpsTrackGeometryCache, @@ -229,7 +271,7 @@ class LocalCacheDatabase extends _$LocalCacheDatabase { LocalCacheDatabase(super.e); @override - int get schemaVersion => 13; + int get schemaVersion => 16; @override MigrationStrategy get migration => MigrationStrategy( @@ -316,6 +358,61 @@ class LocalCacheDatabase extends _$LocalCacheDatabase { if (from < 13) { await m.createTable(decoClassificationCache); } + // v14: swissBATHY3D tile cache (Bathymetrie-Daten Schweiz, part 1). + if (from < 14) { + await m.createTable(swissBathyTileCache); + } + // v15: periodic swissBATHY3D tile freshness check -- the version token + // and last-checked timestamp that let a stale-but-cached tile be + // revalidated with one light STAC item lookup instead of an unbounded + // re-download. + // + // Column-existence checked first, not just from<15: v14's createTable + // above already builds the table with the CURRENT (post-v15) column + // set for any upgrade path that starts below v14, so blindly adding + // these columns again would collide there. And if a ladder collision + // left v14's createTable unrun, the table does not exist yet either -- + // the beforeOpen re-assert below creates it with the full current + // schema, so there is nothing to add here in that case. + if (from < 15) { + final cols = await customSelect( + "PRAGMA table_info('swiss_bathy_tile_cache')", + ).get(); + final columnNames = cols.map((c) => c.read('name')).toSet(); + if (columnNames.isNotEmpty) { + if (!columnNames.contains('source_datetime')) { + await m.addColumn( + swissBathyTileCache, + swissBathyTileCache.sourceDatetime, + ); + } + if (!columnNames.contains('checked_at')) { + await m.addColumn( + swissBathyTileCache, + swissBathyTileCache.checkedAt, + ); + } + } + } + // v16: the href a freshness check's version token was read from, so a + // stale check can match back to the exact previously-covering asset + // instead of assuming the first bbox-overlapping candidate is it. + // + // Column-existence checked first for the same reason as v15 above: + // v14's createTable already builds the table with the current + // (post-v16) column set for upgrades starting below v14. + if (from < 16) { + final cols = await customSelect( + "PRAGMA table_info('swiss_bathy_tile_cache')", + ).get(); + final columnNames = cols.map((c) => c.read('name')).toSet(); + if (columnNames.isNotEmpty && !columnNames.contains('source_href')) { + await m.addColumn( + swissBathyTileCache, + swissBathyTileCache.sourceHref, + ); + } + } }, beforeOpen: (details) async { // Ladder-collision self-heal: a parallel branch that also claimed v7 @@ -397,6 +494,18 @@ class LocalCacheDatabase extends _$LocalCacheDatabase { PRIMARY KEY (dive_id) ) '''); + await customStatement(''' + CREATE TABLE IF NOT EXISTS swiss_bathy_tile_cache ( + tile_key TEXT NOT NULL, + status TEXT NOT NULL, + grid_json TEXT NULL, + fetched_at INTEGER NOT NULL, + source_datetime TEXT NULL, + checked_at INTEGER NULL, + source_href TEXT NULL, + PRIMARY KEY (tile_key) + ) + '''); }, ); } diff --git a/lib/core/database/performance_indexes.dart b/lib/core/database/performance_indexes.dart index 6588770e02..1191d84f57 100644 --- a/lib/core/database/performance_indexes.dart +++ b/lib/core/database/performance_indexes.dart @@ -82,16 +82,33 @@ const List kPerformanceIndexes = [ ), // -- per-dive child tables (the million-row scans) --------------------- ( - name: 'idx_dive_profiles_dive_id', + name: 'idx_dive_profile_series_dive_primary', ddl: - 'CREATE INDEX IF NOT EXISTS idx_dive_profiles_dive_id ' - 'ON dive_profiles(dive_id)', + 'CREATE INDEX IF NOT EXISTS idx_dive_profile_series_dive_primary ' + 'ON dive_profile_series (dive_id, is_primary)', ), ( - name: 'idx_tank_pressure_dive_tank', + name: 'idx_tank_pressure_series_dive_tank', ddl: - 'CREATE INDEX IF NOT EXISTS idx_tank_pressure_dive_tank ' - 'ON tank_pressure_profiles(dive_id, tank_id, timestamp)', + 'CREATE INDEX IF NOT EXISTS idx_tank_pressure_series_dive_tank ' + 'ON tank_pressure_series (dive_id, tank_id)', + ), + // The sync watermark filter. Every publish tick asks whether the pending + // series blobs exceed the changeset budget, and both export paths select + // on the same predicate; unindexed that is a full scan of the two largest + // tables in the database, on a steady-state device that has nothing to + // publish at all. + ( + name: 'idx_dive_profile_series_hlc', + ddl: + 'CREATE INDEX IF NOT EXISTS idx_dive_profile_series_hlc ' + 'ON dive_profile_series (hlc)', + ), + ( + name: 'idx_tank_pressure_series_hlc', + ddl: + 'CREATE INDEX IF NOT EXISTS idx_tank_pressure_series_hlc ' + 'ON tank_pressure_series (hlc)', ), ( name: 'idx_dive_tanks_dive_id', diff --git a/lib/core/database/profile_series_pack.dart b/lib/core/database/profile_series_pack.dart new file mode 100644 index 0000000000..27066a5c31 --- /dev/null +++ b/lib/core/database/profile_series_pack.dart @@ -0,0 +1,720 @@ +import 'package:drift/drift.dart'; +import 'package:submersion/core/database/legacy_tank_orphan_adoption.dart'; +import 'package:submersion/core/database/profile_series_pack_coverage.dart'; +import 'package:submersion/core/database/profile_series_pack_rows.dart'; +import 'package:submersion/core/services/sync/hlc.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_series_identity.dart'; +import 'package:submersion/features/dive_log/domain/services/profile_sample_dedupe.dart'; + +/// What one packing pass inserted, dropped, and skipped. +typedef ProfilePackReport = ({ + int profileSeries, + int tankSeries, + int droppedSamples, + int skippedOrphans, + + /// Legacy rows without a READABLE timestamp or depth (pressure, tank id + /// for tanks), stepped over. + /// + /// Readable, not merely present: a hand-repaired or bit-rotted file can + /// hold text in a REAL column, and such a row holds no sample for the + /// same reason a null one does not. + int skippedRows, + + /// Dives the packer could not read or encode, counted once per dive per + /// legacy table and stepped over. + /// + /// What lands here is a whole GROUP the codec refuses to encode, not one + /// bad value: an unreadable column is [skippedRows] and costs its own row. + /// Isolating the rest per dive is what keeps one such group from costing + /// every dive the scan had not reached yet, which after v183 (when + /// nothing reads the legacy tables any more) would be a silent, permanent + /// loss rather than a deferred retry. + int failedDives, + + /// Dives whose legacy (or staged) rows were discarded because the dive + /// already had a series row, counted once per dive per legacy table. + /// + /// Expected and harmless on the migration path: a retried ladder, or the + /// beforeOpen backstop after the rung already packed, sees every dive this + /// way. On the receive-side staging path it is the count that says an + /// older peer's row-per-sample copy of a dive lost to the series this + /// device already holds, which is the intended precedence but worth being + /// able to see in the sync log. + int skippedAlreadyPacked, +}); + +/// Packs every legacy `dive_profiles` and `tank_pressure_profiles` row into +/// the series tables (v182, spec 2026-08-28-profile-sample-storage). +/// +/// Raw SQL throughout, never the legacy Drift classes: plan 2e removed those +/// classes and dropped the tables, and this function has to keep compiling +/// and keep no-oping on a database that has already lost them. +/// +/// Memory is bounded by one dive's rows, never the table. Each identity group +/// becomes one row whose id is derived from the tuple +/// ([profileSeriesMigratedId]), so a second run, a retry after a failed +/// ladder, or a second device migrating the same synced rows all converge on +/// the same id: the insert is `INSERT OR IGNORE`. +/// +/// Only dives with legacy rows and no series row are visited, which is what +/// lets the beforeOpen backstop call this on every open. Exact duplicate +/// samples (a repeated import) are dropped before packing, which is what +/// every read did on the way out until now. +/// +/// Orphans are skipped and counted in [ProfilePackReport.skippedOrphans]: a +/// legacy row whose dive (or, for a pressure row, whose tank) is already +/// gone could never have been rendered, and under `PRAGMA foreign_keys = ON` +/// inserting it would abort the whole ladder on every retry. A dangling +/// `computer_id` or `source_id` is weaker: the samples are still the dive's, +/// so the group packs with that member resolved to null, and the derived id +/// uses the resolved value so every device agrees. +/// +/// Both legacy tables are read through `PRAGMA table_info`: a database from +/// a very old backup can lack the identity columns entirely. A missing +/// `computer_id`/`source_id` reads as null, a missing `is_primary` as true, +/// and a table without `dive_id`, `timestamp`, or `depth` (`pressure` for +/// tanks) holds nothing packable and is skipped whole. +/// +/// [nowMs] stamps `created_at` and `updated_at`; `hlc` advances the clock +/// persisted in `sync_metadata` so the first sync after the upgrade +/// publishes the rows. A device that never synced has nothing to publish to +/// and stays unstamped until a base publish, which exports everything. +/// +/// [profileTable] / [tankTable] default to the legacy table names the v182 +/// migration and the beforeOpen backstop read. `legacy_sample_staging.dart` +/// passes its own TEMP staging table names instead, so this same function +/// packs an older peer's inbound row-per-sample rows once the real +/// `dive_profiles` / `tank_pressure_profiles` tables are gone (v183). +/// +/// A dive whose legacy rows are ALL malformed (no readable timestamp or +/// depth) gets +/// no series row and is rescanned on every open; that is a bounded per-open +/// cost on a damaged database, not a retry bug. +Future packLegacyProfileRows( + DatabaseConnectionUser db, { + int? nowMs, + String profileTable = 'dive_profiles', + String tankTable = 'tank_pressure_profiles', + bool byGroupIdentity = false, +}) async { + final now = nowMs ?? DateTime.now().millisecondsSinceEpoch; + final profileColumns = await legacyColumnNames(db, profileTable); + final canPackProfiles = profileColumns.containsAll(const { + 'dive_id', + 'timestamp', + 'depth', + }); + final tankColumns = await legacyColumnNames(db, tankTable); + final canPackTanks = tankColumns.containsAll(const { + 'dive_id', + 'tank_id', + 'timestamp', + 'pressure', + }); + final profileScan = canPackProfiles + ? await _scanLegacyDives( + db, + legacyTable: profileTable, + seriesTable: 'dive_profile_series', + byTank: false, + byGroupIdentity: byGroupIdentity, + ) + : _emptyScan; + // Migration path only. The pairing rule assumes it can see the dive's + // COMPLETE pressure history, which is true of this device's own legacy + // table and false of the receive shim's staging: a peer's payload can + // carry a subset, so the same orphan would pair with a different cylinder + // depending on where the payload boundaries fell, and a wrong-cylinder + // attribution is worse than a row that stays staged. A staged orphan is + // kept, not deleted, and the migration's own adoption still heals this + // device's rows. + if (canPackTanks && !byGroupIdentity) { + await adoptStrandedTankPressures(db, tankTable); + } + final tankScan = canPackTanks + ? await _scanLegacyDives( + db, + legacyTable: tankTable, + seriesTable: 'tank_pressure_series', + byTank: true, + byGroupIdentity: byGroupIdentity, + ) + : _emptyScan; + final unpackedProfileDives = profileScan.unpacked; + final unpackedTankDives = tankScan.unpacked; + final alreadyPacked = profileScan.alreadyPacked + tankScan.alreadyPacked; + if (unpackedProfileDives.isEmpty && unpackedTankDives.isEmpty) { + // The common case on every open once a database is packed: nothing to + // do, so nothing else is loaded. The already-packed count still rides + // out, because this is the branch a late legacy row for a packed dive + // takes. + return ( + profileSeries: 0, + tankSeries: 0, + droppedSamples: 0, + skippedOrphans: 0, + skippedRows: 0, + failedDives: 0, + skippedAlreadyPacked: alreadyPacked, + ); + } + final hlc = await _migrationHlc(db, now); + // The identities already packed. A dive reaches the loops below when ANY + // of its rows is uncovered, so each group still has to be checked: without + // this, re-visiting a half-packed dive would write a second series for an + // identity that already has one (its id is a fresh uuid when the existing + // series came from ordinary use rather than a migration, so INSERT OR + // IGNORE would not catch it) and the dive would read as doubled samples. + final coveredProfileGroups = await _coveredProfileGroups(db); + final coveredProfiles = { + for (final g in coveredProfileGroups) (g.diveId, g.computerId), + }; + final coveredTanks = await _coveredTankIdentities(db); + final diveIds = await _parentIds(db, 'dives'); + final computerIds = await _parentIds(db, 'dive_computers'); + final sourceIds = await _parentIds(db, 'dive_data_sources'); + final tankIds = await _parentIds(db, 'dive_tanks'); + var profileSeries = 0; + var tankSeries = 0; + var dropped = 0; + var skipped = 0; + var skippedRows = 0; + var failedDives = 0; + + if (canPackProfiles) { + const codec = ProfileSeriesCodec(); + final hasPrimary = profileColumns.contains('is_primary'); + for (final diveId in unpackedProfileDives) { + // One transaction per dive. Each group's INSERT would otherwise + // autocommit on its own, and the backstop runs outside any + // transaction of its own: a process killed between two groups of + // one dive leaves the rest unwritten, and two groups of the same + // (dive, computer) differ only below the granularity the residue + // count checks, so the survivors would read as covered and v183 + // would drop the legacy table with the missing group still only + // in it. All of a dive's groups land, or none do. + final before = ( + profileSeries: profileSeries, + dropped: dropped, + skipped: skipped, + skippedRows: skippedRows, + ); + try { + await db.transaction(() async { + // Ordered by timestamp alone: the map below does the grouping, and + // ordering by the identity columns first would interleave two raw + // groups that resolve to one key (a dangling computer id merging into + // the null-computer group) out of timestamp order. + final rows = await db + .customSelect( + 'SELECT * FROM $profileTable WHERE dive_id = ? ' + 'ORDER BY timestamp, rowid', + variables: [Variable(diveId)], + ) + .get(); + final groups = <_ProfileKey, _ProfileGroup>{}; + for (final row in rows) { + final sample = profileSampleOf(row.data); + if (sample == null) { + skippedRows++; + continue; + } + // The RAW parent ids are kept alongside the resolved key. The + // key resolves a dangling id to null so the insert can satisfy + // the foreign key, which merges those rows into the + // null-parent group; coverage is a different question, and it + // is asked of the raw value, because "names no computer" and + // "names a computer this device has not merged yet" are the + // wildcard and a distinct source respectively. A merged group + // therefore carries more than one raw identity, and is covered + // only when every one of them is. + final rawComputerId = _textOf(row.data['computer_id']); + final rawSourceId = _textOf(row.data['source_id']); + final key = _ProfileKey( + computerId: _resolvedParent(rawComputerId, computerIds), + sourceId: _resolvedParent(rawSourceId, sourceIds), + isPrimary: hasPrimary ? _boolOf(row.data['is_primary']) : true, + ); + (groups[key] ??= (samples: [], rawParents: {})) + ..samples.add(sample) + ..rawParents.add(( + computerId: rawComputerId, + sourceId: rawSourceId, + )); + } + for (final entry in groups.entries) { + if (!diveIds.contains(diveId)) { + skipped++; + continue; + } + final key = entry.key; + // byGroupIdentity asks the finer question the staging path + // needs: a peer can hold two groups of one computer (an edit + // and the original it demoted), and (dive, computer) alone + // calls the second one done. + final covered = byGroupIdentity + ? entry.value.rawParents.every( + (raw) => coveredProfileGroups.any( + (g) => + g.diveId == diveId && + g.isPrimary == key.isPrimary && + (raw.computerId == null || + g.computerId == key.computerId) && + (raw.sourceId == null || g.sourceId == key.sourceId), + ), + ) + : coveredProfiles.contains((diveId, key.computerId)); + if (covered) { + // Already packed under this identity; the stored series wins. + continue; + } + final samples = dedupeExactSamples(entry.value.samples); + dropped += entry.value.samples.length - samples.length; + final encoded = codec.encode(samples); + final summary = encoded.summary; + final inserted = await db.customUpdate( + 'INSERT OR IGNORE INTO dive_profile_series (' + 'id, dive_id, computer_id, source_id, is_primary, sample_count, ' + 'start_timestamp, end_timestamp, max_depth, first_depth, last_depth, ' + 'has_deco_type, has_deco_stop, has_positive_ceiling, codec_version, ' + 'samples, created_at, updated_at, hlc) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + variables: [ + Variable( + profileSeriesMigratedId( + diveId: diveId, + computerId: key.computerId, + sourceId: key.sourceId, + isPrimary: key.isPrimary, + ), + ), + Variable(diveId), + Variable(key.computerId), + Variable(key.sourceId), + Variable(key.isPrimary ? 1 : 0), + Variable(summary.sampleCount), + Variable(summary.startTimestamp), + Variable(summary.endTimestamp), + Variable(summary.maxDepth), + Variable(summary.firstDepth), + Variable(summary.lastDepth), + Variable(summary.hasDecoType ? 1 : 0), + Variable(summary.hasDecoStop ? 1 : 0), + Variable(summary.hasPositiveCeiling ? 1 : 0), + Variable(encoded.codecVersion), + Variable(encoded.bytes), + Variable(now), + Variable(now), + Variable(hlc), + ], + updateKind: UpdateKind.insert, + ); + profileSeries += inserted; + } + }); + } catch (_) { + // The dive rolled back, so its partial counts did not happen. + profileSeries = before.profileSeries; + dropped = before.dropped; + skipped = before.skipped; + skippedRows = before.skippedRows; + // One dive at a time. A legacy value no reader expects (a text + // depth in a REAL column, from a hand-repaired or bit-rotted file) + // or a group the codec refuses must not cost the dives the scan + // has not reached yet: nothing reads the legacy tables after v183, + // so those would be a silent permanent loss rather than a retry. + // The residue count keeps the legacy table, so a later open tries + // this dive again. + failedDives++; + } + } + } + if (profileSeries > 0) { + // Raw SQL bypasses Drift's own change tracking, so a stream built on + // `tableUpdates(TableUpdateQuery.onTable(db.diveProfileSeries))` would + // otherwise never fire for a table this function just populated. + db.notifyUpdates({ + const TableUpdate('dive_profile_series', kind: UpdateKind.insert), + }); + } + + if (canPackTanks) { + const codec = TankPressureSeriesCodec(); + for (final diveId in unpackedTankDives) { + // One transaction per dive. Each group's INSERT would otherwise + // autocommit on its own, and the backstop runs outside any + // transaction of its own: a process killed between two groups of + // one dive leaves the rest unwritten, and two groups of the same + // (dive, computer) differ only below the granularity the residue + // count checks, so the survivors would read as covered and v183 + // would drop the legacy table with the missing group still only + // in it. All of a dive's groups land, or none do. + final before = ( + tankSeries: tankSeries, + dropped: dropped, + skipped: skipped, + skippedRows: skippedRows, + ); + try { + await db.transaction(() async { + final rows = await db + .customSelect( + 'SELECT * FROM $tankTable WHERE dive_id = ? ' + 'ORDER BY timestamp, rowid', + variables: [Variable(diveId)], + ) + .get(); + final groups = <_TankKey, _TankGroup>{}; + for (final row in rows) { + // Type tests, not casts, for the reason [profileSampleOf] + // carries: an unreadable value means this ROW holds no sample, + // which is what a null in the same column means and has always + // been stepped over. A cast would throw instead, and the catch + // is per dive. + final tankId = _textOf(row.data['tank_id']); + final timestamp = row.data['timestamp']; + final pressure = row.data['pressure']; + if (tankId == null || timestamp is! num || pressure is! num) { + skippedRows++; + continue; + } + // Raw alongside resolved, for the reason the profile loop above + // documents. + final rawComputerId = _textOf(row.data['computer_id']); + final key = _TankKey( + tankId: tankId, + computerId: _resolvedParent(rawComputerId, computerIds), + ); + (groups[key] ??= (samples: [], rawComputerIds: {})) + ..samples.add( + TankPressureSample( + timestamp: timestamp.toInt(), + pressure: pressure.toDouble(), + ), + ) + ..rawComputerIds.add(rawComputerId); + } + for (final entry in groups.entries) { + final key = entry.key; + if (!diveIds.contains(diveId) || !tankIds.contains(key.tankId)) { + skipped++; + continue; + } + // Same null-is-a-wildcard rule as the profile check above, on + // the identity-grained path only, and asked of the raw ids for + // the same reason. + final coveredTank = byGroupIdentity + ? entry.value.rawComputerIds.every( + (raw) => coveredTanks.any( + (g) => + g.diveId == diveId && + g.tankId == key.tankId && + (raw == null || g.computerId == key.computerId), + ), + ) + : coveredTanks.contains(( + diveId: diveId, + tankId: key.tankId, + computerId: key.computerId, + )); + if (coveredTank) { + continue; + } + final samples = dedupeExactPressureSamples(entry.value.samples); + dropped += entry.value.samples.length - samples.length; + final encoded = codec.encode(samples); + final inserted = await db.customUpdate( + 'INSERT OR IGNORE INTO tank_pressure_series (' + 'id, dive_id, tank_id, computer_id, sample_count, start_timestamp, ' + 'end_timestamp, codec_version, samples, created_at, updated_at, hlc) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + variables: [ + Variable( + tankPressureSeriesMigratedId( + diveId: diveId, + tankId: key.tankId, + computerId: key.computerId, + ), + ), + Variable(diveId), + Variable(key.tankId), + Variable(key.computerId), + Variable(encoded.summary.sampleCount), + Variable(encoded.summary.startTimestamp), + Variable(encoded.summary.endTimestamp), + Variable(encoded.codecVersion), + Variable(encoded.bytes), + Variable(now), + Variable(now), + Variable(hlc), + ], + updateKind: UpdateKind.insert, + ); + tankSeries += inserted; + } + }); + } catch (_) { + // The dive rolled back, so its partial counts did not happen. + tankSeries = before.tankSeries; + dropped = before.dropped; + skipped = before.skipped; + skippedRows = before.skippedRows; + // One dive at a time. A legacy value no reader expects (a text + // depth in a REAL column, from a hand-repaired or bit-rotted file) + // or a group the codec refuses must not cost the dives the scan + // has not reached yet: nothing reads the legacy tables after v183, + // so those would be a silent permanent loss rather than a retry. + // The residue count keeps the legacy table, so a later open tries + // this dive again. + failedDives++; + } + } + } + if (tankSeries > 0) { + db.notifyUpdates({ + const TableUpdate('tank_pressure_series', kind: UpdateKind.insert), + }); + } + + return ( + profileSeries: profileSeries, + tankSeries: tankSeries, + droppedSamples: dropped, + skippedOrphans: skipped, + skippedRows: skippedRows, + failedDives: failedDives, + skippedAlreadyPacked: alreadyPacked, + ); +} + +class _ProfileKey { + const _ProfileKey({ + required this.computerId, + required this.sourceId, + required this.isPrimary, + }); + + final String? computerId; + final String? sourceId; + final bool isPrimary; + + @override + bool operator ==(Object other) => + other is _ProfileKey && + other.computerId == computerId && + other.sourceId == sourceId && + other.isPrimary == isPrimary; + + @override + int get hashCode => Object.hash(computerId, sourceId, isPrimary); +} + +/// One packed profile group: the samples, plus every RAW `(computer_id, +/// source_id)` pair that resolved into this group's key. Coverage is asked +/// of the raw pairs, the way [legacyRowCoveredSql] asks it of the raw +/// columns; the key's resolved ids are what the insert can actually store. +typedef _ProfileGroup = ({ + List samples, + Set<({String? computerId, String? sourceId})> rawParents, +}); + +/// [_ProfileGroup]'s pressure twin. A pressure row carries no source or +/// primary flag, so its raw identity is the computer id alone. +typedef _TankGroup = ({ + List samples, + Set rawComputerIds, +}); + +class _TankKey { + const _TankKey({required this.tankId, required this.computerId}); + + final String tankId; + final String? computerId; + + @override + bool operator ==(Object other) => + other is _TankKey && + other.tankId == tankId && + other.computerId == computerId; + + @override + int get hashCode => Object.hash(tankId, computerId); +} + +/// Every dive with legacy rows, split into the ones still to pack and a +/// count of the ones a series row already covers. +typedef _LegacyDiveScan = ({List unpacked, int alreadyPacked}); + +const _LegacyDiveScan _emptyScan = (unpacked: [], alreadyPacked: 0); + +/// One pass over [legacyTable]'s distinct dive ids, marking each with +/// whether [seriesTable] already holds a row for it. Cheap once a database +/// is packed (an indexed EXISTS per legacy dive), which is what lets the +/// beforeOpen backstop call the packer on every open. Empty when the series +/// table is absent: `_assertProfileSeriesSchema` waits for that table's +/// foreign-key parents, so there is nothing to pack into yet. +/// +/// The already-packed side is what feeds +/// [ProfilePackReport.skippedAlreadyPacked], and it comes from this same +/// scan rather than a second COUNT so the per-open cost does not change. +Future<_LegacyDiveScan> _scanLegacyDives( + DatabaseConnectionUser db, { + required String legacyTable, + required String seriesTable, + required bool byTank, + bool byGroupIdentity = false, +}) async { + if (!await legacyTableExists(db, seriesTable)) return _emptyScan; + final covered = await legacyRowCoveredSql( + db, + legacyTable: legacyTable, + seriesTable: seriesTable, + byTank: byTank, + byGroupIdentity: byGroupIdentity, + ); + // A dive is done only when EVERY one of its legacy rows is covered, hence + // MIN over the per-row predicate. Testing the dive alone would leave a + // second computer's rows unpacked forever on a half-packed dive. + // One row per identity, not per sample: the coverage predicate is a + // correlated subquery, so evaluating it inside the aggregate over the raw + // table cost an index seek per sample. Collapsing first makes the cost + // one pass plus a seek per group, which is what this always claimed. + final identity = (await legacyCoverageIdentityColumns( + db, + legacyTable: legacyTable, + byTank: byTank, + byGroupIdentity: byGroupIdentity, + )).join(', '); + final rows = await db + .customSelect( + 'SELECT p.dive_id AS dive_id, ' + 'MIN(CASE WHEN $covered THEN 1 ELSE 0 END) AS all_covered FROM ' + '(SELECT DISTINCT $identity FROM $legacyTable) p ' + 'GROUP BY p.dive_id ORDER BY p.dive_id', + ) + .get(); + final unpacked = []; + var alreadyPacked = 0; + for (final row in rows) { + if (row.read('all_covered') != 0) { + alreadyPacked++; + } else { + unpacked.add(row.read('dive_id')); + } + } + return (unpacked: unpacked, alreadyPacked: alreadyPacked); +} + +/// The `(dive, computer, source, isPrimary)` groups `dive_profile_series` +/// already holds. Callers that only want the coarse `(dive, computer)` +/// coverage project the first two members out. +Future< + Set<({String diveId, String? computerId, String? sourceId, bool isPrimary})> +> +_coveredProfileGroups(DatabaseConnectionUser db) async { + if (!await legacyTableExists(db, 'dive_profile_series')) return const {}; + final rows = await db + .customSelect( + 'SELECT dive_id, computer_id, source_id, is_primary ' + 'FROM dive_profile_series', + ) + .get(); + return { + for (final r in rows) + ( + diveId: r.read('dive_id'), + computerId: r.readNullable('computer_id'), + sourceId: r.readNullable('source_id'), + isPrimary: r.read('is_primary') != 0, + ), + }; +} + +/// The `(dive, tank, computer)` identities `tank_pressure_series` holds. +Future> +_coveredTankIdentities(DatabaseConnectionUser db) async { + if (!await legacyTableExists(db, 'tank_pressure_series')) return const {}; + final rows = await db + .customSelect( + 'SELECT dive_id, tank_id, computer_id FROM tank_pressure_series', + ) + .get(); + return { + for (final r in rows) + ( + diveId: r.read('dive_id'), + tankId: r.read('tank_id'), + computerId: r.readNullable('computer_id'), + ), + }; +} + +/// Every id in [table], or an empty set when the table is absent. Loaded +/// once per run so the per-group parent checks below cost nothing. +Future> _parentIds(DatabaseConnectionUser db, String table) async { + if (!await legacyTableExists(db, table)) return const {}; + final rows = await db.customSelect('SELECT id FROM $table').get(); + return {for (final row in rows) row.read('id')}; +} + +/// [id] when it still names a row in [parents], null otherwise. A legacy +/// row can point at a computer or source that has since been deleted, which +/// under `PRAGMA foreign_keys = ON` no insert could carry. +String? _resolvedParent(String? id, Set parents) => + id != null && parents.contains(id) ? id : null; + +/// A legacy id column read as text, or null when the value is not text. +/// +/// Every id column is declared TEXT, so SQLite's affinity has already made +/// this the identity function for anything a supported build wrote. It is +/// here for a restored or hand-repaired file, where a value of another +/// storage class would otherwise throw from a cast and cost the dive. +String? _textOf(Object? value) => value is String ? value : null; + +/// The clock value migrated rows carry. Null when this device has never +/// synced: there is no device id to stamp with, and nothing to publish to. +Future _migrationHlc(DatabaseConnectionUser db, int nowMs) async { + if (!await legacyTableExists(db, 'sync_metadata')) return null; + final rows = await db + .customSelect("SELECT * FROM sync_metadata WHERE id = 'global' LIMIT 1") + .get(); + if (rows.isEmpty) return null; + final deviceId = rows.first.data['device_id'] as String?; + if (deviceId == null || deviceId.isEmpty) return null; + // Advance the persisted clock rather than minting from wall-clock time: + // a peer merge can have moved this device's clock ahead of now, and a + // stamp below the publish watermark would never ride a changeset. + final persisted = rows.first.data['hlc'] as String?; + if (persisted != null && persisted.isNotEmpty) { + try { + final advanced = Hlc.parse(persisted).increment(nowMs); + // The device id column is the authority on this device's node id. + return Hlc(advanced.physicalTime, advanced.counter, deviceId).toString(); + } on FormatException { + // Fall through to a fresh clock. + } + } + return Hlc(nowMs, 0, deviceId).toString(); +} + +/// The legacy `is_primary` value as a bool. +/// +/// The SQL half of this rule is `_primarySql` in +/// `profile_series_pack_coverage.dart`, and the two decide the same thing +/// for the same row: this one whether to WRITE the series, that one whether +/// the staged rows may be cleared. They have to agree on every value, so +/// change them together. +bool _boolOf(Object? value) { + if (value is bool) return value; + if (value is num) return value != 0; + // Every legacy schema declares is_primary NOT NULL DEFAULT 1, so a null + // here means the column is not carrying a value at all; a legacy row with + // no flag is the dive's live profile, which is what true says. Text lands + // here too: the staging table takes a peer's JSON, and INTEGER affinity + // leaves a value it cannot convert as text. + return true; +} diff --git a/lib/core/database/profile_series_pack_coverage.dart b/lib/core/database/profile_series_pack_coverage.dart new file mode 100644 index 0000000000..34f895151e --- /dev/null +++ b/lib/core/database/profile_series_pack_coverage.dart @@ -0,0 +1,291 @@ +/// Coverage: "is this legacy row already represented by a series row", and +/// everything that asks it. +/// +/// Split from `profile_series_pack.dart` to keep that file under the +/// project's 800-line limit. The pack, the scan that decides which dives it +/// visits, and the residue count that gates dropping a legacy table all +/// have to answer that question the same way, so the predicate and its +/// callers live together. +library; + +import 'package:drift/drift.dart'; + +/// Legacy rows still waiting to be packed, per legacy table. +typedef LegacyPackResidue = ({int profiles, int tanks}); + +/// How many rows each legacy table still holds that the pack should have +/// moved into the series tables and did not. +/// +/// This is the gate on dropping a legacy table. [packLegacyProfileRows] +/// never deletes what it packs, so "is the legacy table empty" cannot be +/// the question; the question is whether a series row now covers every +/// legacy row that could ever become one. Three reachable ways the pack +/// leaves a row behind while returning normally, all of which this counts: +/// a pressure row whose tank is gone is skipped as an orphan; a dive that +/// already had a series row is never revisited, so a second computer's +/// legacy rows are never packed; and every insert is `INSERT OR IGNORE`, so +/// a series table shaped differently by a parallel branch can pack nothing +/// at all. +/// +/// Coverage is checked per (dive, computer) for profiles and per (dive, +/// tank, computer) for tanks, resolving a dangling computer id to null the +/// way the packer's own grouping does. Finer identity terms (`source_id`, +/// `is_primary`) are deliberately left out: the pack works per dive, so +/// partialness below the computer level can only come from an ignored +/// insert, and gating on it would keep a table for a difference no read +/// ever sees. +/// +/// A row that can NEVER be packed does not count, or the table would be +/// kept forever and its pages never reclaimed: a row with no READABLE +/// timestamp, depth, or pressure holds no sample, and a row whose dive is +/// gone could never have been rendered. A row whose tank is gone DOES +/// count: the dive is still the diver's, and those bytes are the only copy +/// left. +Future countLegacyRowsAwaitingPack( + DatabaseConnectionUser db, { + String profileTable = 'dive_profiles', + String tankTable = 'tank_pressure_profiles', +}) async => ( + profiles: await _countUnpackedProfileRows(db, profileTable), + tanks: await _countUnpackedTankRows(db, tankTable), +); + +Future _countUnpackedProfileRows( + DatabaseConnectionUser db, + String table, +) async { + final columns = await legacyColumnNames(db, table); + if (columns.isEmpty) return 0; + // A shape the packer cannot read, a missing series table, or a missing + // `dives` table all mean nothing moved: every row is still only here. + if (!columns.containsAll(const {'dive_id', 'timestamp', 'depth'}) || + !await legacyTableExists(db, 'dive_profile_series') || + !await legacyTableExists(db, 'dives')) { + return _countRows(db, table); + } + final covered = await legacyRowCoveredSql( + db, + legacyTable: table, + seriesTable: 'dive_profile_series', + byTank: false, + ); + // Grouped before the correlated predicate, for the reason + // [legacyCoverageIdentityColumns] documents. + final identity = (await legacyCoverageIdentityColumns( + db, + legacyTable: table, + byTank: false, + )).join(', '); + final rows = await db + .customSelect( + 'SELECT COALESCE(SUM(p.n), 0) AS n FROM ' + '(SELECT $identity, COUNT(*) AS n FROM $table ' + 'WHERE ${readableNumberSql('timestamp')} AND ${readableNumberSql('depth')} ' + 'GROUP BY $identity) p ' + 'WHERE EXISTS (SELECT 1 FROM dives d WHERE d.id = p.dive_id) ' + 'AND NOT $covered', + ) + .getSingle(); + return rows.read('n'); +} + +Future _countUnpackedTankRows( + DatabaseConnectionUser db, + String table, +) async { + final columns = await legacyColumnNames(db, table); + if (columns.isEmpty) return 0; + if (!columns.containsAll(const { + 'dive_id', + 'tank_id', + 'timestamp', + 'pressure', + }) || + !await legacyTableExists(db, 'tank_pressure_series') || + !await legacyTableExists(db, 'dives')) { + return _countRows(db, table); + } + final covered = await legacyRowCoveredSql( + db, + legacyTable: table, + seriesTable: 'tank_pressure_series', + byTank: true, + ); + final identity = (await legacyCoverageIdentityColumns( + db, + legacyTable: table, + byTank: true, + )).join(', '); + final rows = await db + .customSelect( + 'SELECT COALESCE(SUM(p.n), 0) AS n FROM ' + '(SELECT $identity, COUNT(*) AS n FROM $table ' + 'WHERE ${readableNumberSql('timestamp')} ' + 'AND ${readableNumberSql('pressure')} ' + "AND ${readableTextSql('tank_id')} GROUP BY $identity) p " + 'WHERE EXISTS (SELECT 1 FROM dives d WHERE d.id = p.dive_id) ' + 'AND NOT $covered', + ) + .getSingle(); + return rows.read('n'); +} + +/// The columns [legacyRowCoveredSql] reads off the legacy row, so a caller +/// can collapse the table to one row per identity before evaluating it. +/// +/// The predicate is a correlated subquery: evaluated per legacy ROW it costs +/// an index seek per sample, which on a million-sample table is the whole +/// per-open cost of the backstop. Evaluated per identity it costs one seek +/// per group, which is what the scan and the residue count both claim. +Future> legacyCoverageIdentityColumns( + DatabaseConnectionUser db, { + required String legacyTable, + required bool byTank, + bool byGroupIdentity = false, +}) async { + final columns = await legacyColumnNames(db, legacyTable); + return [ + 'dive_id', + if (byTank) 'tank_id', + if (columns.contains('computer_id')) 'computer_id', + if (byGroupIdentity && !byTank) ...[ + if (columns.contains('source_id')) 'source_id', + if (columns.contains('is_primary')) 'is_primary', + ], + ]; +} + +/// SQL predicate: the legacy row aliased `p` is already represented by a +/// row of [seriesTable]. +/// +/// Identity, not dive. A dive can be half packed: this device holds a series +/// for one computer while a peer below v183 still publishes row-per-sample +/// rows for the same dive from two. Asking "does this dive have a series" +/// would call the second computer's rows done and, on the staging path where +/// the staged rows are the only copy, discard them. +/// +/// The identity is `(dive, computer)` for profiles and `(dive, tank, +/// computer)` for pressures, resolving a dangling computer id to null the +/// way the packer's own grouping does. Finer terms (`source_id`, +/// `is_primary`) are deliberately left out, matching what gates dropping a +/// legacy table: a difference below the computer level can only come from an +/// ignored insert, and acting on it would pack a second series for an +/// identity a read already resolves. +Future legacyRowCoveredSql( + DatabaseConnectionUser db, { + required String legacyTable, + required String seriesTable, + required bool byTank, + bool byGroupIdentity = false, +}) async { + final columns = await legacyColumnNames(db, legacyTable); + // A legacy row that names no computer matches a series whatever computer + // the series carries, but ONLY on the identity-grained (staging) path. + // This device stamps a computer onto its own series after packing + // (consolidation's stampComputerWhereNull, relinkComputer, + // adoptUnattributed), while a peer below the floor still holds the same + // readings unattributed: an exact match would find nothing and pack the + // peer's copy a second time, and nothing collapses duplicate series on + // read. A staged row that DOES name a computer is a different source and + // still has to match exactly. + final computerMatch = 's.computer_id IS ${await resolvedComputerSql(db)}'; + final computer = columns.contains('computer_id') + ? byGroupIdentity + ? 'AND (p.computer_id IS NULL OR $computerMatch)' + : 'AND $computerMatch' + : ''; + final tank = byTank ? 'AND s.tank_id = p.tank_id' : ''; + var group = ''; + if (byGroupIdentity && !byTank) { + // The two terms the packer groups on below the computer level. A + // pressure row has neither, so byTank needs nothing extra. source_id + // takes the same null-is-a-wildcard rule as computer_id, for the same + // reason (adoptUnattributed moves it from NULL to a source). + final sourceMatch = 's.source_id IS ${await resolvedSourceSql(db)}'; + final primary = columns.contains('is_primary') ? _primarySql : '1'; + group = + 'AND (p.source_id IS NULL OR $sourceMatch) ' + 'AND s.is_primary = $primary'; + } + return 'EXISTS (SELECT 1 FROM $seriesTable s ' + 'WHERE s.dive_id = p.dive_id $tank $computer $group)'; +} + +/// The legacy `is_primary` value as the 0 or 1 a series row stores, spelled +/// to agree with the packer's `_boolOf` on EVERY value. +/// +/// A plain `CASE WHEN p.is_primary` disagrees with it twice, and each +/// disagreement leaves the staged rows undrained forever while the packer +/// re-runs over them on every sync apply: SQLite sends a NULL to the ELSE +/// and reads it as demoted, where `_boolOf` reads an absent flag as the +/// dive's live profile; and it applies its own text-to-numeric rule, so a +/// peer's `"isPrimary": "yes"`, which INTEGER affinity leaves as text, reads +/// as demoted where `_boolOf` reads any non-num as primary. Testing +/// `typeof` first confines SQLite's truthiness to the numeric values both +/// sides agree on. Change this and `_boolOf` together. +const String _primarySql = + 'CASE WHEN p.is_primary IS NULL THEN 1 ' + "WHEN typeof(p.is_primary) IN ('integer', 'real') " + 'THEN (CASE WHEN p.is_primary THEN 1 ELSE 0 END) ELSE 1 END'; + +/// [resolvedComputerSql]'s twin for `source_id`. +Future resolvedSourceSql(DatabaseConnectionUser db) async => + await legacyTableExists(db, 'dive_data_sources') + ? '(SELECT ds.id FROM dive_data_sources ds WHERE ds.id = p.source_id)' + : 'NULL'; + +/// The scalar subquery that mirrors [_resolvedParent] for `computer_id`: the +/// legacy row's computer when it still names a `dive_computers` row, null +/// otherwise. Null too when the parent table itself is gone, which is what +/// the packer's empty parent set resolves every id to. +Future resolvedComputerSql(DatabaseConnectionUser db) async => + await legacyTableExists(db, 'dive_computers') + ? '(SELECT c.id FROM dive_computers c WHERE c.id = p.computer_id)' + : 'NULL'; + +/// SQL for "[column] holds a number the packer can read", the predicate +/// half of `profileSampleOf`'s `is! num` test. +/// +/// `IS NOT NULL` is not the same question. SQLite carries a storage class +/// per value, so a REAL-affinity column can hold text it could not convert; +/// that row is not null but holds no sample, the packer steps over it, and +/// counting it as awaiting pack would keep the legacy table and its pages +/// forever waiting for a pack that can never claim it. +/// +/// Also the guard on READING such a column as a number. Drift's `read` +/// converts rather than casts, but converting is `int.parse`, so numeric +/// text passes and anything else throws a [FormatException]. Any query that +/// reads a legacy numeric column has to filter on this first. +String readableNumberSql(String column) => + "typeof($column) IN ('integer', 'real')"; + +/// SQL for "[column] holds text", the guard on reading a legacy id column +/// with `read`. See [readableNumberSql] for why a declared type is +/// not enough on these tables. +String readableTextSql(String column) => "typeof($column) = 'text'"; + +Future _countRows(DatabaseConnectionUser db, String table) async { + final rows = await db + .customSelect('SELECT COUNT(*) AS n FROM $table') + .getSingle(); + return rows.read('n'); +} + +Future legacyTableExists(DatabaseConnectionUser db, String table) async { + final rows = await db + .customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(table)], + ) + .get(); + return rows.isNotEmpty; +} + +/// The column names of [table], empty when the table does not exist. +Future> legacyColumnNames( + DatabaseConnectionUser db, + String table, +) async { + final rows = await db.customSelect("PRAGMA table_info('$table')").get(); + return {for (final row in rows) row.read('name')}; +} diff --git a/lib/core/database/profile_series_pack_rows.dart b/lib/core/database/profile_series_pack_rows.dart new file mode 100644 index 0000000000..aefb961e4b --- /dev/null +++ b/lib/core/database/profile_series_pack_rows.dart @@ -0,0 +1,70 @@ +/// Row-decoding helpers for [profileSampleOf], split out of +/// `profile_series_pack.dart` to keep that file under the project's line +/// limit. Same imports discipline as the rest of `lib/core/database`: no +/// Flutter, only what a headless isolate can run. +library; + +import 'package:submersion/core/utils/number_utils.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_sample.dart'; + +/// Reads a legacy `dive_profiles` row by column name. Absent columns (an +/// older fixture or a partially migrated table) read as null. Returns null +/// when the row has no READABLE timestamp or depth: a restored or +/// hand-repaired legacy table can hold such rows and they cannot become a +/// sample. +/// +/// `is! num` rather than a null check, because the two are the same thing +/// here. SQLite carries a storage class per value, so a REAL-affinity +/// column can hold text it could not convert, and a cast would throw. The +/// packer catches that per DIVE, so one unreadable byte would cost the +/// dive its whole profile and keep its legacy rows back for a retry that +/// re-reads the same byte and fails identically, forever. +ProfileSample? profileSampleOf(Map data) { + final timestamp = data['timestamp']; + final depth = data['depth']; + if (timestamp is! num || depth is! num) return null; + return ProfileSample( + timestamp: timestamp.toInt(), + depth: depth.toDouble(), + pressure: realOf(data['pressure']), + temperature: realOf(data['temperature']), + heartRate: intOf(data['heart_rate']), + ascentRate: realOf(data['ascent_rate']), + ceiling: realOf(data['ceiling']), + ndl: intOf(data['ndl']), + setpoint: realOf(data['setpoint']), + ppO2: realOf(data['pp_o2']), + o2Sensor1: realOf(data['o2_sensor1']), + o2Sensor2: realOf(data['o2_sensor2']), + o2Sensor3: realOf(data['o2_sensor3']), + o2Sensor4: realOf(data['o2_sensor4']), + o2Sensor5: realOf(data['o2_sensor5']), + o2Sensor6: realOf(data['o2_sensor6']), + cns: realOf(data['cns']), + tts: intOf(data['tts']), + rbt: intOf(data['rbt']), + decoType: intOf(data['deco_type']), + heartRateSource: data['heart_rate_source'] as String?, + heading: realOf(data['heading']), + o2SensorMv1: intOf(data['o2_sensor_mv1']), + o2SensorMv2: intOf(data['o2_sensor_mv2']), + o2SensorMv3: intOf(data['o2_sensor_mv3']), + o2SensorMv4: intOf(data['o2_sensor_mv4']), + o2SensorMv5: intOf(data['o2_sensor_mv5']), + o2SensorMv6: intOf(data['o2_sensor_mv6']), + ); +} + +/// One OPTIONAL sample field, or null when the stored value is not a number. +/// +/// SQLite carries a storage class per value, not per column, so a +/// REAL-affinity column in a restored or hand-repaired file can hold text it +/// could not convert. A cast would throw, and the packer's isolation is +/// per DIVE: one unreadable pressure reading would cost that dive its entire +/// profile, permanently, because nothing reads the legacy tables after v183. +/// Degrading the one field is the proportionate answer. `timestamp` and +/// `depth` above are deliberately NOT read this way: without them there is +/// no sample, so the row is skipped as a whole. +double? realOf(Object? value) => asDoubleOrNull(value); + +int? intOf(Object? value) => value is num ? value.toInt() : null; diff --git a/lib/core/database/raw_dive_data_codec.dart b/lib/core/database/raw_dive_data_codec.dart new file mode 100644 index 0000000000..583e5e9389 --- /dev/null +++ b/lib/core/database/raw_dive_data_codec.dart @@ -0,0 +1,141 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:drift/drift.dart'; +import 'package:submersion/core/utils/bounded_inflate.dart'; + +/// The at-rest encoding for `dive_data_sources.raw_data` (issue #227). +/// +/// The raw bytes libdivecomputer handed back at download time are the only +/// thing that makes a later re-parse possible, so they are kept forever and +/// there is no second copy to fall back on. Every rule here exists to make +/// losing one impossible. +/// +/// A stored blob is either the compressed form: +/// +/// ```text +/// offset size meaning +/// 0 4 magic, ASCII "SRD1" +/// 4 4 uint32 little-endian, length of the original bytes +/// 8 n zlib stream (level 6) of the original bytes +/// ``` +/// +/// or the original bytes verbatim. Nothing else records which, so the header +/// is the only discriminator and a blob carries its own answer. That is what +/// lets a partially migrated table, a row inbound from a peer that has not +/// updated, and a blob that simply does not compress all sit in one column +/// with no state to consult, and it is what lets `DiveMergeService` keep +/// copying provenance rows wholesale without knowing this format exists. +/// +/// The digit in the magic is the extension point: a future codec mints +/// "SRD2" and this decoder keeps reading "SRD1" for as long as any diver's +/// database still holds one, which is forever. +const List kRawDiveDataMagic = [0x53, 0x52, 0x44, 0x31]; + +/// Magic (4 bytes) plus the uint32 original length (4 bytes). +const int kRawDiveDataHeaderBytes = 8; + +/// The largest blob this codec will compress, and the largest it will inflate. +/// +/// One constant for both directions on purpose. A decode bound without a +/// matching encode refusal would let the encoder mint a blob its own decoder +/// rejects, which would read back as compressed garbage: the exact data loss +/// this file exists to prevent. 8 MiB is far above any dive a computer +/// records and far below anything that threatens memory. +const int kMaxRawDiveBlobBytes = 8 * 1024 * 1024; + +/// Level 6. Level 9 was measured on all three committed fixtures and gained +/// under 1.2%, which does not pay for its time. Not `const`: `ZLibCodec` has +/// no const constructor. +final ZLibCodec _zlib = ZLibCodec(level: 6); + +/// True when [stored] carries the compressed header. +/// +/// A cheap prefix test for callers that want to skip work, such as the v190 +/// migration deciding whether a row is already packed. It is NOT proof the +/// body is intact; only [decodeRawDiveData] establishes that. +bool isCompressedRawDiveData(Uint8List stored) { + if (stored.length < kRawDiveDataHeaderBytes) return false; + for (var i = 0; i < kRawDiveDataMagic.length; i++) { + if (stored[i] != kRawDiveDataMagic[i]) return false; + } + return true; +} + +/// Returns the at-rest form of [raw]. +/// +/// Compressed only when that is strictly smaller AND within +/// [kMaxRawDiveBlobBytes]; otherwise [raw] itself, unchanged and uncopied. +/// Never mutates [raw]. +Uint8List encodeRawDiveData(Uint8List raw) { + if (raw.length > kMaxRawDiveBlobBytes) return raw; + + final body = _zlib.encode(raw); + final total = kRawDiveDataHeaderBytes + body.length; + if (total >= raw.length) return raw; + + final out = Uint8List(total); + out.setRange(0, kRawDiveDataMagic.length, kRawDiveDataMagic); + ByteData.sublistView(out).setUint32(4, raw.length, Endian.little); + out.setRange(kRawDiveDataHeaderBytes, total, body); + return out; +} + +/// Returns the original bytes behind [stored]. +/// +/// Falls back to [stored] itself on anything that is not a well-formed +/// compressed blob, and never throws. A converter that threw would break +/// ordinary row mapping for the whole table, taking down pages that never +/// wanted the raw bytes; a damaged blob instead degrades into a re-parse +/// failure, which `ReparseService` already counts and surfaces. +/// +/// The declared length is load-bearing twice. It bounds the inflate, and it +/// is checked afterwards: zlib accepts a truncated stream and returns what it +/// managed to inflate with no error at all, so length equality is the only +/// completeness check available. +Uint8List decodeRawDiveData(Uint8List stored) { + if (!isCompressedRawDiveData(stored)) return stored; + + final declared = ByteData.sublistView( + stored, + 4, + kRawDiveDataHeaderBytes, + ).getUint32(0, Endian.little); + // The declared length reaches 4 GiB and is not trustworthy on its own, so + // it bounds the inflate only while it is the smaller of the two. + final cap = declared < kMaxRawDiveBlobBytes ? declared : kMaxRawDiveBlobBytes; + + try { + final inflated = inflateBounded( + Uint8List.sublistView(stored, kRawDiveDataHeaderBytes), + decoder: _zlib.decoder, + maxBytes: cap, + maxBlobBytes: kMaxRawDiveBlobBytes, + ); + if (inflated.length != declared) return stored; + return inflated; + } on BoundedInflateException { + return stored; + } +} + +/// Applies [encodeRawDiveData] and [decodeRawDiveData] to every read and +/// write of the column. +/// +/// A converter rather than encoding at the call sites, because the two +/// mistakes are not symmetrical: a write site that forgets to encode stores a +/// raw blob that still reads back correctly, so the bug is invisible, while a +/// read site that forgets to decode hands compressed bytes to +/// libdivecomputer. It is also what keeps sync transparent. Every sync path +/// for this table goes through the row's `toJson`/`fromJson`, which operate +/// on the converted Dart value, so peers keep exchanging uncompressed bytes +/// and no schema floor has to be raised. +class RawDiveDataConverter extends TypeConverter { + const RawDiveDataConverter(); + + @override + Uint8List fromSql(Uint8List fromDb) => decodeRawDiveData(fromDb); + + @override + Uint8List toSql(Uint8List value) => encodeRawDiveData(value); +} diff --git a/lib/core/deco/buhlmann_algorithm.dart b/lib/core/deco/buhlmann_algorithm.dart index 91a39b9415..d70fed8b42 100644 --- a/lib/core/deco/buhlmann_algorithm.dart +++ b/lib/core/deco/buhlmann_algorithm.dart @@ -98,14 +98,30 @@ class BuhlmannAlgorithm { } /// Set compartments to a specific state (for loading from saved data). + /// + /// Treats [compartments] as the start of a new dive: the GF-low anchor is + /// re-derived from that loading via [deriveGfAnchorFromLoading]. void setCompartments(List compartments) { if (compartments.length == zhl16CompartmentCount) { _compartments = List.from(compartments); - _gfLowCeilingAnchor = 0.0; - _updateGfAnchor(); + deriveGfAnchorFromLoading(); } } + /// Re-derive the GF-low anchor from the current loading, discarding any + /// previously recorded maximum. + /// + /// Only for starting a new dive from a seeded state, once that state is + /// final -- notably after off-gassing a surface interval, so the anchor + /// reflects the loading the dive actually begins with rather than the prior + /// dive's. Never call this mid-dive: within a dive the anchor is deliberately + /// a running maximum (see [_gfLowCeilingAnchor]), and resetting it would + /// collapse shallow stops toward GF-low. + void deriveGfAnchorFromLoading() { + _gfLowCeilingAnchor = 0.0; + _updateGfAnchor(); + } + /// Deepest GF-low ceiling reached so far this dive (meters). double get gfLowCeilingAnchor => _gfLowCeilingAnchor; @@ -210,65 +226,93 @@ class BuhlmannAlgorithm { (initialPressure - inspiredPressure) * math.exp(-k * timeMinutes); } - /// Calculate the current decompression ceiling (minimum safe depth). - /// - /// Uses gradient factors to add conservatism. - /// [currentDepth] is used to interpolate the gradient factor. - /// Returns ceiling depth in meters. - double calculateCeiling({double currentDepth = 0}) { + /// Deepest compartment ceiling in meters at a single gradient factor. + double _ceilingAtGf(double gf) { double maxCeiling = 0; - - // Calculate GF at current depth (linear interpolation between GF Low and GF High) - final gf = _interpolateGf(currentDepth); - for (final comp in _compartments) { final ceiling = _ceilingMetersFor(comp, gf); - if (ceiling > maxCeiling) { - maxCeiling = ceiling; - } + if (ceiling > maxCeiling) maxCeiling = ceiling; } - return maxCeiling; } - /// Interpolate gradient factor based on depth. + /// Iterations allowed when solving for the GF ceiling. Each step roughly + /// halves the residual, so single-centimetre agreement is reached in a + /// handful; the cap only bounds pathological input. + static const int _ceilingSolveIterations = 24; + + /// Agreement required between successive ceiling estimates, in meters. + /// One millimetre is far below anything displayed or compared against a + /// 3 m stop grid. + static const double _ceilingSolveToleranceMeters = 0.001; + + /// The current decompression ceiling in meters (0 = clear to the surface + /// at GF-high). + /// + /// The gradient factor is a function of depth - GF-low at the dive's + /// deepest ceiling, GF-high at the surface - so the ceiling is the depth + /// at which the tissues are tolerated *under the gradient factor that + /// applies at that same depth*. That is a fixed point, and it is what makes + /// the ceiling a property of the tissue state alone rather than of wherever + /// the diver happens to be floating. + /// + /// It used to evaluate the GF at the diver's current depth, which reported + /// the GF-low ceiling for anyone deeper than the anchor. That number is not + /// a ceiling the diver has to respect: it is the tolerance at a gradient + /// factor that only applies far shallower. The consequence was a ceiling + /// that contradicted the schedule computed from the same tissues - a plan + /// showing a 4.2 m ceiling whose only stop was at 3 m - because the trial + /// ascent in [_calculateStopTime] evaluated its GF at the shallower target + /// depth and so cleared stops the displayed ceiling said were needed. + /// + /// [_ceilingAtGf] is monotonically shallower as the GF rises, and + /// [_interpolateGf] rises as the depth shallows, so iterating from the + /// GF-low ceiling (the deepest the answer can be) descends monotonically + /// onto the fixed point. + double calculateCeiling() { + var ceiling = _ceilingAtGf(gfLow); + if (ceiling <= 0) return 0; + + // No anchor yet means no ceiling has ever been owed, so the GF is + // GF-high everywhere; there is nothing to interpolate between. + if (_gfLowCeilingAnchor <= 0) return _ceilingAtGf(gfHigh); + + for (var i = 0; i < _ceilingSolveIterations; i++) { + final next = _ceilingAtGf(_interpolateGf(ceiling)); + final converged = (next - ceiling).abs() < _ceilingSolveToleranceMeters; + ceiling = next; + if (converged) break; + } + return ceiling; + } + + /// Interpolate the gradient factor that applies at [depthMeters]. /// - /// GF starts at gfLow at the first stop depth and increases - /// linearly to gfHigh at the surface. - double _interpolateGf(double currentDepth) { - if (currentDepth <= 0) return gfHigh; + /// GF-low at the first stop depth, rising linearly to GF-high at the + /// surface. + double _interpolateGf(double depthMeters) { + if (depthMeters <= 0) return gfHigh; // Anchor GF-low at the dive's deepest ceiling, fixed for the whole ascent // (Subsurface's gf_low_pressure_this_dive), not the current stop. final anchorDepth = _gfLowCeilingAnchor; if (anchorDepth <= 0) return gfHigh; - if (currentDepth >= anchorDepth) return gfLow; + if (depthMeters >= anchorDepth) return gfLow; // Linear interpolation from GF-low at the anchor to GF-high at the surface. - final ratio = currentDepth / anchorDepth; + final ratio = depthMeters / anchorDepth; return gfHigh - (gfHigh - gfLow) * ratio; } - /// Calculate ceiling using GF High only (for NDL/deco obligation checks). + /// Ceiling in meters using GF-high alone: the surface target. /// - /// This method determines if the diver can ascend directly to the surface. - /// GF High represents the surface target, which is the correct GF to use - /// when checking whether decompression stops are required. - /// - /// Note: GF Low and GF interpolation are used for calculating deep stop - /// depths during actual decompression ascent, not for determining if - /// deco is required in the first place. - double _calculateSurfaceTargetCeiling() { - double maxCeiling = 0; - for (final comp in _compartments) { - final ceiling = _ceilingMetersFor(comp, gfHigh); - if (ceiling > maxCeiling) { - maxCeiling = ceiling; - } - } - return maxCeiling; - } + /// Answers "may the diver ascend directly to the surface?" - zero means + /// yes. This is the correct GF for deciding whether decompression is owed + /// at all, which is a different question from [calculateCeiling]'s "how + /// shallow may the diver go right now". A dive can be mid-ascent with a + /// real GF ceiling and still owe nothing at the surface. + double surfaceTargetCeiling() => _ceilingAtGf(gfHigh); /// Calculate No-Decompression Limit (NDL) at current depth. /// @@ -286,7 +330,7 @@ class BuhlmannAlgorithm { }) { // Check if already in deco using GF High (surface target). // NDL is about whether we can ascend directly to the surface. - if (_calculateSurfaceTargetCeiling() > 0) { + if (surfaceTargetCeiling() > 0) { return -1; } @@ -314,7 +358,7 @@ class BuhlmannAlgorithm { ); // Check if this creates a deco obligation using GF High - if (_calculateSurfaceTargetCeiling() > 0) { + if (surfaceTargetCeiling() > 0) { high = mid; } else { low = mid; @@ -328,6 +372,17 @@ class BuhlmannAlgorithm { return low; } + /// The policy to use when a caller passes none: this algorithm's own stop + /// grid and ascent rate, with a single rate everywhere (SchedulePolicy + /// defaults the deco and final rates to it). Only the planner configures + /// the three rates separately, so every other consumer keeps its previous + /// behaviour. + SchedulePolicy _defaultPolicy() => SchedulePolicy( + stopIncrement: stopIncrement, + lastStopDepth: lastStopDepth, + ascentRate: ascentRate, + ); + /// Calculate complete decompression schedule. /// /// [currentDepth] is starting depth in meters. @@ -343,41 +398,70 @@ class BuhlmannAlgorithm { SchedulePolicy? policy, }) { final plan = ascentGas ?? FixedAscentGas(fN2: fN2, fHe: fHe); - final p = - policy ?? - SchedulePolicy( - stopIncrement: stopIncrement, - lastStopDepth: lastStopDepth, - ascentRate: ascentRate, - ); + final p = policy ?? _defaultPolicy(); final stops = []; final savedCompartments = List.from(_compartments); final savedAnchor = _gfLowCeilingAnchor; - final double ceiling = calculateCeiling(currentDepth: currentDepth); + final double ceiling = calculateCeiling(); if (ceiling <= 0) { _compartments = savedCompartments; _gfLowCeilingAnchor = savedAnchor; return stops; // No deco required } - double currentStopDepth = - (ceiling / p.stopIncrement).ceil() * p.stopIncrement; + // The first stop is the grid level at or below the ceiling - but never + // shallower than the last stop, which is where a diver who owes anything + // at all has chosen to hold. + double currentStopDepth = math.max( + (ceiling / p.stopIncrement).ceil() * p.stopIncrement, + p.lastStopDepth, + ); // Travel to first stop may cross a gas MOD: _simulateAscent splits it. _simulateAscent(currentDepth, currentStopDepth, plan, p); + // The ascent clock, counted the way its consumers count it: calculateTts + // and the planner both time the legs between the stops they are HANDED + // (SchedulePolicy.ascentTravelSeconds), so a grid level that clears on + // arrival contributes no leg of its own and no rate change of its own. + // The whole-minute snap has to run on that same clock, or the rows it + // exists to line up land on odd seconds anyway. + // + // [clockPhase] is the single source of truth for which ascent rate is in + // force, so the tissue simulation below reads it too. A diver who has not + // stopped yet is still on the working ascent off the bottom, however many + // grid levels they have passed through: loading those legs at the + // between-stops rate made the tissues fly an ascent minutes longer than + // the one the schedule printed. + var clockSeconds = 0; + var clockDepth = currentDepth; + var clockPhase = AscentPhase.toFirstStop; + AscentGas previousGas = plan.gasForDepth(currentDepth); - while (currentStopDepth >= p.lastStopDepth) { + while (currentStopDepth > 0) { final stopGas = plan.gasForDepth(currentStopDepth); final switched = stopGas.fN2 != previousGas.fN2 || stopGas.fHe != previousGas.fHe; - int stopTime = _calculateStopTime(currentStopDepth, plan, p); + int stopTime = _calculateStopTime(currentStopDepth, plan, p, clockPhase); if (switched && p.gasSwitchStopSeconds > 0) { stopTime = math.max(stopTime, p.gasSwitchStopSeconds); } + final arrivalSeconds = + clockSeconds + + p.ascentSeconds( + fromDepth: clockDepth, + toDepth: currentStopDepth, + phase: clockPhase, + ); + if (p.snapStopsToWholeMinutes && stopTime > 0) { + // The stop absorbs the odd seconds of the leg that led to it, so it + // ends on a whole minute of the ascent clock. Only a stop that is + // actually held snaps: a level cleared on arrival is not a stop. + stopTime += (60 - (arrivalSeconds + stopTime) % 60) % 60; + } if (stopTime > 0) { stops.add( @@ -399,12 +483,16 @@ class BuhlmannAlgorithm { ); _loadStopMinutes(currentStopDepth, stopTime, plan, p); + + clockSeconds = arrivalSeconds + stopTime; + clockDepth = currentStopDepth; + clockPhase = AscentPhase.betweenStops; } previousGas = stopGas; - final nextStop = currentStopDepth - p.stopIncrement; - if (nextStop >= p.lastStopDepth) { - _simulateAscent(currentStopDepth, nextStop, plan, p); + final nextStop = _nextLevel(currentStopDepth, p); + if (nextStop > 0) { + _simulateAscent(currentStopDepth, nextStop, plan, p, phase: clockPhase); } currentStopDepth = nextStop; } @@ -416,6 +504,18 @@ class BuhlmannAlgorithm { return stops; } + /// The level the diver goes to after [stopDepth]: the next grid stop, the + /// last stop when the grid would step past it, or the surface (0) from the + /// last stop itself. + /// + /// The last stop need not sit on the grid. A diver who holds their final + /// stop at 4 or 5 m gets the 3 m grid down to 6 m and then that depth, so + /// the stop increment and the last stop are independent choices. + double _nextLevel(double stopDepth, SchedulePolicy p) { + if (stopDepth <= p.lastStopDepth) return 0; + return math.max(stopDepth - p.stopIncrement, p.lastStopDepth); + } + /// Gas to breathe during minute [minuteIndex] of a stop at [stopDepth]: /// the plan's stop gas, interrupted by air breaks per policy when the /// stop gas is effectively pure O2 and the plan offers a break gas. @@ -462,10 +562,9 @@ class BuhlmannAlgorithm { double stopDepth, AscentGasPlan ascentGas, SchedulePolicy policy, + AscentPhase walked, ) { - final nextStopDepth = stopDepth <= policy.lastStopDepth - ? 0.0 - : stopDepth - policy.stopIncrement; + final nextStopDepth = _nextLevel(stopDepth, policy); int stopTime = 0; const maxStopTime = 120 * 60; @@ -473,43 +572,38 @@ class BuhlmannAlgorithm { final entryAnchor = _gfLowCeilingAnchor; while (stopTime < maxStopTime) { - final minuteGas = _gasForStopMinute( + // Leave the stop once the diver could ascend to the NEXT (shallower) + // level without the ceiling ever rising above them: Subsurface's + // trial_ascent, simulated leg and all. At the last stop the next level + // is the surface, where the GF is GF-high -- the same criterion the + // deco-cleared check uses -- so TTS counts down to surfacing instead of + // collapsing in one sample. + // + // The test comes BEFORE the minute is loaded, and that ordering is the + // whole point: it asks whether the diver may leave with the time spent + // so far. Testing after a trial minute and then discarding the minute + // credited off-gassing the schedule never actually spends, which + // under-reported every stop by up to a minute and deleted outright any + // stop that cleared inside its first minute -- a plan whose ceiling was + // 6.6 m was told to ascend to 6 m. + // + // Leaving mid-break is fine: the cleared check is gas-independent. + if (_trialAscentClears( stopDepth, - stopTime ~/ 60, + nextStopDepth, ascentGas, policy, - ); - - final testCompartments = List.from(_compartments); - final testAnchor = _gfLowCeilingAnchor; - - calculateSegment( - depthMeters: stopDepth, - durationSeconds: 60, - fN2: minuteGas.fN2, - fHe: minuteGas.fHe, - ); - - // Leave the stop once the diver may ascend to the NEXT (shallower) stop: - // evaluate the ceiling at the gradient factor for that shallower depth, - // matching Subsurface's trial_ascent (tissue tolerance at the target - // stoplevel), with GF-low anchored at the dive's deepest ceiling. At the - // last stop the next level is the surface, where the GF is GF-high -- the - // same criterion the deco-cleared check uses -- so TTS counts down to - // surfacing instead of collapsing in one sample. - final ceiling = calculateCeiling(currentDepth: nextStopDepth); - - // Restore for next iteration. The trial minute must not leak into - // persistent state: restore the anchor too, otherwise a trial minute that - // is never taken (the break below) could permanently grow the anchor. - _compartments = testCompartments; - _gfLowCeilingAnchor = testAnchor; - - // Leaving mid-break is fine: the cleared check is gas-independent. - if (ceiling <= nextStopDepth) { + walked, + )) { break; } + final minuteGas = _gasForStopMinute( + stopDepth, + stopTime ~/ 60, + ascentGas, + policy, + ); calculateSegment( depthMeters: stopDepth, durationSeconds: 60, @@ -526,6 +620,82 @@ class BuhlmannAlgorithm { return stopTime; } + /// Longest slice of a trial ascent loaded as one segment. Matches the ramp + /// slicing in `BuhlmannGf.applySegment`, so the trial sees the leg at the + /// same resolution the profile is loaded at. + static const int _trialAscentSliceSeconds = 10; + + /// May the diver leave [stopDepth] for [nextStopDepth] now? + /// + /// Simulates the travel leg itself - at the rate the policy gives that + /// phase, on the gas eligible at each depth - and requires the ceiling to + /// stay at or below the diver the whole way, including on arrival. The + /// leg's own off-gassing therefore counts toward clearing the stop being + /// left, because that is what happens to the tissues in the water: a diver + /// crawling off the last stop at 1 m/min spends three minutes shallower + /// than the stop, and a criterion that ignored those minutes held them at + /// the stop for a further four. This is Subsurface's `trial_ascent` for + /// Buhlmann. The tissue state is restored before returning, so the trial + /// leaves nothing behind whichever way it answers. + bool _trialAscentClears( + double stopDepth, + double nextStopDepth, + AscentGasPlan ascentGas, + SchedulePolicy policy, + AscentPhase walked, + ) { + // The rate the diver would leave at, which is the rate the leg they are + // being asked about is flown at: [walked] is still the working ascent + // until a stop has actually been held, so a level that clears on arrival + // is not credited with the slower between-stops off-gassing of a leg the + // diver never flies that slowly. + final phase = nextStopDepth > 0 + ? walked + : AscentPhase.surfacingAfter(walked); + final legSeconds = policy.ascentSeconds( + fromDepth: stopDepth, + toDepth: nextStopDepth, + phase: phase, + ); + // A leg with no duration (degenerate rate) has nothing to simulate: fall + // back to asking whether the ceiling is already above the next level. + if (legSeconds <= 0) return calculateCeiling() <= nextStopDepth; + + final saved = List.from(_compartments); + final savedAnchor = _gfLowCeilingAnchor; + try { + final span = stopDepth - nextStopDepth; + var elapsed = 0; + while (elapsed < legSeconds) { + final dt = math.min(_trialAscentSliceSeconds, legSeconds - elapsed); + final sliceStart = stopDepth - span * elapsed / legSeconds; + // The last slice lands exactly on the next level, so the arrival + // check below compares against it and not a rounding-error neighbour + // (a cleared ceiling of 0 must not fail against a surface at -1e-16). + final sliceEnd = elapsed + dt >= legSeconds + ? nextStopDepth + : stopDepth - span * (elapsed + dt) / legSeconds; + // Gas eligible at the slice's deeper end, as _ascendLeg charges it. + final gas = ascentGas.gasForDepth(sliceStart); + calculateSegment( + depthMeters: (sliceStart + sliceEnd) / 2.0, + durationSeconds: dt, + fN2: gas.fN2, + fHe: gas.fHe, + ); + elapsed += dt; + // The diver is now at sliceEnd; the ceiling may not be above them. + // On the final slice sliceEnd is the next level itself, which is the + // arrival check the schedule's consistency tests rely on. + if (calculateCeiling() > sliceEnd) return false; + } + return true; + } finally { + _compartments = saved; + _gfLowCeilingAnchor = savedAnchor; + } + } + /// The gas breathed over a stop of [stopSeconds] at [stopDepth], as /// (gas, seconds) chunks: one per whole minute plus a final sub-minute /// remainder. Every chunk — the remainder included — uses _gasForStopMinute @@ -612,22 +782,25 @@ class BuhlmannAlgorithm { /// (MOD) depth it crosses so each sub-leg breathes the gas eligible at that /// sub-leg's deeper end. For [FixedAscentGas] there are no switch depths, so /// this collapses to a single average-depth segment (legacy behavior). + /// [phase] picks the ascent rate: the leg down to the first stop is working + /// travel, every leg after it is climbing the stop grid. void _simulateAscent( double fromDepth, double toDepth, AscentGasPlan ascentGas, - SchedulePolicy policy, - ) { + SchedulePolicy policy, { + AscentPhase phase = AscentPhase.toFirstStop, + }) { if (fromDepth <= toDepth) return; final switches = ascentGas.switchDepthsBetween(fromDepth, toDepth); double segTop = fromDepth; for (final switchDepth in switches) { // switches is descending; each is strictly between toDepth and fromDepth. - _ascendLeg(segTop, switchDepth, ascentGas, policy); + _ascendLeg(segTop, switchDepth, ascentGas, policy, phase); segTop = switchDepth; } - _ascendLeg(segTop, toDepth, ascentGas, policy); + _ascendLeg(segTop, toDepth, ascentGas, policy, phase); } /// Load one un-split ascent sub-leg on the gas eligible at its deeper end. @@ -636,11 +809,15 @@ class BuhlmannAlgorithm { double toDepth, AscentGasPlan ascentGas, SchedulePolicy policy, + AscentPhase phase, ) { if (fromDepth <= toDepth) return; final gas = ascentGas.gasForDepth(fromDepth); - final depthChange = fromDepth - toDepth; - final ascentTimeSeconds = (depthChange / policy.ascentRate * 60).round(); + final ascentTimeSeconds = policy.ascentSeconds( + fromDepth: fromDepth, + toDepth: toDepth, + phase: phase, + ); final avgDepth = (fromDepth + toDepth) / 2.0; calculateSegment( @@ -666,7 +843,7 @@ class BuhlmannAlgorithm { SchedulePolicy? policy, }) { final plan = ascentGas ?? FixedAscentGas(fN2: fN2, fHe: fHe); - final rate = policy?.ascentRate ?? ascentRate; + final p = policy ?? _defaultPolicy(); final stops = calculateDecoSchedule( currentDepth: currentDepth, ascentGas: plan, @@ -677,17 +854,10 @@ class BuhlmannAlgorithm { for (final stop in stops) { tts += stop.durationSeconds; } - - double depth = currentDepth; - for (final stop in stops) { - final ascentTime = ((depth - stop.depthMeters) / rate * 60).round(); - tts += ascentTime; - depth = stop.depthMeters; - } - - if (depth > 0) { - tts += (depth / rate * 60).round(); - } + tts += p.ascentTravelSeconds( + fromDepth: currentDepth, + stopDepths: stops.map((s) => s.depthMeters), + ); return tts; } @@ -718,9 +888,7 @@ class BuhlmannAlgorithm { // Only calculate ceiling/stops when actually in deco (NDL < 0). // The GF-interpolated ceiling is for ascent planning during deco, // not for display when the diver can still ascend directly to surface. - final ceiling = ndl < 0 - ? calculateCeiling(currentDepth: currentDepth) - : 0.0; + final ceiling = ndl < 0 ? calculateCeiling() : 0.0; final stops = ndl < 0 ? calculateDecoSchedule( currentDepth: currentDepth, @@ -747,8 +915,15 @@ class BuhlmannAlgorithm { ); safetyStop = 0; } else { - // No deco obligation: TTS is just the direct ascent to the surface. - tts = (currentDepth / (policy?.ascentRate ?? ascentRate) * 60).round(); + // No deco obligation: TTS is the direct ascent to the surface at the + // working rate, one AscentPhase.toFirstStop leg. The slower deco rates + // describe climbing the stop grid and leaving the last stop, and this + // diver does neither - see AscentPhase.surfacingAfter, which draws the + // same line for the surfacing leg of a schedule that held no stop. + tts = (policy ?? _defaultPolicy()).ascentSeconds( + fromDepth: currentDepth, + toDepth: 0, + ); // Report the recommended safety stop separately: a 3-minute stop, minus // time already accumulated in the 3-6 m safety-stop zone during the ascent diff --git a/lib/core/deco/deco_model.dart b/lib/core/deco/deco_model.dart index 1635a5246b..7e811a92f8 100644 --- a/lib/core/deco/deco_model.dart +++ b/lib/core/deco/deco_model.dart @@ -61,8 +61,17 @@ abstract class DecoModel { BreathingConfig breathing, ); - /// Current ceiling in meters (0 = clear to surface). - double ceilingMeters(TissueState state, {double currentDepth = 0}); + /// Current ceiling in meters: the shallowest depth the diver may ascend to + /// right now. A property of [state] alone - for a gradient-factor model the + /// GF that applies is a function of the ceiling's own depth, so the answer + /// does not depend on where the diver currently is. + double ceilingMeters(TissueState state); + + /// Ceiling in meters against the surface target (GF-high for Buhlmann): + /// 0 means a direct ascent to the surface is allowed and no decompression + /// is owed. Distinct from [ceilingMeters], which can be non-zero mid-dive + /// on a profile that still owes nothing. + double surfaceCeilingMeters(TissueState state); /// No-deco limit in seconds at [depthMeters] on [breathing]; /// -1 when already in deco. @@ -118,6 +127,19 @@ class BuhlmannGf implements DecoModel { return _capture(); } + /// Longest slice of a depth-changing segment loaded at a single depth. + /// + /// Gas loading approaches the inspired pressure exponentially, so holding + /// one mean depth across a long ramp is not the same as travelling it: a + /// 20-minute descent loaded as 20 minutes at its mean depth under-loads the + /// tissues noticeably. Ten seconds keeps the error negligible while staying + /// cheap enough for a half-hour leg (180 slices). + /// + /// Flat segments need no slicing - their mean depth *is* their depth - so + /// dive-log replay, which feeds sample-to-sample segments a few seconds + /// long, is unaffected either way. + static const int _maxRampSliceSeconds = 10; + @override TissueState applySegment( TissueState state, @@ -125,19 +147,48 @@ class BuhlmannGf implements DecoModel { BreathingConfig breathing, ) { _restore(state); - final avgDepth = (segment.startDepth + segment.endDepth) / 2.0; - _algorithm.calculateSegment( - depthMeters: avgDepth, - durationSeconds: segment.durationSeconds, - breathing: breathing, - ); + + final isRamp = segment.startDepth != segment.endDepth; + if (!isRamp || segment.durationSeconds <= _maxRampSliceSeconds) { + final avgDepth = (segment.startDepth + segment.endDepth) / 2.0; + _algorithm.calculateSegment( + depthMeters: avgDepth, + durationSeconds: segment.durationSeconds, + breathing: breathing, + ); + return _capture(); + } + + final total = segment.durationSeconds; + final span = segment.endDepth - segment.startDepth; + var elapsed = 0; + while (elapsed < total) { + final remaining = total - elapsed; + final slice = remaining < _maxRampSliceSeconds + ? remaining + : _maxRampSliceSeconds; + final from = segment.startDepth + span * (elapsed / total); + final to = segment.startDepth + span * ((elapsed + slice) / total); + _algorithm.calculateSegment( + depthMeters: (from + to) / 2.0, + durationSeconds: slice, + breathing: breathing, + ); + elapsed += slice; + } return _capture(); } @override - double ceilingMeters(TissueState state, {double currentDepth = 0}) { + double ceilingMeters(TissueState state) { + _restore(state); + return _algorithm.calculateCeiling(); + } + + @override + double surfaceCeilingMeters(TissueState state) { _restore(state); - return _algorithm.calculateCeiling(currentDepth: currentDepth); + return _algorithm.surfaceTargetCeiling(); } @override diff --git a/lib/core/deco/schedule_policy.dart b/lib/core/deco/schedule_policy.dart index 4acde7e9d2..10e23c8ac0 100644 --- a/lib/core/deco/schedule_policy.dart +++ b/lib/core/deco/schedule_policy.dart @@ -7,18 +7,55 @@ class AirBreakPolicy { final int breakSeconds; } +/// Which phase of the ascent a travel leg belongs to. +/// +/// The rates a diver is taught are named after the phase, not the depth: the +/// leg off the bottom is a working ascent whether the first stop is at 21 m or +/// 6 m, and the leg off the last stop is the slow one whether or not anything +/// deeper was required. Only [betweenStops] needs a depth, to tell an +/// intermediate stop from a shallow one. +enum AscentPhase { + /// Bottom to the first decompression stop - or, on a dive that owes + /// nothing, straight to the surface. A no-deco ascent is this phase and + /// only this phase: none of the slower deco rates apply to it. + toFirstStop, + + /// Between two decompression stops. + betweenStops, + + /// The last decompression stop to the surface. + fromLastStop; + + /// The phase of a leg that lands on the surface, given the phase the walk + /// over the stops has reached. + /// + /// Off the last stop once any stop has actually been held; still the + /// working ascent when none was, because a dive that owes nothing never + /// leaves the [toFirstStop] leg it started on. + static AscentPhase surfacingAfter(AscentPhase walked) => + walked == AscentPhase.toFirstStop + ? AscentPhase.toFirstStop + : AscentPhase.fromLastStop; +} + /// How a decompression schedule is generated, independent of the tissue /// model. Defaults reproduce the engine's legacy behavior. class SchedulePolicy { const SchedulePolicy({ this.stopIncrement = 3.0, this.lastStopDepth = 3.0, + this.shallowStopDepth = 9.0, this.ascentRate = 9.0, + double? intermediateAscentRate, + double? shallowAscentRate, + double? finalAscentRate, this.descentRate = 18.0, - this.ascentRateBands, this.gasSwitchStopSeconds = 0, + this.snapStopsToWholeMinutes = false, this.airBreaks, - }); + }) : intermediateAscentRate = intermediateAscentRate ?? ascentRate, + shallowAscentRate = shallowAscentRate ?? ascentRate, + finalAscentRate = finalAscentRate ?? ascentRate; /// Deco stop depth increment in meters. final double stopIncrement; @@ -26,32 +63,120 @@ class SchedulePolicy { /// Shallowest deco stop depth in meters (3 or 6). final double lastStopDepth; - /// Ascent rate in meters per minute (used when [ascentRateBands] is null). + /// Depth in meters below which a decompression stop counts as *shallow*, + /// switching travel between stops from [intermediateAscentRate] to + /// [shallowAscentRate]. + /// + /// 9 m by convention: the 9/6/3 stops are the shallow ones. Not a plan + /// setting, because the standards that name these phases do not + /// parameterise the boundary either. + final double shallowStopDepth; + + /// Working ascent rate in meters per minute: bottom to the first stop, and + /// the whole ascent on a dive that owes no decompression. final double ascentRate; + /// Ascent rate in meters per minute between intermediate stops - those + /// deeper than [shallowStopDepth]. + /// + /// Defaults to [ascentRate], so a caller that knows about only one rate + /// keeps its previous behaviour exactly. That is what keeps the dive log, + /// profile analysis and VPM-B unaffected: only the planner sets the phases + /// apart. + final double intermediateAscentRate; + + /// Ascent rate in meters per minute between shallow stops - those at or + /// above [shallowStopDepth]. Defaults to [ascentRate]. + final double shallowAscentRate; + + /// Ascent rate in meters per minute from the last stop to the surface. + /// + /// The shallowest metres are where a given depth change costs the most + /// pressure change, so this is conventionally the slowest. Applies only + /// when there was a last stop to leave; see [AscentPhase.toFirstStop]. + /// Defaults to [ascentRate]. + final double finalAscentRate; + /// Descent rate in meters per minute. final double descentRate; - /// Optional per-depth-band ascent rates (Subsurface's four bands), ordered - /// `[belowMean75, mean75to50, mean50to6, last6m]` in m/min. Null = use the - /// single [ascentRate] everywhere (legacy behavior). - final List? ascentRateBands; - /// Minimum time in seconds to hold at a stop where the breathed gas /// changes (0 = no minimum). final int gasSwitchStopSeconds; + /// Extend each stop so it ends on a whole minute of the ascent clock. + /// + /// Travel between stops rarely takes a whole number of minutes (3 m at + /// 6 m/min is thirty seconds), so without this every stop would start and + /// end at odd seconds and a slate could not be read against a watch. With + /// it, the stop absorbs the odd seconds of the leg that led to it - at most + /// 59 s more at each stop, always in the conservative direction - and the + /// schedule reads as whole minutes from the first stop on. Subsurface does + /// the same (its first stop chunk is sized to land the clock on a minute). + /// + /// Off by default so live decompression numbers in the dive log and on the + /// profile keep their exact values; the planner turns it on. + final bool snapStopsToWholeMinutes; + /// Optional O2 air-break policy; null = no air breaks. final AirBreakPolicy? airBreaks; - /// The ascent rate that applies at [depthMeters], given the plan's - /// [meanDepthMeters]. Bands split at 75% and 50% of mean depth and at 6 m. - double ascentRateForDepth(double depthMeters, double meanDepthMeters) { - final bands = ascentRateBands; - if (bands == null || bands.length != 4) return ascentRate; - if (depthMeters <= 6) return bands[3]; - if (depthMeters <= meanDepthMeters * 0.5) return bands[2]; - if (depthMeters <= meanDepthMeters * 0.75) return bands[1]; - return bands[0]; + /// The rate in meters per minute for a leg of [phase] starting at + /// [fromDepth]. + double ascentRateFor(AscentPhase phase, {double fromDepth = 0}) => + switch (phase) { + AscentPhase.toFirstStop => ascentRate, + AscentPhase.betweenStops => + fromDepth > shallowStopDepth + ? intermediateAscentRate + : shallowAscentRate, + AscentPhase.fromLastStop => finalAscentRate, + }; + + /// Seconds to ascend from [fromDepth] to [toDepth] during [phase]. + /// + /// Returns 0 for a level or descending leg, which lets callers hand over + /// whatever pair of depths they have without guarding first. + int ascentSeconds({ + required double fromDepth, + required double toDepth, + AscentPhase phase = AscentPhase.toFirstStop, + }) { + if (fromDepth <= toDepth) return 0; + final rate = ascentRateFor(phase, fromDepth: fromDepth); + if (rate <= 0) return 0; + return ((fromDepth - toDepth) / rate * 60).round(); + } + + /// Total seconds spent *travelling* on an ascent from [fromDepth] that + /// pauses at each of [stopDepths] (deepest first) and finishes at the + /// surface. Excludes the time held at the stops themselves. + /// + /// Assigns the phases the way a schedule runs: down to the first stop, then + /// between stops, then off the last stop. With no stops at all the whole + /// ascent is one [AscentPhase.toFirstStop] leg. Callers that need the legs + /// individually - to sample the profile, or to charge gas against the right + /// cylinder - walk the same sequence, which this mirrors so a total can + /// never disagree with the legs it is made of. + int ascentTravelSeconds({ + required double fromDepth, + required Iterable stopDepths, + }) { + var seconds = 0; + var depth = fromDepth; + var phase = AscentPhase.toFirstStop; + for (final stop in stopDepths) { + seconds += ascentSeconds(fromDepth: depth, toDepth: stop, phase: phase); + depth = stop; + phase = AscentPhase.betweenStops; + } + if (depth > 0) { + seconds += ascentSeconds( + fromDepth: depth, + toDepth: 0, + phase: AscentPhase.surfacingAfter(phase), + ); + } + return seconds; } } diff --git a/lib/core/deco/vpm_b.dart b/lib/core/deco/vpm_b.dart index cfb9825d61..6a40b6f520 100644 --- a/lib/core/deco/vpm_b.dart +++ b/lib/core/deco/vpm_b.dart @@ -196,11 +196,17 @@ class VpmB implements DecoModel { } @override - double ceilingMeters(TissueState state, {double currentDepth = 0}) { + double ceilingMeters(TissueState state) { _load(state as VpmBState); return _algo.ascentCeilingDepthMeters(); } + /// VPM-B has a single ascent ceiling: there is no GF-low/GF-high split, so + /// the operative ceiling and the surface target are the same number. A + /// non-zero ascent ceiling already means the surface is not permitted. + @override + double surfaceCeilingMeters(TissueState state) => ceilingMeters(state); + @override int ndlSeconds( TissueState state, { diff --git a/lib/core/icons/mdi_icons.dart b/lib/core/icons/mdi_icons.dart index cb334d2dff..6bec4e3e85 100644 --- a/lib/core/icons/mdi_icons.dart +++ b/lib/core/icons/mdi_icons.dart @@ -13,11 +13,13 @@ import 'package:flutter/widgets.dart'; /// /// Code points are taken verbatim from material_design_icons_flutter 7.0.7296. abstract final class MdiIcons { - static const String _family = 'Material Design Icons'; + /// The family declared for the vendored font in `pubspec.yaml`. Public so + /// tests can look the code points below up in the committed `.ttf`. + static const String fontFamily = 'Material Design Icons'; static const IconData divingScubaTank = IconData( 0xf0dc3, - fontFamily: _family, + fontFamily: fontFamily, ); // Dive glyphs that were sitting unused in the bundled font while the @@ -25,21 +27,42 @@ abstract final class MdiIcons { // eyeball for a mask, scissors for a knife, a gym dumbbell for weights and a // generic flag for an SMB (#1189). `knifeMilitary` rather than `knife`, // which is a chef's knife. - static const IconData divingFlippers = IconData(0xf0dbf, fontFamily: _family); + static const IconData divingFlippers = IconData( + 0xf0dbf, + fontFamily: fontFamily, + ); static const IconData divingScubaMask = IconData( 0xf0dc1, - fontFamily: _family, + fontFamily: fontFamily, ); static const IconData divingScubaFlag = IconData( 0xf0dc2, - fontFamily: _family, + fontFamily: fontFamily, + ); + static const IconData knifeMilitary = IconData( + 0xf09fc, + fontFamily: fontFamily, + ); + static const IconData weight = IconData(0xf05a1, fontFamily: fontFamily); + + // Five of the six equipment types added for #1518 found their shape already + // sitting in the bundled font: a literal snorkel, a bezelled compass card, a + // needle gauge for the instrument family (SPG, depth gauge, bottom timer), a + // crossed screwdriver and wrench for a save-a-dive kit, and a crew-neck + // shirt for a rash guard. The sixth, the undergarment, is drawn in + // `SubmersionIcons`: no icon font has a thermal base layer. + static const IconData divingSnorkel = IconData( + 0xf0dc5, + fontFamily: fontFamily, ); - static const IconData knifeMilitary = IconData(0xf09fc, fontFamily: _family); - static const IconData weight = IconData(0xf05a1, fontFamily: _family); + static const IconData compass = IconData(0xf018b, fontFamily: fontFamily); + static const IconData gauge = IconData(0xf029a, fontFamily: fontFamily); + static const IconData tools = IconData(0xf1064, fontFamily: fontFamily); + static const IconData tshirtCrew = IconData(0xf0a7b, fontFamily: fontFamily); - static const IconData fish = IconData(0xf023a, fontFamily: _family); - static const IconData turtle = IconData(0xf0cd7, fontFamily: _family); - static const IconData shark = IconData(0xf18ba, fontFamily: _family); - static const IconData jellyfish = IconData(0xf0f01, fontFamily: _family); - static const IconData dolphin = IconData(0xf18b4, fontFamily: _family); + static const IconData fish = IconData(0xf023a, fontFamily: fontFamily); + static const IconData turtle = IconData(0xf0cd7, fontFamily: fontFamily); + static const IconData shark = IconData(0xf18ba, fontFamily: fontFamily); + static const IconData jellyfish = IconData(0xf0f01, fontFamily: fontFamily); + static const IconData dolphin = IconData(0xf18b4, fontFamily: fontFamily); } diff --git a/lib/core/icons/submersion_icons.dart b/lib/core/icons/submersion_icons.dart index 30519d6d75..e25cf0cc2e 100644 --- a/lib/core/icons/submersion_icons.dart +++ b/lib/core/icons/submersion_icons.dart @@ -10,8 +10,8 @@ import 'package:flutter/widgets.dart'; /// a reel, a kick scooter for a DPV. Issue #1189 reported the result as icons /// that "don't really fit the equipment". /// -/// These ten are drawn in `tool/equipment_glyphs.py` and compiled into -/// `assets/fonts/submersion-equipment.ttf` by +/// The constants below are drawn in `tool/equipment_glyphs.py` and compiled +/// into `assets/fonts/submersion-equipment.ttf` by /// `tool/build_equipment_icon_font.py`. Regenerate with: /// /// ```sh @@ -63,4 +63,10 @@ abstract final class SubmersionIcons { /// Diver propulsion vehicle: torpedo hull with a shrouded prop. static const IconData dpv = IconData(0xe909, fontFamily: fontFamily); + + /// Drysuit undersuit: the suit silhouette again, quilted. + static const IconData undersuit = IconData(0xe90a, fontFamily: fontFamily); + + /// Base layer: a long-sleeved thermal top, stopping at the hips. + static const IconData baselayer = IconData(0xe90b, fontFamily: fontFamily); } diff --git a/lib/core/presentation/pages/startup_page.dart b/lib/core/presentation/pages/startup_page.dart index 4a88e8716a..edc486149c 100644 --- a/lib/core/presentation/pages/startup_page.dart +++ b/lib/core/presentation/pages/startup_page.dart @@ -6,12 +6,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqlite3/sqlite3.dart' as sqlite3; import 'package:url_launcher/url_launcher.dart'; import 'package:submersion/app.dart' show resolveAppLocale; import 'package:submersion/app.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/storage/scratch_sweep.dart'; +import 'package:submersion/core/services/sync/changeset_log/local_only_tombstone_gc.dart'; +import 'package:submersion/core/services/sync/changeset_log/peer_cursor_store.dart'; +import 'package:submersion/core/services/sync/changeset_log/publish_state_store.dart'; import 'package:submersion/core/database/database.dart'; import 'package:submersion/core/database/database_engine_preflight.dart'; import 'package:submersion/core/database/database_version_exception.dart'; @@ -40,13 +46,17 @@ import 'package:submersion/core/services/notification_service.dart'; import 'package:submersion/core/utils/app_version.dart'; import 'package:submersion/features/backup/data/repositories/backup_preferences.dart'; import 'package:submersion/features/backup/data/services/backup_service.dart'; +import 'package:submersion/features/backup/data/services/backup_schema_probe.dart'; import 'package:submersion/features/backup/data/services/backup_target.dart'; +import 'package:submersion/features/backup/data/services/downgrade_restore_candidates.dart'; +import 'package:submersion/features/backup/data/services/pre_downgrade_backup_service.dart'; import 'package:submersion/features/backup/data/services/pre_migration_backup_service.dart'; import 'package:submersion/features/backup/domain/entities/backup_record.dart'; import 'package:submersion/features/backup/domain/entities/backup_type.dart'; import 'package:submersion/features/backup/domain/exceptions/backup_failed_exception.dart'; import 'package:submersion/features/maps/data/services/tile_cache_service.dart'; import 'package:submersion/features/marine_life/data/repositories/species_repository.dart'; +import 'package:submersion/features/marine_life/data/services/builtin_species_seed_version_store.dart'; import 'package:submersion/features/media/data/repositories/media_repository.dart'; import 'package:submersion/features/media_store/data/media_deletion_coordinator.dart'; import 'package:submersion/features/media_store/data/media_orphan_backlog_sweep.dart'; @@ -128,6 +138,21 @@ class StartupWrapper extends StatefulWidget { })? preMigrationBackupFactory; + /// Optional override for the pre-downgrade backup service factory (tests, + /// which must not copy real database files). + @visibleForTesting + final PreDowngradeBackupService Function({ + required String livePath, + required BackupPreferences preferences, + })? + preDowngradeBackupFactory; + + /// Optional override for the `PRAGMA user_version` probe run against a + /// candidate backup before it is offered (used in tests, whose fixture + /// "backups" are text files no SQLite build would open). + @visibleForTesting + final int? Function(String path)? downgradeCandidateProbeOverride; + /// Optional override for the database engine preflight (used in tests to /// simulate a build whose native library does not resolve). @visibleForTesting @@ -151,6 +176,8 @@ class StartupWrapper extends StatefulWidget { this.schemaVersionProbeOverride, this.closeAppOverride, this.preMigrationBackupFactory, + this.preDowngradeBackupFactory, + this.downgradeCandidateProbeOverride, this.enginePreflightOverride, this.restoreOverride, }); @@ -190,6 +217,13 @@ class _StartupWrapperState extends State /// runs before the router and the database exist. String? _backupsDirectory; + /// The pre-upgrade safety copy offered on the schema-mismatch screen, or + /// null when the registry holds none this build could open. Separate from + /// [_recoveryBackup] because the two are chosen by opposite rules: the + /// terminal failure screen wants the NEWEST copy, while a mismatch needs + /// the newest copy that is still OLD enough to open here. + BackupRecord? _downgradeBackup; + StartupRestoreStatus _restoreStatus = StartupRestoreStatus.idle; String? _restoreError; @@ -337,6 +371,7 @@ class _StartupWrapperState extends State _dbVersion = e.storedSchemaVersion; _appVersion = e.supportedSchemaVersion; }); + await _loadDowngradeOption(); } } on DatabaseLockedException { // The cached/typed key did not open the file (e.g. a keychain restored @@ -665,6 +700,17 @@ class _StartupWrapperState extends State await timeStartupStep('tileCache', () async { try { await TileCacheService.instance.initialize(); + // Age-sweep the browse cache. Not awaited: it is an indexed delete + // that nothing downstream depends on, and blocking first frame on + // housekeeping is how a splash screen grows a 20-second hang. Scoped + // to the browse store, so a downloaded offline region is never swept. + unawaited( + TileCacheService.instance + .removeOldTiles(TileCacheService.browseTileMaxAge) + .catchError((Object e) { + debugPrint('Browse tile age sweep failed (will retry): $e'); + }), + ); } catch (e) { debugPrint('Warning: Tile cache initialization failed: $e'); } @@ -672,7 +718,11 @@ class _StartupWrapperState extends State await timeStartupStep('speciesSeed', () async { final speciesRepository = SpeciesRepository(); - await speciesRepository.seedBuiltInSpecies(); + await speciesRepository.seedBuiltInSpecies( + versionStore: PrefsBuiltInSpeciesSeedVersionStore( + await SharedPreferences.getInstance(), + ), + ); }); // Unlinked-media sweep, every launch. Fire-and-forget: it must not delay @@ -702,6 +752,55 @@ class _StartupWrapperState extends State ); } }()); + + // Tombstone GC for a library that never syncs, every launch. The cloud + // path runs GC at the tail of a successful sync, which a device with no + // provider never reaches, so its deletion log otherwise grows forever. + // Gated on there being no trace of a prior sync: a device that synced + // and then went local-only still owes its peers those tombstones. Same + // fire-and-forget shape as the media sweep above, for the same reasons. + unawaited(() async { + try { + final db = DatabaseService.instance.database; + await LocalOnlyTombstoneGc( + syncRepository: SyncRepository(database: db), + peerCursors: PeerCursorStore(db), + publishStates: PublishStateStore(db), + ).run(); + } catch (e, stackTrace) { + debugPrint( + 'Local-only tombstone GC failed (will retry): $e\n$stackTrace', + ); + } + }()); + + // Scratch-file sweep, at most once a day. Unlike the media sweep above, + // whose probe is one indexed SELECT, this walks the filesystem, so it + // carries a stamp rather than running unguarded on every launch. Same + // fire-and-forget shape: housekeeping must never delay first frame, and + // a failure is a whole launch away from its retry. + unawaited(() async { + try { + final prefs = await SharedPreferences.getInstance(); + final stampMs = prefs.getInt(kScratchSweepStampKey); + final lastSweptAt = stampMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(stampMs); + final now = DateTime.now(); + if (!shouldSweepScratch(lastSweptAt: lastSweptAt, now: now)) return; + + await StorageScratchSweep( + temporaryDirectory: getTemporaryDirectory, + supportDirectory: getApplicationSupportDirectory, + ).run(now: now); + + // Stamped after the pass, so a sweep that throws part way retries + // tomorrow rather than being recorded as done. + await prefs.setInt(kScratchSweepStampKey, now.millisecondsSinceEpoch); + } catch (e, stackTrace) { + debugPrint('Scratch sweep failed (will retry): $e\n$stackTrace'); + } + }()); // coverage:ignore-end } @@ -871,13 +970,171 @@ class _StartupWrapperState extends State } } + /// Finds a pre-upgrade safety copy the diver could go BACK to, for the + /// schema-mismatch screen. + /// + /// Everything this needs is already in memory when the mismatch is raised: + /// `main.dart` loads SharedPreferences before it opens the database, and + /// the backup registry lives there precisely so it survives a database swap + /// (issue #1589). No restore UI is reachable from here -- it lives behind + /// the database that will not open. + /// + /// The registry only records what a copy CLAIMED to hold. Opening it is + /// what proves the claim, so each candidate is probed before it is offered: + /// a button that fails the same way the database just did would leave the + /// diver exactly where this screen already left them. + /// + /// Best-effort and silent on failure, like [_loadRecoveryOptions]: a second + /// failure here must degrade to the plain mismatch screen rather than + /// replace one terminal state with another. + Future _loadDowngradeOption() async { + try { + final history = BackupPreferences(widget.prefs).getHistory(); + final candidates = downgradeRestoreCandidates( + history, + supportedSchemaVersion: _appVersion, + ); + + for (final record in candidates) { + // Non-null by construction: downgradeRestoreCandidates drops records + // without a plain local path. + final path = record.localPath!; + // Synchronous stat, matching _loadRecoveryOptions: the set is a + // handful of registry entries read once on an already-terminal + // screen, and the async form left the widget tests covering this + // screen pumping until their timeout. + if (!File(path).existsSync()) continue; + final stored = _probeCandidateSchema(path); + if (stored == null) continue; + + // Two separate questions, and the offer needs both. + // + // Can this build open the file at all? The selector already applied + // this bound to what the registry CLAIMS; re-applying it to what the + // file actually holds is what makes the claim load-bearing. + if (stored > _appVersion) continue; + + // Is it the copy the card is about to describe? The card names + // record.fromSchemaVersion and toSchemaVersion, so a file whose own + // user_version disagrees with the claim would have the screen promise + // to undo one upgrade and restore a database from another. For a + // pre-migration copy the two are equal by construction: the service + // records fromSchemaVersion from the live file's user_version at copy + // time, and settling the journal mode does not change it. A + // discrepancy therefore means the registry and the file have come + // apart -- a replaced file, or a hand-edited registry -- and the + // honest move on a data-loss path is to offer nothing rather than + // something mislabelled. + if (stored != record.fromSchemaVersion) { + debugPrint( + 'Skipping backup at $path: it holds schema v$stored but the ' + 'registry claims v${record.fromSchemaVersion}', + ); + continue; + } + + if (!mounted) return; + setState(() => _downgradeBackup = record); + return; + } + } catch (e) { + debugPrint('Startup downgrade options unavailable: $e'); + } + } + + /// Reads the schema version a candidate backup actually holds. + /// + /// The real work lives in [probeBackupSchemaVersion], which owns the + /// keyed-then-unkeyed fallback and is tested directly; this only supplies + /// the live key and the test seam. + int? _probeCandidateSchema(String path) { + final probeOverride = widget.downgradeCandidateProbeOverride; + if (probeOverride != null) return probeOverride(path); + + return probeBackupSchemaVersion( + path, + keyHex: DatabaseService.instance.databaseKeyHex, + onDiagnostic: debugPrint, + ); + } + + /// Copies the newer-schema database into the backups folder and registers + /// it, BEFORE the downgrade restore swaps it away. + /// + /// [DatabaseService.restore] deletes its own `.pre-restore` copy once the + /// swap succeeds, so without this step accepting the offer would be the + /// moment every dive logged in the newer build stopped existing. A throw + /// here aborts the restore, which costs the diver nothing they had a + /// moment ago. + Future _preserveNewerDatabase() async { + final prefs = BackupPreferences(widget.prefs); + final dbPath = await widget.locationService.getDatabasePath(); + + final PreDowngradeBackupService service; + final String appVersion; + BackupDirLease? lease; + if (widget.preDowngradeBackupFactory != null) { + service = widget.preDowngradeBackupFactory!( + livePath: dbPath, + preferences: prefs, + ); + appVersion = '0.0.0.0'; + } else { + final info = await PackageInfo.fromPlatform(); + appVersion = formatAppVersion(info); + service = PreDowngradeBackupService( + livePathProvider: () async => dbPath, + // Resolved lazily inside the provider, for the same reason + // _runPreMigrationBackup does it: resolution touches the filesystem + // and can throw, and inside the provider that throw is recoverable + // through fallbackBackupsDirProvider. + backupsDirProvider: () async { + lease = await BackupService.resolveBackupsDirectoryLeased(prefs); + return lease!.path; + }, + fallbackBackupsDirProvider: + BackupService.resolveDefaultBackupsDirectory, + preferences: prefs, + databaseKeyHexProvider: () => DatabaseService.instance.databaseKeyHex, + ); + } + + try { + await service.preserve( + storedSchemaVersion: _dbVersion, + appVersion: appVersion, + ); + } finally { + await lease?.release(); + } + } + + /// Goes back to [_downgradeBackup], keeping the newer database first. + /// + /// The order is the whole point: preserve, then swap. A failure to preserve + /// leaves the diver on the mismatch screen with both files intact, which is + /// strictly better than a completed downgrade that lost the newer one. + Future _restoreFromDowngradeBackup() => + _restoreAtStartup(_downgradeBackup, before: _preserveNewerDatabase); + /// Swaps [_recoveryBackup] in for the live database, then resumes startup. /// /// Safe here precisely because startup failed: the database is closed, so /// [DatabaseService.restore] does its staged swap without contending with an /// open connection, and it rolls the original file back if the swap fails. - Future _restoreFromStartupBackup() async { - final record = _recoveryBackup; + Future _restoreFromStartupBackup() => + _restoreAtStartup(_recoveryBackup); + + /// Shared body of both startup restores: swap [record] in, then resume + /// startup from the top. + /// + /// [before] runs while the screen already shows progress and before + /// anything is swapped, so a throw from it aborts with the live database + /// untouched. + Future _restoreAtStartup( + BackupRecord? record, { + Future Function()? before, + }) async { final path = record?.localPath; if (path == null) return; if (_restoreStatus == StartupRestoreStatus.running) return; @@ -899,6 +1156,7 @@ class _StartupWrapperState extends State } try { + await before?.call(); if (widget.restoreOverride != null) { await widget.restoreOverride!(path, onProgress); } else { @@ -921,6 +1179,11 @@ class _StartupWrapperState extends State _state = _StartupState.initializing; _errorMessage = ''; _recoveryBackup = null; + _downgradeBackup = null; + // Cleared so the relaunch shows the splash rather than the screen the + // diver just acted on. The reopen re-raises the mismatch if the swap + // somehow left a newer file in place. + _isVersionMismatch = false; }); await _runInitialization(); } catch (e) { @@ -960,40 +1223,50 @@ class _StartupWrapperState extends State StartupFailureView.previousReleasesUrl, ); - Future _openPreviousReleases() async { - // Same reasoning as _showBackupsFolder: a refused hand-off shows up as a - // false return at least as often as an exception, and an unlogged one - // makes the button look broken. The address is rendered beneath the - // button either way, so the diver keeps a usable route. + /// Hands [uri] to the browser, reporting both ways the hand-off can fail. + /// + /// Same reasoning as _showBackupsFolder: a refused hand-off shows up as a + /// false return at least as often as an exception, and an unlogged one makes + /// the button look broken. Every caller renders its address on screen too, + /// so the diver keeps a usable route either way, and leaving them on this + /// screen is the only safe fallback: the database is untouched and must stay + /// that way. + /// + /// [label] names the destination in the log line, since the three terminal + /// screens offer three different release pages. + Future _openReleasePage(Uri uri, String label) async { try { final launched = await launchUrl( - _previousReleasesUri, + uri, mode: LaunchMode.externalApplication, ); if (!launched) { - debugPrint( - 'Could not open the releases page: launchUrl returned false', - ); + debugPrint('Could not open the $label page: launchUrl returned false'); } } catch (e) { - debugPrint('Could not open the releases page: $e'); + debugPrint('Could not open the $label page: $e'); } } + Future _openPreviousReleases() => + _openReleasePage(_previousReleasesUri, 'releases'); + static final Uri _latestReleaseUri = Uri.parse( VersionMismatchView.latestReleaseUrl, ); - Future _openLatestRelease() async { - try { - await launchUrl(_latestReleaseUri, mode: LaunchMode.externalApplication); - } catch (_) { - // Leaving the user on this screen is the only safe fallback: the - // database is untouched and must stay that way. VersionMismatchView - // renders this same URL beneath the button, so a launch failure still - // leaves the user an address they can type in manually. - } - } + Future _openLatestRelease() => + _openReleasePage(_latestReleaseUri, 'latest stable release'); + + static final Uri _betaReleasesUri = Uri.parse( + VersionMismatchView.betaReleasesUrl, + ); + + /// The other half of the version-mismatch screen's answer (#1588). When a + /// beta build wrote the file, the stable page above is the build that just + /// refused it; this is where the build that can open it actually lives. + Future _openBetaReleases() => + _openReleasePage(_betaReleasesUri, 'beta builds'); void _quitApp() { if (widget.closeAppOverride != null) { @@ -1223,7 +1496,14 @@ class _StartupWrapperState extends State textColor: textColor, subtitleColor: subtitleColor, onDownloadLatest: _openLatestRelease, + onOpenBetaBuilds: _openBetaReleases, onClose: _closeApp, + restoreCandidate: _downgradeBackup, + onRestoreBackup: _downgradeBackup == null + ? null + : _restoreFromDowngradeBackup, + restoreStatus: _restoreStatus, + restoreError: _restoreError, ); } diff --git a/lib/core/presentation/startup_restore_status.dart b/lib/core/presentation/startup_restore_status.dart new file mode 100644 index 0000000000..a809d50789 --- /dev/null +++ b/lib/core/presentation/startup_restore_status.dart @@ -0,0 +1,6 @@ +/// How far an in-place restore offered by a startup screen has got. +/// +/// Shared by the terminal failure screen and the schema-mismatch screen, both +/// of which can offer a backup and both of which run before the router, the +/// database and any state management exist. +enum StartupRestoreStatus { idle, running, failed } diff --git a/lib/core/presentation/widgets/chart_zoom_controls.dart b/lib/core/presentation/widgets/chart_zoom_controls.dart new file mode 100644 index 0000000000..257c6d5122 --- /dev/null +++ b/lib/core/presentation/widgets/chart_zoom_controls.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Minus / level / plus, led by a reset button once zoomed. +/// +/// Extracted from the dive profile legend so the statistics trend charts get +/// the same control rather than a lookalike. Zooming in with no visible way +/// back out is the state this exists to prevent. +class ChartZoomControls extends StatelessWidget { + const ChartZoomControls({ + super.key, + required this.zoomLevel, + required this.minZoom, + required this.maxZoom, + required this.onZoomIn, + required this.onZoomOut, + required this.onResetZoom, + this.keyPrefix, + this.dense = false, + }); + + final double zoomLevel; + final double minZoom; + final double maxZoom; + final VoidCallback onZoomIn; + final VoidCallback onZoomOut; + final VoidCallback onResetZoom; + + /// Distinguishes several control rows on one page, as the statistics pages + /// stack a chart per card. + final String? keyPrefix; + + /// Drops the buttons' padded 48px tap targets to the 32px box they already + /// declare, making the row 32px tall instead of 48. + /// + /// For hosts where these controls share a line with dense content and the + /// spare height reads as a band of dead space - the dive profile legend + /// sits directly above its chart. Off by default: a padded tap target is + /// the better default on touch. + final bool dense; + + Key? _key(String name) => + keyPrefix == null ? null : ValueKey('$keyPrefix-$name'); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isZoomed = zoomLevel > minZoom; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Leads the row so that appearing does not displace the buttons: the + // hosts pin these controls to the trailing edge, so a trailing reset + // button would slide minus/plus left and land on the plus the user + // just clicked. + if (isZoomed) + IconButton( + key: _key('zoom-reset'), + onPressed: onResetZoom, + icon: const Icon(Icons.fit_screen), + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + style: dense + ? IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null, + tooltip: context.l10n.diveLog_profile_tooltip_resetZoom, + ), + IconButton( + key: _key('zoom-out'), + onPressed: zoomLevel > minZoom ? onZoomOut : null, + icon: const Icon(Icons.remove), + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + style: dense + ? IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null, + tooltip: context.l10n.diveLog_profile_tooltip_zoomOut, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + '${zoomLevel.toStringAsFixed(1)}x', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: isZoomed + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + IconButton( + key: _key('zoom-in'), + onPressed: zoomLevel < maxZoom ? onZoomIn : null, + icon: const Icon(Icons.add), + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + style: dense + ? IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null, + tooltip: context.l10n.diveLog_profile_tooltip_zoomIn, + ), + ], + ); + } +} diff --git a/lib/core/presentation/widgets/startup_failure_view.dart b/lib/core/presentation/widgets/startup_failure_view.dart index 659f61196f..79249be46d 100644 --- a/lib/core/presentation/widgets/startup_failure_view.dart +++ b/lib/core/presentation/widgets/startup_failure_view.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/presentation/startup_failure.dart'; +import 'package:submersion/core/presentation/startup_restore_status.dart'; +import 'package:submersion/core/presentation/widgets/startup_restore_card.dart'; import 'package:submersion/features/backup/domain/entities/backup_record.dart'; -import 'package:submersion/features/backup/domain/entities/backup_type.dart'; import 'package:submersion/l10n/l10n_extension.dart'; -/// How far the in-place restore offered by [StartupFailureView] has got. -enum StartupRestoreStatus { idle, running, failed } +// Re-exported so callers that reach StartupRestoreStatus through this screen +// keep working now that the schema-mismatch screen shares the same enum. +export 'package:submersion/core/presentation/startup_restore_status.dart'; /// The terminal startup failure screen. /// @@ -204,8 +206,10 @@ class StartupFailureView extends StatelessWidget { ], if (_canRestore) ...[ const SizedBox(height: 24), - _RecoveryBackupCard( + StartupRestoreCard( record: recoveryBackup!, + title: context.l10n.startup_failure_backupAvailable_title, + actionLabel: context.l10n.startup_failure_restoreAction, onRestore: onRestoreBackup!, status: restoreStatus, error: restoreError, @@ -282,124 +286,3 @@ class StartupFailureView extends StatelessWidget { ); } } - -/// The offered backup, with whatever the restore attempt is currently doing. -class _RecoveryBackupCard extends StatelessWidget { - const _RecoveryBackupCard({ - required this.record, - required this.onRestore, - required this.status, - required this.error, - required this.textColor, - required this.subtitleColor, - }); - - final BackupRecord record; - final VoidCallback onRestore; - final StartupRestoreStatus status; - final String? error; - final Color textColor; - final Color subtitleColor; - - /// Formatted through [MaterialLocalizations] rather than `intl` directly: - /// this screen renders before the diver's saved locale is readable, so the - /// only sensible source of formatting is the resolved system locale that - /// the splash [MaterialApp] already carries. - String _taken(BuildContext context) { - final local = record.timestamp.toLocal(); - final l = MaterialLocalizations.of(context); - final date = l.formatMediumDate(local); - final time = l.formatTimeOfDay(TimeOfDay.fromDateTime(local)); - return '$date $time'; - } - - @override - Widget build(BuildContext context) { - final captionStyle = TextStyle(fontSize: 12, color: subtitleColor); - - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.startup_failure_backupAvailable_title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: textColor, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - context.l10n.startup_failure_backupAvailable_taken( - _taken(context), - ), - style: captionStyle, - textAlign: TextAlign.center, - ), - if (record.type == BackupType.preMigration && - record.fromSchemaVersion != null && - record.toSchemaVersion != null) ...[ - const SizedBox(height: 4), - Text( - context.l10n.startup_failure_backupAvailable_preMigration( - record.fromSchemaVersion!, - record.toSchemaVersion!, - ), - style: captionStyle, - textAlign: TextAlign.center, - ), - ], - if (status == StartupRestoreStatus.failed) ...[ - const SizedBox(height: 12), - Text( - context.l10n.startup_failure_restoreFailed, - style: captionStyle, - textAlign: TextAlign.center, - ), - if (error != null && error!.isNotEmpty) ...[ - const SizedBox(height: 4), - SelectableText( - error!, - style: TextStyle( - fontSize: 12, - color: subtitleColor, - fontFamily: 'monospace', - ), - textAlign: TextAlign.center, - ), - ], - ], - const SizedBox(height: 16), - if (status == StartupRestoreStatus.running) - Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(height: 8), - Text( - context.l10n.startup_failure_restoring, - style: captionStyle, - textAlign: TextAlign.center, - ), - ], - ) - else - FilledButton.tonal( - onPressed: onRestore, - child: Text(context.l10n.startup_failure_restoreAction), - ), - ], - ), - ), - ); - } -} diff --git a/lib/core/presentation/widgets/startup_restore_card.dart b/lib/core/presentation/widgets/startup_restore_card.dart new file mode 100644 index 0000000000..c121fc3099 --- /dev/null +++ b/lib/core/presentation/widgets/startup_restore_card.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/presentation/startup_restore_status.dart'; +import 'package:submersion/features/backup/domain/entities/backup_record.dart'; +import 'package:submersion/features/backup/domain/entities/backup_type.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The backup a startup screen is offering, with whatever the restore attempt +/// is currently doing. +/// +/// Shared by the terminal failure screen and the schema-mismatch screen. The +/// two ask for a restore for different reasons and say different things about +/// it, so the wording is passed in; the parts that must not differ (how the +/// backup identifies itself, what a failure looks like, what happens to the +/// button while a restore runs) live here. +class StartupRestoreCard extends StatelessWidget { + const StartupRestoreCard({ + super.key, + required this.record, + required this.title, + required this.actionLabel, + required this.onRestore, + required this.status, + required this.error, + required this.textColor, + required this.subtitleColor, + this.body, + this.warning, + }); + + final BackupRecord record; + final String title; + final String actionLabel; + final VoidCallback onRestore; + final StartupRestoreStatus status; + final String? error; + final Color textColor; + final Color subtitleColor; + + /// Optional lead-in shown above the backup's own details. + final String? body; + + /// Optional consequence the diver has to weigh before accepting, rendered + /// last and emphasised. Used by the schema-mismatch screen, where accepting + /// means going back to an older database. + final String? warning; + + /// Formatted through [MaterialLocalizations] rather than `intl` directly: + /// this screen renders before the diver's saved locale is readable, so the + /// only sensible source of formatting is the resolved system locale that + /// the splash [MaterialApp] already carries. + String _taken(BuildContext context) { + final local = record.timestamp.toLocal(); + final l = MaterialLocalizations.of(context); + final date = l.formatMediumDate(local); + final time = l.formatTimeOfDay(TimeOfDay.fromDateTime(local)); + return '$date $time'; + } + + @override + Widget build(BuildContext context) { + final captionStyle = TextStyle(fontSize: 12, color: subtitleColor); + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: textColor, + ), + textAlign: TextAlign.center, + ), + if (body != null) ...[ + const SizedBox(height: 8), + Text( + body!, + style: TextStyle(fontSize: 13, color: subtitleColor), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 8), + Text( + context.l10n.startup_failure_backupAvailable_taken( + _taken(context), + ), + style: captionStyle, + textAlign: TextAlign.center, + ), + if (record.type == BackupType.preMigration && + record.fromSchemaVersion != null && + record.toSchemaVersion != null) ...[ + const SizedBox(height: 4), + Text( + context.l10n.startup_failure_backupAvailable_preMigration( + record.fromSchemaVersion!, + record.toSchemaVersion!, + ), + style: captionStyle, + textAlign: TextAlign.center, + ), + ], + if (warning != null) ...[ + const SizedBox(height: 12), + Text( + warning!, + style: TextStyle( + fontSize: 13, + color: subtitleColor, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ], + if (status == StartupRestoreStatus.failed) ...[ + const SizedBox(height: 12), + Text( + context.l10n.startup_failure_restoreFailed, + style: captionStyle, + textAlign: TextAlign.center, + ), + if (error != null && error!.isNotEmpty) ...[ + const SizedBox(height: 4), + SelectableText( + error!, + style: TextStyle( + fontSize: 12, + color: subtitleColor, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ), + ], + ], + const SizedBox(height: 16), + if (status == StartupRestoreStatus.running) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(height: 8), + Text( + context.l10n.startup_failure_restoring, + style: captionStyle, + textAlign: TextAlign.center, + ), + ], + ) + else + FilledButton.tonal( + onPressed: onRestore, + child: Text(actionLabel), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/presentation/widgets/version_mismatch_view.dart b/lib/core/presentation/widgets/version_mismatch_view.dart index 19208f876a..3591612629 100644 --- a/lib/core/presentation/widgets/version_mismatch_view.dart +++ b/lib/core/presentation/widgets/version_mismatch_view.dart @@ -1,13 +1,36 @@ import 'package:flutter/material.dart'; +import 'package:submersion/core/presentation/startup_restore_status.dart'; +import 'package:submersion/core/presentation/widgets/startup_restore_card.dart'; import 'package:submersion/features/auto_update/domain/entities/update_channel.dart'; +import 'package:submersion/features/backup/domain/entities/backup_record.dart'; import 'package:submersion/l10n/l10n_extension.dart'; /// Startup screen shown when the database on disk was written by a newer /// version of the app than the one running (schema `user_version` exceeds /// [appVersion]). The database has not been opened or modified at this point; -/// the only safe paths forward are updating the app or restoring an older -/// backup after updating. +/// the only safe paths forward are running a build that understands the file +/// or restoring an older backup. +/// +/// The screen deliberately does not name a cause. A build at schema N seeing a +/// file at schema N+k cannot tell whether a newer *stable* release wrote it or +/// a *beta* build did: every version constant it ships was frozen when it was +/// compiled. Asserting "Update Required" guessed, and guessed wrong for the +/// common case, sending the #1568 reporter to reinstall the same build twice +/// (#1588). Both destinations are therefore offered, with the causes stated +/// plainly so the diver can pick the one that matches their situation. +/// +/// A third route appears when a pre-upgrade safety copy this build can open +/// is found on disk: [restoreCandidate] (#1589). It is listed first because +/// it is the only one that works without leaving the app, and on a store +/// build, where an update may still be in review, the only one that works at +/// all. It does NOT displace the stable download as the primary button: which +/// route is right depends on facts this build does not have, and asserting +/// "you want to go back" would be the same guess in the other direction. +/// +/// The restore is offered only when a candidate has been found AND validated +/// by the caller. An offer that fails the same way the database just did +/// would repeat the dead end this screen exists to end. class VersionMismatchView extends StatelessWidget { const VersionMismatchView({ super.key, @@ -16,8 +39,13 @@ class VersionMismatchView extends StatelessWidget { required this.textColor, required this.subtitleColor, required this.onDownloadLatest, + required this.onOpenBetaBuilds, required this.onClose, this.channelOverride, + this.restoreCandidate, + this.onRestoreBackup, + this.restoreStatus = StartupRestoreStatus.idle, + this.restoreError, }); /// Canonical download location, shown on screen and opened by the button. @@ -28,17 +56,44 @@ class VersionMismatchView extends StatelessWidget { static const String latestReleaseUrl = 'https://github.com/submersion-app/submersion/releases/latest'; + /// Where the per-merge beta builds live. Deliberately the releases index + /// rather than `/latest`: the diver needs the build that matches their + /// file's schema, which is not necessarily the newest one. + /// + /// Owned here for the same no-drift reason as [latestReleaseUrl]. + static const String betaReleasesUrl = + 'https://github.com/submersion-app/beta-builds/releases'; + final int databaseVersion; final int appVersion; final Color textColor; final Color subtitleColor; + + /// Opens [latestReleaseUrl]. Kept the primary action: a newer stable may + /// genuinely exist, and it is the destination that cannot make things worse. final VoidCallback onDownloadLatest; + + /// Opens [betaReleasesUrl]. Secondary and captioned as pre-release on + /// purpose. An unguarded route onto beta is what stranded the #1568 + /// reporter, so this screen must not become a second one. + final VoidCallback onOpenBetaBuilds; + final VoidCallback onClose; /// Test seam: UpdateChannelConfig.current reads a compile-time constant, /// which a test binary cannot vary. final UpdateChannel? channelOverride; + /// A pre-upgrade safety copy this build can open, or null when the registry + /// holds none (never taken, already pruned, or every surviving copy is + /// itself too new). Null hides the whole restore route rather than showing + /// a button that cannot work. + final BackupRecord? restoreCandidate; + + final VoidCallback? onRestoreBackup; + final StartupRestoreStatus restoreStatus; + final String? restoreError; + @override Widget build(BuildContext context) { // A store build cannot act on a GitHub download link, and its update @@ -46,65 +101,117 @@ class VersionMismatchView extends StatelessWidget { // a different instruction and no download affordances (issue #1089). final channel = channelOverride ?? UpdateChannelConfig.current; final isStore = UpdateChannelConfig.isStoreChannel(channel); + final canRestore = restoreCandidate != null && onRestoreBackup != null; - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.update, size: 64, color: Colors.orange), - const SizedBox(height: 24), - Text( - context.l10n.startup_versionMismatch_title, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: textColor, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - context.l10n.startup_versionMismatch_body( - databaseVersion, - appVersion, - ), - style: TextStyle(fontSize: 14, color: subtitleColor), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - isStore - ? context.l10n.startup_versionMismatch_storeInstructions - : context.l10n.startup_versionMismatch_instructions, - style: TextStyle(fontSize: 14, color: subtitleColor), - textAlign: TextAlign.center, - ), - if (!isStore) ...[ + final bodyStyle = TextStyle(fontSize: 14, color: subtitleColor); + final captionStyle = TextStyle(fontSize: 12, color: subtitleColor); + + // Scrolls itself, exactly as StartupFailureView does. StartupWrapper hosts + // the terminal screens in a bare SafeArea > Center, so a short window or a + // large text scale would otherwise clip the copy below the fold, hiding + // the very links this screen exists to offer. + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Not Icons.update: that glyph is the visual half of the "you are + // behind, install the update" claim this screen no longer makes. + const Icon(Icons.sync_problem, size: 64, color: Colors.orange), const SizedBox(height: 24), - FilledButton( - onPressed: onDownloadLatest, - child: Text(context.l10n.startup_versionMismatch_download), + Text( + context.l10n.startup_versionMismatch_title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + context.l10n.startup_versionMismatch_body( + databaseVersion, + appVersion, + ), + style: bodyStyle, + textAlign: TextAlign.center, ), - const SizedBox(height: 12), + const SizedBox(height: 16), + // Shown on every channel: TestFlight and Play testing tracks are + // beta channels too, so a store build can land here the same way. Text( - context.l10n.startup_versionMismatch_manualLink, - style: TextStyle(fontSize: 12, color: subtitleColor), + context.l10n.startup_versionMismatch_causes, + style: bodyStyle, textAlign: TextAlign.center, ), - const SizedBox(height: 4), - SelectableText( - latestReleaseUrl, - style: TextStyle(fontSize: 12, color: subtitleColor), + if (canRestore) ...[ + const SizedBox(height: 24), + StartupRestoreCard( + record: restoreCandidate!, + title: context.l10n.startup_versionMismatch_restore_title, + body: context.l10n.startup_versionMismatch_restore_body, + warning: context.l10n.startup_versionMismatch_restore_warning, + actionLabel: context.l10n.startup_failure_restoreAction, + onRestore: onRestoreBackup!, + status: restoreStatus, + error: restoreError, + textColor: textColor, + subtitleColor: subtitleColor, + ), + ], + const SizedBox(height: 16), + Text( + isStore + ? context.l10n.startup_versionMismatch_storeInstructions + : context.l10n.startup_versionMismatch_instructions, + style: bodyStyle, textAlign: TextAlign.center, ), + if (!isStore) ...[ + const SizedBox(height: 24), + FilledButton( + onPressed: onDownloadLatest, + child: Text(context.l10n.startup_versionMismatch_download), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: onOpenBetaBuilds, + child: Text(context.l10n.startup_versionMismatch_betaAction), + ), + const SizedBox(height: 8), + Text( + context.l10n.startup_versionMismatch_betaNote, + style: captionStyle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + context.l10n.startup_versionMismatch_manualLink, + style: captionStyle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + SelectableText( + latestReleaseUrl, + style: captionStyle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + SelectableText( + betaReleasesUrl, + style: captionStyle, + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 8), + TextButton( + onPressed: onClose, + child: Text(context.l10n.common_action_close), + ), ], - const SizedBox(height: 8), - TextButton( - onPressed: onClose, - child: Text(context.l10n.common_action_close), - ), - ], + ), ), ); } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index c734cadd43..83721374d4 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:submersion/core/accessibility/app_shortcuts.dart'; import 'package:submersion/core/constants/feature_flags.dart'; @@ -113,6 +115,7 @@ import 'package:submersion/features/settings/presentation/pages/language_setting import 'package:submersion/features/settings/presentation/pages/nav_customization_page.dart'; import 'package:submersion/features/settings/presentation/pages/theme_gallery_page.dart'; import 'package:submersion/features/settings/presentation/pages/storage_settings_page.dart'; +import 'package:submersion/features/settings/presentation/pages/storage_usage_page.dart'; import 'package:submersion/features/settings/presentation/pages/diver_profile_hub_page.dart'; import 'package:submersion/features/settings/presentation/pages/personal_info_edit_page.dart'; import 'package:submersion/features/settings/presentation/pages/emergency_contacts_edit_page.dart'; @@ -142,6 +145,11 @@ import 'package:submersion/features/gps_log/presentation/pages/gps_track_detail_ import 'package:submersion/features/gps_log/presentation/pages/gps_track_map_page.dart'; import 'package:submersion/features/weight_planner/presentation/pages/weight_planner_page.dart'; import 'package:submersion/features/deco_calculator/presentation/pages/deco_calculator_page.dart'; +import 'package:submersion/features/gas_calculators/presentation/gas_calculator_tools.dart'; +import 'package:submersion/features/gas_calculators/presentation/pages/blender_settings_page.dart'; +import 'package:submersion/features/gas_calculators/presentation/pages/blender_invoice_archive_detail_page.dart'; +import 'package:submersion/features/gas_calculators/presentation/pages/blender_invoice_archive_page.dart'; +import 'package:submersion/features/gas_calculators/presentation/pages/gas_calculator_detail_page.dart'; import 'package:submersion/features/gas_calculators/presentation/pages/gas_calculators_page.dart'; import 'package:submersion/features/dive_computer/presentation/pages/device_list_page.dart'; import 'package:submersion/features/dive_computer/presentation/pages/device_detail_page.dart'; @@ -149,6 +157,8 @@ import 'package:submersion/features/dive_computer/presentation/providers/downloa show diveImportServiceProvider; import 'package:submersion/features/dive_log/presentation/providers/dive_computer_providers.dart'; import 'package:submersion/features/import_wizard/data/adapters/dive_computer_adapter.dart'; +import 'package:submersion/features/import_wizard/data/adapters/garmin_cloud_adapter.dart'; +import 'package:submersion/features/import_wizard/data/adapters/suunto_cloud_adapter.dart'; import 'package:submersion/features/dashboard/presentation/pages/dashboard_page.dart'; import 'package:submersion/features/planner/presentation/pages/plan_canvas_page.dart'; import 'package:submersion/features/planner/presentation/pages/plan_compare_page.dart'; @@ -276,6 +286,40 @@ final appRouterProvider = Provider((ref) { path: 'gas-calculators', name: 'gasCalculators', builder: (context, state) => const GasCalculatorsPage(), + // The six calculators are children, not tabs. On a narrow + // window each is pushed as its own page; in split view they + // ride in ?calc= instead and these routes go unused. + routes: [ + for (final id in kGasCalculatorIds) + GoRoute( + path: id, + builder: (context, state) => + GasCalculatorDetailPage(toolId: id), + ), + ], + ), + // The blender's paid-invoice archive is a SIBLING of the + // generated calculator routes above, not a child of the + // 'blender' entry: that entry is built inside the loop and has + // no routes: list of its own to extend. Declared after the loop + // for the same reason 'dive-planner/:planId' is declared after + // its subtree - static routes still win matching regardless of + // declaration order here, but this keeps the generated block + // readable as one unit. + GoRoute( + path: 'gas-calculators/blender/invoices', + name: 'blenderInvoiceArchive', + builder: (context, state) => const BlenderInvoiceArchivePage(), + routes: [ + GoRoute( + path: ':invoiceId', + name: 'blenderInvoiceArchiveDetail', + builder: (context, state) => + BlenderInvoiceArchiveDetailPage( + invoiceId: state.pathParameters['invoiceId']!, + ), + ), + ], ), GoRoute( path: 'weight-calculator', @@ -596,6 +640,7 @@ final appRouterProvider = Provider((ref) { initialName: extra?['name'] as String?, initialEmail: extra?['email'] as String?, initialPhone: extra?['phone'] as String?, + initialPhoto: extra?['photo'] as Uint8List?, ); }, ), @@ -876,6 +921,18 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => const _UniversalImportWizardRoute(), ), + GoRoute( + path: 'import-cloud/suunto', + name: 'importFromCloudSuunto', + builder: (context, state) => + const _SuuntoCloudImportWizardRoute(), + ), + GoRoute( + path: 'import-cloud/garmin', + name: 'importFromCloudGarmin', + builder: (context, state) => + const _GarminCloudImportWizardRoute(), + ), ], ), @@ -970,6 +1027,11 @@ final appRouterProvider = Provider((ref) { name: 'storageSettings', builder: (context, state) => const StorageSettingsPage(), ), + GoRoute( + path: 'storage-usage', + name: 'storageUsage', + builder: (context, state) => const StorageUsagePage(), + ), GoRoute( path: 'data-quality', name: 'dataQualitySettings', @@ -1159,6 +1221,11 @@ final appRouterProvider = Provider((ref) { name: 'debugLogs', builder: (context, state) => const DebugLogViewerPage(), ), + GoRoute( + path: 'trimix-mixer', + name: 'trimixMixerSettings', + builder: (context, state) => const BlenderSettingsPage(), + ), GoRoute( path: 'media-sources', name: 'mediaSources', @@ -1342,7 +1409,12 @@ final appRouterProvider = Provider((ref) { GoRoute( path: '/species', name: 'species', - builder: (context, state) => const SpeciesPage(), + // A nav destination: the rail and bottom bar reach it with `go`, + // so it cross-fades like its siblings instead of animating in. + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const SpeciesPage(), + ), routes: [ GoRoute( path: 'manage', @@ -1531,7 +1603,14 @@ class _DiveComputerDiscoveryWizardRoute extends ConsumerWidget { /// Wrapper that creates a [DiveComputerAdapter] for quick download /// from a known (previously paired) computer. -class _DiveComputerDownloadWizardRoute extends ConsumerWidget { +/// +/// The adapter is created once per route instance and reused across +/// rebuilds. [diveComputerByIdProvider] re-emits on every dive_computers +/// table tick, and a completed download writes that table (device serial +/// and firmware), so this widget rebuilds in the middle of the wizard. A +/// fresh adapter built here on every rebuild would carry none of the +/// downloaded dives, and the wizard's Review step would list nothing. +class _DiveComputerDownloadWizardRoute extends ConsumerStatefulWidget { const _DiveComputerDownloadWizardRoute({ required this.computerId, this.forceFullDownload = false, @@ -1541,13 +1620,28 @@ class _DiveComputerDownloadWizardRoute extends ConsumerWidget { final bool forceFullDownload; @override - Widget build(BuildContext context, WidgetRef ref) { - final diverId = ref.watch(currentDiverIdProvider) ?? ''; - final importService = ref.watch(diveImportServiceProvider); - final computerRepo = ref.watch(diveComputerRepositoryProvider); - final diveRepo = ref.watch(diveRepositoryProvider); - final consolidationService = ref.watch(diveConsolidationServiceProvider); - final computerAsync = ref.watch(diveComputerByIdProvider(computerId)); + ConsumerState<_DiveComputerDownloadWizardRoute> createState() => + _DiveComputerDownloadWizardRouteState(); +} + +class _DiveComputerDownloadWizardRouteState + extends ConsumerState<_DiveComputerDownloadWizardRoute> { + DiveComputerAdapter? _adapter; + + @override + void didUpdateWidget(_DiveComputerDownloadWizardRoute oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.computerId != widget.computerId || + oldWidget.forceFullDownload != widget.forceFullDownload) { + _adapter = null; + } + } + + @override + Widget build(BuildContext context) { + final computerAsync = ref.watch( + diveComputerByIdProvider(widget.computerId), + ); return computerAsync.when( data: (computer) { @@ -1561,18 +1655,21 @@ class _DiveComputerDownloadWizardRoute extends ConsumerWidget { ), ); } - return UnifiedImportWizard( - adapter: DiveComputerAdapter( - importService: importService, - computerRepository: computerRepo, - diveRepository: diveRepo, - consolidationService: consolidationService, - diverId: diverId, - knownComputer: computer, - ref: ref, - forceFullDownload: forceFullDownload, - ), + final adapter = _adapter ??= DiveComputerAdapter( + importService: ref.read(diveImportServiceProvider), + computerRepository: ref.read(diveComputerRepositoryProvider), + diveRepository: ref.read(diveRepositoryProvider), + consolidationService: ref.read(diveConsolidationServiceProvider), + diverId: ref.read(currentDiverIdProvider) ?? '', + knownComputer: computer, + ref: ref, + forceFullDownload: widget.forceFullDownload, ); + // A new adapter is a new session: key the wizard on it so the reset + // in didUpdateWidget starts the wizard over instead of handing a + // fresh adapter to a wizard mid-flow (which pins the one it started + // with). + return UnifiedImportWizard(key: ObjectKey(adapter), adapter: adapter); }, loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), @@ -1598,6 +1695,59 @@ class _UniversalImportWizardRoute extends ConsumerWidget { } } +/// Wrapper that creates a [SuuntoCloudAdapter] with dependencies from +/// Riverpod, for importing dives from a Suunto cloud (app.suunto.com) +/// account. +class _SuuntoCloudImportWizardRoute extends ConsumerWidget { + const _SuuntoCloudImportWizardRoute(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final diverId = ref.watch(currentDiverIdProvider) ?? ''; + final importService = ref.watch(diveImportServiceProvider); + final computerRepo = ref.watch(diveComputerRepositoryProvider); + final diveRepo = ref.watch(diveRepositoryProvider); + final consolidationService = ref.watch(diveConsolidationServiceProvider); + + return UnifiedImportWizard( + adapter: SuuntoCloudAdapter( + importService: importService, + computerRepository: computerRepo, + diveRepository: diveRepo, + consolidationService: consolidationService, + diverId: diverId, + ref: ref, + ), + ); + } +} + +/// Wrapper that creates a [GarminCloudAdapter] with dependencies from +/// Riverpod, for importing dives from a Garmin Connect account. +class _GarminCloudImportWizardRoute extends ConsumerWidget { + const _GarminCloudImportWizardRoute(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final diverId = ref.watch(currentDiverIdProvider) ?? ''; + final importService = ref.watch(diveImportServiceProvider); + final computerRepo = ref.watch(diveComputerRepositoryProvider); + final diveRepo = ref.watch(diveRepositoryProvider); + final consolidationService = ref.watch(diveConsolidationServiceProvider); + + return UnifiedImportWizard( + adapter: GarminCloudAdapter( + importService: importService, + computerRepository: computerRepo, + diveRepository: diveRepo, + consolidationService: consolidationService, + diverId: diverId, + ref: ref, + ), + ); + } +} + /// Parses the `forceFull` URL query parameter for the DC download route. /// /// Strict equality against `'true'` — any other value (null, empty, `'1'`, diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart deleted file mode 100644 index 8f8585ed19..0000000000 --- a/lib/core/router/router.dart +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export router for convenience -export 'app_router.dart'; diff --git a/lib/core/services/accounts/adapters/google_drive_account_adapter.dart b/lib/core/services/accounts/adapters/google_drive_account_adapter.dart index e4e57dc0ba..b201789ce8 100644 --- a/lib/core/services/accounts/adapters/google_drive_account_adapter.dart +++ b/lib/core/services/accounts/adapters/google_drive_account_adapter.dart @@ -48,8 +48,13 @@ class GoogleDriveAccountAdapter extends AccountProviderAdapter Future mediaObjectStore( domain.ConnectedAccount account, ) async { - final client = await _provider.mediaHttpClient(); - if (client == null) return null; - return GoogleDriveMediaObjectStore(client: client); + // Probe once so an unusable Google session still yields null here, + // then hand the store the supplier rather than the client: the + // authenticator closes and replaces its client on every re-auth, and + // the media store runtime holding this store outlives that. + if (await _provider.mediaHttpClient() == null) return null; + return GoogleDriveMediaObjectStore( + clientSupplier: _provider.mediaHttpClient, + ); } } diff --git a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart index 46f4491301..e37580e3d7 100644 --- a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart +++ b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:googleapis/drive/v3.dart' as drive; import 'package:googleapis_auth/auth_io.dart' as gauth; import 'package:http/http.dart' as http; -import 'package:url_launcher/url_launcher_string.dart'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; @@ -12,6 +11,8 @@ import 'package:submersion/core/services/cloud_storage/google_drive/google_drive import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/utils/log_failure.dart'; +import 'package:submersion/shared/utils/browser_launch.dart'; /// Runs the user-consent step of the loopback flow and returns credentials. typedef ObtainConsentCredentials = @@ -50,7 +51,7 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { ObtainConsentCredentials? obtainConsent, BuildRefreshingClient? buildClient, http.Client Function()? baseClientFactory, - Future Function(String url)? launchBrowser, + Future Function(String url)? launchBrowser, String? clientSecret, }) : _clientSecret = clientSecret ?? GoogleDriveClientConfig.desktopClientSecret, @@ -59,7 +60,7 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { obtainConsent ?? gauth.obtainAccessCredentialsViaUserConsent, _buildClient = buildClient ?? gauth.autoRefreshingClient, _baseClientFactory = baseClientFactory ?? _timedClient, - _launchBrowser = launchBrowser ?? launchUrlString; + _launchBrowser = launchBrowser ?? _openConsentPage; static final _log = LoggerService.forClass(DesktopOAuthAuthenticator); @@ -72,6 +73,40 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { /// transfer queue (#1279). static http.Client _timedClient() => TimeoutHttpClient.overSockets(); + /// Hands the consent URL to the browser, with the Linux fallback chain + /// url_launcher alone does not provide. + static Future _openConsentPage(String url) => + openInBrowser(Uri.parse(url)); + + /// Opens the consent page, turning "nothing took the URL" into a throw. + /// + /// [openInBrowser] reports that by returning false rather than throwing -- + /// exactly the no-scheme-handler case this flow has to survive -- so a + /// discarded result would leave the loopback wait looking like a hang with + /// nothing in the log to explain it. + Future _launchConsentPage(String url) async { + try { + if (await _launchBrowser(url)) return; + } on Exception { + _logManualConsentUrl(url); + rethrow; + } + _logManualConsentUrl(url); + throw const CloudStorageException( + 'No browser accepted the Google consent URL. Open the URL logged just ' + 'above this error to finish connecting by hand.', + ); + } + + /// Logs the consent URL as the manual way out of a failed launch. + /// + /// Only on failure: the URL carries the PKCE challenge and the state + /// parameter, so it has no business in the log of a flow that worked, where + /// it would be a very long line nobody reads and a needless copy of the + /// request's one-time secrets. + void _logManualConsentUrl(String url) => + _log.warning('Open this URL to finish Google consent: $url'); + /// openid + email are included so the id_token carries the account email /// for the settings tile subtitle; drive.appdata is the only Drive scope. static const List scopes = [ @@ -87,7 +122,7 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { final ObtainConsentCredentials _obtainConsent; final BuildRefreshingClient _buildClient; final http.Client Function() _baseClientFactory; - final Future Function(String url) _launchBrowser; + final Future Function(String url) _launchBrowser; gauth.AutoRefreshingAuthClient? _authClient; StreamSubscription? _updateSubscription; @@ -112,7 +147,16 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { final base = _baseClientFactory(); try { final credentials = await _obtainConsent(_clientId, scopes, base, (url) { - unawaited(_launchBrowser(url)); + // The loopback flow then parks on the redirect until the browser + // comes back with a code, so a launch that failed reads as a hang. + // _launchConsentPage logs the URL if that happens: on a desktop with + // no working https scheme handler (see openInBrowser) that log line + // is the only way to finish consent by hand. + logFailure( + _launchConsentPage(url), + DesktopOAuthAuthenticator, + 'open the Google consent page', + ); }); await _tokenStore.save(credentials); _installClient(credentials); @@ -127,8 +171,28 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { } } + /// Single-flights [attemptSilentAuth]: the `_authClient != null` guard is + /// read before the token-store await, so two concurrent callers would both + /// get past it and both install. The second install closes the client the + /// first already published to the provider and the media store, and nothing + /// rebuilds from a still-non-null authClient -- so every later Drive call + /// failed with "HTTP request failed. Client is already closed." until the + /// process restarted. + /// + /// Two callers is the ordinary case: the Cloud Sync page fires an unawaited + /// refreshState() -> isAuthenticated() while a launch sync sits inside its + /// own isAuthenticated(). + Future? _silentAuthInFlight; + @override - Future attemptSilentAuth() async { + Future attemptSilentAuth() { + if (_authClient != null) return Future.value(true); + return _silentAuthInFlight ??= _runSilentAuth().whenComplete( + () => _silentAuthInFlight = null, + ); + } + + Future _runSilentAuth() async { try { if (_authClient != null) return true; diff --git a/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart b/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart index 81cc1ecb74..737dc8374a 100644 --- a/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart +++ b/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart @@ -54,8 +54,28 @@ class GoogleSignInAuthenticator implements GoogleDriveAuthenticator { _initialized = true; } + /// Single-flights [attemptSilentAuth]: the `_authClient != null` guard is + /// read before the SDK round trip, so two concurrent callers would both get + /// past it and both install. The second install closes the client the first + /// already published to the provider and the media store, and nothing + /// rebuilds from a still-non-null authClient -- so every later Drive call + /// failed with "HTTP request failed. Client is already closed." until the + /// process restarted. + /// + /// Two callers is the ordinary case: the Cloud Sync page fires an unawaited + /// refreshState() -> isAuthenticated() while a launch sync sits inside its + /// own isAuthenticated(). + Future? _silentAuthInFlight; + @override - Future attemptSilentAuth() async { + Future attemptSilentAuth() { + if (_authClient != null) return Future.value(true); + return _silentAuthInFlight ??= _runSilentAuth().whenComplete( + () => _silentAuthInFlight = null, + ); + } + + Future _runSilentAuth() async { try { if (_authClient != null) return true; diff --git a/lib/core/services/cloud_storage/google_drive_storage_provider.dart b/lib/core/services/cloud_storage/google_drive_storage_provider.dart index 9a65ecc799..a5ad86e555 100644 --- a/lib/core/services/cloud_storage/google_drive_storage_provider.dart +++ b/lib/core/services/cloud_storage/google_drive_storage_provider.dart @@ -56,6 +56,10 @@ class GoogleDriveStorageProvider final GoogleDriveAuthenticator _authenticator; drive.DriveApi? _driveApi; + + /// The client [_driveApi] was built over, compared by identity so a client + /// swapped underneath us is never kept in use. + http.Client? _driveApiClient; drive.DriveApi? _injectedApi; String? _syncFolderId; @@ -114,6 +118,13 @@ class GoogleDriveStorageProvider /// when not authenticated. Rebuilt lazily so a re-auth (new client) /// transparently produces a new API instance. /// + /// The cache is keyed on the client's IDENTITY, not merely on its + /// nullness. A re-auth closes the client it replaces, and an api cached + /// over that closed client kept sending through it -- "HTTP request + /// failed. Client is already closed." on every Drive call for the rest of + /// the process, while isAuthenticated() went on answering true because + /// authClient was non-null the whole time. + /// /// An api injected by [debugSetDriveApi] wins outright: tests exercise the /// transfer paths with no authenticator at all, so deriving from /// [GoogleDriveAuthenticator.authClient] would resolve to null and every @@ -125,9 +136,14 @@ class GoogleDriveStorageProvider final client = _authenticator.authClient; if (client == null) { _driveApi = null; + _driveApiClient = null; return null; } - return _driveApi ??= drive.DriveApi(client); + if (_driveApi == null || !identical(client, _driveApiClient)) { + _driveApi = drive.DriveApi(client); + _driveApiClient = client; + } + return _driveApi; } drive.DriveApi get _requireApi { @@ -407,13 +423,12 @@ class GoogleDriveStorageProvider query += " and name contains '$namePattern'"; } - final fileList = await _requireApi.files.list( - spaces: 'appDataFolder', - q: query, - $fields: 'files(id,name,modifiedTime,size)', + final files = await _listAllFiles( + query: query, + fileFields: 'files(id,name,modifiedTime,size)', ); - return (fileList.files ?? []) + return files .where((f) => f.id != null && f.name != null) .map( (f) => CloudFileInfo( @@ -504,14 +519,13 @@ class GoogleDriveStorageProvider "and 'appDataFolder' in parents " "and trashed = false"; - final fileList = await _requireApi.files.list( - spaces: 'appDataFolder', - q: query, - $fields: 'files(id,name)', + final folders = await _listAllFiles( + query: query, + fileFields: 'files(id,name)', ); - if (fileList.files != null && fileList.files!.isNotEmpty) { - _syncFolderId = fileList.files!.first.id!; + if (folders.isNotEmpty) { + _syncFolderId = folders.first.id!; _log.info('Found existing sync folder: $_syncFolderId'); return _syncFolderId!; } @@ -535,6 +549,55 @@ class GoogleDriveStorageProvider } } + /// Drive's per-page cap, requested explicitly. `files.list` defaults to + /// 100 results per page; 1000 is the documented maximum, so a typical + /// sync folder is covered by a single round trip. + static const _listPageSize = 1000; + + /// Every appDataFolder file matching [query], following `nextPageToken` + /// to exhaustion. + /// + /// A single `files.list` call returns at most one page and reports the + /// rest only through `nextPageToken`, so it silently truncated any sync + /// folder holding more than 100 artifacts. A base publish splits into + /// many `.pNNNN` parts and every device adds its own changeset log, so + /// real backends cross that line: peer discovery, manifest reads, + /// compaction pruning and wipe deletion all ran against a partial view, + /// with no error to say so. + /// + /// [fileFields] is the `files(...)` projection. `nextPageToken` is added + /// alongside it because Drive's `fields` filter applies to the whole + /// response, not just the file records: a projection that names only + /// `files(...)` strips the token, and the loop would stop after one page + /// exactly as before. + /// + /// Callers wrap this in [_run], so a 401 mid-listing restarts from the + /// first page rather than resuming with a token minted under the old + /// credentials -- which is what Drive asks for anyway ("if the token is + /// rejected, pagination should be restarted from the first page"). + Future> _listAllFiles({ + required String query, + required String fileFields, + }) async { + final files = []; + String? pageToken; + do { + final page = await _requireApi.files.list( + spaces: 'appDataFolder', + q: query, + pageSize: _listPageSize, + pageToken: pageToken, + $fields: 'nextPageToken,$fileFields', + ); + files.addAll(page.files ?? const []); + // An empty token means the same thing as an absent one; treating it + // as a real token would re-issue the first page forever. + final next = page.nextPageToken; + pageToken = (next == null || next.isEmpty) ? null : next; + } while (pageToken != null); + return files; + } + /// Find a file by name in a specific folder Future _findFile(String filename, String folderId) async { try { @@ -543,16 +606,12 @@ class GoogleDriveStorageProvider "and '$folderId' in parents " "and trashed = false"; - final fileList = await _requireApi.files.list( - spaces: 'appDataFolder', - q: query, - $fields: 'files(id,name)', + final matches = await _listAllFiles( + query: query, + fileFields: 'files(id,name)', ); - if (fileList.files != null && fileList.files!.isNotEmpty) { - return fileList.files!.first; - } - return null; + return matches.isNotEmpty ? matches.first : null; } on drive.DetailedApiRequestError { // Let auth errors reach _run's 401 handling instead of masking them // as "file not found" (which would create a duplicate). diff --git a/lib/core/services/database_service.dart b/lib/core/services/database_service.dart index 32ff8735ce..a930219e65 100644 --- a/lib/core/services/database_service.dart +++ b/lib/core/services/database_service.dart @@ -42,6 +42,12 @@ void Function(sqlite3.Database) _connectionSetup(String? keyHex) { return (db) => applyMainDatabaseSetup(db, keyHex: keyHex); } +/// Default for [DatabaseService._runUpgradeLadder]'s VACUUM ticket: a caller +/// that hands over no ticket gets the pre-existing behaviour. +bool _alwaysPending() => true; + +void _noop() {} + class DatabaseService { DatabaseService._(); @@ -237,7 +243,9 @@ class DatabaseService { /// /// A single synchronous `PRAGMA user_version` read (via /// [getStoredSchemaVersion]) drives BOTH the newer-than-app guard and - /// the migration-pending decision, so the file is opened synchronously + /// the migration-pending decision, and is handed on to + /// [_runUpgradeLadder] for its one-time VACUUM rather than re-read there, + /// so the file is opened synchronously /// on the UI isolate at most once per open — the rest is executor work. Future _openDatabase( String dbPath, { @@ -286,8 +294,20 @@ class DatabaseService { // That is safe -- every step is idempotent by contract -- but it does // mean onMigrationProgress can restart at step 1, so a progress bar may // visibly rewind. A rewinding bar beats a bricked launch. + // The VACUUM ticket lives out here, not inside the ladder: a busy lock + // makes retryWhileDatabaseBusy call the whole thing again from the + // top, and rewriting a 769 MB file a second time is exactly the cost + // the one-shot design was avoiding. Taken at most once per open. + var vacuumTicket = true; await retryWhileDatabaseBusy( - () => _runUpgradeLadder(file, keyHex, onMigrationProgress), + () => _runUpgradeLadder( + file, + keyHex, + stored, + onMigrationProgress, + vacuumPending: () => vacuumTicket, + takeVacuumTicket: () => vacuumTicket = false, + ), ); lastOpenMode = DatabaseOpenMode.migrationThenBackground; } else { @@ -309,15 +329,94 @@ class DatabaseService { Future _runUpgradeLadder( File file, String? keyHex, - void Function(int currentStep, int totalSteps)? onMigrationProgress, - ) async { + int? storedBefore, + void Function(int currentStep, int totalSteps)? onMigrationProgress, { + bool Function() vacuumPending = _alwaysPending, + void Function() takeVacuumTicket = _noop, + }) async { + // Whether this attempt will VACUUM, decided before the ladder starts so + // every progress report of this open counts the same total. + final willVacuum = + storedBefore != null && storedBefore < 183 && vacuumPending(); + final ladderSteps = storedBefore == null + ? 0 + : AppDatabase.migrationStepCount(storedBefore); + // The VACUUM is a step of the upgrade as the diver experiences it, so it + // is counted as one. Without it the ladder's last report reads + // "finished" and the bar sits full while a large file is rewritten, with + // nothing on screen saying the app is still working. The callback shape + // carries the total on every call, so an extra step needs no new API. + void report(int currentStep, int totalSteps) { + onMigrationProgress?.call( + currentStep, + willVacuum ? totalSteps + 1 : totalSteps, + ); + } + final migrator = AppDatabase( NativeDatabase(file, setup: _connectionSetup(keyHex)), - onMigrationProgress: onMigrationProgress, + onMigrationProgress: onMigrationProgress == null ? null : report, ); try { // Force the upgrade ladder to completion before switching executors. await migrator.customSelect('SELECT 1').get(); + // [storedBefore] is the version the file had ON DISK before the + // ladder ran, read by the caller: keying off the migrator's own + // version here would always read 183 and never VACUUM. The ladder's + // own beforeOpen backstop can also drop the legacy tables on a file + // already stamped 183, which no version comparison can see, so the + // drop itself is the second reason to reclaim. + // + // Unreachable while 183 is the current version, because this method + // only runs with a ladder pending and every such file is below 183. + // It is here for the rung after next: a file stamped 183 whose v184 + // ladder is pending has willVacuum false, and its backstop can still + // be the open that finally drops the tables. The background executor + // reopens on a fresh connection that no longer sees them, so nothing + // downstream would catch it. + // + // v190 (issue #227) recompresses raw_data in place, which frees pages + // the same way the v183 drop did. A file upgrading from 184 or later + // has willVacuum false, so the recompression reaches the one VACUUM + // through this branch, unannounced as a progress step. The totals were + // fixed before the ladder started, and reporting an unplanned reclaim + // as a step would send the bar past its own total. + final unplannedReclaim = !willVacuum && migrator.hasUnreclaimedPages; + if (vacuumPending() && (willVacuum || unplannedReclaim)) { + // v183 dropped the row-per-sample tables, which on an older file are + // most of its pages, and v190 rewrites every raw_data blob smaller; + // both leave the freed pages on the freelist. VACUUM here: outside + // any migration transaction, + // on the one exclusive main-isolate connection, and before the + // background executor opens the file. Non-fatal: a busy lock or an + // out-of-space temp store leaves a correct database that is merely + // larger than it needs to be. + // + // One shot per open. A VACUUM killed part way leaves a correct + // database still carrying its free pages, and the drop that earned + // the reclamation has already happened, so nothing asks again. The + // ticket is taken before the attempt, not after, so a VACUUM that + // throws is not retried either. + // + // Announced as a step only when it was PLANNED. The totals were + // fixed before the ladder started so every report of this open + // agrees, and reporting an unplanned reclaim as a step would send + // the bar past its own total. + takeVacuumTicket(); + if (!unplannedReclaim) report(ladderSteps, ladderSteps); + try { + await migrator.customStatement('VACUUM'); + } catch (e, stackTrace) { + _log.warning( + 'Post-migration VACUUM skipped; the database is correct but has ' + 'not returned its free pages to the filesystem ' + '(${migrator.unreclaimedPagesReason})', + error: e, + stackTrace: stackTrace, + ); + } + if (!unplannedReclaim) report(ladderSteps + 1, ladderSteps); + } } catch (_) { // Best-effort close so we don't leak the connection (or its locks, which // would defeat the retry), then let the original error surface. @@ -368,6 +467,35 @@ class DatabaseService { await closeDatabaseForAppShutdown(database, background: background); rethrow; } + // The forcing statement above ran beforeOpen, and that is where the + // v183 backstop drops a legacy sample table the rung had to skip. This + // open has no pending ladder, so nothing else will ever reclaim those + // pages: without this the diver's file stays at its pre-migration size + // permanently, because there is no other VACUUM of the live database + // anywhere in the app. + // + // On the worker isolate, so a large rewrite does not block the UI, and + // awaited, so nothing queries a file mid-VACUUM. Non-fatal for the same + // reason as the migration path's copy: a busy lock or a full temp store + // leaves a correct database that is merely larger than it needs to be. + // + // The v190 recompression reaches this the same way if it ever runs on an + // open with no pending ladder. It cannot today, but the gate is one + // signal so a future rung that frees pages does not have to remember to + // add itself in two places. + if (database.hasUnreclaimedPages) { + try { + await database.customStatement('VACUUM'); + } catch (e, stackTrace) { + _log.warning( + 'VACUUM after an upgrade freed pages was skipped; the database is ' + 'correct but has not returned them to the filesystem ' + '(${database.unreclaimedPagesReason})', + error: e, + stackTrace: stackTrace, + ); + } + } _background = background; return database; } diff --git a/lib/core/services/export/excel/blender_invoice_excel_export_service.dart b/lib/core/services/export/excel/blender_invoice_excel_export_service.dart new file mode 100644 index 0000000000..23db87ead9 --- /dev/null +++ b/lib/core/services/export/excel/blender_invoice_excel_export_service.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; +import 'dart:ui' show Rect; + +import 'package:excel_community/excel_community.dart' as xl; +import 'package:file_picker/file_picker.dart'; +import 'package:intl/intl.dart'; + +import 'package:submersion/core/services/export/models/blender_invoice_export_data.dart'; +import 'package:submersion/core/services/export/shared/file_export_utils.dart'; + +/// Exports a trimix blender's running bill to a spreadsheet. +/// +/// One row per gas line, with the fill it belongs to repeated on every row of +/// that fill: a flat sheet, matching [MaintenanceExcelExportService], is +/// easier to sort and pivot than one that nests fills under a merged header. +class BlenderInvoiceExcelExportService { + static const invoiceSheet = 'Invoice'; + + static const _mimeType = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + + final _fileDateFormat = DateFormat('yyyy-MM-dd'); + + List generateBytes(BlenderInvoiceExportData data) { + final excel = xl.Excel.createExcel(); + final sheet = excel[invoiceSheet]; + + _writeRow(sheet, 0, [data.date]); + if (data.billedTo.isNotEmpty) { + _writeRow(sheet, 1, ['Billed to', data.billedTo]); + } + if (data.tariff.isNotEmpty) { + _writeRow(sheet, 2, ['Tariff', data.tariff]); + } + + var row = 4; + _writeRow(sheet, row, const ['Fill', 'Gas', 'Volume', 'Cost', 'Total']); + row++; + for (final fill in data.fills) { + // A lump-sum fill (BilledFill.isManual) has no gas lines, so its label + // still belongs in the Fill column with the rest blank. + if (fill.lines.isEmpty) { + _writeRow(sheet, row, [fill.label, '', '', '', fill.total]); + row++; + continue; + } + for (var i = 0; i < fill.lines.length; i++) { + final line = fill.lines[i]; + _writeRow(sheet, row, [ + i == 0 ? fill.label : '', + line.gas, + line.volume, + line.cost, + i == 0 ? fill.total : '', + ]); + row++; + } + } + + row++; + _writeRow(sheet, row, ['Total', '', '', '', data.total]); + if (data.incomplete) { + row++; + _writeRow(sheet, row, ['Incomplete: one or more lines have no price.']); + } + + // Deleted only after the real sheet exists: the excel package refuses to + // remove a workbook's last remaining sheet. + excel.delete('Sheet1'); + return excel.encode() ?? const []; + } + + Future exportToExcel( + BlenderInvoiceExportData data, { + Rect? sharePositionOrigin, + }) { + final bytes = generateBytes(data); + return saveAndShareFileBytes( + bytes, + _fileName(), + _mimeType, + sharePositionOrigin: sharePositionOrigin, + ); + } + + /// Prompts for a destination. Returns null when the diver cancelled. + Future saveToFile(BlenderInvoiceExportData data) async { + final bytes = generateBytes(data); + final result = await FilePicker.saveFile( + dialogTitle: 'Save Invoice', + fileName: _fileName(), + type: FileType.custom, + bytes: Uint8List.fromList(bytes), + mimeType: _mimeType, + ); + + if (result == null) return null; + return savedFileLocation(result); + } + + String _fileName() => + 'submersion_blender_invoice_${_fileDateFormat.format(DateTime.now())}.xlsx'; + + void _writeRow(xl.Sheet sheet, int rowIndex, List values) { + for (var col = 0; col < values.length; col++) { + sheet + .cell( + xl.CellIndex.indexByColumnRow(columnIndex: col, rowIndex: rowIndex), + ) + .value = xl.TextCellValue( + values[col]?.toString() ?? '', + ); + } + } +} diff --git a/lib/core/services/export/excel/pre_dive_excel_export_service.dart b/lib/core/services/export/excel/pre_dive_excel_export_service.dart index 4b29c420ce..69278944a3 100644 --- a/lib/core/services/export/excel/pre_dive_excel_export_service.dart +++ b/lib/core/services/export/excel/pre_dive_excel_export_service.dart @@ -226,6 +226,7 @@ class PreDiveExcelExportService { PreDiveItemType.check => 'Check', PreDiveItemType.value => 'Value', PreDiveItemType.equipmentSet => 'Equipment', + PreDiveItemType.equipment => 'Equipment', }; xl.CellValue _toCellValue(dynamic value) { diff --git a/lib/core/services/export/export_service.dart b/lib/core/services/export/export_service.dart index 3f6a2e3b63..6ae1a138d2 100644 --- a/lib/core/services/export/export_service.dart +++ b/lib/core/services/export/export_service.dart @@ -1,12 +1,17 @@ +import 'dart:typed_data'; import 'dart:ui' show Rect; import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/services/export/csv/csv_export_service.dart'; +import 'package:submersion/core/services/export/excel/blender_invoice_excel_export_service.dart'; import 'package:submersion/core/services/export/excel/excel_export_service.dart'; import 'package:submersion/core/services/export/excel/maintenance_excel_export_service.dart'; import 'package:submersion/core/services/export/kml/kml_export_service.dart'; +import 'package:submersion/core/services/export/models/blender_invoice_export_data.dart'; import 'package:submersion/core/services/export/models/export_service_record.dart'; +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; import 'package:submersion/core/services/export/models/uddf_import_result.dart'; +import 'package:submersion/core/services/export/pdf/blender_invoice_pdf_export_service.dart'; import 'package:submersion/core/services/export/pdf/pdf_course_export_service.dart'; import 'package:submersion/core/services/export/pdf/pdf_export_service.dart'; import 'package:submersion/core/services/export/shared/file_export_utils.dart' @@ -16,11 +21,15 @@ import 'package:submersion/core/services/export/uddf/uddf_full_export_service.da import 'package:submersion/core/services/export/uddf/uddf_full_import_service.dart'; import 'package:submersion/core/services/export/uddf/uddf_import_service.dart'; import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/core/constants/pdf_templates.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_profile_series.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/certifications/domain/entities/certification.dart'; import 'package:submersion/features/courses/domain/entities/course.dart'; import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; @@ -36,7 +45,9 @@ import 'package:submersion/features/marine_life/domain/entities/species.dart'; import 'package:submersion/features/tags/domain/entities/tag.dart'; import 'package:submersion/features/trips/domain/entities/trip.dart'; +export 'package:submersion/core/services/export/models/blender_invoice_export_data.dart'; export 'package:submersion/core/services/export/models/export_service_record.dart'; +export 'package:submersion/core/services/export/models/uddf_export_options.dart'; export 'package:submersion/core/services/export/models/uddf_import_result.dart'; /// Facade for all export/import operations. @@ -51,8 +62,10 @@ class ExportService { final _csv = CsvExportService(); final _pdf = PdfExportService(); final _pdfCourse = PdfCourseExportService(); + final _blenderInvoicePdf = BlenderInvoicePdfExportService(); final _excel = ExcelExportService(); final _maintenance = MaintenanceExcelExportService(); + final _blenderInvoiceExcel = BlenderInvoiceExcelExportService(); final _kml = KmlExportService(); final _uddf = UddfExportService(); final _uddfFull = UddfFullExportService(); @@ -103,42 +116,97 @@ class ExportService { Future<({List bytes, String fileName})> generateDivePdfBytes( List dives, { required PdfDateFormatter dates, + required UnitFormatter units, + PdfExportOptions options = const PdfExportOptions(), String title = 'Dive Logbook', - List? allSightings, + Map? profiles, + List? certifications, + Diver? diver, + Uint8List? diverPhoto, }) => _pdf.generateDivePdfBytes( dives, dates: dates, + units: units, + options: options, + profiles: profiles, + certifications: certifications, + diver: diver, + diverPhoto: diverPhoto, title: title, - allSightings: allSightings, ); Future exportDivesToPdf( List dives, { required PdfDateFormatter dates, + required UnitFormatter units, + PdfExportOptions options = const PdfExportOptions(), String title = 'Dive Logbook', - List? allSightings, + Map? profiles, + List? certifications, + Diver? diver, + Uint8List? diverPhoto, }) => _pdf.exportDivesToPdf( dives, dates: dates, + units: units, + options: options, + profiles: profiles, + certifications: certifications, + diver: diver, + diverPhoto: diverPhoto, title: title, - allSightings: allSightings, ); Future saveDivesToPdfFile( List dives, { required PdfDateFormatter dates, + required UnitFormatter units, + PdfExportOptions options = const PdfExportOptions(), String title = 'Dive Logbook', - List? allSightings, + Map? profiles, + List? certifications, + Diver? diver, + Uint8List? diverPhoto, }) => _pdf.saveDivesToPdfFile( dives, dates: dates, + units: units, + options: options, + profiles: profiles, + certifications: certifications, + diver: diver, + diverPhoto: diverPhoto, title: title, - allSightings: allSightings, ); Future savePdfBytesToFile(List bytes, String fileName) => _pdf.savePdfBytesToFile(bytes, fileName); + // ==================== Blender Invoice Export ==================== + + Future> generateBlenderInvoicePdfBytes( + BlenderInvoiceExportData data, + ) => _blenderInvoicePdf.generateBytes(data); + + Future exportBlenderInvoiceToPdf( + BlenderInvoiceExportData data, { + Rect? sharePositionOrigin, + }) => _blenderInvoicePdf.exportToPdf( + data, + sharePositionOrigin: sharePositionOrigin, + ); + + List generateBlenderInvoiceExcelBytes(BlenderInvoiceExportData data) => + _blenderInvoiceExcel.generateBytes(data); + + Future exportBlenderInvoiceToExcel( + BlenderInvoiceExportData data, { + Rect? sharePositionOrigin, + }) => _blenderInvoiceExcel.exportToExcel( + data, + sharePositionOrigin: sharePositionOrigin, + ); + // ==================== PDF Course Export ==================== Future exportCourseTrainingLogToPdf( @@ -281,20 +349,28 @@ class ExportService { List dives, { List? sites, Map>>? diveTankPressures, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), }) => _uddf.exportDivesToUddf( dives, sites: sites, diveTankPressures: diveTankPressures, + dataSources: dataSources, + options: options, ); Future saveDivesToUddfFile( List dives, { List? sites, Map>>? diveTankPressures, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), }) => _uddf.saveDivesToUddfFile( dives, sites: sites, diveTankPressures: diveTankPressures, + dataSources: dataSources, + options: options, ); Future exportAllDataToUddf({ @@ -321,6 +397,8 @@ class ExportService { List? courses, Map>? diveGasSwitches, Map>>? diveTankPressures, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), }) => _uddfFull.exportAllDataToUddf( dives: dives, sites: sites, @@ -345,6 +423,8 @@ class ExportService { courses: courses, diveGasSwitches: diveGasSwitches, diveTankPressures: diveTankPressures, + dataSources: dataSources, + options: options, ); Future saveAllDataToUddfFile({ @@ -371,6 +451,8 @@ class ExportService { List? courses, Map>? diveGasSwitches, Map>>? diveTankPressures, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), }) => _uddfFull.saveAllDataToUddfFile( dives: dives, sites: sites, @@ -395,6 +477,8 @@ class ExportService { courses: courses, diveGasSwitches: diveGasSwitches, diveTankPressures: diveTankPressures, + dataSources: dataSources, + options: options, ); // ==================== UDDF Import ==================== diff --git a/lib/core/services/export/models/blender_invoice_export_data.dart b/lib/core/services/export/models/blender_invoice_export_data.dart new file mode 100644 index 0000000000..7ed5ed03ef --- /dev/null +++ b/lib/core/services/export/models/blender_invoice_export_data.dart @@ -0,0 +1,60 @@ +/// What the blender invoice export services (PDF, Excel) need, already +/// formatted for display. +/// +/// The export services never convert units or money themselves: the invoice +/// card already knows the diver's unit and currency preferences, so every +/// figure is formatted once, at the source, rather than duplicated per +/// export format. This mirrors how the card itself never lets a second +/// conversion path creep in (see `blender_formatting.dart`). +library; + +/// One priced gas line within a saved fill. +class BlenderInvoiceExportLine { + const BlenderInvoiceExportLine({ + required this.gas, + required this.volume, + required this.cost, + }); + + final String gas; + final String volume; + final String cost; +} + +/// One fill on the bill, with its itemisation. +class BlenderInvoiceExportFill { + const BlenderInvoiceExportFill({ + required this.label, + required this.total, + required this.lines, + }); + + final String label; + final String total; + final List lines; +} + +/// The whole running bill, ready to hand to a PDF or Excel writer. +class BlenderInvoiceExportData { + const BlenderInvoiceExportData({ + required this.date, + required this.billedTo, + required this.tariff, + required this.fills, + required this.total, + required this.incomplete, + }); + + final String date; + final String billedTo; + + /// The configured gas tariff, e.g. "O2 1.20/100L, He 4.50/100L". Empty + /// when nothing is priced yet. + final String tariff; + + final List fills; + final String total; + + /// True when one or more lines had no price, so [total] is a partial sum. + final bool incomplete; +} diff --git a/lib/core/services/export/models/uddf_export_options.dart b/lib/core/services/export/models/uddf_export_options.dart new file mode 100644 index 0000000000..0458dd8c82 --- /dev/null +++ b/lib/core/services/export/models/uddf_export_options.dart @@ -0,0 +1,19 @@ +/// Options for a UDDF export. +/// +/// Shaped after `PdfExportOptions` in `lib/core/constants/pdf_templates.dart`: +/// a const class with defaults, passed as a defaulted named parameter, so no +/// existing call site has to change. +class UddfExportOptions { + /// Whether to carry each dive's raw dive computer bytes. + /// + /// Defaults to true, matching what every export UI shows: the checkbox is + /// pre-checked in both the full backup and the dives only share paths, and + /// the code level default is kept in step with it rather than diverging. + /// A caller that omits options therefore gets a complete export. + final bool includeRawData; + + const UddfExportOptions({this.includeRawData = true}); + + UddfExportOptions copyWith({bool? includeRawData}) => + UddfExportOptions(includeRawData: includeRawData ?? this.includeRawData); +} diff --git a/lib/core/services/export/models/uddf_import_result.dart b/lib/core/services/export/models/uddf_import_result.dart index 4d18da6982..0f39529b67 100644 --- a/lib/core/services/export/models/uddf_import_result.dart +++ b/lib/core/services/export/models/uddf_import_result.dart @@ -29,6 +29,21 @@ class UddfImportResult { /// [UddfEntityImporter]) can record it on [DiveDataSource] records. final String? sourceFileName; + /// Per-source provenance records, keyed by the `` value + /// (for example `dive_7f3a...`), each list sorted by ordinal. + /// + /// Carries every `dive_data_sources` column the exporter wrote, plus + /// `rawData` where a `` paired with the entry. Empty for + /// any file that was not written by Submersion with raw data enabled. + final Map>> dataSourcesByDiveRef; + + /// Dumps that could not be attached to a source record: no resolvable dive + /// link, an unreadable payload, or a payload over the size ceiling. + /// + /// Counted rather than thrown, because one bad dump in someone else's file + /// must not cost the user the rest of the import. + final int unpairedDumps; + const UddfImportResult({ this.dives = const [], this.sites = const [], @@ -49,6 +64,8 @@ class UddfImportResult { this.equipmentSets = const [], this.courses = const [], this.sourceFileName, + this.dataSourcesByDiveRef = const {}, + this.unpairedDumps = 0, }); /// Check if any data was imported @@ -148,6 +165,8 @@ class UddfImportResult { equipmentSets: equipmentSets, courses: courses, sourceFileName: sourceFileName, + dataSourcesByDiveRef: dataSourcesByDiveRef, + unpairedDumps: unpairedDumps, ); } } diff --git a/lib/core/services/export/pdf/blender_invoice_pdf_export_service.dart b/lib/core/services/export/pdf/blender_invoice_pdf_export_service.dart new file mode 100644 index 0000000000..13c16db9d5 --- /dev/null +++ b/lib/core/services/export/pdf/blender_invoice_pdf_export_service.dart @@ -0,0 +1,167 @@ +import 'dart:ui' show Rect; + +import 'package:intl/intl.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +import 'package:submersion/core/services/export/models/blender_invoice_export_data.dart'; +import 'package:submersion/core/services/export/shared/file_export_utils.dart'; + +/// Renders a trimix blender's running bill to a one-page PDF. +class BlenderInvoicePdfExportService { + static final _fileNameDate = DateFormat('yyyy-MM-dd'); + + /// Builds the PDF bytes without touching the filesystem, so this half is + /// independently testable (see [pdfVisibleText] in the test helpers). + Future> generateBytes(BlenderInvoiceExportData data) async { + final pdf = pw.Document(); + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + build: (context) => pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + data.date, + style: const pw.TextStyle( + fontSize: 20, + fontWeight: pw.FontWeight.bold, + color: PdfColors.blue800, + ), + ), + if (data.billedTo.isNotEmpty) ...[ + pw.SizedBox(height: 6), + pw.Text( + data.billedTo, + style: const pw.TextStyle( + fontSize: 12, + color: PdfColors.grey700, + ), + ), + ], + if (data.tariff.isNotEmpty) ...[ + pw.SizedBox(height: 10), + pw.Text( + data.tariff, + style: const pw.TextStyle( + fontSize: 10, + color: PdfColors.grey600, + ), + ), + ], + pw.SizedBox(height: 16), + pw.Divider(color: PdfColors.grey300), + for (final fill in data.fills) _buildFill(fill), + pw.Divider(color: PdfColors.grey300), + pw.SizedBox(height: 8), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Total', + style: const pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Text( + data.total, + style: const pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + ], + ), + if (data.incomplete) ...[ + pw.SizedBox(height: 4), + pw.Text( + 'Incomplete: one or more lines have no price.', + style: const pw.TextStyle(fontSize: 9, color: PdfColors.red700), + ), + ], + ], + ), + ), + ); + return pdf.save(); + } + + pw.Widget _buildFill(BlenderInvoiceExportFill fill) { + return pw.Padding( + padding: const pw.EdgeInsets.only(top: 10, bottom: 4), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + fill.label, + style: const pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Text( + fill.total, + style: const pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + ), + ), + ], + ), + for (final line in fill.lines) + pw.Padding( + padding: const pw.EdgeInsets.only(left: 12, top: 2), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + line.gas, + style: const pw.TextStyle( + fontSize: 10, + color: PdfColors.grey700, + ), + ), + pw.Text( + line.volume, + style: const pw.TextStyle( + fontSize: 10, + color: PdfColors.grey700, + ), + ), + pw.Text( + line.cost, + style: const pw.TextStyle( + fontSize: 10, + color: PdfColors.grey700, + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Writes the PDF to the documents directory and opens the system share + /// sheet. + Future exportToPdf( + BlenderInvoiceExportData data, { + Rect? sharePositionOrigin, + }) async { + final bytes = await generateBytes(data); + return saveAndShareFileBytes( + bytes, + _fileName(), + 'application/pdf', + sharePositionOrigin: sharePositionOrigin, + ); + } + + String _fileName() => + 'submersion_blender_invoice_${_fileNameDate.format(DateTime.now())}.pdf'; +} diff --git a/lib/core/services/export/pdf/diver_photo_loader.dart b/lib/core/services/export/pdf/diver_photo_loader.dart new file mode 100644 index 0000000000..e90c2f3bc8 --- /dev/null +++ b/lib/core/services/export/pdf/diver_photo_loader.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:submersion/core/providers/provider.dart'; + +/// Reads a diver's portrait for the PDF front matter. +typedef DiverPhotoLoader = Future Function(String? photoPath); + +/// Best-effort read of the portrait at [photoPath]. +/// +/// Returns null for an absent path or an unreadable file rather than +/// throwing: a logbook export must not fail because a portrait was moved or +/// deleted, and the front matter falls back to its placeholder frame. +Future loadDiverPhotoFromDisk(String? photoPath) async { + if (photoPath == null || photoPath.isEmpty) return null; + try { + final file = File(photoPath); + if (!await file.exists()) return null; + return await file.readAsBytes(); + } catch (_) { + return null; + } +} + +/// The loader every PDF export entry point reads the portrait through. +/// +/// A provider rather than a bare function call for two reasons. It keeps the +/// three entry points (settings export, dive-list bulk export, single-dive +/// export) on one implementation, which is what they diverged on: the bulk +/// and single-dive routes passed `diver` but no `diverPhoto`, so the diver +/// page silently fell back to its placeholder on every path except settings. +/// It also gives widget tests a seam, which they need: a real `dart:io` await +/// never completes inside `testWidgets`' FakeAsync zone. +final diverPhotoLoaderProvider = Provider( + (ref) => loadDiverPhotoFromDisk, +); diff --git a/lib/core/services/export/pdf/pdf_export_service.dart b/lib/core/services/export/pdf/pdf_export_service.dart index bc13b6f3db..8fa1d707d3 100644 --- a/lib/core/services/export/pdf/pdf_export_service.dart +++ b/lib/core/services/export/pdf/pdf_export_service.dart @@ -7,8 +7,14 @@ import 'package:pdf/widgets.dart' as pw; import 'package:submersion/core/services/export/shared/file_export_utils.dart'; import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/core/constants/pdf_templates.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_fonts.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_profile_series.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_template_factory.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; -import 'package:submersion/features/marine_life/domain/entities/species.dart'; import 'package:submersion/features/signatures/data/services/signature_storage_service.dart'; import 'package:submersion/features/signatures/domain/entities/signature.dart'; import 'package:submersion/features/trips/domain/entities/trip.dart'; @@ -147,11 +153,21 @@ class PdfExportService { // ==================== Dive Logbook PDF ==================== /// Generate PDF dive logbook bytes without sharing. + /// + /// Routes through [PdfTemplateFactory] like the Transfer page export does, + /// so the dive-list bulk export and the single-dive export produce the same + /// document the diver picked. This replaced a duplicated copy of the old + /// layout that hardcoded A4 and shipped without a Unicode font theme. Future<({List bytes, String fileName})> generateDivePdfBytes( List dives, { required PdfDateFormatter dates, + required UnitFormatter units, + PdfExportOptions options = const PdfExportOptions(), String title = 'Dive Logbook', - List? allSightings, + Map? profiles, + List? certifications, + Diver? diver, + Uint8List? diverPhoto, }) async { final signatureService = SignatureStorageService(); final diveSignatures = >{}; @@ -163,14 +179,29 @@ class PdfExportService { } } - final pdfBytes = await _buildDivePdf( - dives, + // The legacy builder never did this, so accented site names were dropped. + await PdfFonts.instance.initialize(); + + final builder = PdfTemplateFactory().getBuilder(options.template); + final pdfBytes = await builder.buildPdf( + dives: dives, + pageSize: options.pageSize, dates: dates, + units: units, title: title, - allSightings: allSightings, diveSignatures: diveSignatures.isNotEmpty ? diveSignatures : null, + profiles: profiles, + // Only honored when the diver asked for the cards, matching the + // settings export path. + certifications: options.includeCertificationCards ? certifications : null, + diver: diver, + diverPhoto: diverPhoto, + includeVerificationAreas: options.includeVerificationAreas, ); - final fileName = 'dive_logbook_${_fileNameDate.format(DateTime.now())}.pdf'; + + final fileName = + 'dive_logbook_${options.template.name}_' + '${_fileNameDate.format(DateTime.now())}.pdf'; return (bytes: pdfBytes, fileName: fileName); } @@ -178,14 +209,24 @@ class PdfExportService { Future exportDivesToPdf( List dives, { required PdfDateFormatter dates, + required UnitFormatter units, + PdfExportOptions options = const PdfExportOptions(), String title = 'Dive Logbook', - List? allSightings, + Map? profiles, + List? certifications, + Diver? diver, + Uint8List? diverPhoto, }) async { final result = await generateDivePdfBytes( dives, dates: dates, + units: units, + options: options, title: title, - allSightings: allSightings, + profiles: profiles, + certifications: certifications, + diver: diver, + diverPhoto: diverPhoto, ); return saveAndShareFileBytes( result.bytes, @@ -198,14 +239,24 @@ class PdfExportService { Future saveDivesToPdfFile( List dives, { required PdfDateFormatter dates, + required UnitFormatter units, + PdfExportOptions options = const PdfExportOptions(), String title = 'Dive Logbook', - List? allSightings, + Map? profiles, + List? certifications, + Diver? diver, + Uint8List? diverPhoto, }) async { final result = await generateDivePdfBytes( dives, dates: dates, + units: units, + options: options, title: title, - allSightings: allSightings, + profiles: profiles, + certifications: certifications, + diver: diver, + diverPhoto: diverPhoto, ); return savePdfBytesToFile(result.bytes, result.fileName); } @@ -227,385 +278,4 @@ class PdfExportService { if (saveResult == null) return null; return savedFileLocation(saveResult); } - - // ==================== Internal PDF Building ==================== - - Future> _buildDivePdf( - List dives, { - required PdfDateFormatter dates, - String title = 'Dive Logbook', - List? allSightings, - Map>? diveSignatures, - }) async { - final pdf = pw.Document(); - - // Cover page - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - build: (context) => pw.Center( - child: pw.Column( - mainAxisAlignment: pw.MainAxisAlignment.center, - children: [ - pw.Text( - title, - style: const pw.TextStyle( - fontSize: 36, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.SizedBox(height: 20), - pw.Text( - '${dives.length} Dives', - style: const pw.TextStyle(fontSize: 24), - ), - pw.SizedBox(height: 10), - if (dives.isNotEmpty) ...[ - pw.Text( - '${dates.date(dives.last.dateTime)} - ${dates.date(dives.first.dateTime)}', - style: const pw.TextStyle(fontSize: 16), - ), - ], - pw.SizedBox(height: 40), - pw.Text( - 'Generated on ${dates.dateTime(DateTime.now())}', - style: const pw.TextStyle( - fontSize: 12, - color: PdfColors.grey600, - ), - ), - ], - ), - ), - ), - ); - - // Summary page - if (dives.isNotEmpty) { - final totalDiveTime = dives - .where((d) => d.effectiveRuntime != null) - .fold(Duration.zero, (sum, d) => sum + d.effectiveRuntime!); - final maxDepth = dives - .where((d) => d.maxDepth != null) - .map((d) => d.maxDepth!) - .fold(0, (max, depth) => depth > max ? depth : max); - final avgDepth = dives.where((d) => d.avgDepth != null).isEmpty - ? 0.0 - : dives - .where((d) => d.avgDepth != null) - .map((d) => d.avgDepth!) - .reduce((a, b) => a + b) / - dives.where((d) => d.avgDepth != null).length; - - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - build: (context) => pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'Summary', - style: const pw.TextStyle( - fontSize: 24, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.SizedBox(height: 20), - pw.Divider(), - pw.SizedBox(height: 20), - _buildPdfStatRow('Total Dives', '${dives.length}'), - _buildPdfStatRow( - 'Total Dive Time', - '${totalDiveTime.inHours}h ${totalDiveTime.inMinutes % 60}m', - ), - _buildPdfStatRow( - 'Deepest Dive', - '${maxDepth.toStringAsFixed(1)}m', - ), - _buildPdfStatRow( - 'Average Depth', - '${avgDepth.toStringAsFixed(1)}m', - ), - _buildPdfStatRow( - 'Unique Sites', - '${dives.map((d) => d.site?.id).where((id) => id != null).toSet().length}', - ), - ], - ), - ), - ); - } - - // Dive log pages (multiple dives per page) - const divesPerPage = 3; - for (var i = 0; i < dives.length; i += divesPerPage) { - final pageDives = dives.skip(i).take(divesPerPage).toList(); - - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(32), - build: (context) => pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - ...pageDives.expand( - (dive) => [ - _buildPdfDiveEntry( - dive, - dates: dates, - signatures: diveSignatures?[dive.id], - ), - pw.SizedBox(height: 16), - pw.Divider(), - pw.SizedBox(height: 16), - ], - ), - ], - ), - ), - ); - } - - return await pdf.save(); - } - - // ==================== PDF Widget Helpers ==================== - - pw.Widget _buildPdfStatRow(String label, String value) { - return pw.Padding( - padding: const pw.EdgeInsets.symmetric(vertical: 8), - child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text(label, style: const pw.TextStyle(fontSize: 14)), - pw.Text( - value, - style: const pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - ), - ), - ], - ), - ); - } - - pw.Widget _buildPdfDiveEntry( - Dive dive, { - required PdfDateFormatter dates, - List? signatures, - }) { - final tank = dive.tanks.isNotEmpty ? dive.tanks.first : null; - - return pw.Container( - padding: const pw.EdgeInsets.all(12), - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey400), - borderRadius: pw.BorderRadius.circular(8), - ), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text( - '#${dive.diveNumber ?? '-'} - ${dive.site?.name ?? 'Unknown Site'}', - style: const pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.Text( - dates.dateTime(dive.dateTime), - style: const pw.TextStyle( - fontSize: 12, - color: PdfColors.grey600, - ), - ), - ], - ), - pw.SizedBox(height: 8), - pw.Row( - children: [ - _buildPdfInfoChip( - 'Depth', - '${dive.maxDepth?.toStringAsFixed(1) ?? '-'}m', - ), - pw.SizedBox(width: 16), - _buildPdfInfoChip( - 'Duration', - '${dive.effectiveRuntime?.inMinutes ?? '-'} min', - ), - pw.SizedBox(width: 16), - _buildPdfInfoChip( - 'Temp', - '${dive.waterTemp?.toStringAsFixed(0) ?? '-'}\u00B0C', - ), - if (tank != null) ...[ - pw.SizedBox(width: 16), - _buildPdfInfoChip( - 'Air', - '${tank.startPressure?.round() ?? '-'} \u2192 ${tank.endPressure?.round() ?? '-'} bar', - ), - ], - ], - ), - if (dive.notes.isNotEmpty) ...[ - pw.SizedBox(height: 8), - pw.Text( - dive.notes, - style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700), - maxLines: 2, - ), - ], - if (dive.customFields.isNotEmpty) ...[ - pw.SizedBox(height: 6), - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: dive.customFields - .map( - (field) => pw.Padding( - padding: const pw.EdgeInsets.symmetric(vertical: 1), - child: pw.Row( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - '${field.key}: ', - style: const pw.TextStyle( - fontSize: 9, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey700, - ), - ), - pw.Expanded( - child: pw.Text( - field.value, - style: const pw.TextStyle( - fontSize: 9, - color: PdfColors.grey700, - ), - ), - ), - ], - ), - ), - ) - .toList(), - ), - ], - if (dive.rating != null && dive.rating! > 0) ...[ - pw.SizedBox(height: 4), - pw.Text( - '${'\u2605' * dive.rating!}${'\u2606' * (5 - dive.rating!)}', - style: const pw.TextStyle(fontSize: 12, color: PdfColors.amber), - ), - ], - if (signatures != null && signatures.isNotEmpty) ...[ - pw.SizedBox(height: 8), - pw.Text( - 'Verified by:', - style: const pw.TextStyle( - fontSize: 10, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey700, - ), - ), - pw.SizedBox(height: 4), - pw.Wrap( - spacing: 8, - runSpacing: 8, - children: signatures - .map((sig) => _buildPdfSignatureBlock(sig, dates)) - .toList(), - ), - ], - ], - ), - ); - } - - pw.Widget _buildPdfSignatureBlock( - Signature signature, - PdfDateFormatter dates, - ) { - pw.ImageProvider? signatureImage; - if (signature.hasImage) { - try { - signatureImage = pw.MemoryImage(signature.imageData!); - } catch (_) { - // Ignore image load errors - } - } - - return pw.Container( - width: 80, - padding: const pw.EdgeInsets.all(4), - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey300), - borderRadius: pw.BorderRadius.circular(4), - ), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.center, - children: [ - if (signatureImage != null) - pw.SizedBox( - height: 30, - child: pw.Image(signatureImage, fit: pw.BoxFit.contain), - ) - else - pw.SizedBox( - height: 30, - child: pw.Center( - child: pw.Text( - '[Signature]', - style: const pw.TextStyle( - fontSize: 8, - color: PdfColors.grey500, - ), - ), - ), - ), - pw.SizedBox(height: 2), - pw.Text( - signature.signerName, - style: const pw.TextStyle( - fontSize: 7, - fontWeight: pw.FontWeight.bold, - ), - textAlign: pw.TextAlign.center, - ), - pw.Text( - signature.isBuddySignature ? 'Buddy' : 'Instructor', - style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey600), - textAlign: pw.TextAlign.center, - ), - pw.Text( - dates.date(signature.signedAt), - style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey600), - textAlign: pw.TextAlign.center, - ), - ], - ), - ); - } - - pw.Widget _buildPdfInfoChip(String label, String value) { - return pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - label, - style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey600), - ), - pw.Text( - value, - style: const pw.TextStyle( - fontSize: 11, - fontWeight: pw.FontWeight.bold, - ), - ), - ], - ); - } } diff --git a/lib/core/services/export/shared/file_export_utils.dart b/lib/core/services/export/shared/file_export_utils.dart index 83e5ab76ad..b5f17834dd 100644 --- a/lib/core/services/export/shared/file_export_utils.dart +++ b/lib/core/services/export/shared/file_export_utils.dart @@ -1,19 +1,43 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'dart:ui' show Rect; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:gal/gal.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; -/// Save string content to a file and open the system share sheet. +/// Whether the running platform's share sheet can carry files. +/// +/// share_plus has no file sharing on Linux: its backend there is a `mailto:` +/// launcher whose `share` throws `UnimplementedError` as soon as +/// [ShareParams.files] is non-empty. Every helper in this file shares files, +/// so on Linux each export wrote its file into the documents directory and +/// then appeared to do nothing at all -- the throw only ever reached the log. +/// +/// Where the sheet cannot take files the share helpers fall back to the same +/// save dialog the `save*ToFile` helpers use, which is the destination a +/// desktop user expects anyway. +bool get canShareFiles => _canShareFilesOverride ?? !Platform.isLinux; + +bool? _canShareFilesOverride; + +/// Pin [canShareFiles] for a test; null restores the platform default. +/// +/// The gate reads the host platform, so without this the share-sheet tests +/// take the save-dialog branch whenever the suite runs on Linux, and the +/// save-dialog tests pass for the wrong reason everywhere else. +@visibleForTesting +set debugCanShareFiles(bool? value) => _canShareFilesOverride = value; + +/// Save string content and hand it to the system share sheet, or to the save +/// dialog where [canShareFiles] is false. /// /// This is the "share" half of the export idiom. Callers that want the user to -/// pick a destination on disk should use the matching `save*ToFile` helper -/// instead -- those return `null` when the save dialog is cancelled, which this -/// function has no way to express. +/// pick a destination on every platform should use the matching `save*ToFile` +/// helper instead -- those return `null` when the save dialog is cancelled, +/// which this function has no way to express. /// /// [sharePositionOrigin] is the screen rect the iPad share popover points at, /// normally from `shareAnchorFrom` on the button's context. It is optional @@ -26,44 +50,79 @@ Future saveAndShareFile( String fileName, String mimeType, { Rect? sharePositionOrigin, +}) => _shareOrSave( + Uint8List.fromList(utf8.encode(content)), + fileName, + mimeType, + sharePositionOrigin: sharePositionOrigin, +); + +/// Save raw bytes and hand them to the system share sheet, or to the save +/// dialog where [canShareFiles] is false. +/// +/// See [saveAndShareFile] for why this never reports a cancelled dialog, and +/// for what [sharePositionOrigin] is and when it is null. +Future saveAndShareFileBytes( + List bytes, + String fileName, + String mimeType, { + Rect? sharePositionOrigin, +}) => _shareOrSave( + Uint8List.fromList(bytes), + fileName, + mimeType, + sharePositionOrigin: sharePositionOrigin, +); + +Future _shareOrSave( + Uint8List bytes, + String fileName, + String mimeType, { + Rect? sharePositionOrigin, }) async { - final directory = await getApplicationDocumentsDirectory(); - final file = File('${directory.path}/$fileName'); - await file.writeAsString(content); + if (!canShareFiles) return _saveViaDialog(bytes, fileName, mimeType); + + // The sheet takes a file, not bytes, so the export has to exist on disk + // before it opens. + final path = await _writeToDocuments(bytes, fileName); await SharePlus.instance.share( ShareParams( - files: [XFile(file.path, mimeType: mimeType)], + files: [XFile(path, mimeType: mimeType)], subject: fileName, sharePositionOrigin: sharePositionOrigin, ), ); - return file.path; + return path; } -/// Save raw bytes to a file and open the system share sheet. +/// The share sheet's stand-in: let the user choose where the export lands. /// -/// See [saveAndShareFile] for why this never opens a save dialog, and for what -/// [sharePositionOrigin] is and when it is null. -Future saveAndShareFileBytes( - List bytes, +/// [FilePicker.saveFile] writes the bytes itself, so nothing is written to the +/// documents directory first -- doing both would leave a stray copy of every +/// export behind. Cancelling picks no destination but does not throw the +/// export away: the bytes are already built and callers are promised a path, +/// so the file lands where the share path would have left it. +Future _saveViaDialog( + Uint8List bytes, String fileName, - String mimeType, { - Rect? sharePositionOrigin, -}) async { + String mimeType, +) async { + final saved = await FilePicker.saveFile( + fileName: fileName, + bytes: bytes, + mimeType: mimeType, + ); + + if (saved != null) return savedFileLocation(saved); + return _writeToDocuments(bytes, fileName); +} + +Future _writeToDocuments(Uint8List bytes, String fileName) async { final directory = await getApplicationDocumentsDirectory(); final file = File('${directory.path}/$fileName'); await file.writeAsBytes(bytes); - - await SharePlus.instance.share( - ShareParams( - files: [XFile(file.path, mimeType: mimeType)], - subject: fileName, - sharePositionOrigin: sharePositionOrigin, - ), - ); - return file.path; } diff --git a/lib/core/services/export/uddf/uddf_dump_codec.dart b/lib/core/services/export/uddf/uddf_dump_codec.dart new file mode 100644 index 0000000000..8f291089d8 --- /dev/null +++ b/lib/core/services/export/uddf/uddf_dump_codec.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; + +// The size ceiling for a raw dive blob is defined once, by the at-rest codec +// issue #227 added. This file is the second of the two to land, so it imports +// that definition rather than keeping its own copy. +import 'package:submersion/core/database/raw_dive_data_codec.dart' + show kMaxRawDiveBlobBytes; + +/// A dump exceeded [kMaxRawDiveBlobBytes], encoding or decoding. +/// +/// Decompression bombs are the reason this exists: a `` in a file from +/// anywhere else is untrusted input, and bzip2 expands far more aggressively +/// than zlib, so a small element can decode to a great deal. +class UddfDumpTooLargeException implements Exception { + const UddfDumpTooLargeException(); + + @override + String toString() => + 'UddfDumpTooLargeException: raw dive dump exceeds ' + '$kMaxRawDiveBlobBytes bytes'; +} + +/// Encodes and decodes the payload of a UDDF `` element. +/// +/// UDDF 3.2.1 requires that "the memory dump data shall be compressed with +/// bzip2 algorithm and encoded into ASCII using Base64 encoder". bzip2 is +/// therefore not a choice this class makes; gzip would be faster and would +/// produce a file no other UDDF reader can open. +/// +/// This is NOT the codec issue #227 uses to compress the same bytes at rest. +/// That one is zlib behind an `SRD1` header, chosen for space. Bytes read +/// through Drift arrive already decoded by #227's `TypeConverter`, so the +/// export always decodes and then recompresses. Copying the stored blob +/// through would be faster and would produce a spec-violating file. +class UddfDumpCodec { + const UddfDumpCodec._(); + + /// bzip2 then base64. + /// + /// Throws [UddfDumpTooLargeException] rather than mint a dump its own + /// decoder would refuse. + static String encodeOne(Uint8List raw) { + if (raw.length > kMaxRawDiveBlobBytes) { + throw const UddfDumpTooLargeException(); + } + return base64.encode(BZip2Encoder().encodeBytes(raw)); + } + + /// base64 then bzip2, bounded at [kMaxRawDiveBlobBytes] and CRC checked. + /// + /// Decoding runs into a bounded [OutputStream] rather than + /// `BZip2Decoder.decodeBytes`, so a bomb is abandoned partway instead of + /// being fully allocated and only then measured. + /// + /// `verify: true` is not optional here. The decoder compares bzip2's block + /// and stream CRCs only when it is set, and these bytes are the sole + /// recoverable copy of a download: a damaged `` whose framing still + /// parses would otherwise restore silently wrong bytes that surface much + /// later as a wrong re-parse. Failing instead hands the dump to the + /// caller's skip-and-count path, which reports the loss. + static Uint8List decodeOne(String base64Text) { + final trimmed = base64Text.trim(); + + // Refuse an absurd payload before decoding it. bzip2 never much exceeds + // its input (worst case is a little over 100% on incompressible data) and + // base64 adds a third, so a legitimate dump of at most + // kMaxRawDiveBlobBytes cannot encode to more than roughly 11 MiB of text. + // Twice the ceiling is comfortably clear of that. + // + // This bounds base64.decode's own allocation, not the caller's: by the + // time a dump reaches here the whole document has already been read and + // parsed into memory, so an enormous has cost its size several + // times over regardless. Bounding the input to the import as a whole + // would have to happen where the file is read. + if (trimmed.length > kMaxRawDiveBlobBytes * 2) { + throw const UddfDumpTooLargeException(); + } + + final compressed = base64.decode(trimmed); + final output = _BoundedOutputStream(kMaxRawDiveBlobBytes); + final ok = BZip2Decoder().decodeStream( + InputMemoryStream(compressed), + output, + verify: true, + ); + if (!ok) { + throw const FormatException('Malformed bzip2 payload in '); + } + return Uint8List.fromList(output.getBytes()); + } + + /// Encode many blobs on a worker isolate. + /// + /// Returns a list positionally matching [raws], carrying `null` wherever + /// that blob could not be encoded. A single bad blob must not cost the user + /// the whole export: this is a backup path, and a file missing one dump + /// beats no file at all. + /// + /// bzip2 is a block sorting compressor and is markedly slower in pure Dart + /// than zlib, which is why a whole logbook's worth of it does not run on the + /// calling isolate. + static Future> encodeAll(List raws) { + if (raws.isEmpty) return Future.value(const []); + return _encodeOnWorker(raws); + } + + /// Minimal-scope hop to the worker isolate. + /// + /// Kept as its own method so the [Isolate.run] closure's enclosing scope + /// holds only sendable values: Dart closures capture the whole enclosing + /// scope, not only what they reference. This mirrors `backup_crypto.dart`, + /// which documents the same hazard. + static Future> _encodeOnWorker(List raws) { + return Isolate.run(() => _encodeAllImpl(raws), debugName: 'uddf-dcdump'); + } + + static List _encodeAllImpl(List raws) { + return raws + .map((raw) { + try { + return encodeOne(raw); + } catch (_) { + return null; + } + }) + .toList(growable: false); + } +} + +/// An [OutputStream] that refuses to grow past [maxBytes]. +/// +/// Delegates to an [OutputMemoryStream] rather than reimplementing its buffer, +/// and checks the bound before each write, so an oversized stream is abandoned +/// partway rather than after it is fully materialised. +class _BoundedOutputStream extends OutputStream { + _BoundedOutputStream(this.maxBytes) + : _inner = OutputMemoryStream(), + super(byteOrder: ByteOrder.littleEndian); + + final int maxBytes; + final OutputMemoryStream _inner; + + void _guard(int adding) { + if (_inner.length + adding > maxBytes) { + throw const UddfDumpTooLargeException(); + } + } + + @override + int get length => _inner.length; + + @override + void writeByte(int value) { + _guard(1); + _inner.writeByte(value); + } + + @override + void writeBytes(List bytes, {int? length}) { + _guard(length ?? bytes.length); + _inner.writeBytes(bytes, length: length); + } + + @override + void writeStream(InputStream stream) { + _guard(stream.length); + _inner.writeStream(stream); + } + + @override + void clear() => _inner.clear(); + + @override + void flush() => _inner.flush(); + + @override + Uint8List subset(int start, [int? end]) => _inner.subset(start, end); +} diff --git a/lib/core/services/export/uddf/uddf_export_builders.dart b/lib/core/services/export/uddf/uddf_export_builders.dart index ac5ed955ef..9c58a39642 100644 --- a/lib/core/services/export/uddf/uddf_export_builders.dart +++ b/lib/core/services/export/uddf/uddf_export_builders.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:xml/xml.dart'; import 'package:submersion/core/constants/enums.dart' hide Visibility; @@ -10,6 +12,7 @@ import 'package:submersion/features/courses/domain/entities/course.dart'; import 'package:submersion/features/dive_centers/domain/entities/dive_center.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/dive_source_export.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; import 'package:submersion/features/dive_log/domain/entities/profile_event.dart'; @@ -293,8 +296,10 @@ class UddfExportBuilders { // Link to dive computer if (dive.diveComputerModel != null && dive.diveComputerModel!.isNotEmpty) { - final computerId = - 'dc_${dive.diveComputerModel!.replaceAll(' ', '_')}_${dive.diveComputerSerial ?? 'unknown'}'; + final computerId = computerRefId( + dive.diveComputerModel!, + dive.diveComputerSerial, + ); builder.element('link', attributes: {'ref': computerId}); } }, @@ -685,6 +690,15 @@ class UddfExportBuilders { if (dive.isFavorite) { builder.element('isfavorite', nest: 'true'); } + // Statistics exclusion (#526 / #1272). App-specific, like + // isfavorite: another application's UDDF omits these and imports + // as included, which is the right default. + if (dive.excludedFromStats) { + builder.element('excludedfromstats', nest: 'true'); + } + if (dive.excludedFromGasStats) { + builder.element('excludedfromgasstats', nest: 'true'); + } if (dive.photoIds.isNotEmpty) { builder.element( 'photos', @@ -863,6 +877,8 @@ class UddfExportBuilders { List? equipmentSets, List? trips, List? courses, + List? dataSources, + Map dataSourceDumps = const {}, }) { final hasData = (equipment?.isNotEmpty ?? false) || @@ -878,7 +894,11 @@ class UddfExportBuilders { (diveComputers?.isNotEmpty ?? false) || (equipmentSets?.isNotEmpty ?? false) || (trips?.isNotEmpty ?? false) || - (courses?.isNotEmpty ?? false); + (courses?.isNotEmpty ?? false) || + // Without this a logbook whose only extra payload is its data source + // records would write no at all, and the dumps in + // would lose their descriptors. + (dataSources?.isNotEmpty ?? false); if (!hasData) return; @@ -1480,18 +1500,23 @@ class UddfExportBuilders { }, ); } - if (owner.insurance.provider != null) { + // Gated on any detail, not on the provider name: a policy + // number or an assistance line saved without naming the + // insurer would otherwise never reach the file. + if (owner.insurance.hasAnyDetail) { builder.element( 'insurance', nest: () { - builder.element( - 'provider', - nest: owner.insurance.provider, - ); - if (owner.insurance.policyNumber != null) { + if (owner.insurance.providerLabel != null) { + builder.element( + 'provider', + nest: owner.insurance.providerLabel, + ); + } + if (owner.insurance.policyLabel != null) { builder.element( 'policynumber', - nest: owner.insurance.policyNumber, + nest: owner.insurance.policyLabel, ); } if (owner.insurance.expiryDate != null) { @@ -1500,6 +1525,18 @@ class UddfExportBuilders { nest: owner.insurance.expiryDate!.toIso8601String(), ); } + if (owner.insurance.assistanceLine != null) { + builder.element( + 'emergencyphone', + nest: owner.insurance.assistanceLine, + ); + } + if (owner.insurance.officeLine != null) { + builder.element( + 'phone', + nest: owner.insurance.officeLine, + ); + } }, ); } @@ -1555,12 +1592,216 @@ class UddfExportBuilders { ); } } + + // Per-source provenance for the dumps in . + buildDataSources(builder, dataSources ?? const [], dataSourceDumps); }, ); }, ); } + /// The id a `` is declared under in the UDDF standard + /// sections, and therefore the only id a `` may point at. + /// + /// Built from model and serial rather than the `dive_computers` row id: the + /// standard `` elements are minted from the dives' display + /// snapshots, and the row's UUID appears only inside + /// ``, which is not a valid + /// IDREF target. Callers must still check the id is actually declared + /// before linking to it; see [buildDiveComputerControl]. + static String computerRefId(String model, String? serial) => + 'dc_${model.replaceAll(' ', '_')}_${serial ?? 'unknown'}'; + + /// Hex encode, matching SQLite's `hex()` and the convention + /// `dive_repository_impl.dart` documents for raw fingerprints. + static String _hex(Uint8List bytes) => bytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(); + + /// The Submersion per-source provenance record. + /// + /// One `` entry per `dive_data_sources` row, carrying every column + /// except the blob itself. This exists because UDDF has nowhere to put the + /// libdivecomputer descriptor triple, without which a restored dump can + /// never be re-parsed, and nowhere to put the per-source metric snapshot, + /// since a UDDF `` carries only the consolidated values. + /// + /// Entries are written for rows with no bytes as well. Omitting them would + /// restore a dive that had one plain source beside one carrying bytes with + /// fewer sources than it had. + /// + /// [encodedById] is the same map [buildDiveComputerControl] writes from, and + /// `hasdump` is derived from it rather than from the row's own bytes. The + /// two must agree: the importer pairs the nth dump with the nth entry + /// claiming one, so a row that claims a dump it did not get would shift + /// every later dump onto the wrong row, descriptor triple included. A row + /// can hold bytes and still get no dump when its encode failed. + static void buildDataSources( + XmlBuilder builder, + List sources, + Map encodedById, + ) { + if (sources.isEmpty) return; + + builder.element( + 'datasources', + nest: () { + for (final source in sources) { + builder.element( + 'source', + attributes: { + 'diveref': 'dive_${source.diveId}', + 'ordinal': '${source.ordinal}', + 'hasdump': '${encodedById[source.id] != null}', + }, + nest: () { + if (source.descriptorVendor != null || + source.descriptorProduct != null || + source.descriptorModel != null) { + builder.element( + 'descriptor', + attributes: { + if (source.descriptorVendor != null) + 'vendor': source.descriptorVendor!, + if (source.descriptorProduct != null) + 'product': source.descriptorProduct!, + if (source.descriptorModel != null) + 'model': '${source.descriptorModel}', + }, + ); + } + _dsText( + builder, + 'libdivecomputerversion', + source.libdivecomputerVersion, + ); + _dsText(builder, 'sourceuuid', source.sourceUuid); + if (source.rawFingerprint != null && + source.rawFingerprint!.isNotEmpty) { + builder.element( + 'fingerprint', + nest: _hex(source.rawFingerprint!), + ); + } + builder.element('primary', nest: '${source.isPrimary}'); + _dsNumber(builder, 'mergesourceslot', source.mergeSourceSlot); + _dsNumber(builder, 'timeoffsetseconds', source.timeOffsetSeconds); + _dsText(builder, 'computermodel', source.computerModel); + _dsText(builder, 'computerserial', source.computerSerial); + _dsText(builder, 'sourceformat', source.sourceFormat); + _dsText(builder, 'sourcefilename', source.sourceFileName); + _dsText(builder, 'sourcefileformat', source.sourceFileFormat); + _dsDate(builder, 'importedat', source.importedAt); + _dsDate(builder, 'createdat', source.createdAt); + _dsDate(builder, 'lastparsedat', source.lastParsedAt); + _dsNumber(builder, 'maxdepth', source.maxDepth); + _dsNumber(builder, 'avgdepth', source.avgDepth); + _dsNumber(builder, 'duration', source.duration); + _dsNumber(builder, 'watertemp', source.waterTemp); + _dsNumber(builder, 'entrylatitude', source.entryLatitude); + _dsNumber(builder, 'entrylongitude', source.entryLongitude); + _dsNumber(builder, 'exitlatitude', source.exitLatitude); + _dsNumber(builder, 'exitlongitude', source.exitLongitude); + _dsDate(builder, 'entrytime', source.entryTime); + _dsDate(builder, 'exittime', source.exitTime); + _dsNumber(builder, 'maxascentrate', source.maxAscentRate); + _dsNumber(builder, 'maxdescentrate', source.maxDescentRate); + _dsNumber(builder, 'surfaceinterval', source.surfaceInterval); + _dsNumber(builder, 'cns', source.cns); + _dsNumber(builder, 'otu', source.otu); + _dsText(builder, 'decoalgorithm', source.decoAlgorithm); + _dsNumber(builder, 'gradientfactorlow', source.gradientFactorLow); + _dsNumber( + builder, + 'gradientfactorhigh', + source.gradientFactorHigh, + ); + }, + ); + } + }, + ); + } + + static void _dsText(XmlBuilder builder, String name, String? value) { + if (value == null || value.isEmpty) return; + builder.element(name, nest: value); + } + + static void _dsNumber(XmlBuilder builder, String name, num? value) { + if (value == null) return; + builder.element(name, nest: '$value'); + } + + static void _dsDate(XmlBuilder builder, String name, DateTime? value) { + if (value == null) return; + builder.element(name, nest: value.toIso8601String()); + } + + /// The UDDF standard `` section, which the + /// specification places last in the document. + /// + /// One `` per source that has an encoded payload in + /// [encodedById], keyed by [DiveSourceExport.id]. A source mapped to null + /// failed to compress; its dump and nothing else is omitted, because this + /// is a backup path and a file missing one dump beats no file at all. + /// + /// [declaredComputerIds] is the set of ids the document actually declared + /// as ``. A computer link is written only for a source + /// whose [computerRefId] is in that set, because every ref written here has + /// to point at an id the standard itself declares or it dangles under IDREF + /// validation. + /// + /// Two cases make this a set rather than a boolean. The dives only export + /// declares no computers at all, so it passes an empty set. And in the full + /// export the declarations are minted from the dives' own model and serial + /// snapshots, so a multi-source dive's second computer is never declared + /// even though that source row names it. + static void buildDiveComputerControl( + XmlBuilder builder, + List sources, + Map encodedById, { + required Set declaredComputerIds, + }) { + final withPayload = sources + .where((s) => encodedById[s.id] != null) + .toList(growable: false); + if (withPayload.isEmpty) return; + + builder.element( + 'divecomputercontrol', + nest: () { + for (final source in withPayload) { + builder.element( + 'divecomputerdump', + nest: () { + builder.element( + 'link', + attributes: {'ref': 'dive_${source.diveId}'}, + ); + final model = source.computerModel; + if (model != null && model.isNotEmpty) { + final ref = computerRefId(model, source.computerSerial); + if (declaredComputerIds.contains(ref)) { + builder.element('link', attributes: {'ref': ref}); + } + } + // The specification means "when the dump was captured", so this + // is the source row's importedAt, not the dive's own datetime. + // The dive link is what ties the dump back to its dive. + builder.element( + 'datetime', + nest: source.importedAt.toIso8601String(), + ); + builder.element('dcdump', nest: encodedById[source.id]!); + }, + ); + } + }, + ); + } + /// Find pressure at a given timestamp using binary search. /// Points must be sorted by timestamp (ascending). /// Returns null if no point exists within 2 seconds of [timestamp]. diff --git a/lib/core/services/export/uddf/uddf_export_service.dart b/lib/core/services/export/uddf/uddf_export_service.dart index ee2841d5d1..77e97e2427 100644 --- a/lib/core/services/export/uddf/uddf_export_service.dart +++ b/lib/core/services/export/uddf/uddf_export_service.dart @@ -5,9 +5,12 @@ import 'package:file_picker/file_picker.dart'; import 'package:intl/intl.dart'; import 'package:xml/xml.dart'; +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; import 'package:submersion/core/services/export/shared/file_export_utils.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart'; import 'package:submersion/core/services/export/uddf/uddf_export_builders.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; /// Handles simple UDDF export of dives with optional site data. @@ -15,11 +18,27 @@ class UddfExportService { final _dateFormat = DateFormat('yyyy-MM-dd'); /// Build the UDDF document for [dives] without delivering it anywhere. - String generateDivesUddfContent( + Future generateDivesUddfContent( List dives, { List? sites, Map>>? diveTankPressures, - }) { + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), + }) async { + // Encoding happens before the XML build so the builders stay pure + // synchronous functions, and bzip2 runs on a worker isolate rather than + // whichever isolate called the export. + final sources = options.includeRawData + ? (dataSources ?? const []) + : const []; + final withBytes = sources.where((s) => s.hasDump).toList(growable: false); + final encoded = await UddfDumpCodec.encodeAll( + withBytes.map((s) => s.rawData!).toList(growable: false), + ); + final encodedById = { + for (var i = 0; i < withBytes.length; i++) withBytes[i].id: encoded[i], + }; + final builder = XmlBuilder(); builder.processing('xml', 'version="1.0" encoding="UTF-8"'); @@ -292,7 +311,7 @@ class UddfExportService { .toString(), ); // Kelvin } - // Tank pressure from tank_pressure_profiles + // Tank pressure from tank_pressure_series final divePressures = diveTankPressures?[dive.id]; if (divePressures != null) { @@ -545,6 +564,41 @@ class UddfExportService { } }, ); + + // This path has no top level block of + // its own: the only it writes is the per dive + // inline one for custom fields, a different element in a different + // position. So the source records get their own wrapper here. UDDF + // permits in both places and the inline ones are + // untouched. + if (sources.isNotEmpty) { + builder.element( + 'applicationdata', + nest: () { + builder.element( + 'submersion', + attributes: {'version': '1.0'}, + nest: () { + UddfExportBuilders.buildDataSources( + builder, + sources, + encodedById, + ); + }, + ); + }, + ); + } + + // Last section, per the UDDF specification. The empty declared set is + // not an oversight: this export emits no element, so + // any computer ref would dangle under IDREF validation. + UddfExportBuilders.buildDiveComputerControl( + builder, + sources, + encodedById, + declaredComputerIds: const {}, + ); }, ); @@ -560,11 +614,15 @@ class UddfExportService { List dives, { List? sites, Map>>? diveTankPressures, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), }) async { - final xmlString = generateDivesUddfContent( + final xmlString = await generateDivesUddfContent( dives, sites: sites, diveTankPressures: diveTankPressures, + dataSources: dataSources, + options: options, ); return saveAndShareFile(xmlString, _fileName(), 'application/xml'); } @@ -576,11 +634,15 @@ class UddfExportService { List dives, { List? sites, Map>>? diveTankPressures, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), }) async { - final xmlString = generateDivesUddfContent( + final xmlString = await generateDivesUddfContent( dives, sites: sites, diveTankPressures: diveTankPressures, + dataSources: dataSources, + options: options, ); final result = await FilePicker.saveFile( diff --git a/lib/core/services/export/uddf/uddf_full_export_service.dart b/lib/core/services/export/uddf/uddf_full_export_service.dart index fc8c6ad796..fd4f4b3af6 100644 --- a/lib/core/services/export/uddf/uddf_full_export_service.dart +++ b/lib/core/services/export/uddf/uddf_full_export_service.dart @@ -6,7 +6,9 @@ import 'package:intl/intl.dart'; import 'package:xml/xml.dart'; import 'package:submersion/core/services/export/models/export_service_record.dart'; +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; import 'package:submersion/core/services/export/shared/file_export_utils.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart'; import 'package:submersion/core/services/export/uddf/uddf_export_builders.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/certifications/domain/entities/certification.dart'; @@ -14,6 +16,7 @@ import 'package:submersion/features/courses/domain/entities/course.dart'; import 'package:submersion/features/dive_centers/domain/entities/dive_center.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/dive_source_export.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; import 'package:submersion/features/dive_log/domain/entities/profile_event.dart'; @@ -38,7 +41,7 @@ class UddfFullExportService { /// Generate comprehensive UDDF XML content as a string. /// /// Shared by both the share and save-to-file export paths. - String _generateAllDataXml({ + Future _generateAllDataXml({ required List dives, List? sites, List? equipment, @@ -62,7 +65,39 @@ class UddfFullExportService { List? courses, Map>? diveGasSwitches, Map>>? diveTankPressures, - }) { + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), + }) async { + // Encoding happens before the XML build, so the builders stay pure + // synchronous functions over already-encoded strings like every other + // builder in that file. bzip2 is slow in pure Dart, so it runs on a + // worker isolate rather than whichever isolate called the export. + final sources = options.includeRawData + ? (dataSources ?? const []) + : const []; + final withBytes = sources.where((s) => s.hasDump).toList(growable: false); + final encoded = await UddfDumpCodec.encodeAll( + withBytes.map((s) => s.rawData!).toList(growable: false), + ); + final encodedById = { + for (var i = 0; i < withBytes.length; i++) withBytes[i].id: encoded[i], + }; + + // Which computers this document will actually declare as + // . Mirrors the uniqueComputers block below, which + // only runs when there is an owner, and which mints ids from the dives' + // own model and serial snapshots. A dump may only link to an id in here. + final declaredComputerIds = { + if (owner != null) + for (final dive in dives) + if (dive.diveComputerModel != null && + dive.diveComputerModel!.isNotEmpty) + UddfExportBuilders.computerRefId( + dive.diveComputerModel!, + dive.diveComputerSerial, + ), + }; + final builder = XmlBuilder(); builder.processing('xml', 'version="1.0" encoding="UTF-8"'); @@ -125,8 +160,10 @@ class UddfFullExportService { for (final dive in dives) { if (dive.diveComputerModel != null && dive.diveComputerModel!.isNotEmpty) { - final computerId = - 'dc_${dive.diveComputerModel!.replaceAll(' ', '_')}_${dive.diveComputerSerial ?? 'unknown'}'; + final computerId = UddfExportBuilders.computerRefId( + dive.diveComputerModel!, + dive.diveComputerSerial, + ); uniqueComputers[computerId] = { 'model': dive.diveComputerModel!, 'serial': dive.diveComputerSerial ?? '', @@ -418,6 +455,17 @@ class UddfFullExportService { equipmentSets: equipmentSets, trips: trips, courses: courses, + dataSources: sources, + dataSourceDumps: encodedById, + ); + + // The UDDF specification places last, so this + // stays the final child of . + UddfExportBuilders.buildDiveComputerControl( + builder, + sources, + encodedById, + declaredComputerIds: declaredComputerIds, ); }, ); @@ -426,6 +474,23 @@ class UddfFullExportService { return xmlDoc.toXmlString(pretty: true, indent: ' '); } + /// The document this service would write, without delivering it anywhere. + /// + /// Visible for testing: the two public methods below both hand their XML + /// straight to the filesystem or the share sheet, so this is how a test + /// inspects the document itself. + Future generateAllDataXmlForTest({ + required List dives, + Diver? owner, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), + }) => _generateAllDataXml( + dives: dives, + owner: owner, + dataSources: dataSources, + options: options, + ); + /// Export ALL application data to UDDF and share via the system share sheet. /// Returns the share result path. Future exportAllDataToUddf({ @@ -452,8 +517,10 @@ class UddfFullExportService { List? courses, Map>? diveGasSwitches, Map>>? diveTankPressures, - }) { - final xmlString = _generateAllDataXml( + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), + }) async { + final xmlString = await _generateAllDataXml( dives: dives, sites: sites, equipment: equipment, @@ -477,6 +544,8 @@ class UddfFullExportService { courses: courses, diveGasSwitches: diveGasSwitches, diveTankPressures: diveTankPressures, + dataSources: dataSources, + options: options, ); final fileName = 'submersion_backup_${_dateFormat.format(DateTime.now())}.uddf'; @@ -509,8 +578,10 @@ class UddfFullExportService { List? courses, Map>? diveGasSwitches, Map>>? diveTankPressures, + List? dataSources, + UddfExportOptions options = const UddfExportOptions(), }) async { - final xmlString = _generateAllDataXml( + final xmlString = await _generateAllDataXml( dives: dives, sites: sites, equipment: equipment, @@ -534,6 +605,8 @@ class UddfFullExportService { courses: courses, diveGasSwitches: diveGasSwitches, diveTankPressures: diveTankPressures, + dataSources: dataSources, + options: options, ); final fileName = 'submersion_backup_${_dateFormat.format(DateTime.now())}.uddf'; diff --git a/lib/core/services/export/uddf/uddf_full_import_service.dart b/lib/core/services/export/uddf/uddf_full_import_service.dart index d2d1b81680..690ee1dad6 100644 --- a/lib/core/services/export/uddf/uddf_full_import_service.dart +++ b/lib/core/services/export/uddf/uddf_full_import_service.dart @@ -1,8 +1,11 @@ +import 'dart:typed_data'; + import 'package:xml/xml.dart'; import 'package:submersion/core/constants/enums.dart' as enums; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/export/models/uddf_import_result.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart'; import 'package:submersion/core/services/export/uddf/uddf_import_parsers.dart'; import 'package:submersion/core/services/export/uddf/uddf_normalizer.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; @@ -70,17 +73,32 @@ class UddfFullImportService { } } - // Parse trips (UDDF standard divetrip elements) + // Parse trips. Submersion writes one per trip, carrying the + // trip's fields directly; UDDF 3.2 and Subsurface's exporter instead wrap + // children in a single container. Reading only the + // outer element turned a whole Subsurface trip list into one nameless + // trip, which the entity importer then skipped (#239). final trips = >[]; final tripMap = >{}; - for (final tripElement in uddfElement.findElements('divetrip')) { - final tripData = UddfImportParsers.parseTrip(tripElement); - final tripId = tripElement.getAttribute('id'); - if (tripId != null) { - tripData['uddfId'] = tripId; - tripMap[tripId] = tripData; - } - trips.add(tripData); + for (final diveTripElement in uddfElement.findElements('divetrip')) { + final nestedTrips = diveTripElement.findElements('trip').toList(); + final tripElements = nestedTrips.isEmpty + ? [diveTripElement] + : nestedTrips; + for (final tripElement in tripElements) { + final tripData = UddfImportParsers.parseTrip(tripElement); + // Subsurface writes an empty even when the logbook has no + // trips at all. Carrying that through as a nameless trip made the + // import preview offer a trip the entity importer then skipped. + final tripName = tripData['name'] as String?; + if (tripName == null || tripName.isEmpty) continue; + final tripId = tripElement.getAttribute('id'); + if (tripId != null) { + tripData['uddfId'] = tripId; + tripMap[tripId] = tripData; + } + trips.add(tripData); + } } // Parse gas definitions @@ -434,7 +452,13 @@ class UddfFullImportService { } } + _applyTripDateRanges(trips, dives); + + final sources = _parseDataSources(uddfElement); + return UddfImportResult( + dataSourcesByDiveRef: sources.byDiveRef, + unpairedDumps: sources.unpaired, dives: dives, sites: sites, equipment: equipment, @@ -456,6 +480,336 @@ class UddfFullImportService { ); } + /// Parse `` and + /// ``, joining them on (diveref, ordinal). + /// + /// Two passes rather than one: the standard section carries the bytes and + /// the Submersion section carries everything the standard cannot express, + /// most importantly the libdivecomputer descriptor triple. Without that + /// triple a restored blob can never be re-parsed, because + /// `ReparseService.reparseAllForComputer` skips any source missing it. + /// + /// A dump with no matching entry still yields its bytes, as a lone primary + /// source with a null descriptor: bytes from someone else's file are not + /// ours to discard, and re-parse already reports a source it cannot parse. + ({Map>> byDiveRef, int unpaired}) + _parseDataSources(XmlElement uddfElement) { + final entries = >>{}; + + for (final appData in uddfElement.findElements('applicationdata')) { + for (final submersion in appData.findElements('submersion')) { + for (final block in submersion.findElements('datasources')) { + for (final source in block.findElements('source')) { + final diveRef = source.getAttribute('diveref'); + if (diveRef == null || diveRef.isEmpty) continue; + entries + .putIfAbsent(diveRef, () => >[]) + .add(_parseSourceEntry(source)); + } + } + } + } + + for (final list in entries.values) { + list.sort((a, b) => (a['ordinal'] as int).compareTo(b['ordinal'] as int)); + } + + // The dump-carrying entries of each dive, in order, computed once. The + // pairing below indexes into these, and building them per dump would + // allocate a list for every in the file. + // + // Entries appended below (a dump with no record beside it) are absent + // from these lists on purpose: a later dump for the same dive has already + // advanced past the claimant count, so it appends too, exactly as it + // would have when this was rebuilt each time. + final claimantsByRef = >>{ + for (final entry in entries.entries) + entry.key: entry.value + .where((e) => e['hasDump'] == true) + .toList(growable: false), + }; + + // Every dive id this document declares, so a dump's links can be matched + // against real dives rather than guessed at by shape. + final declaredDiveRefs = { + ...entries.keys, + for (final profileData in uddfElement.findElements('profiledata')) + for (final group in profileData.findElements('repetitiongroup')) + for (final dive in group.findElements('dive')) + ?dive.getAttribute('id'), + }; + + var unpaired = 0; + final nextOrdinal = {}; + + for (final control in uddfElement.findElements('divecomputercontrol')) { + for (final dump in control.findElements('divecomputerdump')) { + final diveRef = _resolveDumpDiveRef(dump, declaredDiveRefs); + if (diveRef == null) { + // Nothing to attribute it to, and no dive's counter to advance. + unpaired++; + continue; + } + + // Dumps are written in the same order as the entries that have them, + // so the nth dump for a dive belongs to its nth dump-carrying entry. + // + // Advanced for EVERY dump carrying a resolvable dive ref, before the + // payload is even looked at. A dump that is empty or fails to decode + // still occupies its slot: skipping the increment would hand its + // claimant to the NEXT readable dump, restoring one source's bytes + // under another's descriptor triple. Decode failures are not + // hypothetical, since the codec verifies bzip2's CRCs. + final ordinal = nextOrdinal.update( + diveRef, + (v) => v + 1, + ifAbsent: () => 0, + ); + + final text = dump.findElements('dcdump').firstOrNull?.innerText; + if (text == null || text.trim().isEmpty) { + unpaired++; + continue; + } + + final Uint8List bytes; + try { + bytes = UddfDumpCodec.decodeOne(text); + } catch (e) { + // Untrusted input: a decompression bomb, a truncated stream, a CRC + // mismatch, or something that is not bzip2 at all. Count it and + // keep importing; its claimant simply restores without bytes. + _logger.warning('Skipping unreadable for $diveRef: $e'); + unpaired++; + continue; + } + + final claimants = + claimantsByRef[diveRef] ?? const >[]; + + if (ordinal < claimants.length) { + claimants[ordinal]['rawData'] = bytes; + } else { + // A spec-shaped dump from another application, with no Submersion + // record beside it. Keep the bytes as a bare primary source. + final list = entries.putIfAbsent( + diveRef, + () => >[], + ); + list.add({ + 'ordinal': list.length, + 'hasDump': true, + 'rawData': bytes, + 'isPrimary': list.isEmpty, + }); + } + } + } + + return (byDiveRef: entries, unpaired: unpaired); + } + + /// One `` element as a map keyed by `dive_data_sources` field name. + Map _parseSourceEntry(XmlElement source) { + String? text(String name) { + final value = UddfImportParsers.getElementText(source, name); + return (value == null || value.isEmpty) ? null : value; + } + + int? integer(String name) { + final value = text(name); + return value == null ? null : int.tryParse(value); + } + + double? decimal(String name) { + final value = text(name); + return value == null ? null : double.tryParse(value); + } + + DateTime? date(String name) { + final value = text(name); + return value == null ? null : DateTime.tryParse(value); + } + + final descriptor = source.findElements('descriptor').firstOrNull; + final modelAttr = descriptor?.getAttribute('model'); + + return { + 'ordinal': int.tryParse(source.getAttribute('ordinal') ?? '') ?? 0, + 'hasDump': _parseUddfBool(source.getAttribute('hasdump')), + 'rawData': null, + 'isPrimary': _parseUddfBool(text('primary')), + 'descriptorVendor': descriptor?.getAttribute('vendor'), + 'descriptorProduct': descriptor?.getAttribute('product'), + 'descriptorModel': modelAttr == null ? null : int.tryParse(modelAttr), + 'libdivecomputerVersion': text('libdivecomputerversion'), + 'sourceUuid': text('sourceuuid'), + 'rawFingerprint': _decodeHex(text('fingerprint')), + 'mergeSourceSlot': integer('mergesourceslot'), + 'timeOffsetSeconds': integer('timeoffsetseconds'), + 'computerModel': text('computermodel'), + 'computerSerial': text('computerserial'), + 'sourceFormat': text('sourceformat'), + 'sourceFileName': text('sourcefilename'), + 'sourceFileFormat': text('sourcefileformat'), + 'importedAt': date('importedat'), + 'createdAt': date('createdat'), + 'lastParsedAt': date('lastparsedat'), + 'maxDepth': decimal('maxdepth'), + 'avgDepth': decimal('avgdepth'), + 'duration': integer('duration'), + 'waterTemp': decimal('watertemp'), + 'entryLatitude': decimal('entrylatitude'), + 'entryLongitude': decimal('entrylongitude'), + 'exitLatitude': decimal('exitlatitude'), + 'exitLongitude': decimal('exitlongitude'), + 'entryTime': date('entrytime'), + 'exitTime': date('exittime'), + 'maxAscentRate': decimal('maxascentrate'), + 'maxDescentRate': decimal('maxdescentrate'), + 'surfaceInterval': integer('surfaceinterval'), + 'cns': decimal('cns'), + 'otu': decimal('otu'), + 'decoAlgorithm': text('decoalgorithm'), + 'gradientFactorLow': integer('gradientfactorlow'), + 'gradientFactorHigh': integer('gradientfactorhigh'), + }; + } + + /// Which dive a `` belongs to, or null if it names none. + /// + /// A dump may carry several `` elements: Submersion's own writes the + /// dive and, when the document declares it, the computer. So the ref cannot + /// simply be the first one, or a dump whose dive link is missing would be + /// filed under a computer id that no dive will ever resolve to. + /// + /// Nor can it require the `dive_` prefix. That is Submersion's own id shape; + /// UDDF dive ids are arbitrary, and the restore side already accepts both + /// shapes. Requiring the prefix silently dropped another application's + /// dumps, whose bytes are not ours to discard. + /// + /// So a link is matched against the dives the document actually declares, in + /// either shape, and only then falls back to the prefix for a file that + /// declares no dives to match. A dump naming nothing recognisable is + /// reported as unpaired rather than filed under a guess. + static String? _resolveDumpDiveRef( + XmlElement dump, + Set declaredDiveRefs, + ) { + final refs = dump + .findElements('link') + .map((link) => link.getAttribute('ref')) + .whereType() + .where((ref) => ref.isNotEmpty) + .toList(growable: false); + + for (final ref in refs) { + if (declaredDiveRefs.contains(ref)) return ref; + if (declaredDiveRefs.contains('dive_$ref')) return 'dive_$ref'; + } + for (final ref in refs) { + if (ref.startsWith('dive_')) return ref; + } + return null; + } + + /// A UDDF boolean, read leniently. + /// + /// Case-insensitive, matching how every other boolean flag in this file is + /// read, and accepting the `1` and `0` that `xs:boolean` also permits. Our + /// own exports only ever write lowercase `true`/`false`, so this matters for + /// hand-edited files and other applications' output. + /// + /// Getting it wrong is not cosmetic: a `hasdump` of `TRUE` would read as + /// false, drop that entry from the dump claimants, and let a dump pair with + /// the wrong source or be restored as a bare one. + static bool _parseUddfBool(String? value) { + final normalized = value?.trim().toLowerCase(); + return normalized == 'true' || normalized == '1'; + } + + /// Decode the hex a `` carries, matching SQLite's `hex()`. + static Uint8List? _decodeHex(String? hex) { + if (hex == null || hex.isEmpty || hex.length.isOdd) return null; + final bytes = Uint8List(hex.length ~/ 2); + for (var i = 0; i < bytes.length; i++) { + final byte = int.tryParse(hex.substring(i * 2, i * 2 + 2), radix: 16); + if (byte == null) return null; + bytes[i] = byte; + } + return bytes; + } + + /// Fills in a trip's date range from the dives that belong to it. + /// + /// Only Submersion's own export writes ``. A Subsurface trip + /// carries no dates at all, and an undated trip lands in the library dated + /// the day of the import. The trip's dives cover the same days, so derive + /// the range from them, falling back to the date Subsurface embeds in the + /// trip name when none of its dives were imported. + void _applyTripDateRanges( + List> trips, + List> dives, + ) { + final undatedTrips = trips + .where((trip) => trip['startDate'] == null || trip['endDate'] == null) + .toList(); + if (undatedTrips.isEmpty) { + _dropTripNameDates(trips); + return; + } + + // One pass over the dives, so a large logbook does not pay for a scan per + // trip. + final ranges = {}; + for (final dive in dives) { + final tripRef = dive['tripRef'] as String?; + final diveDateTime = dive['dateTime'] as DateTime?; + if (tripRef == null || diveDateTime == null) continue; + final range = ranges[tripRef]; + ranges[tripRef] = range == null + ? (earliest: diveDateTime, latest: diveDateTime) + : ( + earliest: diveDateTime.isBefore(range.earliest) + ? diveDateTime + : range.earliest, + latest: diveDateTime.isAfter(range.latest) + ? diveDateTime + : range.latest, + ); + } + + for (final trip in undatedTrips) { + final uddfId = trip['uddfId'] as String?; + final range = uddfId == null ? null : ranges[uddfId]; + final nameDate = trip['_nameDate'] as DateTime?; + final start = range?.earliest ?? nameDate; + final end = range?.latest ?? nameDate; + // Trip dates are calendar days in the diver's own frame, while dive + // datetimes are wall clocks flagged UTC, so carry the components across + // rather than the instant. + if (trip['startDate'] == null && start != null) { + trip['startDate'] = DateTime(start.year, start.month, start.day); + } + if (trip['endDate'] == null && end != null) { + trip['endDate'] = DateTime(end.year, end.month, end.day); + } + } + + _dropTripNameDates(trips); + } + + /// Drops the date recovered from a trip's name once it has been used. + /// + /// These maps travel across layers, and the payload merger copies every key + /// it finds when folding a duplicate trip from a second file, so a scratch + /// key left behind does not stay local to parsing. + void _dropTripNameDates(List> trips) { + for (final trip in trips) { + trip.remove('_nameDate'); + } + } + Map _parseFullSite(XmlElement siteElement) { // Parse base site fields using simple import service final baseSite = _parseUddfSite(siteElement); @@ -626,6 +980,18 @@ class UddfFullImportService { } } + // Subsurface points a dive at its trip with , and its + // generated trip ids carry none of the prefixes the handling + // above keys on. Read after the links, so that where a file somehow + // carries both, the UDDF-standard element is the one that decides. + final tripMembershipRef = beforeElement + .findElements('tripmembership') + .firstOrNull + ?.getAttribute('ref'); + if (tripMembershipRef != null && tripMembershipRef.isNotEmpty) { + diveData['tripRef'] = tripMembershipRef; + } + // Also parse inline buddy elements in informationbeforedive for (final buddyElement in beforeElement.findElements('buddy')) { final personalElement = buddyElement @@ -866,6 +1232,23 @@ class UddfFullImportService { diveData['isFavorite'] = true; } + // Statistics exclusion. Absent means included, so a document from + // another application never arrives pre-excluded. + final excludedFromStats = UddfImportParsers.getElementText( + afterElement, + 'excludedfromstats', + ); + if (excludedFromStats?.toLowerCase() == 'true') { + diveData['excludedFromStats'] = true; + } + final excludedFromGasStats = UddfImportParsers.getElementText( + afterElement, + 'excludedfromgasstats', + ); + if (excludedFromGasStats?.toLowerCase() == 'true') { + diveData['excludedFromGasStats'] = true; + } + // Parse additional weights (app-specific, beyond single weight) final weightsElement = afterElement.findElements('weights').firstOrNull; if (weightsElement != null) { @@ -2240,7 +2623,7 @@ class UddfFullImportService { 'bcd': (label: 'BCD', type: enums.EquipmentType.bcd), 'boots': (label: 'Boots', type: enums.EquipmentType.boots), 'fins': (label: 'Fins', type: enums.EquipmentType.fins), - 'compass': (label: 'Compass', type: enums.EquipmentType.other), + 'compass': (label: 'Compass', type: enums.EquipmentType.compass), 'knife': (label: 'Knife', type: enums.EquipmentType.knife), 'tankrelatedequipment': (label: 'Tank', type: enums.EquipmentType.tank), }; diff --git a/lib/core/services/export/uddf/uddf_import_parsers.dart b/lib/core/services/export/uddf/uddf_import_parsers.dart index 923c37c389..ec05b34376 100644 --- a/lib/core/services/export/uddf/uddf_import_parsers.dart +++ b/lib/core/services/export/uddf/uddf_import_parsers.dart @@ -47,6 +47,15 @@ class UddfImportParsers { // "15.0" or "15.000". Captures the integer so it can be parsed exactly. static final RegExp _zeroFractionPattern = RegExp(r'^([+-]?\d+)\.0*$'); + // A trip name of the form "", which + // is how Subsurface's UDDF exporter names a trip. The location half is + // optional: a trip with no location exports as just the separator and the + // date, and Dart counts U+00A0 as whitespace, so by the time the name has + // been read and trimmed the separator is gone and only the date is left. + static final RegExp _tripNamePattern = RegExp( + r'^(?:(.*)\u00A0)?(\d{4}-\d{2}-\d{2})$', + ); + /// Parses a UDDF integer-semantics value, tolerating the float /// serialization several exporters emit. /// @@ -283,18 +292,59 @@ class UddfImportParsers { return GasMix(o2: o2, he: he); } + /// A datetime carrying trailing timezone info: a "Z", or an offset in any + /// of the shapes ISO 8601 allows ("+02:00", "+0200", "+02"). Group 1 is the + /// datetime with that suffix removed. + /// + /// Requiring a clock time in front of the offset is what makes the compact + /// forms safe to strip: the trailing "-05" of a bare "2008-09-05" cannot be + /// mistaken for one. Without the compact forms the suffix survived, + /// [DateTime.parse] applied the offset, and the wall clock was stored + /// shifted by it ("08:42:30+0200" landed as 06:42:30). + /// + /// The time is captured rather than asserted with a lookbehind, because + /// this package builds for web too, where a Dart RegExp becomes a JS one + /// and lookbehind throws on Safari older than 16.4. A static final field + /// would take UDDF import down entirely on first use there. + static final RegExp _timeZoneSuffixPattern = RegExp( + r'^(.*\d{2}:\d{2}(?::\d{2})?(?:[.,]\d+)?)\s*(?:Z|[+-]\d{2}:?\d{2}|[+-]\d{2})$', + ); + + /// A date carrying no time, optionally followed by the "T" separator that + /// an exporter leaves dangling when the time is unknown. + static final RegExp _dateWithoutTimePattern = RegExp( + r'^(\d{4}-\d{2}-\d{2})[T ]?$', + ); + /// Parse a UDDF datetime string using the wall-clock-as-UTC convention. /// /// UDDF dive datetimes represent the wall-clock reading from the dive /// computer (e.g. "2024-06-15T08:42:00"). Any timezone suffix is ignored /// and the date/time components are stored verbatim as a UTC DateTime so /// that they survive a DB round-trip unchanged. + /// + /// A value with no time is read as midnight on that date. Subsurface builds + /// this element as `concat(@date, 'T', @time)`, so a dive logged by hand + /// with no entry time exports as a bare "2008-09-05T" that + /// [DateTime.parse] rejects. Returning null for those let the caller fall + /// back to the import clock, which stamped every such dive with the day it + /// was imported rather than the day it was dived (#239). static DateTime? parseDiveDateTime(String? text) { - if (text == null || text.isEmpty) return null; + if (text == null) return null; + final trimmed = text.trim(); + if (trimmed.isEmpty) return null; // Strip any trailing timezone info (Z, +HH:MM, -HH:MM, etc.) - final bare = text.split(RegExp(r'[Z+\-](?=\d{2}:\d{2}$)|Z$')).first; - final dt = DateTime.tryParse(bare); - if (dt == null) return null; + final bare = + _timeZoneSuffixPattern.firstMatch(trimmed)?.group(1) ?? trimmed; + var dt = DateTime.tryParse(bare); + if (dt == null) { + final dateWithoutTime = _dateWithoutTimePattern + .firstMatch(bare) + ?.group(1); + if (dateWithoutTime == null) return null; + dt = DateTime.tryParse(dateWithoutTime); + if (dt == null) return null; + } return DateTime.utc( dt.year, dt.month, @@ -398,14 +448,56 @@ class UddfImportParsers { if (expiryDate != null) { owner['insuranceExpiryDate'] = DateTime.tryParse(expiryDate); } + owner['insuranceEmergencyPhone'] = getElementText( + insuranceElement, + 'emergencyphone', + ); + owner['insurancePhone'] = getElementText(insuranceElement, 'phone'); } } + /// Reads one trip element. + /// + /// Handles both shapes in circulation: Submersion writes each trip as its + /// own `` carrying the trip fields directly, while UDDF 3.2 (and + /// Subsurface's exporter) nests `` elements inside a single + /// `` container and hangs the name and notes off a ``. static Map parseTrip(XmlElement tripElement) { final trip = {}; - trip['name'] = getElementText(tripElement, 'name') ?? ''; - trip['notes'] = getElementText(tripElement, 'notes') ?? ''; + final tripPart = tripElement.findElements('trippart').firstOrNull; + // getElementText already trims, but the name is matched against a + // pattern below, so trim once here and use that value throughout. + final rawName = + (getElementText(tripElement, 'name') ?? + (tripPart == null ? null : getElementText(tripPart, 'name')) ?? + '') + .trim(); + + // Subsurface has no trip-name field of its own, so its exporter builds + // one as "\u00A0". Recovering the two halves keeps + // the trip out of the library named after a date, and gives it a date to + // fall back on when no dive of the trip was imported. + String? locationFromName; + var name = rawName; + final nameParts = _tripNamePattern.firstMatch(rawName); + if (nameParts != null) { + final location = nameParts.group(1)?.trim(); + final nameDate = DateTime.tryParse(nameParts.group(2)!); + if (nameDate != null) { + trip['_nameDate'] = nameDate; + if (location != null && location.isNotEmpty) { + name = location; + locationFromName = location; + } + } + } + + trip['name'] = name; + trip['notes'] = + getElementText(tripElement, 'notes') ?? + (tripPart == null ? null : getElementText(tripPart, 'notes')) ?? + ''; // Parse date range final dateOfTrip = tripElement.findElements('dateoftrip').firstOrNull; @@ -428,8 +520,13 @@ class UddfImportParsers { // Parse geography final geographyElement = tripElement.findElements('geography').firstOrNull; - if (geographyElement != null) { - trip['location'] = getElementText(geographyElement, 'location'); + final location = geographyElement == null + ? null + : getElementText(geographyElement, 'location'); + if (location != null) { + trip['location'] = location; + } else if (locationFromName != null) { + trip['location'] = locationFromName; } return trip; diff --git a/lib/core/services/export/uddf/uddf_source_fetch.dart b/lib/core/services/export/uddf/uddf_source_fetch.dart new file mode 100644 index 0000000000..dc560b4f35 --- /dev/null +++ b/lib/core/services/export/uddf/uddf_source_fetch.dart @@ -0,0 +1,39 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/services/export/models/uddf_export_options.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_source_export.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; + +/// Fetches the data source rows a UDDF export should carry for [diveIds]. +typedef UddfSourceFetch = + Future> Function( + List diveIds, + UddfExportOptions options, + ); + +/// The fetch every UDDF export path uses. +/// +/// A provider rather than a direct repository read at each call site, because +/// the export actions live in widgets whose tests have no database. Overriding +/// this is how such a test opts out, the same way they already override +/// `exportServiceProvider`, and it keeps the rule itself in one place. +final uddfSourceFetchProvider = Provider((ref) { + return (diveIds, options) => + resolveDataSources(ref.read(diveRepositoryProvider), diveIds, options); +}); + +/// Whether a UDDF export fetches data source rows, and the fetch itself. +/// +/// One rule rather than four copies of it: the settings notifier and the three +/// page level export actions all go through here. A share with the raw data +/// box unchecked must not pay for a query it will not use, which is the whole +/// point of the toggle on the dives only paths. +Future> resolveDataSources( + DiveRepository repository, + List diveIds, + UddfExportOptions options, +) async { + if (!options.includeRawData) return const []; + return repository.getSourcesForExport(diveIds); +} diff --git a/lib/core/services/files/picked_file_materializer.dart b/lib/core/services/files/picked_file_materializer.dart index acc4bc7861..8f607c3c0a 100644 --- a/lib/core/services/files/picked_file_materializer.dart +++ b/lib/core/services/files/picked_file_materializer.dart @@ -47,9 +47,11 @@ Future> materializePickedFiles( await scratch.create(recursive: true); // Name collisions are real here: two SAF picks from different folders can - // share a display name, so give each its own subdirectory. - final dir = Directory(p.join(scratch.path, '${out.length}')); - await dir.create(recursive: true); + // share a display name, so give each its own subdirectory. The name is + // unique rather than a per-call index, so it collides neither with an + // earlier pick's leftovers nor with a directory the scratch sweep has + // already decided to prune. + final dir = await scratch.createTemp('pick_'); final dest = File(p.join(dir.path, file.name)); try { diff --git a/lib/core/services/garmin_connect/garmin_api_exception.dart b/lib/core/services/garmin_connect/garmin_api_exception.dart new file mode 100644 index 0000000000..dd460217bb --- /dev/null +++ b/lib/core/services/garmin_connect/garmin_api_exception.dart @@ -0,0 +1,32 @@ +/// Thrown by [GarminConnectClient] operations against the undocumented +/// Garmin Connect mobile API. +/// +/// Mirrors `CloudStorageException`'s `displayMessage` convention so callers +/// can surface [message] directly in a snackbar/dialog without a leading +/// `Exception:`/class-name prefix. +class GarminApiException implements Exception { + const GarminApiException(this.message, {this.cause, this.statusCode}); + + final String message; + final Object? cause; + final int? statusCode; + + /// True when the stored session is no longer accepted and the diver has to + /// sign in again (as opposed to a transient network or server fault). + bool get isAuthExpired => statusCode == 401 || statusCode == 403; + + String get displayMessage => cause == null ? message : '$message ($cause)'; + + @override + String toString() => 'GarminApiException: $displayMessage'; +} + +/// Thrown when Garmin answers the sign-in with a challenge this client +/// cannot satisfy programmatically -- currently a CAPTCHA. +/// +/// Separate from the generic [GarminApiException] because the only useful +/// remedy is "sign in through the Garmin app or website once, then retry", +/// which the UI phrases differently from an ordinary failure. +class GarminChallengeException extends GarminApiException { + const GarminChallengeException(super.message); +} diff --git a/lib/core/services/garmin_connect/garmin_auth_tokens.dart b/lib/core/services/garmin_connect/garmin_auth_tokens.dart new file mode 100644 index 0000000000..b0ea17dee5 --- /dev/null +++ b/lib/core/services/garmin_connect/garmin_auth_tokens.dart @@ -0,0 +1,67 @@ +/// The long-lived OAuth 1 resource-owner credential Garmin issues once, in +/// exchange for an SSO service ticket. +/// +/// This is the credential worth persisting: it survives for roughly a year +/// and can be re-exchanged for a fresh [GarminOAuth2Token] whenever the +/// short-lived access token lapses, so the diver only signs in once. +class GarminOAuth1Token { + const GarminOAuth1Token({ + required this.token, + required this.tokenSecret, + this.mfaToken, + }); + + final String token; + final String tokenSecret; + + /// Present when the account uses multi-factor auth. Garmin requires it to + /// be echoed back on the OAuth2 exchange, otherwise the exchange is + /// rejected and the diver is asked for a code again on every refresh. + final String? mfaToken; + + Map toJson() => { + 'token': token, + 'tokenSecret': tokenSecret, + 'mfaToken': mfaToken, + }; + + factory GarminOAuth1Token.fromJson(Map json) => + GarminOAuth1Token( + token: json['token'] as String, + tokenSecret: json['tokenSecret'] as String, + mfaToken: json['mfaToken'] as String?, + ); +} + +/// The short-lived bearer token used for actual Connect API calls. +/// +/// Not persisted: it expires in about an hour, and re-deriving it from the +/// stored [GarminOAuth1Token] is a single request, so caching it to disk +/// would add a stale-credential failure mode for no benefit. +class GarminOAuth2Token { + const GarminOAuth2Token({ + required this.accessToken, + required this.tokenType, + required this.expiresAt, + }); + + final String accessToken; + final String tokenType; + final DateTime expiresAt; + + /// Treated as expired slightly early so a token is never presented within + /// its final minute, which would otherwise race an in-flight request. + bool isExpired({DateTime? now}) => !(now ?? DateTime.now()).isBefore( + expiresAt.subtract(const Duration(minutes: 1)), + ); + + /// `Bearer `, with the token type normalised -- Garmin returns it + /// lowercased ("bearer"), which some proxies reject in an Authorization + /// header. + String get authorizationHeader { + final type = tokenType.isEmpty + ? 'Bearer' + : tokenType[0].toUpperCase() + tokenType.substring(1).toLowerCase(); + return '$type $accessToken'; + } +} diff --git a/lib/core/services/garmin_connect/garmin_connect_client.dart b/lib/core/services/garmin_connect/garmin_connect_client.dart new file mode 100644 index 0000000000..60a340a34d --- /dev/null +++ b/lib/core/services/garmin_connect/garmin_connect_client.dart @@ -0,0 +1,657 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/garmin_connect/garmin_api_exception.dart'; +import 'package:submersion/core/services/garmin_connect/garmin_auth_tokens.dart'; +import 'package:submersion/core/services/garmin_connect/garmin_oauth1_signer.dart'; + +/// A dive activity summary from the Connect activity list -- not yet the dive +/// profile, which arrives as a FIT file via +/// [GarminConnectClient.downloadActivityFit]. +class GarminActivitySummary { + const GarminActivitySummary({ + required this.activityId, + required this.startTime, + required this.activityType, + this.name, + this.maxDepth, + this.durationSeconds, + this.latitude, + this.longitude, + }); + + final int activityId; + + /// Activity start in UTC. Approximate -- the authoritative dive start comes + /// from the FIT file once downloaded. + final DateTime startTime; + + /// Garmin activity type key, e.g. `single_gas_diving`. + final String activityType; + + final String? name; + + /// Best-effort, for the progress list only; units are metres. + final double? maxDepth; + final int? durationSeconds; + + /// Garmin Connect's own start-position estimate for the activity. + /// + /// The FIT file's own `session.start_position_lat/long` field is only set + /// when the watch already had a GPS fix at the instant it started + /// recording -- easy to miss underwater/on descent -- so this is kept as + /// a fallback: Connect's backend derives it from the full recorded track + /// (or phone-assisted positioning) and often has it even when the FIT + /// file's own start-of-session field doesn't. + final double? latitude; + final double? longitude; +} + +/// One page of the dive-activity listing, plus the cursor needed to ask for +/// the page after it. +class GarminDivePage { + const GarminDivePage({ + required this.dives, + required this.nextStart, + required this.hasMore, + }); + + /// The page's dive activities, newest first. Empty only when the account's + /// history is exhausted -- [GarminConnectClient.fetchDivePage] walks past + /// listing pages that hold no dives rather than returning them. + final List dives; + + /// The `start` to hand [GarminConnectClient.fetchDivePage] for the next + /// page. Meaningless once [hasMore] is false. + final int nextStart; + + /// Whether Garmin reported a full listing page, meaning there is more + /// history past this one. + final bool hasMore; +} + +/// The outcome of a sign-in attempt. +/// +/// Garmin interposes a code challenge for MFA-enabled accounts, so a +/// successful password does not necessarily yield a session. The caller must +/// branch on [mfaRequired] and, when set, collect a code and call +/// [GarminConnectClient.submitMfaCode]. +class GarminLoginResult { + const GarminLoginResult._({required this.mfaRequired, this.mfaMethod}); + + const GarminLoginResult.success() : this._(mfaRequired: false); + const GarminLoginResult.needsMfa(String method) + : this._(mfaRequired: true, mfaMethod: method); + + final bool mfaRequired; + + /// How Garmin says it delivered the code (e.g. `email`), for the prompt. + final String? mfaMethod; +} + +/// Talks to the UNDOCUMENTED, UNOFFICIAL Garmin Connect mobile API. Using +/// this against your own Garmin account may violate Garmin's Terms of +/// Service -- read them before using this. Use only with your own account +/// and your own data. +/// +/// A Dart port of the sign-in and token-exchange flow implemented by the +/// MIT-licensed https://github.com/matin/garth, which is the reference for +/// this protocol. The consumer credential below is the one Garmin ships in +/// its own Android app; garth publishes it because the OAuth 1 exchange +/// cannot be performed without it. +class GarminConnectClient { + GarminConnectClient({http.Client? httpClient}) + : _http = httpClient ?? http.Client(); + + static const String _ssoBase = 'https://sso.garmin.com'; + static const String _apiBase = 'https://connectapi.garmin.com'; + static const String _clientId = 'GCM_ANDROID_DARK'; + static const String _serviceUrl = + 'https://mobile.integration.garmin.com/gcm/android'; + + static const String _consumerKey = 'fc3e99d2-118c-44b8-8ae3-03370dde24c0'; + static const String _consumerSecret = 'E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF'; + + /// The SSO endpoints are served to a WebView and sit behind Cloudflare, so + /// a plain HTTP-client user agent draws a bot challenge. These mimic the + /// in-app browser closely enough to pass. + static const Map _ssoHeaders = { + 'User-Agent': + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Dest': 'document', + }; + + /// The OAuth endpoints check the user agent against the consumer key's + /// registered client, so this must stay the Android app's. + static const Map _oauthHeaders = { + 'User-Agent': 'com.garmin.android.apps.connectmobile', + }; + + static const Map _apiHeaders = { + 'User-Agent': 'GCM-iOS-5.22.1.4', + }; + + /// Garmin activity type keys that denote a dive. Matched by substring so a + /// subtype Garmin adds later (it has grown this list several times) is + /// still recognised rather than silently dropped. + static const List _diveTypeFragments = ['diving', 'apnea']; + + final http.Client _http; + + /// Minimal cookie jar. The SSO handshake threads Cloudflare and Garmin + /// session cookies across three requests, and `package:http` does not + /// persist them. + final Map _cookies = {}; + + GarminOAuth1Token? _oauth1Token; + GarminOAuth2Token? _oauth2Token; + + /// Login parameters captured between the password step and the MFA step. + Map? _pendingMfaParams; + + GarminOAuth1Token? get oauth1Token => _oauth1Token; + + bool get hasSession => _oauth1Token != null; + + /// Restores a previously persisted session. Throws [GarminApiException] if + /// Garmin no longer accepts the stored credential. + Future restoreSession(GarminOAuth1Token token) async { + _oauth1Token = token; + _oauth2Token = await _exchangeForOAuth2(token); + } + + /// Step 1 of sign-in: prime cookies, then post the password. + /// + /// Returns [GarminLoginResult.needsMfa] when Garmin wants a code, in which + /// case the caller must follow up with [submitMfaCode]. + Future login(String email, String password) async { + _cookies.clear(); + _pendingMfaParams = null; + + // Priming request: establishes the Cloudflare and SSO session cookies + // the login POST is validated against. + await _send( + 'GET', + Uri.parse('$_ssoBase/mobile/sso/en/sign-in?clientId=$_clientId'), + headers: {..._ssoHeaders, 'Sec-Fetch-Site': 'none'}, + ); + + final loginParams = { + 'clientId': _clientId, + 'locale': 'en-US', + 'service': _serviceUrl, + }; + + final response = await _send( + 'POST', + Uri.parse( + '$_ssoBase/mobile/api/login', + ).replace(queryParameters: loginParams), + headers: {..._ssoHeaders, 'Content-Type': 'application/json'}, + body: utf8.encode( + jsonEncode({ + 'username': email, + 'password': password, + 'rememberMe': false, + 'captchaToken': '', + }), + ), + ); + + final json = _decodeJson(response); + final status = json['responseStatus'] as Map? ?? const {}; + final type = status['type'] as String?; + + switch (type) { + case 'SUCCESSFUL': + await _completeLogin(json['serviceTicketId'] as String); + return const GarminLoginResult.success(); + + case 'MFA_REQUIRED': + _pendingMfaParams = loginParams; + final mfaInfo = + json['customerMfaInfo'] as Map? ?? const {}; + final method = mfaInfo['mfaLastMethodUsed'] as String? ?? 'email'; + return GarminLoginResult.needsMfa(method); + + // Garmin returns a CAPTCHA challenge after repeated attempts or from an + // unfamiliar network. There is no headless way through it. + case 'CAPTCHA_REQUIRED': + throw const GarminChallengeException( + 'Garmin is asking for a CAPTCHA. Sign in once at connect.garmin.com ' + 'in a browser, then try again.', + ); + + default: + final message = status['message'] as String?; + throw GarminApiException( + 'Sign-in failed${message == null ? '' : ': $message'}', + statusCode: response.statusCode, + ); + } + } + + /// Step 2 of sign-in, only for MFA accounts: submits the emailed/authenticator + /// code and completes the token exchange. + Future submitMfaCode(String code, {String mfaMethod = 'email'}) async { + final params = _pendingMfaParams; + if (params == null) { + throw const GarminApiException( + 'No sign-in is awaiting a verification code', + ); + } + + final response = await _send( + 'POST', + Uri.parse( + '$_ssoBase/mobile/api/mfa/verifyCode', + ).replace(queryParameters: params), + headers: {..._ssoHeaders, 'Content-Type': 'application/json'}, + body: utf8.encode( + jsonEncode({ + 'mfaMethod': mfaMethod, + 'mfaVerificationCode': code, + 'rememberMyBrowser': false, + 'reconsentList': [], + 'mfaSetup': false, + }), + ), + ); + + final json = _decodeJson(response); + final status = json['responseStatus'] as Map? ?? const {}; + if (status['type'] != 'SUCCESSFUL') { + final message = status['message'] as String?; + throw GarminApiException( + message == null + ? 'That verification code was not accepted' + : 'Verification failed: $message', + ); + } + + _pendingMfaParams = null; + await _completeLogin(json['serviceTicketId'] as String); + } + + /// Fetches the page of dive activities at [start], newest first. + /// + /// Garmin's activity list is itself paginated newest-to-oldest (`start` + /// walks forward from the most recent activity). Fetching one page at a + /// time -- rather than buffering the diver's entire history before + /// returning anything -- lets a caller start acting on the newest dives + /// while older pages are still being fetched. Each page is additionally + /// sorted newest first on its own as a defensive measure, in case Garmin + /// ever returns a page out of order. + /// + /// A listing page can hold no dives at all once the per-item check has + /// run, so this walks past such pages internally: a returned page is + /// either non-empty or genuinely the end of the account's history, and a + /// caller never has to tell "nothing on this page" apart from "nothing + /// left". + /// + /// Exposed as an explicit cursor rather than only as [listDivesPaged] so + /// a caller can retry a single failed page. A `Stream` that has thrown is + /// terminated for good, and asking a terminated one for its next page + /// reports "no more pages" -- silently truncating the history at whatever + /// page happened to hit a transient network error. + Future fetchDivePage({ + int start = 0, + int pageSize = 15, + }) async { + var offset = start; + + while (true) { + final page = await _getJsonList( + Uri.parse( + '$_apiBase/activitylist-service/activities/search/activities', + ).replace( + queryParameters: { + 'start': '$offset', + 'limit': '$pageSize', + // Server-side narrowing to the diving parent category. The + // per-item check below still runs, so a change in how Garmin + // honours this filter cannot let a run or a swim through. + 'activityType': 'diving', + }, + ), + ); + if (page.isEmpty) { + return GarminDivePage( + dives: const [], + nextStart: offset, + hasMore: false, + ); + } + + final summaries = []; + for (final entry in page) { + final item = entry as Map; + final summary = _toActivitySummary(item); + if (summary != null) summaries.add(summary); + } + + final hasMore = page.length >= pageSize; + offset += pageSize; + if (summaries.isNotEmpty || !hasMore) { + summaries.sort((a, b) => b.startTime.compareTo(a.startTime)); + return GarminDivePage( + dives: summaries, + nextStart: offset, + hasMore: hasMore, + ); + } + } + } + + /// Streams pages of dive activities, newest first, walking the cursor + /// [fetchDivePage] hands back until the history runs out. + Stream> listDivesPaged({ + int pageSize = 15, + }) async* { + var start = 0; + + while (true) { + final page = await fetchDivePage(start: start, pageSize: pageSize); + if (page.dives.isNotEmpty) yield page.dives; + if (!page.hasMore) break; + start = page.nextStart; + } + } + + /// Every dive activity, newest first. A convenience wrapper over + /// [listDivesPaged] for callers that don't need progressive access to + /// pages as they arrive from the network. + Future> listDives({int pageSize = 15}) async { + final dives = []; + await for (final page in listDivesPaged(pageSize: pageSize)) { + dives.addAll(page); + } + return dives; + } + + /// Downloads an activity's original upload. Garmin serves this as a ZIP + /// archive wrapping the FIT file, even for a single activity, so the + /// archive is unwrapped here and only the FIT bytes are returned. + Future downloadActivityFit(int activityId) async { + final response = await _apiRequest( + 'GET', + Uri.parse('$_apiBase/download-service/files/activity/$activityId'), + ); + return _extractFitBytes(response.bodyBytes, activityId); + } + + static Uint8List _extractFitBytes(Uint8List bytes, int activityId) { + final Archive archive; + try { + archive = ZipDecoder().decodeBytes(bytes, verify: false); + } catch (e) { + throw GarminApiException( + 'Garmin returned an unreadable activity archive for activity ' + '$activityId', + cause: e, + ); + } + + for (final entry in archive) { + if (entry.isFile && entry.name.toLowerCase().endsWith('.fit')) { + return Uint8List.fromList(entry.readBytes() ?? const []); + } + } + throw GarminApiException( + 'Garmin activity $activityId archive contained no FIT file', + ); + } + + GarminActivitySummary? _toActivitySummary(Map item) { + final typeKey = + (item['activityType'] as Map?)?['typeKey'] as String?; + if (typeKey == null || !_isDiveType(typeKey)) return null; + + final activityId = (item['activityId'] as num?)?.toInt(); + final startTime = _parseGarminUtc(item['startTimeGMT'] as String?); + if (activityId == null || startTime == null) return null; + + return GarminActivitySummary( + activityId: activityId, + startTime: startTime, + activityType: typeKey, + name: item['activityName'] as String?, + maxDepth: (item['maxDepth'] as num?)?.toDouble(), + durationSeconds: (item['duration'] as num?)?.round(), + latitude: (item['startLatitude'] as num?)?.toDouble(), + longitude: (item['startLongitude'] as num?)?.toDouble(), + ); + } + + static bool _isDiveType(String typeKey) { + final lower = typeKey.toLowerCase(); + return _diveTypeFragments.any(lower.contains); + } + + /// Garmin serves `startTimeGMT` as `yyyy-MM-dd HH:mm:ss` with no zone + /// designator, despite it being UTC. + static DateTime? _parseGarminUtc(String? value) { + if (value == null || value.isEmpty) return null; + return DateTime.tryParse('${value.replaceFirst(' ', 'T')}Z'); + } + + // --------------------------------------------------------------------------- + // Auth internals + // --------------------------------------------------------------------------- + + Future _completeLogin(String serviceTicket) async { + final oauth1 = await _fetchOAuth1Token(serviceTicket); + _oauth1Token = oauth1; + _oauth2Token = await _exchangeForOAuth2(oauth1); + } + + Future _fetchOAuth1Token(String serviceTicket) async { + final url = Uri.parse('$_apiBase/oauth-service/oauth/preauthorized') + .replace( + queryParameters: { + 'ticket': serviceTicket, + 'login-url': _serviceUrl, + 'accepts-mfa-tokens': 'true', + }, + ); + + const signer = GarminOAuth1Signer( + consumerKey: _consumerKey, + consumerSecret: _consumerSecret, + ); + + final response = await _send( + 'GET', + url, + headers: { + ..._oauthHeaders, + 'Authorization': signer.authorizationHeader(method: 'GET', url: url), + }, + ); + _throwForStatus(response, 'Could not obtain a Garmin access token'); + + // The response is form-encoded, not JSON. + final fields = Uri.splitQueryString(response.body); + final token = fields['oauth_token']; + final secret = fields['oauth_token_secret']; + if (token == null || secret == null) { + throw const GarminApiException('Garmin did not return an access token'); + } + + return GarminOAuth1Token( + token: token, + tokenSecret: secret, + mfaToken: fields['mfa_token'], + ); + } + + Future _exchangeForOAuth2(GarminOAuth1Token oauth1) async { + final url = Uri.parse('$_apiBase/oauth-service/oauth/exchange/user/2.0'); + + final bodyParams = { + 'audience': 'GARMIN_CONNECT_MOBILE_ANDROID_DI', + if (oauth1.mfaToken != null) 'mfa_token': oauth1.mfaToken!, + }; + + final signer = GarminOAuth1Signer( + consumerKey: _consumerKey, + consumerSecret: _consumerSecret, + token: oauth1.token, + tokenSecret: oauth1.tokenSecret, + ); + + final response = await _send( + 'POST', + url, + headers: { + ..._oauthHeaders, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': signer.authorizationHeader( + method: 'POST', + url: url, + bodyParams: bodyParams, + ), + }, + body: utf8.encode( + bodyParams.entries + .map( + (e) => + '${Uri.encodeQueryComponent(e.key)}=' + '${Uri.encodeQueryComponent(e.value)}', + ) + .join('&'), + ), + ); + _throwForStatus(response, 'Garmin rejected the stored sign-in'); + + final json = _decodeJson(response); + final accessToken = json['access_token'] as String?; + if (accessToken == null) { + throw const GarminApiException('Garmin did not return an access token'); + } + + final expiresIn = (json['expires_in'] as num?)?.toInt() ?? 3600; + return GarminOAuth2Token( + accessToken: accessToken, + tokenType: json['token_type'] as String? ?? 'Bearer', + expiresAt: DateTime.now().add(Duration(seconds: expiresIn)), + ); + } + + /// Issues an authenticated Connect API request, re-exchanging the stored + /// OAuth 1 credential first if the bearer token has lapsed. + /// + /// Garmin has no refresh-token grant here: the long-lived OAuth 1 token is + /// the refresh mechanism. + Future _apiRequest(String method, Uri url) async { + final oauth1 = _oauth1Token; + if (oauth1 == null) { + throw const GarminApiException('Not signed in to Garmin Connect'); + } + if (_oauth2Token == null || _oauth2Token!.isExpired()) { + _oauth2Token = await _exchangeForOAuth2(oauth1); + } + + final response = await _send( + method, + url, + headers: { + ..._apiHeaders, + 'Authorization': _oauth2Token!.authorizationHeader, + }, + ); + _throwForStatus(response, 'Garmin request failed'); + return response; + } + + Future> _getJsonList(Uri url) async { + final response = await _apiRequest('GET', url); + if (response.bodyBytes.isEmpty) return const []; + final decoded = jsonDecode(utf8.decode(response.bodyBytes)); + if (decoded is List) return decoded; + // Some Connect endpoints wrap the list; tolerate both shapes. + if (decoded is Map) { + final list = decoded['activityList']; + if (list is List) return list; + } + return const []; + } + + // --------------------------------------------------------------------------- + // Transport + // --------------------------------------------------------------------------- + + Future _send( + String method, + Uri url, { + Map headers = const {}, + List? body, + }) async { + final request = http.Request(method, url) + ..followRedirects = true + ..headers.addAll(headers); + if (_cookies.isNotEmpty) { + request.headers['cookie'] = _cookies.entries + .map((e) => '${e.key}=${e.value}') + .join('; '); + } + if (body != null) request.bodyBytes = body; + + http.Response response; + try { + final streamed = await _http + .send(request) + .timeout(const Duration(seconds: 30)); + response = await http.Response.fromStream(streamed); + } on GarminApiException { + rethrow; + } catch (e) { + throw GarminApiException('Could not reach Garmin Connect', cause: e); + } + + _storeCookies(response); + return response; + } + + void _storeCookies(http.Response response) { + for (final raw in response.headersSplitValues['set-cookie'] ?? const []) { + final pair = raw.split(';').first.trim(); + final eq = pair.indexOf('='); + if (eq <= 0) continue; + _cookies[pair.substring(0, eq)] = pair.substring(eq + 1); + } + } + + Map _decodeJson(http.Response response) { + try { + final decoded = jsonDecode(utf8.decode(response.bodyBytes)); + if (decoded is Map) return decoded; + } on FormatException { + // Falls through to the shared error below: a non-JSON body here is + // almost always a Cloudflare interstitial rather than a Garmin reply. + } + throw GarminApiException( + response.statusCode == 200 + ? 'Unexpected response from Garmin for ${response.request?.url} ' + '(a sign-in page or other interstitial may be blocked from ' + 'this network)' + : 'Garmin returned HTTP ${response.statusCode}', + statusCode: response.statusCode, + ); + } + + void _throwForStatus(http.Response response, String message) { + if (response.statusCode < 400) return; + throw GarminApiException( + '$message (HTTP ${response.statusCode})', + statusCode: response.statusCode, + ); + } +} diff --git a/lib/core/services/garmin_connect/garmin_dive_mapper.dart b/lib/core/services/garmin_connect/garmin_dive_mapper.dart new file mode 100644 index 0000000000..375452f22e --- /dev/null +++ b/lib/core/services/garmin_connect/garmin_dive_mapper.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; +import 'package:submersion/features/dive_import/domain/entities/imported_dive.dart'; + +/// A dive mapped from a Garmin FIT export, plus the device identity fields +/// needed to resolve/create the owning [DiveComputer] record (kept separate +/// from [DownloadedDive], which has no computer-identity fields of its own). +class GarminParsedDive { + const GarminParsedDive({ + required this.dive, + this.deviceModel, + this.serialNumber, + this.firmwareVersion, + }); + + final DownloadedDive dive; + final String? deviceModel; + final String? serialNumber; + final String? firmwareVersion; +} + +/// Converts an [ImportedDive] (as produced by the existing +/// `FitParserService.parseFitFile` from a downloaded Garmin FIT file) into a +/// [DownloadedDive], the same shape a BLE/USB dive computer download +/// produces -- so the rest of the import pipeline (tanks, gas switches, +/// duplicate detection, consolidation) is shared with [DiveComputerAdapter] +/// and the Suunto cloud adapter, rather than falling back to the +/// [ImportedDiveConverter] path that drops tanks and gas switches. +class GarminDiveMapper { + const GarminDiveMapper._(); + + static GarminParsedDive map( + ImportedDive imported, { + required int activityId, + double? fallbackLatitude, + double? fallbackLongitude, + }) { + final tanks = _mapTanks(imported.tanks); + final gasSwitches = _mapGasSwitches(imported.gasSwitches); + final profile = _mapProfile(imported.profile); + + // The FIT file's own entry position is only set when the watch already + // had a GPS fix the instant it started recording -- easy to miss + // underwater/on descent. Connect's activity list carries its own + // start-position estimate (derived from the full track or phone-assisted + // positioning), which fills the gap when the FIT file's is missing. + // Both the FIT file's own position and the fallback are used as pairs -- + // never mixing a lat from one source with a long from the other. + final hasFitEntryPosition = + imported.latitude != null && imported.longitude != null; + final hasFallbackEntryPosition = + fallbackLatitude != null && fallbackLongitude != null; + final entryLatitude = hasFitEntryPosition + ? imported.latitude + : (hasFallbackEntryPosition ? fallbackLatitude : null); + final entryLongitude = hasFitEntryPosition + ? imported.longitude + : (hasFallbackEntryPosition ? fallbackLongitude : null); + + final dive = DownloadedDive( + diveNumber: imported.diveNumber, + startTime: imported.startTime, + durationSeconds: imported.durationSeconds, + maxDepth: imported.maxDepth, + avgDepth: imported.avgDepth, + minTemperature: imported.minTemperature, + maxTemperature: imported.maxTemperature, + entryLatitude: entryLatitude, + entryLongitude: entryLongitude, + exitLatitude: imported.exitLatitude, + exitLongitude: imported.exitLongitude, + profile: profile, + tanks: tanks, + gasSwitches: gasSwitches, + // A Garmin activity ID is stable and unique per dive, so it makes an + // exact-match fingerprint for detectDuplicate -- re-running the import + // skips already-imported dives instantly instead of relying solely on + // fuzzy time/depth/duration matching. + rawFingerprint: _fingerprintFor(activityId), + decoAlgorithm: (imported.decoModel?.isNotEmpty ?? false) + ? imported.decoModel + : null, + gfLow: imported.gfLow, + gfHigh: imported.gfHigh, + ); + + return GarminParsedDive( + dive: dive, + deviceModel: imported.computerModel, + serialNumber: imported.computerSerial, + firmwareVersion: imported.computerFirmware, + ); + } + + static Uint8List _fingerprintFor(int activityId) => + Uint8List.fromList(utf8.encode('garmin-activity-$activityId')); + + static List _mapTanks(List tanks) { + return [ + for (final tank in tanks) + DownloadedTank( + index: tank.order, + o2Percent: tank.o2Percent ?? 21.0, + hePercent: tank.hePercent ?? 0.0, + startPressure: tank.startPressureBar, + endPressure: tank.endPressureBar, + volumeLiters: tank.volumeLiters, + ), + ]; + } + + static List _mapGasSwitches( + List gasSwitches, + ) { + return [ + for (final gasSwitch in gasSwitches) + GasSwitchEvent( + timeSeconds: gasSwitch.timeSeconds, + depth: gasSwitch.depth ?? 0.0, + toTankIndex: gasSwitch.tankIndex, + ), + ]; + } + + /// Maps profile samples 1:1, except a sample with more than one + /// simultaneous tank-pressure reading (multiple air-integration + /// transmitters): the first reading rides on the main sample, and every + /// additional reading becomes an extra minimal sample at the same + /// timestamp/depth. [ProfileSample] carries only one pressure/tankIndex + /// pair per row, so this mirrors how multi-transmitter dive-computer + /// downloads are represented elsewhere in this app (see + /// SuuntoDiveParser._buildProfile, which documents the same convention). + static List _mapProfile(List profile) { + final result = []; + for (final sample in profile) { + final pressures = sample.tankPressures ?? const []; + + result.add( + ProfileSample( + timeSeconds: sample.timeSeconds, + depth: sample.depth, + temperature: sample.temperature, + heartRate: sample.heartRate, + cns: sample.cns, + ndl: sample.ndlSeconds, + tts: sample.ttsSeconds, + ceiling: sample.ceiling, + pressure: pressures.isEmpty ? null : pressures.first.pressureBar, + tankIndex: pressures.isEmpty ? null : pressures.first.tankIndex, + ), + ); + + for (final extra in pressures.skip(1)) { + result.add( + ProfileSample( + timeSeconds: sample.timeSeconds, + depth: sample.depth, + pressure: extra.pressureBar, + tankIndex: extra.tankIndex, + ), + ); + } + } + return result; + } +} diff --git a/lib/core/services/garmin_connect/garmin_oauth1_signer.dart b/lib/core/services/garmin_connect/garmin_oauth1_signer.dart new file mode 100644 index 0000000000..e51c211024 --- /dev/null +++ b/lib/core/services/garmin_connect/garmin_oauth1_signer.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// Minimal OAuth 1.0a (RFC 5849) HMAC-SHA1 request signer. +/// +/// Garmin's token endpoints are the only place in this app that speak OAuth 1, +/// and no pub package is depended on for it, so this implements just the +/// subset those two calls need: HMAC-SHA1, `Authorization` header transport, +/// consumer-only or consumer+token credentials. +/// +/// Verified against `oauthlib` (a mature independent implementation) -- see +/// test/core/services/garmin_connect/garmin_oauth1_signer_test.dart, whose +/// expected signatures were generated by it rather than hand-computed. +class GarminOAuth1Signer { + const GarminOAuth1Signer({ + required this.consumerKey, + required this.consumerSecret, + this.token, + this.tokenSecret, + }); + + final String consumerKey; + final String consumerSecret; + + /// Resource-owner token. Absent for the initial `preauthorized` call, which + /// is signed with consumer credentials only. + final String? token; + final String? tokenSecret; + + /// Builds the `Authorization: OAuth ...` header value for a request. + /// + /// [bodyParams] must be supplied for an `application/x-www-form-urlencoded` + /// body: RFC 5849 folds those into the signature base string alongside the + /// query parameters, so omitting them produces a signature the server + /// rejects. + /// + /// [nonce] and [timestamp] are injectable purely so tests can pin them; + /// production callers omit both and get a fresh random nonce and the + /// current time. + String authorizationHeader({ + required String method, + required Uri url, + Map bodyParams = const {}, + String? nonce, + String? timestamp, + }) { + final oauthParams = { + 'oauth_consumer_key': consumerKey, + 'oauth_nonce': nonce ?? _generateNonce(), + 'oauth_signature_method': 'HMAC-SHA1', + 'oauth_timestamp': timestamp ?? _generateTimestamp(), + 'oauth_version': '1.0', + 'oauth_token': ?token, + }; + + final signature = _signature( + method: method, + url: url, + oauthParams: oauthParams, + bodyParams: bodyParams, + ); + + final headerParams = { + ...oauthParams, + 'oauth_signature': signature, + }; + + final parts = headerParams.entries + .map((e) => '${_encode(e.key)}="${_encode(e.value)}"') + .join(', '); + return 'OAuth $parts'; + } + + String _signature({ + required String method, + required Uri url, + required Map oauthParams, + required Map bodyParams, + }) { + // Every oauth_* param (except the signature itself), every query + // parameter, and every form-body parameter participate in the base string. + final collected = >[ + ...oauthParams.entries, + ...url.queryParameters.entries, + ...bodyParams.entries, + ]; + + // Sort by percent-encoded key, then by percent-encoded value. + final encoded = + collected + .map((e) => MapEntry(_encode(e.key), _encode(e.value))) + .toList() + ..sort((a, b) { + final byKey = a.key.compareTo(b.key); + return byKey != 0 ? byKey : a.value.compareTo(b.value); + }); + + final normalizedParams = encoded + .map((e) => '${e.key}=${e.value}') + .join('&'); + + final baseString = [ + method.toUpperCase(), + _encode(_baseStringUri(url)), + _encode(normalizedParams), + ].join('&'); + + final signingKey = + '${_encode(consumerSecret)}&${_encode(tokenSecret ?? '')}'; + + final digest = Hmac( + sha1, + utf8.encode(signingKey), + ).convert(utf8.encode(baseString)); + return base64.encode(digest.bytes); + } + + /// Scheme and authority lowercased, default port dropped, query and + /// fragment removed -- the "base string URI" of RFC 5849 section 3.4.1.2. + static String _baseStringUri(Uri url) { + final scheme = url.scheme.toLowerCase(); + final host = url.host.toLowerCase(); + final isDefaultPort = + (scheme == 'http' && url.port == 80) || + (scheme == 'https' && url.port == 443); + final authority = isDefaultPort ? host : '$host:${url.port}'; + final path = url.path.isEmpty ? '/' : url.path; + return '$scheme://$authority$path'; + } + + /// RFC 3986 percent-encoding. Dart's [Uri.encodeComponent] leaves `!*'()` + /// unescaped, which OAuth requires escaped, so the unreserved set is + /// applied explicitly here rather than patching up its output. + static String _encode(String value) { + const unreserved = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789-._~'; + final buffer = StringBuffer(); + for (final byte in utf8.encode(value)) { + final char = String.fromCharCode(byte); + if (byte < 128 && unreserved.contains(char)) { + buffer.write(char); + } else { + buffer.write( + '%${byte.toRadixString(16).toUpperCase().padLeft(2, '0')}', + ); + } + } + return buffer.toString(); + } + + static String _generateNonce() { + final random = Random.secure(); + return List.generate( + 32, + (_) => '0123456789abcdef'[random.nextInt(16)], + ).join(); + } + + static String _generateTimestamp() => + (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); +} diff --git a/lib/core/services/garmin_connect/garmin_session_store.dart b/lib/core/services/garmin_connect/garmin_session_store.dart new file mode 100644 index 0000000000..30c810dae7 --- /dev/null +++ b/lib/core/services/garmin_connect/garmin_session_store.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:submersion/core/services/garmin_connect/garmin_auth_tokens.dart'; +import 'package:submersion/core/services/secure_storage/fallback_secure_storage.dart'; + +/// The cached Garmin session: the long-lived OAuth 1 token plus the account +/// email shown in the sign-in UI. The password is never persisted -- only +/// this resulting token, which Garmin can revoke independently of it. +class GarminSessionData { + const GarminSessionData({required this.email, required this.token}); + + final String email; + final GarminOAuth1Token token; + + Map toJson() => {'email': email, ...token.toJson()}; + + factory GarminSessionData.fromJson(Map json) => + GarminSessionData( + email: json['email'] as String, + token: GarminOAuth1Token.fromJson(json), + ); +} + +/// Persists the Garmin session as a single JSON blob in the platform +/// keychain, mirroring `SuuntoSessionStore`/`DropboxAuthStore`: one blob +/// keeps load/save atomic; nothing touches SharedPreferences or the +/// database. +/// +/// A corrupt blob is left in place rather than deleted, so a transient +/// decode bug cannot destroy credentials; [save] simply overwrites it. +class GarminSessionStore { + GarminSessionStore({FlutterSecureStorage? storage}) + : _storage = FallbackSecureStorage(storage ?? const FlutterSecureStorage()); + + final FallbackSecureStorage _storage; + + static const String storageKey = 'garmin_connect_session'; + + /// The stored session, or null when unset or the stored blob is corrupt. + Future load() async { + final raw = await _storage.read(key: storageKey); + if (raw == null) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + if (decoded['email'] is! String || + decoded['token'] is! String || + decoded['tokenSecret'] is! String) { + return null; + } + return GarminSessionData.fromJson(decoded); + } on FormatException { + return null; + } on TypeError { + return null; + } + } + + Future save(GarminSessionData data) => + _storage.write(key: storageKey, value: jsonEncode(data.toJson())); + + Future clear() => _storage.delete(key: storageKey); +} diff --git a/lib/core/services/geocoding/sea_area.dart b/lib/core/services/geocoding/sea_area.dart new file mode 100644 index 0000000000..43e62d7bb2 --- /dev/null +++ b/lib/core/services/geocoding/sea_area.dart @@ -0,0 +1,258 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +/// Kilometres per degree of latitude. Constant enough at this scale: the +/// resolver only ever measures a few kilometres of coastline slack. +const double _kmPerDegreeLatitude = 110.574; + +/// Kilometres per degree of longitude at the equator, shrunk by cos(lat) +/// at the point being measured. +const double _kmPerDegreeLongitudeAtEquator = 111.320; + +/// A closed ring of longitude/latitude pairs, stored flat as +/// `[lon, lat, lon, lat, ...]` with the first pair repeated at the end. +/// +/// Flat storage matters here: the shipped table is around ninety thousand +/// vertices, and a list of point objects would cost several times the +/// memory of one [Float64List] holding the same numbers. +class SeaAreaRing { + SeaAreaRing(this.coordinates) + : assert( + coordinates.length >= 8 && coordinates.length.isEven, + 'a ring needs at least three distinct points, closed', + ) { + var minX = coordinates[0]; + var maxX = coordinates[0]; + var minY = coordinates[1]; + var maxY = coordinates[1]; + for (var i = 2; i < coordinates.length; i += 2) { + final x = coordinates[i]; + final y = coordinates[i + 1]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + minLon = minX; + maxLon = maxX; + minLat = minY; + maxLat = maxY; + } + + final Float64List coordinates; + + late final double minLon; + late final double minLat; + late final double maxLon; + late final double maxLat; + + /// Vertex count, counting the repeated closing point once. + int get vertexCount => coordinates.length ~/ 2 - 1; + + /// Crossing-number point-in-polygon test in plate carree space. + /// + /// The shipped rings are pre-split at the antimeridian, apart from the + /// two circumpolar rings (Arctic and Southern oceans) which genuinely + /// span the full longitude range and close along the top or bottom edge, + /// so a flat test is correct for every ring in the table. + bool containsPoint(double lon, double lat) { + if (lon < minLon || lon > maxLon || lat < minLat || lat > maxLat) { + return false; + } + var inside = false; + final n = coordinates.length; + var jx = coordinates[n - 2]; + var jy = coordinates[n - 1]; + for (var i = 0; i < n; i += 2) { + final ix = coordinates[i]; + final iy = coordinates[i + 1]; + if ((iy > lat) != (jy > lat)) { + final crossing = (jx - ix) * (lat - iy) / (jy - iy) + ix; + if (lon < crossing) inside = !inside; + } + jx = ix; + jy = iy; + } + return inside; + } + + /// Distance from the point to the nearest edge, in kilometres. + /// + /// Degrees are projected to kilometres at the query latitude before the + /// comparison, so the answer is meaningful at both the equator and high + /// latitudes. Zero for a point lying on the ring; inside and outside are + /// not distinguished, which is what the caller wants when it is asking + /// "how far off this coastline am I?". + double distanceKm(double lon, double lat) { + final kx = _kmPerDegreeLongitudeAtEquator * math.cos(lat * math.pi / 180.0); + const ky = _kmPerDegreeLatitude; + var best = double.infinity; + for (var i = 0; i + 3 < coordinates.length; i += 2) { + final ax = (coordinates[i] - lon) * kx; + final ay = (coordinates[i + 1] - lat) * ky; + final bx = (coordinates[i + 2] - lon) * kx; + final by = (coordinates[i + 3] - lat) * ky; + final dx = bx - ax; + final dy = by - ay; + final lengthSquared = dx * dx + dy * dy; + var t = 0.0; + if (lengthSquared > 0) { + t = -(ax * dx + ay * dy) / lengthSquared; + if (t < 0) { + t = 0; + } else if (t > 1) { + t = 1; + } + } + final px = ax + t * dx; + final py = ay + t * dy; + final squared = px * px + py * py; + if (squared < best) best = squared; + } + return math.sqrt(best); + } +} + +/// One outline plus the landmasses cut out of it. +/// +/// Only islands big enough to hold an inland dive site survive into +/// [holes]; the generator drops smaller ones, so a site on a small island +/// reports the sea around it rather than nothing. +class SeaAreaPolygon { + const SeaAreaPolygon({required this.outer, this.holes = const []}); + + final SeaAreaRing outer; + final List holes; + + bool contains(double lon, double lat) { + if (!outer.containsPoint(lon, lat)) return false; + for (final hole in holes) { + if (hole.containsPoint(lon, lat)) return false; + } + return true; + } + + /// Distance to the nearest edge of the outline or of any hole. A point + /// just inland on a carved-out island is near that island's coast, which + /// is the answer a shore dive wants. + double distanceKm(double lon, double lat) { + var best = outer.distanceKm(lon, lat); + for (final hole in holes) { + final d = hole.distanceKm(lon, lat); + if (d < best) best = d; + } + return best; + } +} + +/// One named ocean or sea from the shipped IHO table. +class SeaArea { + const SeaArea({ + required this.name, + required this.minLon, + required this.minLat, + required this.maxLon, + required this.maxLat, + required this.areaSquareDegrees, + required this.polygons, + this.localizedNames = const {}, + }); + + factory SeaArea.fromJson(Map json) { + final bbox = (json['bbox'] as List).cast(); + return SeaArea( + name: json['name'] as String, + minLon: bbox[0].toDouble(), + minLat: bbox[1].toDouble(), + maxLon: bbox[2].toDouble(), + maxLat: bbox[3].toDouble(), + areaSquareDegrees: (json['area'] as num).toDouble(), + polygons: [ + for (final p in json['polygons'] as List) + _polygonFromJson(p as Map), + ], + localizedNames: { + for (final e + in (json['names'] as Map? ?? const {}).entries) + e.key.trim().toLowerCase(): e.value as String, + }, + ); + } + + final String name; + final double minLon; + final double minLat; + final double maxLon; + final double maxLat; + + /// Extent in square degrees, and the specificity ranking with it: the + /// smallest area containing a point carries the most specific name for + /// it, which is why the Gulf of Aqaba beats the Red Sea. + final double areaSquareDegrees; + + final List polygons; + + /// The sea's name by language code, English excluded: [name] is the + /// English one and the fallback for everything else. Coverage is not + /// uniform -- Wikidata has no Hungarian label for about one sea in six -- + /// and a language simply absent here falls back rather than being guessed. + final Map localizedNames; + + /// The name in [languageCode], falling back to the English [name]. + /// + /// Case and surrounding whitespace are forgiven, matching what + /// `PlaceNameLanguage.normalize` already tolerates on the stored setting: + /// a padded or upper-cased 'DE ' must not silently read as English. + String nameIn(String languageCode) => + localizedNames[languageCode.trim().toLowerCase()] ?? name; + + bool contains(double lon, double lat) { + if (lon < minLon || lon > maxLon || lat < minLat || lat > maxLat) { + return false; + } + for (final polygon in polygons) { + if (polygon.contains(lon, lat)) return true; + } + return false; + } + + /// Whether the point falls within [marginKm] of the bounding box: a cheap + /// reject before the per-edge distance walk. + bool isNear(double lon, double lat, double marginKm) { + final latMargin = marginKm / _kmPerDegreeLatitude; + final scale = math.cos(lat * math.pi / 180.0); + final lonMargin = scale > 0.01 + ? marginKm / (_kmPerDegreeLongitudeAtEquator * scale) + : 180.0; + return lon >= minLon - lonMargin && + lon <= maxLon + lonMargin && + lat >= minLat - latMargin && + lat <= maxLat + latMargin; + } + + /// Distance from the point to this area's nearest coastline, in + /// kilometres. + double distanceKm(double lon, double lat) { + var best = double.infinity; + for (final polygon in polygons) { + final d = polygon.distanceKm(lon, lat); + if (d < best) best = d; + } + return best; + } + + @override + String toString() => 'SeaArea($name, ${polygons.length} polygons)'; +} + +SeaAreaPolygon _polygonFromJson(Map json) => SeaAreaPolygon( + outer: _ringFromJson(json['outer'] as List), + holes: [ + for (final h in (json['holes'] as List? ?? const [])) + _ringFromJson(h as List), + ], +); + +SeaAreaRing _ringFromJson(List flat) => SeaAreaRing( + Float64List.fromList([for (final v in flat) (v as num).toDouble()]), +); diff --git a/lib/core/services/geocoding/sea_area_index.dart b/lib/core/services/geocoding/sea_area_index.dart new file mode 100644 index 0000000000..78abf5bfda --- /dev/null +++ b/lib/core/services/geocoding/sea_area_index.dart @@ -0,0 +1,88 @@ +import 'package:submersion/core/services/geocoding/sea_area.dart'; + +/// Resolves a coordinate to the name of the ocean or sea it lies in. +/// +/// This exists because OpenStreetMap has no ocean or sea polygons at all: a +/// Nominatim reverse geocode in open salt water answers "Unable to geocode", +/// and one near a coast snaps to the closest land feature instead (a +/// peninsula, a mountain range, a stream). Almost every dive happens in salt +/// water, so the body-of-water field stayed empty for most sites. The table +/// behind this index is the IHO's own "Limits of Oceans & Seas", generated +/// by `scripts/sea_area_harvester.py`. +/// +/// Being asset-resident, it also answers on a boat with no signal. +class SeaAreaIndex { + const SeaAreaIndex(this.areas); + + factory SeaAreaIndex.fromJson(Map json) => SeaAreaIndex([ + for (final a in json['areas'] as List) + SeaArea.fromJson(a as Map), + ]); + + /// Ascending by [SeaArea.areaSquareDegrees], so the first area containing + /// a point is also the most specific name for it. The generator writes + /// them in this order and `sea_area_asset_test.dart` holds it to that. + final List areas; + + /// How far outside a sea's limit a point still counts as being in it. + /// + /// IHO limits stop at the legal boundary of a sea, which routinely runs + /// outside an archipelago or a fringing reef, and dive-site coordinates + /// are routinely recorded from a beach, a jetty or a moored boat. Without + /// this slack, famous sites in plain salt water (Ras Mohammed, Sipadan, + /// Bonaire, Santorini) resolve to nothing. + /// + /// Four kilometres was measured against the 3,256 bundled dive sites and + /// a curated set of inland ones: it is the widest slack that still named + /// nothing for any site in a landlocked country, and for no Yucatan + /// cenote. Five kilometres starts claiming cenotes for the Caribbean. + static const double nearShoreKm = 4.0; + + /// The sea or ocean at this coordinate, or null when the point is inland + /// or in fresh water. + /// + /// Cheap enough to run per site in a backfill. Containment rejects on the + /// bounding box before touching a ring, and the distance walk only runs + /// when nothing contained the point: measured at roughly 5us for a point + /// at sea and 11us for the worst case, a point in no area at all, against + /// the shipped 93,000-vertex table. + /// + /// Answers in [languageCode] where the table has that language, and in + /// English otherwise. Every other geocoded field already honours the + /// diver's place-name setting, so a sea that stayed English would leave + /// one column holding two languages: a German diver would read + /// "Vierwaldstättersee" from the online lookup next to "Red Sea" from + /// here. + /// + /// Returning null is a real answer, not a failure: lakes, quarries, + /// cenotes and fjord interiors are not in the IHO table, and the caller + /// falls back to the online lookup, which is good at exactly those. + /// + /// A few narrow bands of open water answer null too, because IHO assigns + /// them to no sea at all. The archipelagic waters inside Fiji are the + /// one that touches diving: around 16.8 degrees south, the source itself + /// leaves roughly 180.0E to 179.87W unassigned. That is the dataset, not + /// the simplification -- the raw layer has the same gap -- so it is + /// documented rather than papered over. + String? nameAt( + double latitude, + double longitude, { + String languageCode = 'en', + }) { + for (final area in areas) { + if (area.contains(longitude, latitude)) return area.nameIn(languageCode); + } + + SeaArea? nearest; + var nearestKm = double.infinity; + for (final area in areas) { + if (!area.isNear(longitude, latitude, nearShoreKm)) continue; + final distance = area.distanceKm(longitude, latitude); + if (distance <= nearShoreKm && distance < nearestKm) { + nearest = area; + nearestKm = distance; + } + } + return nearest?.nameIn(languageCode); + } +} diff --git a/lib/core/services/geocoding/sea_area_service.dart b/lib/core/services/geocoding/sea_area_service.dart new file mode 100644 index 0000000000..fdf4df1b14 --- /dev/null +++ b/lib/core/services/geocoding/sea_area_service.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart' + show LicenseEntryWithLineBreaks, LicenseRegistry, visibleForTesting; +import 'package:flutter/services.dart' show rootBundle; + +import 'package:submersion/core/services/geocoding/sea_area_index.dart'; +import 'package:submersion/core/services/logger_service.dart'; + +/// Loads the bundled ocean and sea table. +/// +/// The asset is about 1.3 MB, so it is parsed on first use and kept for the +/// process: a backfill run over a whole logbook pays for it once, and a +/// diver who never touches a coordinate never pays at all. +abstract final class SeaAreaService { + static const String assetPath = 'assets/data/sea_areas.json'; + + static final _log = LoggerService.forClass(SeaAreaService); + + static SeaAreaIndex? _index; + + /// Whether a load has finished, successfully or not. + /// + /// A failed read sets this too. The asset is bundled and immutable for + /// the life of the process, so a read that failed once fails every time, + /// and remembering that is what stops a backfill over a whole logbook + /// from retrying a 1.3 MB read, and logging the same warning, once per + /// site. + static bool _loaded = false; + + /// The read in progress, so concurrent callers share one parse rather + /// than each decoding the asset. Cleared once it settles; [_loaded] is + /// what makes the result stick. + static Future? _inFlight; + + /// The shipped index, or null when the asset is missing or unreadable. + /// + /// A missing table is not worth failing a geocode over: the caller simply + /// gets no body of water, exactly as before this table existed. + static Future load() { + // Built here rather than held as a completed future, so it belongs to + // the caller's zone. A future created in one zone and awaited inside a + // fakeAsync zone never resolves there. + if (_loaded) return Future.value(_index); + return _inFlight ??= _read(); + } + + static Future _read() async { + try { + final raw = await rootBundle.loadString(assetPath); + final json = jsonDecode(raw) as Map; + final index = SeaAreaIndex.fromJson(json); + _index = index; + _log.info('Loaded ${index.areas.length} sea areas'); + } catch (e, stackTrace) { + _index = null; + _log.warning( + 'Sea area table unavailable, body of water will be left empty: $e', + error: e, + stackTrace: stackTrace, + ); + } finally { + _loaded = true; + _inFlight = null; + } + return _index; + } + + /// Adds the table's attribution to the app's license page. + /// + /// IHO Sea Areas is CC-BY 4.0, so redistributing it inside the app means + /// carrying the credit with it. Called once from `main()`. + static void registerLicense() { + LicenseRegistry.addLicense(() async* { + yield const LicenseEntryWithLineBreaks( + ['IHO Sea Areas'], + ''' +Ocean and sea boundaries (assets/data/sea_areas.json) are derived from +IHO Sea Areas version 3, published by the Flanders Marine Institute and +licensed under Creative Commons Attribution 4.0 (CC-BY 4.0). + +The underlying limits are from "Limits of Oceans & Seas, Special +Publication No. 23", International Hydrographic Organization, 1953. + +Flanders Marine Institute (2018). IHO Sea Areas, version 3. +Available online at https://www.marineregions.org/ +https://doi.org/10.14284/323 + +The bundled copy is simplified for size; see +scripts/sea_area_harvester.py for exactly how it was derived.''', + ); + }); + } + + /// Stands in for a completed load. Passing null models an unreadable + /// asset, which callers must treat as "no body of water", not an error. + @visibleForTesting + static void setIndexForTesting(SeaAreaIndex? index) { + _index = index; + _loaded = true; + _inFlight = null; + } + + @visibleForTesting + static void resetCacheForTesting() { + _index = null; + _loaded = false; + _inFlight = null; + } +} diff --git a/lib/core/services/images/profile_photo_codec.dart b/lib/core/services/images/profile_photo_codec.dart new file mode 100644 index 0000000000..b1f8a303b5 --- /dev/null +++ b/lib/core/services/images/profile_photo_codec.dart @@ -0,0 +1,202 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +/// How a stored image blob is bounded. +/// +/// Sizing is enforced HERE, in Dart, and never through `ImagePicker`'s +/// `maxWidth` / `maxHeight` / `imageQuality`: those arguments are silently +/// ignored by image_picker_macos, image_picker_windows and image_picker_linux, +/// so a desktop pick would enter the database at full size and ride into every +/// sync changeset as base64. +@immutable +class ImageEncodeSpec { + const ImageEncodeSpec({ + required this.maxDimension, + required this.jpegQuality, + required this.square, + }); + + /// Longest edge of the output, in pixels. A source already within this is + /// never upscaled. + final int maxDimension; + + final int jpegQuality; + + /// When true the output is cropped to a square before resizing, so every + /// avatar render site can assume a 1:1 aspect and never letterbox. + final bool square; + + /// Profile photo for a diver or buddy: 512x512, roughly 50-80 KB. The + /// largest avatar in the app draws at radius 50, so 300 physical pixels at + /// 3x. 512 is deliberate headroom for a future enlarged view, since the + /// source is discarded at pick time and re-encoding upward is impossible. + static const avatar = ImageEncodeSpec( + maxDimension: 512, + jpegQuality: 85, + square: true, + ); + + /// Certification card face. Card text must stay readable, so the ceiling is + /// higher and the source aspect ratio is preserved. + static const certificationCard = ImageEncodeSpec( + maxDimension: 2000, + jpegQuality: 85, + square: false, + ); +} + +/// One encode job, sendable to an isolate. +@immutable +class ImageEncodeRequest { + const ImageEncodeRequest.fromBytes({ + required this.bytes, + required this.spec, + this.cropRect, + this.declaredName, + this.maxSourcePixels = defaultMaxSourcePixels, + }); + + /// 80 megapixels. Decoding is what allocates, and nothing caps a desktop + /// pick, so a source beyond this is refused rather than risking an isolate + /// out-of-memory that would surface as an unexplained failure. + static const int defaultMaxSourcePixels = 80 * 1000 * 1000; + + final Uint8List bytes; + final ImageEncodeSpec spec; + + /// Region of the DECODED, orientation-corrected source to keep, in source + /// pixels. Null means the largest centered square when [ImageEncodeSpec + /// .square] is set, or the whole image otherwise. + final Rect? cropRect; + + /// Filename whose extension picks the decoder. package:image's generic + /// probe tries every format and the permissive ones accept arbitrary bytes, + /// so pass this whenever the source name is known. + final String? declaredName; + + final int maxSourcePixels; +} + +/// What [encodeStoredImage] did. +enum ImageEncodeOutcome { + /// A JPEG was produced and is in [ImageEncodeResult.bytes]. + encoded, + + /// package:image could not decode the source. + undecodable, + + /// The decoded source exceeded [ImageEncodeRequest.maxSourcePixels]. + tooLarge, +} + +@immutable +class ImageEncodeResult { + const ImageEncodeResult( + this.outcome, { + this.bytes, + this.sizeBytes = 0, + this.error, + }); + + final ImageEncodeOutcome outcome; + + /// The encoded JPEG, non-null only for [ImageEncodeOutcome.encoded]. + final Uint8List? bytes; + + final int sizeBytes; + + /// Why the source could not be decoded, carried as a String rather than + /// letting the exception cross the isolate boundary and lose the outcome + /// the caller switches on. + final String? error; +} + +/// Runs [request] on a background isolate. +Future encodeStoredImage(ImageEncodeRequest request) => + compute(runImageEncodeRequest, request); + +/// The isolate body. Top-level and public so a test can exercise the work +/// itself without paying for an isolate spawn per case. +@visibleForTesting +ImageEncodeResult runImageEncodeRequest(ImageEncodeRequest request) { + final name = request.declaredName; + final img.Image? decoded; + try { + decoded = name != null && name.contains('.') + ? img.decodeNamedImage(name, request.bytes) + : img.decodeImage(request.bytes); + } on Exception catch (e) { + return ImageEncodeResult( + ImageEncodeOutcome.undecodable, + error: e.toString(), + ); + } + if (decoded == null) { + return const ImageEncodeResult(ImageEncodeOutcome.undecodable); + } + + if (decoded.width * decoded.height > request.maxSourcePixels) { + return const ImageEncodeResult(ImageEncodeOutcome.tooLarge); + } + + // Orientation MUST be resolved before any geometry is applied. The JPEG + // decoder does no orientation handling, so a portrait phone photo arrives + // as a sideways buffer carrying exif.orientation = 6. Cropping first would + // apply a rectangle chosen in the upright preview to a rotated buffer and + // select the wrong region. + final upright = img.bakeOrientation(decoded); + + final cropped = _crop(upright, request); + + final longest = cropped.width > cropped.height + ? cropped.width + : cropped.height; + final resized = longest > request.spec.maxDimension + ? img.copyResize( + cropped, + // Width or height only: passing both resizes to exact bounds and + // distorts the aspect ratio. + width: cropped.width >= cropped.height + ? request.spec.maxDimension + : null, + height: cropped.height > cropped.width + ? request.spec.maxDimension + : null, + ) + : cropped; + + // Re-encoding drops EXIF, so GPS embedded in a gallery or contact photo + // cannot ride into a blob that syncs to every device and cloud provider. + final jpeg = Uint8List.fromList( + img.encodeJpg(resized, quality: request.spec.jpegQuality), + ); + return ImageEncodeResult( + ImageEncodeOutcome.encoded, + bytes: jpeg, + sizeBytes: jpeg.length, + ); +} + +img.Image _crop(img.Image source, ImageEncodeRequest request) { + final rect = request.cropRect; + if (rect != null) { + final x = rect.left.round().clamp(0, source.width - 1); + final y = rect.top.round().clamp(0, source.height - 1); + final w = rect.width.round().clamp(1, source.width - x); + final h = rect.height.round().clamp(1, source.height - y); + return img.copyCrop(source, x: x, y: y, width: w, height: h); + } + + if (!request.spec.square) return source; + + final side = source.width < source.height ? source.width : source.height; + return img.copyCrop( + source, + x: (source.width - side) ~/ 2, + y: (source.height - side) ~/ 2, + width: side, + height: side, + ); +} diff --git a/lib/core/services/location_service.dart b/lib/core/services/location_service.dart index 6a4f89999b..74898ce03d 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -8,6 +8,7 @@ import 'package:geocoding/geocoding.dart'; import 'package:submersion/core/services/geocoding/nominatim_throttle.dart'; import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/geocoding/sea_area_service.dart'; import 'package:submersion/core/services/logger_service.dart'; /// Check if we're on a mobile platform (iOS/Android) @@ -88,8 +89,9 @@ class LocationService { /// Nominatim reverse-geocode URI for the natural layer, which answers with /// the lake, bay or strait a point lies in. zoom=14 keeps the answer to a - /// named feature rather than the whole region. Nominatim has no ocean - /// polygons, so open-sea points come back "Unable to geocode". + /// named feature rather than the whole region. Nominatim has no ocean or + /// sea polygons, so open-sea points come back "Unable to geocode"; the + /// bundled table in [SeaAreaService] covers those instead. static Uri buildNaturalFeatureUri( double latitude, double longitude, { @@ -396,11 +398,30 @@ class LocationService { } /// Best-effort: any failure here leaves the address result untouched. + /// + /// The bundled ocean and sea table answers first. OpenStreetMap has no + /// ocean or sea polygons at all, so the natural layer used to answer + /// "Unable to geocode" for any point in open salt water and to snap to + /// the nearest land feature near a coast, which left the body of water + /// empty for most dive sites. Asking the table first also spares + /// Nominatim a throttled request per marine site, and works with no + /// signal. + /// + /// Inland and coastal fresh water is the other way round: lakes, quarries + /// and fjord interiors are not in the IHO table, and the natural layer + /// names them well, so a miss there falls through to the network. Future _lookupBodyOfWater( double latitude, double longitude, String languageCode, ) async { + final index = await SeaAreaService.load(); + final sea = index?.nameAt(latitude, longitude, languageCode: languageCode); + if (sea != null) { + _log.info('Sea area table: $sea'); + return sea; + } + try { final json = await _fetchNominatimJson( buildNaturalFeatureUri(latitude, longitude, languageCode: languageCode), diff --git a/lib/core/services/media_store/google_drive_media_object_store.dart b/lib/core/services/media_store/google_drive_media_object_store.dart index 1e6db36657..d73c12d69c 100644 --- a/lib/core/services/media_store/google_drive_media_object_store.dart +++ b/lib/core/services/media_store/google_drive_media_object_store.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; import 'package:submersion/core/services/media_store/media_object_store.dart'; @@ -13,15 +14,40 @@ import 'package:submersion/core/services/media_store/media_object_store.dart'; /// photos and videos - with resume via the `Content-Range: bytes */total` /// probe. [chunkSizeBytes] must be a multiple of 256 KiB (Drive rule). class GoogleDriveMediaObjectStore implements MediaObjectStore { + /// [clientSupplier] is asked for the authenticator's *current* client on + /// every request. It must not be replaced by a captured client: the + /// Google authenticators close and replace theirs on every re-auth + /// (GoogleSignInAuthenticator._installClient / signOut / + /// handleAuthFailure, DesktopOAuthAuthenticator._teardownClient), and + /// the runtime holding this store outlives all of them, so a captured + /// client goes dead at the first token refresh and every later transfer + /// throws "Client is already closed". Null means no Google session is + /// available right now. GoogleDriveMediaObjectStore({ - required http.Client client, + required Future Function() clientSupplier, this.folderName = 'submersion-media', this.chunkSizeBytes = 8 * 1024 * 1024, String apiBase = 'https://www.googleapis.com', - }) : _client = client, + }) : _clientSupplier = clientSupplier, _apiBase = apiBase; - final http.Client _client; + /// For tests and any caller that owns the client's lifetime outright. + /// Production callers must use the default constructor so the store + /// follows re-auth. + @visibleForTesting + GoogleDriveMediaObjectStore.withClient( + http.Client client, { + String folderName = 'submersion-media', + int chunkSizeBytes = 8 * 1024 * 1024, + String apiBase = 'https://www.googleapis.com', + }) : this( + clientSupplier: () async => client, + folderName: folderName, + chunkSizeBytes: chunkSizeBytes, + apiBase: apiBase, + ); + + final Future Function() _clientSupplier; final String folderName; final int chunkSizeBytes; final String _apiBase; @@ -307,34 +333,63 @@ class GoogleDriveMediaObjectStore implements MediaObjectStore { return files.isEmpty ? null : files.first; } + /// Drive's documented per-page maximum, requested explicitly so a large + /// media folder needs as few round trips as possible. + static const _queryPageSize = 1000; + + /// Every file matching [q], following Drive's `nextPageToken` to + /// exhaustion. + /// + /// files.list serves one page at a time (100 results by default, 1000 + /// max) and reports the rest only through `nextPageToken`. A single call + /// hid every media object past the cap from [list], and could return a + /// short or empty first page for a name lookup -- which [_findByKey] + /// would read as "absent" and re-upload. + /// + /// `nextPageToken` has to be named in `fields` alongside `files(...)`: + /// the projection applies to the whole response, so asking only for + /// `files(...)` strips the token and the loop would stop after one page. Future> _query(String q) async { - final uri = Uri.parse('$_apiBase/drive/v3/files').replace( - queryParameters: { - 'spaces': 'appDataFolder', - 'q': q, - 'fields': 'files(id,name,modifiedTime,size)', - }, - ); - final response = await _send(http.Request('GET', uri)); - if (response.statusCode != 200) { - throw _forStatus('query', q, response); - } - final decoded = jsonDecode(response.body) as Map; - final files = decoded['files'] as List? ?? const []; - return [ - for (final raw in files.cast>()) - _DriveFile( - id: raw['id'] as String, - name: raw['name'] as String, - size: int.tryParse(raw['size'] as String? ?? ''), - modified: DateTime.tryParse(raw['modifiedTime'] as String? ?? ''), - ), - ]; + final results = <_DriveFile>[]; + String? pageToken; + do { + final uri = Uri.parse('$_apiBase/drive/v3/files').replace( + queryParameters: { + 'spaces': 'appDataFolder', + 'q': q, + 'pageSize': '$_queryPageSize', + 'pageToken': ?pageToken, + 'fields': 'nextPageToken,files(id,name,modifiedTime,size)', + }, + ); + final response = await _send(http.Request('GET', uri)); + if (response.statusCode != 200) { + throw _forStatus('query', q, response); + } + final decoded = jsonDecode(response.body) as Map; + final files = decoded['files'] as List? ?? const []; + for (final raw in files.cast>()) { + results.add( + _DriveFile( + id: raw['id'] as String, + name: raw['name'] as String, + size: int.tryParse(raw['size'] as String? ?? ''), + modified: DateTime.tryParse(raw['modifiedTime'] as String? ?? ''), + ), + ); + } + // An empty token means the same thing as an absent one; treating it + // as a real token would re-issue the first page forever. + final next = decoded['nextPageToken'] as String?; + pageToken = (next == null || next.isEmpty) ? null : next; + } while (pageToken != null); + return results; } Future _send(http.Request request) async { + final client = await _resolveClient(); try { - return await http.Response.fromStream(await _client.send(request)); + return await http.Response.fromStream(await client.send(request)); } on Exception catch (e) { throw MediaStoreException( 'Could not reach Google Drive', @@ -344,6 +399,29 @@ class GoogleDriveMediaObjectStore implements MediaObjectStore { } } + /// The authenticator's client as of right now. Resolved per request so a + /// re-auth that swapped the client is picked up transparently; see the + /// constructor. + Future _resolveClient() async { + final http.Client? client; + try { + client = await _clientSupplier(); + } on Exception catch (e) { + throw MediaStoreException( + 'Could not reach Google Drive', + kind: MediaStoreErrorKind.transient, + cause: e, + ); + } + if (client == null) { + throw const MediaStoreException( + 'No Google Drive session; sign in again', + kind: MediaStoreErrorKind.auth, + ); + } + return client; + } + MediaStoreException _forStatus( String op, String subject, diff --git a/lib/core/services/media_store/icloud_media_object_store.dart b/lib/core/services/media_store/icloud_media_object_store.dart index 65f05237a2..fb5770cd62 100644 --- a/lib/core/services/media_store/icloud_media_object_store.dart +++ b/lib/core/services/media_store/icloud_media_object_store.dart @@ -38,7 +38,17 @@ class ICloudMediaObjectStore implements MediaObjectStore { @override Future head(String key) async { final path = await _pathFor(key); - if (!await _platform.ensureDownloaded(path)) return null; + // Same distinction getFile draws below, and it matters more here: null + // means "not in the store" to every caller, and the upload pipeline + // answers that by uploading. Reporting a placeholder still coming down + // as absent re-uploads the whole library from the second device, and + // lets the dedup stamp fall back to this device's local byte count. + if (!await _platform.ensureDownloaded(path)) { + throw MediaStoreException( + 'still downloading from iCloud: $key', + kind: MediaStoreErrorKind.transient, + ); + } final file = File(path); if (!await file.exists()) return null; final stat = await file.stat(); @@ -95,10 +105,17 @@ class ICloudMediaObjectStore implements MediaObjectStore { TransferProgressCallback? onProgress, }) async { final path = await _pathFor(key); + // A false answer means iCloud KNOWS the file: the placeholder is on disk, + // startDownloadingUbiquitousItem succeeded, and the native poll ran out + // waiting for the bytes. A key the container has never held does not get + // here (the native side answers true for a non-ubiquitous path and the + // exists() check below reports it absent). Reporting the slow download + // as notFound made StoreMarkerStore.ensure mint a fresh store id over a + // marker another device had already written (issue #1356). if (!await _platform.ensureDownloaded(path)) { throw MediaStoreException( - 'not found: $key', - kind: MediaStoreErrorKind.notFound, + 'still downloading from iCloud: $key', + kind: MediaStoreErrorKind.transient, ); } final file = File(path); diff --git a/lib/core/services/media_store/icloud_media_platform.dart b/lib/core/services/media_store/icloud_media_platform.dart index 994463ee88..8163f7d41b 100644 --- a/lib/core/services/media_store/icloud_media_platform.dart +++ b/lib/core/services/media_store/icloud_media_platform.dart @@ -90,8 +90,13 @@ class DirectoryICloudMediaPlatform implements ICloudMediaPlatform { return true; } + /// Always true, matching the native answer for a path that is not a + /// ubiquitous item: a plain directory has nothing to download, and a + /// missing file is reported by the adapter's own exists() check. A false + /// answer is reserved for a placeholder whose download did not finish, so + /// a fake that wants that case overrides this explicitly. @override - Future ensureDownloaded(String path) => File(path).exists(); + Future ensureDownloaded(String path) async => true; @override Future refreshFolder(String path) async {} diff --git a/lib/core/services/media_store/store_marker.dart b/lib/core/services/media_store/store_marker.dart index 26142d4064..c2f0105183 100644 --- a/lib/core/services/media_store/store_marker.dart +++ b/lib/core/services/media_store/store_marker.dart @@ -69,6 +69,23 @@ class StoreMarkerStore { Future<({StoreMarker marker, bool created})> ensure() async { final existing = await read(); if (existing != null) return (marker: existing, created: false); + // Confirm the absence before minting, because [read] cannot tell "this + // store has no marker" from "this device cannot see it yet". On iCloud a + // marker another device wrote has no local entry at all until the + // container's metadata reaches this device, and the native download + // check answers "nothing to do" for a path it has never heard of - so + // the read fails as absent and minting overwrites the other device's + // marker, splitting one store into two (issue #1356). + // + // A listing is the one call that makes an adapter go and look: the + // iCloud adapter refreshes the container folder before enumerating it. + // It costs one request, on the mint path only. + if (await _markerVisibleInListing()) { + final afterListing = await read(); + if (afterListing != null) { + return (marker: afterListing, created: false); + } + } final marker = StoreMarker( storeId: const Uuid().v4(), formatVersion: 1, @@ -91,4 +108,19 @@ class StoreMarkerStore { } return (marker: marker, created: true); } + + /// Whether a listing can see the marker this device failed to read. + /// + /// Never blocks a first connect: a store that cannot serve the listing + /// answers the same as one that has no marker, which is the state a first + /// connect is entitled to assume. + Future _markerVisibleInListing() async { + try { + return await _store + .list(StoreKeys.markerKey) + .any((object) => object.key == StoreKeys.markerKey); + } on Object { + return false; + } + } } diff --git a/lib/core/services/pdf_templates/pdf_front_matter.dart b/lib/core/services/pdf_templates/pdf_front_matter.dart new file mode 100644 index 0000000000..ddad193da9 --- /dev/null +++ b/lib/core/services/pdf_templates/pdf_front_matter.dart @@ -0,0 +1,208 @@ +import 'dart:typed_data'; + +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/features/certifications/domain/certification_title.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; + +/// Front-matter pages shared by the logbook templates. +/// +/// The diver page originated in the Professional template, which drew an +/// empty placeholder box where the portrait belongs and capped the +/// certification list at five entries. Both limitations are lifted here so +/// every template can present the diver the same way (#1017). +class PdfFrontMatter { + const PdfFrontMatter._(); + + /// Build the diver information page. + /// + /// [photoBytes] is the decoded portrait. Callers load it from + /// [Diver.photoPath]; templates never touch the file system. When it is + /// null an empty frame is drawn so the layout keeps its shape. + /// Body widgets for a [pw.MultiPage]. + /// + /// A list rather than one column so a diver with many certifications + /// paginates instead of overflowing a fixed page, the same reason + /// [PdfSharedComponents.buildCertificationCardsBody] returns a list. + static List buildDiverPageBody({ + required Diver diver, + required PdfDateFormatter dates, + required int diveCount, + List certifications = const [], + Uint8List? photoBytes, + PdfColor accentColor = PdfColors.blue800, + }) { + return [ + buildDiverPage( + diver: diver, + dates: dates, + diveCount: diveCount, + photoBytes: photoBytes, + accentColor: accentColor, + ), + if (certifications.isNotEmpty) ...[ + pw.Text( + 'Certifications', + style: const pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.SizedBox(height: 8), + ...certifications.map((cert) => _buildCertificationLine(cert, dates)), + ], + ]; + } + + static pw.Widget buildDiverPage({ + required Diver diver, + required PdfDateFormatter dates, + required int diveCount, + List certifications = const [], + Uint8List? photoBytes, + PdfColor accentColor = PdfColors.blue800, + }) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'Diver Profile', + style: pw.TextStyle( + fontSize: 20, + fontWeight: pw.FontWeight.bold, + color: accentColor, + ), + ), + pw.SizedBox(height: 16), + pw.Divider(color: PdfColors.grey400), + pw.SizedBox(height: 24), + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _buildProfileField('Name', diver.name), + if (diver.email != null) + _buildProfileField('Email', diver.email!), + _buildProfileField('Total Dives', '$diveCount'), + ], + ), + ), + pw.SizedBox(width: 40), + _buildPortrait(photoBytes), + ], + ), + pw.SizedBox(height: 24), + if (certifications.isNotEmpty) ...[ + pw.Text( + 'Certifications', + style: const pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.SizedBox(height: 8), + ...certifications.map((cert) => _buildCertificationLine(cert, dates)), + ], + ], + ); + } + + static pw.Widget _buildPortrait(Uint8List? photoBytes) { + if (photoBytes == null) { + return pw.Container( + width: 100, + height: 120, + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey400), + ), + child: pw.Center( + child: pw.Text( + 'Photo', + style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey400), + ), + ), + ); + } + + return pw.Container( + width: 100, + height: 120, + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey400), + ), + child: pw.Image(pw.MemoryImage(photoBytes), fit: pw.BoxFit.cover), + ); + } + + static pw.Widget _buildCertificationLine( + Certification cert, + PdfDateFormatter dates, + ) { + return pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 4), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Container( + width: 4, + height: 4, + margin: const pw.EdgeInsets.only(top: 4), + decoration: const pw.BoxDecoration( + color: PdfColors.grey600, + shape: pw.BoxShape.circle, + ), + ), + pw.SizedBox(width: 8), + // This bullet is a single line with no separate agency field, + // unlike the certifications section in PdfSharedComponents, so the + // agency stays here. The title is derived so it is not printed + // twice. + pw.Expanded( + child: pw.Text( + '${cert.agency.displayName} - ${certificationTitle(cert)}', + style: const pw.TextStyle(fontSize: 10), + ), + ), + if (cert.cardNumber != null) + pw.Text( + ' Card #: ${cert.cardNumber}', + style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700), + ), + if (cert.issueDate != null) + pw.Text( + ' (${dates.date(cert.issueDate!)})', + style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey600), + ), + ], + ), + ); + } + + static pw.Widget _buildProfileField(String label, String value) { + return pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 12), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + label.toUpperCase(), + style: const pw.TextStyle( + fontSize: 8, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey600, + letterSpacing: 1, + ), + ), + pw.SizedBox(height: 2), + pw.Text(value, style: const pw.TextStyle(fontSize: 12)), + ], + ), + ); + } +} diff --git a/lib/core/services/pdf_templates/pdf_profile_chart.dart b/lib/core/services/pdf_templates/pdf_profile_chart.dart new file mode 100644 index 0000000000..c3f9c45133 --- /dev/null +++ b/lib/core/services/pdf_templates/pdf_profile_chart.dart @@ -0,0 +1,149 @@ +import 'dart:math' as math; + +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +import 'package:submersion/core/services/pdf_templates/pdf_profile_series.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; + +/// Depth profile drawn as native PDF vector graphics. +/// +/// Uses the pdf package's own chart widgets rather than rasterizing the +/// on-screen `DiveProfileChart`. That widget needs a live element tree, and +/// `buildPdf` has no BuildContext; `RenderRepaintBoundary.toImage()` also +/// hangs under `flutter test`, which would leave this untestable. Vector +/// output additionally stays sharp at print resolution and costs few bytes. +class PdfProfileChart { + const PdfProfileChart._(); + + /// Build the chart, or null when [series] carries no samples. + /// + /// Returning null lets callers drop the whole region, including its heading, + /// so a manually logged dive does not print an empty frame. + static pw.Widget? build({ + required PdfProfileSeries series, + required UnitFormatter units, + double height = 150, + PdfColor color = PdfColors.blue700, + }) { + if (series.isEmpty) return null; + + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // The axis labels are bare numbers to keep them narrow, so the units + // are stated once in the heading. + pw.Text( + 'Depth Profile (${units.depthSymbol} vs min)', + style: const pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey700, + ), + ), + pw.SizedBox(height: 4), + pw.SizedBox(height: height, child: _chart(series, units, color)), + ], + ); + } + + /// Depth runs downward, so it is plotted as a negative value and the axis + /// labels are negated back for display. + static pw.Widget _chart( + PdfProfileSeries series, + UnitFormatter units, + PdfColor color, + ) { + final data = series.points + .map( + (p) => pw.PointChartValue( + p.timestamp / 60.0, + -units.convertDepth(p.depth), + ), + ) + .toList(); + + return pw.Chart( + grid: pw.CartesianGrid( + xAxis: pw.FixedAxis( + _timeTicks(series), + buildLabel: (value) => pw.Text( + value.toStringAsFixed(0), + style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey600), + ), + ), + yAxis: pw.FixedAxis( + _depthTicks(series, units), + buildLabel: (value) => pw.Text( + // Negated back to a positive depth for the reader. + (-value).toStringAsFixed(0), + style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey600), + ), + ), + ), + datasets: [ + pw.LineDataSet( + data: data, + drawPoints: false, + drawSurface: true, + surfaceOpacity: 0.2, + color: color, + lineWidth: 1.0, + ), + ], + ); + } + + /// Minute ticks, ascending, as [pw.FixedAxis] requires. + /// + /// Every 5 minutes for a normal dive, every 10 for a long one, so a two hour + /// dive does not crowd the axis with 24 labels. + static List _timeTicks(PdfProfileSeries series) { + final totalMinutes = series.durationSeconds / 60.0; + if (totalMinutes <= 0) return [0, 1]; + + final step = totalMinutes > 60 ? 10.0 : 5.0; + final ticks = []; + for (var t = 0.0; t < totalMinutes; t += step) { + ticks.add(t); + } + ticks.add(totalMinutes.ceilToDouble()); + return ticks; + } + + /// Depth ticks from below the deepest sample up to the surface. + /// + /// Negated, so the list is built deepest-first to come out ascending, which + /// [pw.FixedAxis] asserts on. + /// + /// The floor is deliberately deeper than the dive. Anchoring it exactly at + /// max depth puts the whole bottom phase on the axis line, where the filled + /// area has zero height and the flat part of the dive vanishes. + static List _depthTicks( + PdfProfileSeries series, + UnitFormatter units, + ) { + final maxConverted = units.convertDepth(series.maxDepth); + if (maxConverted <= 0) return [-1, 0]; + + final step = _niceStep(maxConverted / 4); + var divisions = (maxConverted / step).ceil(); + // Guarantee at least a quarter step of clearance under the deepest sample. + if (step * divisions - maxConverted < step * 0.25) divisions++; + + return [for (var i = divisions; i >= 0; i--) -(step * i)]; + } + + /// Round [raw] up to a readable axis increment (1, 2, 2.5 or 5 per decade). + static double _niceStep(double raw) { + if (raw <= 0) return 1; + final magnitude = math + .pow(10, (math.log(raw) / math.ln10).floor()) + .toDouble(); + final normalized = raw / magnitude; + for (final candidate in const [1.0, 2.0, 2.5, 5.0]) { + if (normalized <= candidate) return candidate * magnitude; + } + return 10 * magnitude; + } +} diff --git a/lib/core/services/pdf_templates/pdf_profile_series.dart b/lib/core/services/pdf_templates/pdf_profile_series.dart new file mode 100644 index 0000000000..88a9d2fef7 --- /dev/null +++ b/lib/core/services/pdf_templates/pdf_profile_series.dart @@ -0,0 +1,62 @@ +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +/// A dive's depth profile, thinned for printing. +/// +/// Dive computers sample every few seconds, so an hour-long dive carries well +/// over a thousand points. A printed chart is a few inches wide and cannot +/// resolve anything close to that, and a large logbook would otherwise hold +/// every sample of every dive in memory at once. [downsampled] bounds both. +class PdfProfileSeries { + /// Upper bound on the points handed to a chart. + static const int maxPoints = 200; + + /// The thinned samples, in chronological order. + final List points; + + const PdfProfileSeries(this.points); + + /// Thin [raw] to at most [maxPoints] samples. + /// + /// Buckets the samples and keeps the deepest in each, rather than taking + /// every Nth point. A plain stride can miss the deepest sample entirely, + /// which would draw a chart that contradicts the dive's own max depth field. + /// The first and last samples are always kept so the curve still starts and + /// ends at the surface. + factory PdfProfileSeries.downsampled(List raw) { + if (raw.length <= maxPoints) return PdfProfileSeries(List.of(raw)); + + final kept = [raw.first]; + // Interior buckets only: the first and last samples are added by hand, so + // the budget for bucketed samples is maxPoints - 2. + const buckets = maxPoints - 2; + final interiorLength = raw.length - 2; + + for (var bucket = 0; bucket < buckets; bucket++) { + final start = 1 + (interiorLength * bucket) ~/ buckets; + final end = 1 + (interiorLength * (bucket + 1)) ~/ buckets; + if (end <= start) continue; + + var deepest = raw[start]; + for (var i = start + 1; i < end; i++) { + if (raw[i].depth > deepest.depth) deepest = raw[i]; + } + kept.add(deepest); + } + + kept.add(raw.last); + return PdfProfileSeries(kept); + } + + bool get isEmpty => points.isEmpty; + + bool get isNotEmpty => points.isNotEmpty; + + /// Deepest sample in meters, or 0 when the series carries no samples. + double get maxDepth => points.isEmpty + ? 0 + : points.map((p) => p.depth).reduce((a, b) => a > b ? a : b); + + /// Elapsed seconds between the first and last sample. + int get durationSeconds => + points.isEmpty ? 0 : points.last.timestamp - points.first.timestamp; +} diff --git a/lib/core/services/pdf_templates/pdf_shared_components.dart b/lib/core/services/pdf_templates/pdf_shared_components.dart index 8f3a4dfc48..aa1e04747f 100644 --- a/lib/core/services/pdf_templates/pdf_shared_components.dart +++ b/lib/core/services/pdf_templates/pdf_shared_components.dart @@ -2,6 +2,7 @@ import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/certifications/domain/entities/certification.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/divers/domain/entities/diver.dart'; @@ -21,6 +22,32 @@ Duration pdfTotalRuntime(List dives) => dives .where((d) => d.effectiveRuntime != null) .fold(Duration.zero, (sum, d) => sum + d.effectiveRuntime!); +/// Deepest recorded depth in meters, or 0 when no dive records one. +/// +/// Shared so the summary page and the templates that print their own stat +/// boxes cannot drift apart on what "max depth" means. +double pdfMaxDepth(List dives) => dives + .where((d) => d.maxDepth != null) + .map((d) => d.maxDepth!) + .fold(0, (max, depth) => depth > max ? depth : max); + +/// A cylinder's start-to-end pressure range, with the unit printed once. +/// +/// The range prints whatever the diver recorded, so one endpoint may be the +/// formatter's `--` placeholder. Both endpoints are therefore rendered as +/// bare values around a spaced separator: appending an already-united +/// `formatPressure` to a bare `formatPressureValue` produced `200---` on a +/// half-filled pair (the separator and the placeholder running together) and +/// dropped the unit entirely, since the placeholder carries none. +/// +/// Returns the plain placeholder when the cylinder records no pressure at +/// all, rather than a unit with nothing in front of it. +String pdfPressureRange(UnitFormatter units, double? start, double? end) { + if (start == null && end == null) return '--'; + return '${units.formatPressureValue(start)} - ' + '${units.formatPressureValue(end)} ${units.pressureSymbol}'; +} + /// Shared PDF components used across multiple templates. /// /// These helper methods provide consistent styling and layout for @@ -238,48 +265,51 @@ class PdfSharedComponents { /// Displays certifications with their scanned card images (if available). /// For agency-specific templates, certifications from that agency are /// highlighted. - static pw.Widget buildCertificationCardsPage({ + /// Certification cards as a flat widget list for a [pw.MultiPage] body. + /// + /// Returns a list rather than a single column so the pdf package can break + /// between cards. Callers previously placed one `pw.Column` on a fixed + /// `pw.Page`, which cannot paginate: a diver with more certifications than + /// fit on one page silently lost the remainder (#1017). + static List buildCertificationCardsBody({ required List certifications, required PdfDateFormatter dates, Diver? diver, String? highlightAgency, PdfColor accentColor = PdfColors.blue800, }) { - return pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'Certifications', - style: pw.TextStyle( - fontSize: 24, - fontWeight: pw.FontWeight.bold, - color: accentColor, - ), + return [ + pw.Text( + 'Certifications', + style: pw.TextStyle( + fontSize: 24, + fontWeight: pw.FontWeight.bold, + color: accentColor, ), - if (diver != null) ...[ - pw.SizedBox(height: 8), - pw.Text( - diver.name, - style: const pw.TextStyle(fontSize: 14, color: PdfColors.grey700), - ), - ], - pw.SizedBox(height: 16), - pw.Divider(color: PdfColors.grey300), - pw.SizedBox(height: 16), - ...certifications.map( - (cert) => _buildCertificationCard( - cert, - dates: dates, - isHighlighted: - highlightAgency != null && - cert.agency.name.toLowerCase().contains( - highlightAgency.toLowerCase(), - ), - accentColor: accentColor, - ), + ), + if (diver != null) ...[ + pw.SizedBox(height: 8), + pw.Text( + diver.name, + style: const pw.TextStyle(fontSize: 14, color: PdfColors.grey700), ), ], - ); + pw.SizedBox(height: 16), + pw.Divider(color: PdfColors.grey300), + pw.SizedBox(height: 16), + ...certifications.map( + (cert) => _buildCertificationCard( + cert, + dates: dates, + isHighlighted: + highlightAgency != null && + cert.agency.name.toLowerCase().contains( + highlightAgency.toLowerCase(), + ), + accentColor: accentColor, + ), + ), + ]; } static pw.Widget _buildCertificationCard( @@ -394,6 +424,16 @@ class PdfSharedComponents { color: PdfColors.grey600, ), ), + if (cert.expiryDate != null) ...[ + pw.SizedBox(width: 16), + pw.Text( + 'Expires: ${dates.date(cert.expiryDate!)}', + style: const pw.TextStyle( + fontSize: 10, + color: PdfColors.grey600, + ), + ), + ], ], ), // Card images @@ -476,6 +516,18 @@ class PdfSharedComponents { Diver? diver, PdfColor accentColor = PdfColors.blue800, }) { + // Callers pass dives newest-first (getAllDives) or oldest-first depending + // on the path, so order the pair here rather than trusting the caller. + // Otherwise the cover contradicts the summary page, which sorts. Ordering + // once here fixes every template at the same time, which is why it lives + // on the shared cover rather than at the call sites. + final reversed = + firstDiveDate != null && + lastDiveDate != null && + firstDiveDate.isAfter(lastDiveDate); + final rangeStart = reversed ? lastDiveDate : firstDiveDate; + final rangeEnd = reversed ? firstDiveDate : lastDiveDate; + return pw.Center( child: pw.Column( mainAxisAlignment: pw.MainAxisAlignment.center, @@ -498,9 +550,9 @@ class PdfSharedComponents { pw.SizedBox(height: 24), pw.Text('$diveCount Dives', style: const pw.TextStyle(fontSize: 24)), pw.SizedBox(height: 10), - if (firstDiveDate != null && lastDiveDate != null) + if (rangeStart != null && rangeEnd != null) pw.Text( - '${dates.date(firstDiveDate)} - ${dates.date(lastDiveDate)}', + '${dates.date(rangeStart)} - ${dates.date(rangeEnd)}', style: const pw.TextStyle(fontSize: 16), ), pw.SizedBox(height: 40), @@ -516,17 +568,23 @@ class PdfSharedComponents { /// Build a summary page with dive statistics. static pw.Widget buildSummaryPage({ required List dives, + required PdfDateFormatter dates, + required UnitFormatter units, PdfColor accentColor = PdfColors.blue800, }) { if (dives.isEmpty) { return pw.Center(child: pw.Text('No dives to summarize')); } + // Sorted rather than taken from the ends of the list: callers pass dives + // newest-first (getAllDives) or oldest-first depending on the path, and + // the summary must report the true range either way. + final orderedDates = dives.map((d) => d.dateTime).toList()..sort(); + final firstDive = orderedDates.first; + final lastDive = orderedDates.last; + final totalDiveTime = pdfTotalRuntime(dives); - final maxDepth = dives - .where((d) => d.maxDepth != null) - .map((d) => d.maxDepth!) - .fold(0, (max, depth) => depth > max ? depth : max); + final maxDepth = pdfMaxDepth(dives); final avgDepth = dives.where((d) => d.avgDepth != null).isEmpty ? 0.0 : dives @@ -550,12 +608,14 @@ class PdfSharedComponents { pw.Divider(), pw.SizedBox(height: 20), buildStatRow('Total Dives', '${dives.length}'), + buildStatRow('First Dive', dates.date(firstDive)), + buildStatRow('Last Dive', dates.date(lastDive)), buildStatRow( 'Total Dive Time', '${totalDiveTime.inHours}h ${totalDiveTime.inMinutes % 60}m', ), - buildStatRow('Deepest Dive', '${maxDepth.toStringAsFixed(1)}m'), - buildStatRow('Average Depth', '${avgDepth.toStringAsFixed(1)}m'), + buildStatRow('Deepest Dive', units.formatDepth(maxDepth)), + buildStatRow('Average Depth', units.formatDepth(avgDepth)), buildStatRow( 'Unique Sites', '${dives.map((d) => d.site?.id).where((id) => id != null).toSet().length}', diff --git a/lib/core/services/pdf_templates/pdf_template_builder.dart b/lib/core/services/pdf_templates/pdf_template_builder.dart index 427ad08988..ce5359a667 100644 --- a/lib/core/services/pdf_templates/pdf_template_builder.dart +++ b/lib/core/services/pdf_templates/pdf_template_builder.dart @@ -1,7 +1,11 @@ +import 'dart:typed_data'; + import 'package:pdf/pdf.dart'; import 'package:submersion/core/constants/pdf_templates.dart'; import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_profile_series.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/certifications/domain/entities/certification.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/divers/domain/entities/diver.dart'; @@ -28,20 +32,35 @@ abstract class PdfTemplateBuilder { /// - [dives]: List of dives to include in the logbook /// - [pageSize]: Page size for the PDF (A4 or Letter) /// - [dates]: Date and time rendering for the diver's preferences (#964) + /// - [units]: Depth, temperature and pressure rendering for the diver's + /// unit settings. Dive fields are stored metric, so every displayed + /// measurement goes through this rather than a literal suffix. /// - [title]: Title for the logbook cover page /// - [diveSignatures]: Map of dive ID to list of signatures for that dive /// - [certifications]: Optional list of certifications to include /// - [diver]: Optional diver profile for personalization + /// - [profiles]: Downsampled depth profiles keyed by dive id. Absent or + /// empty entries mean the dive has no samples and its chart region is + /// omitted. Loaded by the caller because [getAllDives] does not hydrate + /// profiles. + /// - [diverPhoto]: Decoded portrait bytes. Loaded by the caller; templates + /// never touch the file system. + /// - [includeVerificationAreas]: Adds an official stamp box and large + /// signature blocks, for logbooks presented to an agency. /// /// Returns the PDF document as a byte array. Future> buildPdf({ required List dives, required PdfPageSize pageSize, required PdfDateFormatter dates, + required UnitFormatter units, String title = 'Dive Logbook', Map>? diveSignatures, List? certifications, Diver? diver, + Map? profiles, + Uint8List? diverPhoto, + bool includeVerificationAreas = false, }); /// Convert [PdfPageSize] to the pdf package's [PdfPageFormat]. diff --git a/lib/core/services/pdf_templates/pdf_template_detailed.dart b/lib/core/services/pdf_templates/pdf_template_detailed.dart index 0140f4eb50..c18893fe86 100644 --- a/lib/core/services/pdf_templates/pdf_template_detailed.dart +++ b/lib/core/services/pdf_templates/pdf_template_detailed.dart @@ -1,21 +1,28 @@ +import 'dart:typed_data'; + import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; +import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/constants/pdf_templates.dart'; import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; import 'package:submersion/core/services/pdf_templates/pdf_fonts.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_front_matter.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_profile_chart.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_profile_series.dart'; import 'package:submersion/core/services/pdf_templates/pdf_shared_components.dart'; import 'package:submersion/core/services/pdf_templates/pdf_template_builder.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/certifications/domain/entities/certification.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/divers/domain/entities/diver.dart'; import 'package:submersion/features/signatures/domain/entities/signature.dart'; -/// Detailed PDF template with full dive information. +/// Detailed PDF template: one dive per page with the full field set. /// -/// This is the default template that displays 3 dives per page in a card -/// format with all available information including notes, gas info, tank -/// data, ratings, and signatures. +/// Carries the depth profile chart plus every group #1017 asks for. Each +/// group omits itself when the dive records nothing for it, so a manually +/// logged dive prints a short page rather than a wall of empty labels. class PdfTemplateDetailed extends PdfTemplateBuilder { @override PdfTemplate get templateType => PdfTemplate.detailed; @@ -25,15 +32,18 @@ class PdfTemplateDetailed extends PdfTemplateBuilder { required List dives, required PdfPageSize pageSize, required PdfDateFormatter dates, + required UnitFormatter units, String title = 'Dive Logbook', Map>? diveSignatures, List? certifications, Diver? diver, + Map? profiles, + Uint8List? diverPhoto, + bool includeVerificationAreas = false, }) async { final pdf = pw.Document(theme: PdfFonts.instance.theme); final pageFormat = getPageFormat(pageSize); - // Cover page pdf.addPage( pw.Page( pageFormat: pageFormat, @@ -49,24 +59,41 @@ class PdfTemplateDetailed extends PdfTemplateBuilder { ), ); - // Summary page + if (diver != null) { + pdf.addPage( + pw.MultiPage( + pageFormat: pageFormat, + margin: const pw.EdgeInsets.all(40), + build: (context) => PdfFrontMatter.buildDiverPageBody( + diver: diver, + dates: dates, + diveCount: dives.length, + certifications: certifications ?? const [], + photoBytes: diverPhoto, + ), + ), + ); + } + if (dives.isNotEmpty) { pdf.addPage( pw.Page( pageFormat: pageFormat, - build: (context) => - PdfSharedComponents.buildSummaryPage(dives: dives), + build: (context) => PdfSharedComponents.buildSummaryPage( + dives: dives, + dates: dates, + units: units, + ), ), ); } - // Certification cards page (if requested and available) if (certifications != null && certifications.isNotEmpty) { pdf.addPage( - pw.Page( + pw.MultiPage( pageFormat: pageFormat, margin: const pw.EdgeInsets.all(32), - build: (context) => PdfSharedComponents.buildCertificationCardsPage( + build: (context) => PdfSharedComponents.buildCertificationCardsBody( certifications: certifications, dates: dates, diver: diver, @@ -75,31 +102,21 @@ class PdfTemplateDetailed extends PdfTemplateBuilder { ); } - // Dive log pages (3 dives per page) - const divesPerPage = 3; - for (var i = 0; i < dives.length; i += divesPerPage) { - final pageDives = dives.skip(i).take(divesPerPage).toList(); - + // One dive per page. A MultiPage rather than a Page so an unusually long + // note or a long cylinder list spills onto a continuation sheet instead of + // being clipped, which is the failure this template is fixing. + for (final dive in dives) { pdf.addPage( - pw.Page( + pw.MultiPage( pageFormat: pageFormat, margin: const pw.EdgeInsets.all(32), - build: (context) => pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - ...pageDives.expand( - (dive) => [ - _buildDiveEntry( - dive, - dates: dates, - signatures: diveSignatures?[dive.id], - ), - pw.SizedBox(height: 16), - pw.Divider(), - pw.SizedBox(height: 16), - ], - ), - ], + build: (context) => _buildDivePage( + dive, + dates: dates, + units: units, + profile: _seriesFor(dive, profiles), + signatures: diveSignatures?[dive.id], + includeVerificationAreas: includeVerificationAreas, ), ), ); @@ -108,110 +125,500 @@ class PdfTemplateDetailed extends PdfTemplateBuilder { return await pdf.save(); } - pw.Widget _buildDiveEntry( + /// The chart series for [dive], falling back to samples already on the + /// entity. + /// + /// The logbook path loads profiles in batch because `getAllDives` skips + /// them, but `getDivesByIds` and `getDiveById` hydrate `Dive.profile` + /// already. Without this fallback every bulk or single-dive Detailed export + /// would silently omit the chart. + PdfProfileSeries? _seriesFor( + Dive dive, + Map? profiles, + ) { + final supplied = profiles?[dive.id]; + if (supplied != null && supplied.isNotEmpty) return supplied; + if (dive.profile.isEmpty) return null; + return PdfProfileSeries.downsampled(dive.profile); + } + + List _buildDivePage( Dive dive, { required PdfDateFormatter dates, + required UnitFormatter units, + PdfProfileSeries? profile, List? signatures, + required bool includeVerificationAreas, }) { - final tank = dive.tanks.isNotEmpty ? dive.tanks.first : null; + final chart = profile == null + ? null + : PdfProfileChart.build(series: profile, units: units); - return pw.Container( - padding: const pw.EdgeInsets.all(12), - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey400), - borderRadius: pw.BorderRadius.circular(8), - ), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - // Header row - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + return [ + _buildHeader(dive, dates: dates), + pw.SizedBox(height: 12), + pw.Divider(color: PdfColors.grey400), + pw.SizedBox(height: 12), + + if (chart != null) ...[chart, pw.SizedBox(height: 16)], + + ..._section('Profile', _profileFields(dive, dates: dates, units: units)), + ..._cylinderSection(dive, units: units), + ..._section('Conditions', _conditionFields(dive, units: units)), + ..._section('Weather', _weatherFields(dive, units: units)), + ..._section('Team', _teamFields(dive)), + ..._section('Equipment', _equipmentFields(dive, units: units)), + ..._section('Technical', _technicalFields(dive)), + ..._marineLifeSection(dive), + ..._notesSection(dive), + ..._customFieldsSection(dive), + ..._signatureSection(signatures, dates: dates), + if (includeVerificationAreas) ..._verificationSection(), + ]; + } + + pw.Widget _buildHeader(Dive dive, {required PdfDateFormatter dates}) { + return pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text( - '#${dive.diveNumber ?? '-'} - ${dive.site?.name ?? 'Unknown Site'}', + '#${dive.diveNumber ?? '-'} - ' + '${dive.site?.name ?? 'Unknown Site'}', style: const pw.TextStyle( - fontSize: 14, + fontSize: 18, fontWeight: pw.FontWeight.bold, + color: PdfColors.blue800, ), ), - pw.Text( - dates.dateTime(dive.dateTime), - style: const pw.TextStyle( - fontSize: 12, - color: PdfColors.grey600, - ), - ), - ], - ), - pw.SizedBox(height: 8), - // Metrics row - pw.Row( - children: [ - PdfSharedComponents.buildInfoChip( - 'Depth', - '${dive.maxDepth?.toStringAsFixed(1) ?? '-'}m', - ), - pw.SizedBox(width: 16), - PdfSharedComponents.buildInfoChip( - 'Duration', - '${pdfDiveDurationMinutes(dive)} min', - ), - pw.SizedBox(width: 16), - PdfSharedComponents.buildInfoChip( - 'Temp', - '${dive.waterTemp?.toStringAsFixed(0) ?? '-'}°C', - ), - if (tank != null) ...[ - pw.SizedBox(width: 16), - PdfSharedComponents.buildInfoChip( - 'Air', - '${tank.startPressure?.round() ?? '-'} - ${tank.endPressure?.round() ?? '-'} bar', + if (dive.site?.region != null || dive.site?.country != null) + pw.Text( + [ + dive.site?.region, + dive.site?.country, + ].whereType().join(', '), + style: const pw.TextStyle( + fontSize: 10, + color: PdfColors.grey600, + ), ), - ], ], ), - // Notes - if (dive.notes.isNotEmpty) ...[ - pw.SizedBox(height: 8), + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ pw.Text( - dive.notes, - style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700), - maxLines: 2, + dates.dateTime(dive.dateTime), + style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700), ), + if (dive.rating != null && dive.rating! > 0) ...[ + pw.SizedBox(height: 4), + PdfSharedComponents.buildRating(dive.rating), + ], ], - // Rating - if (dive.rating != null && dive.rating! > 0) ...[ - pw.SizedBox(height: 4), - PdfSharedComponents.buildRating(dive.rating), - ], - // Signatures section - if (signatures != null && signatures.isNotEmpty) ...[ - pw.SizedBox(height: 8), - pw.Text( - 'Verified by:', + ), + ], + ); + } + + /// A titled group of label/value pairs, or nothing when [fields] is empty. + List _section(String title, List<_Field> fields) { + if (fields.isEmpty) return const []; + + return [ + _sectionTitle(title), + pw.SizedBox(height: 6), + pw.Wrap( + spacing: 24, + runSpacing: 8, + children: fields + .map( + (f) => pw.SizedBox( + width: 110, + child: PdfSharedComponents.buildInfoChip(f.label, f.value), + ), + ) + .toList(), + ), + pw.SizedBox(height: 14), + ]; + } + + pw.Widget _sectionTitle(String title) => pw.Text( + title.toUpperCase(), + style: const pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey700, + letterSpacing: 1, + ), + ); + + List<_Field> _profileFields( + Dive dive, { + required PdfDateFormatter dates, + required UnitFormatter units, + }) { + return [ + if (dive.maxDepth != null) + _Field('Max Depth', units.formatDepth(dive.maxDepth)), + if (dive.avgDepth != null) + _Field('Avg Depth', units.formatDepth(dive.avgDepth)), + if (dive.effectiveRuntime != null) + _Field('Runtime', '${dive.effectiveRuntime!.inMinutes} min'), + if (dive.bottomTime != null) + _Field('Bottom Time', '${dive.bottomTime!.inMinutes} min'), + if (dive.entryTime != null) _Field('In', dates.time(dive.entryTime!)), + if (dive.exitTime != null) _Field('Out', dates.time(dive.exitTime!)), + if (dive.surfaceInterval != null) + _Field('Surface Interval', _duration(dive.surfaceInterval!)), + ..._gasConsumptionFields(dive, units), + ]; + } + + /// The diver's gas-consumption lanes, following + /// `AppSettings.gasConsumptionDisplay` as the dive detail page does. + /// + /// SAC and RMV are two quantities rather than one value in two units + /// (discussions #354, #803): [Dive.sac] is a bar/min pressure drop on the + /// reference cylinder, [Dive.rmvFor] is L/min summed over every cylinder + /// that carries pressures and a volume. So RMV is read from the entity, not + /// scaled from SAC by a cylinder volume -- bar/min from a 12 L back gas and + /// a 7 L stage cannot be averaged. + /// + /// A lane the dive cannot supply is omitted. The one exception mirrors the + /// detail page: an RMV-only diver whose cylinders have no volumes still + /// gets the SAC row, because a page that silently drops gas consumption is + /// worse than one showing the other lane (issue #386). The page has no + /// place for the tappable volume hint the app shows there. + List<_Field> _gasConsumptionFields(Dive dive, UnitFormatter units) { + final display = units.settings.gasConsumptionDisplay; + final sac = dive.sac; + final rmv = display.showsRmv ? dive.rmvFor(units.settings.gasModel) : null; + final sacFallback = display.showsRmv && !display.showsSac && rmv == null; + + return [ + if ((display.showsSac || sacFallback) && sac != null) + _Field('SAC', units.formatSac(sac)), + if (rmv != null) _Field('RMV', units.formatRmv(rmv)), + ]; + } + + /// Every cylinder, not just the first: a technical dive carries stage and + /// deco bottles whose pressures matter as much as the back gas. + List _cylinderSection(Dive dive, {required UnitFormatter units}) { + if (dive.tanks.isEmpty) return const []; + + return [ + _sectionTitle('Cylinders'), + pw.SizedBox(height: 6), + ...dive.tanks.map((tank) => _buildCylinderRow(tank, units: units)), + pw.SizedBox(height: 14), + ]; + } + + pw.Widget _buildCylinderRow(DiveTank tank, {required UnitFormatter units}) { + final descriptors = [ + tank.gasMix.name, + if (tank.volume != null) + units.formatTankVolume(tank.volume, tank.workingPressure), + if (tank.material != null) tank.material!.displayName, + if (tank.role != TankRole.backGas) tank.role.displayName, + ]; + + // A half-filled pressure pair is still worth printing: a logbook records + // what the diver has, so a missing endpoint becomes a placeholder rather + // than suppressing the whole range. + final pressures = [ + if (tank.startPressure != null || tank.endPressure != null) + pdfPressureRange(units, tank.startPressure, tank.endPressure), + if (tank.pressureUsed != null) + '${units.formatPressure(tank.pressureUsed)} used', + ]; + + return pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 4), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.SizedBox( + width: 90, + child: pw.Text( + tank.name ?? tank.presetName ?? 'Cylinder ${tank.order + 1}', style: const pw.TextStyle( fontSize: 10, fontWeight: pw.FontWeight.bold, - color: PdfColors.grey700, ), ), - pw.SizedBox(height: 4), - pw.Wrap( - spacing: 8, - runSpacing: 8, - children: signatures - .map( - (sig) => PdfSharedComponents.buildSignatureBlock( - sig, - dates: dates, - ), - ) - .toList(), + ), + pw.Expanded( + child: pw.Text( + descriptors.join(' '), + style: const pw.TextStyle(fontSize: 10), ), - ], + ), + pw.Text( + pressures.join(' '), + style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700), + ), ], ), ); } + + List<_Field> _conditionFields(Dive dive, {required UnitFormatter units}) { + return [ + if (dive.waterTemp != null) + _Field('Water Temp', units.formatTemperature(dive.waterTemp)), + if (dive.airTemp != null) + _Field('Air Temp', units.formatTemperature(dive.airTemp)), + if (dive.visibilityMeters != null) + _Field('Visibility', units.formatDistance(dive.visibilityMeters!)) + else if (dive.visibility != null) + _Field('Visibility', dive.visibility!.displayName), + if (dive.currentStrength != null) + _Field('Current', dive.currentStrength!.displayName), + if (dive.currentDirection != null) + _Field('Current Dir', dive.currentDirection!.displayName), + if (dive.waterType != null) + _Field('Water Type', dive.waterType!.displayName), + if (dive.entryMethod != null) + _Field('Entry', dive.entryMethod!.displayName), + if (dive.exitMethod != null) _Field('Exit', dive.exitMethod!.displayName), + if (dive.altitude != null) + _Field('Altitude', units.formatAltitude(dive.altitude)), + ]; + } + + List<_Field> _teamFields(Dive dive) { + return [ + for (final buddy in dive.buddies) + _Field(buddy.role.name, buddy.buddy.name), + if (dive.buddies.isEmpty && dive.buddy != null) + _Field('Buddy', dive.buddy!), + if (dive.diveMaster != null) _Field('Dive Master', dive.diveMaster!), + if (dive.diveCenter != null) _Field('Dive Center', dive.diveCenter!.name), + if (dive.trip != null) _Field('Trip', dive.trip!.name), + ]; + } + + List<_Field> _equipmentFields(Dive dive, {required UnitFormatter units}) { + return [ + // The current editor writes Dive.weights; weightAmount is the legacy + // scalar kept for older dives. + if (dive.weights.isNotEmpty) + _Field('Weight', units.formatWeight(dive.totalWeight)) + else if (dive.weightAmount != null) + _Field('Weight', units.formatWeight(dive.weightAmount)), + if (dive.weightType != null) + _Field('Weight Type', dive.weightType!.displayName), + for (final item in dive.equipment) + _Field(item.type.displayName, item.name), + ]; + } + + List<_Field> _technicalFields(Dive dive) { + return [ + if (dive.diveComputerModel != null) + _Field('Computer', dive.diveComputerModel!), + if (dive.diveMode != DiveMode.oc) + _Field('Dive Mode', dive.diveMode.displayName), + if (dive.decoAlgorithm != null) _Field('Algorithm', dive.decoAlgorithm!), + if (dive.gradientFactorLow != null && dive.gradientFactorHigh != null) + _Field( + 'Gradient Factors', + '${dive.gradientFactorLow}/${dive.gradientFactorHigh}', + ), + if (dive.setpointHigh != null) + // Deliberately not unit-converted: a CCR setpoint is a partial + // pressure of oxygen, quoted in bar (or ata) whatever the diver's + // cylinder-pressure preference. The CCR settings panel does the same. + _Field('Setpoint', '${dive.setpointHigh} bar'), + if (dive.diveTypeNames.isNotEmpty) + _Field('Dive Type', dive.diveTypeNames.join(', ')), + ]; + } + + /// Recorded weather, which #1017 asks for beyond the free-text summary. + List<_Field> _weatherFields(Dive dive, {required UnitFormatter units}) { + return [ + if (dive.weatherDescription != null) + _Field('Conditions', dive.weatherDescription!), + if (dive.windSpeed != null) + _Field('Wind', units.formatWindSpeed(dive.windSpeed)), + if (dive.windDirection != null) + _Field('Wind Dir', dive.windDirection!.displayName), + if (dive.cloudCover != null) + _Field('Cloud', dive.cloudCover!.displayName), + if (dive.precipitation != null) + _Field('Precipitation', dive.precipitation!.displayName), + if (dive.humidity != null) + _Field('Humidity', '${dive.humidity!.toStringAsFixed(0)}%'), + // Swell is a height in meters, so it follows the depth unit the way the + // dive editor renders it. + if (dive.swellHeight != null) + _Field('Swell', units.formatDepth(dive.swellHeight)), + ]; + } + + /// User-defined key/value entries. The legacy builder rendered these, so + /// dropping them would lose data that has no other section to hold it. + List _customFieldsSection(Dive dive) { + if (dive.customFields.isEmpty) return const []; + + return [ + _sectionTitle('Additional Fields'), + pw.SizedBox(height: 6), + ...dive.customFields.map( + (field) => pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 2), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.SizedBox( + width: 120, + child: pw.Text( + field.key, + style: const pw.TextStyle( + fontSize: 10, + color: PdfColors.grey600, + ), + ), + ), + pw.Expanded( + child: pw.Text( + field.value, + style: const pw.TextStyle(fontSize: 10), + ), + ), + ], + ), + ), + ), + pw.SizedBox(height: 14), + ]; + } + + /// Full text. The truncation here is the reported bug. + /// Recorded marine life, which #1017 lists among the detailed groups. + /// + /// A list rather than the `_section` chips: a sighting carries free-text + /// notes that would not survive a fixed-width chip. + List _marineLifeSection(Dive dive) { + if (dive.sightings.isEmpty) return const []; + + return [ + _sectionTitle('Marine Life'), + pw.SizedBox(height: 6), + ...dive.sightings.map(_sightingLine), + pw.SizedBox(height: 14), + ]; + } + + pw.Widget _sightingLine(MarineSighting sighting) { + // A count on a lone animal reads as noise, so only a real tally is shown. + final species = sighting.count > 1 + ? '${sighting.speciesName} x${sighting.count}' + : sighting.speciesName; + + return pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 3), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + flex: 2, + child: pw.Text( + species, + style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey800), + ), + ), + pw.Expanded( + flex: 3, + child: pw.Text( + sighting.notes, + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600), + ), + ), + ], + ), + ); + } + + List _notesSection(Dive dive) { + if (dive.notes.isEmpty) return const []; + + return [ + _sectionTitle('Notes'), + pw.SizedBox(height: 6), + pw.Text( + dive.notes, + style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey800), + ), + pw.SizedBox(height: 14), + ]; + } + + List _signatureSection( + List? signatures, { + required PdfDateFormatter dates, + }) { + if (signatures == null || signatures.isEmpty) return const []; + + return [ + _sectionTitle('Verified By'), + pw.SizedBox(height: 6), + pw.Wrap( + spacing: 8, + runSpacing: 8, + children: signatures + .map( + (sig) => + PdfSharedComponents.buildSignatureBlock(sig, dates: dates), + ) + .toList(), + ), + pw.SizedBox(height: 14), + ]; + } + + List _verificationSection() { + return [ + _sectionTitle('Verification'), + pw.SizedBox(height: 6), + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + PdfSharedComponents.buildLargeSignatureBlock( + label: 'Instructor Signature', + ), + pw.SizedBox(width: 24), + PdfSharedComponents.buildLargeSignatureBlock( + label: 'Buddy Signature', + ), + pw.SizedBox(width: 24), + PdfSharedComponents.buildStampArea(), + ], + ), + ]; + } + + String _duration(Duration d) { + final hours = d.inHours; + final minutes = d.inMinutes % 60; + return hours > 0 ? '${hours}h ${minutes}m' : '${minutes}m'; + } +} + +/// One label/value pair inside a section. +class _Field { + final String label; + final String value; + + const _Field(this.label, this.value); } diff --git a/lib/core/services/pdf_templates/pdf_template_factory.dart b/lib/core/services/pdf_templates/pdf_template_factory.dart index 44ffe2329b..191bae1f5e 100644 --- a/lib/core/services/pdf_templates/pdf_template_factory.dart +++ b/lib/core/services/pdf_templates/pdf_template_factory.dart @@ -3,7 +3,6 @@ import 'package:submersion/core/services/pdf_templates/pdf_template_builder.dart import 'package:submersion/core/services/pdf_templates/pdf_template_detailed.dart'; import 'package:submersion/core/services/pdf_templates/pdf_template_naui.dart'; import 'package:submersion/core/services/pdf_templates/pdf_template_padi.dart'; -import 'package:submersion/core/services/pdf_templates/pdf_template_professional.dart'; import 'package:submersion/core/services/pdf_templates/pdf_template_simple.dart'; /// Factory for creating PDF template builders. @@ -34,8 +33,6 @@ class PdfTemplateFactory { return PdfTemplateSimple(); case PdfTemplate.detailed: return PdfTemplateDetailed(); - case PdfTemplate.professional: - return PdfTemplateProfessional(); case PdfTemplate.padiStyle: return PdfTemplatePadi(); case PdfTemplate.nauiStyle: diff --git a/lib/core/services/pdf_templates/pdf_template_naui.dart b/lib/core/services/pdf_templates/pdf_template_naui.dart index ad580faf22..8fcf50ebd4 100644 --- a/lib/core/services/pdf_templates/pdf_template_naui.dart +++ b/lib/core/services/pdf_templates/pdf_template_naui.dart @@ -1,8 +1,12 @@ +import 'dart:typed_data'; + import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:submersion/core/constants/pdf_templates.dart'; import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_profile_series.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/core/services/pdf_templates/pdf_fonts.dart'; import 'package:submersion/core/services/pdf_templates/pdf_shared_components.dart'; import 'package:submersion/core/services/pdf_templates/pdf_template_builder.dart'; @@ -28,10 +32,16 @@ class PdfTemplateNaui extends PdfTemplateBuilder { required List dives, required PdfPageSize pageSize, required PdfDateFormatter dates, + required UnitFormatter units, String title = 'Dive Logbook', Map>? diveSignatures, List? certifications, Diver? diver, + // Accepted for the shared builder contract; this template does not chart + // profiles, show a portrait, or offer verification areas. + Map? profiles, + Uint8List? diverPhoto, + bool includeVerificationAreas = false, }) async { final pdf = pw.Document(theme: PdfFonts.instance.theme); final pageFormat = getPageFormat(pageSize); @@ -44,6 +54,7 @@ class PdfTemplateNaui extends PdfTemplateBuilder { title: title, diveCount: dives.length, dates: dates, + units: units, diver: diver, dives: dives, ), @@ -53,10 +64,10 @@ class PdfTemplateNaui extends PdfTemplateBuilder { // Certification cards page - highlight NAUI certs if (certifications != null && certifications.isNotEmpty) { pdf.addPage( - pw.Page( + pw.MultiPage( pageFormat: pageFormat, margin: const pw.EdgeInsets.all(32), - build: (context) => PdfSharedComponents.buildCertificationCardsPage( + build: (context) => PdfSharedComponents.buildCertificationCardsBody( certifications: certifications, dates: dates, diver: diver, @@ -88,6 +99,7 @@ class PdfTemplateNaui extends PdfTemplateBuilder { _buildNauiDiveEntry( dive, dates: dates, + units: units, signatures: diveSignatures?[dive.id], ), pw.SizedBox(height: 8), @@ -106,15 +118,14 @@ class PdfTemplateNaui extends PdfTemplateBuilder { required String title, required int diveCount, required PdfDateFormatter dates, + required UnitFormatter units, Diver? diver, required List dives, }) { - // Calculate summary stats + // Summary stats come from the shared helpers so this cover page cannot + // disagree with the summary the other templates print. final totalRuntime = pdfTotalRuntime(dives); - final maxDepth = dives - .where((d) => d.maxDepth != null) - .map((d) => d.maxDepth!) - .fold(0, (max, depth) => depth > max ? depth : max); + final maxDepth = pdfMaxDepth(dives); return pw.Center( child: pw.Column( @@ -170,7 +181,10 @@ class PdfTemplateNaui extends PdfTemplateBuilder { 'Hours', ), pw.SizedBox(width: 20), - _buildStatBox('${maxDepth.toStringAsFixed(0)}m', 'Max Depth'), + _buildStatBox( + units.formatDepth(maxDepth, decimals: 0), + 'Max Depth', + ), ], ), pw.Spacer(), @@ -240,6 +254,7 @@ class PdfTemplateNaui extends PdfTemplateBuilder { pw.Widget _buildNauiDiveEntry( Dive dive, { required PdfDateFormatter dates, + required UnitFormatter units, List? signatures, }) { final tank = dive.tanks.isNotEmpty ? dive.tanks.first : null; @@ -319,11 +334,11 @@ class PdfTemplateNaui extends PdfTemplateBuilder { children: [ _buildNauiField( 'Depth', - '${dive.maxDepth?.toStringAsFixed(1) ?? '-'}m', + units.formatDepth(dive.maxDepth), ), _buildNauiField( 'Avg', - '${dive.avgDepth?.toStringAsFixed(1) ?? '-'}m', + units.formatDepth(dive.avgDepth), ), _buildNauiField( 'Time', @@ -338,11 +353,11 @@ class PdfTemplateNaui extends PdfTemplateBuilder { _buildNauiField('Gas', tank.gasMix.name), _buildNauiField( 'Start', - '${tank.startPressure?.round() ?? '-'} bar', + units.formatPressure(tank.startPressure), ), _buildNauiField( 'End', - '${tank.endPressure?.round() ?? '-'} bar', + units.formatPressure(tank.endPressure), ), ], ], @@ -356,14 +371,14 @@ class PdfTemplateNaui extends PdfTemplateBuilder { children: [ _buildNauiField( 'Temp', - '${dive.waterTemp?.toStringAsFixed(0) ?? '-'}°C', + units.formatTemperature(dive.waterTemp), ), _buildNauiField( 'Vis', // Measured distance from v144; pre-v144 dives fall // back to their bucket label. dive.visibilityMeters != null - ? '${dive.visibilityMeters!.toStringAsFixed(0)}m' + ? units.formatDistance(dive.visibilityMeters!) : (dive.visibility?.displayName ?? '-'), ), if (dive.currentStrength != null) diff --git a/lib/core/services/pdf_templates/pdf_template_padi.dart b/lib/core/services/pdf_templates/pdf_template_padi.dart index ed45d06849..d0cbe43937 100644 --- a/lib/core/services/pdf_templates/pdf_template_padi.dart +++ b/lib/core/services/pdf_templates/pdf_template_padi.dart @@ -1,8 +1,12 @@ +import 'dart:typed_data'; + import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:submersion/core/constants/pdf_templates.dart'; import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_profile_series.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/core/services/pdf_templates/pdf_fonts.dart'; import 'package:submersion/core/services/pdf_templates/pdf_shared_components.dart'; import 'package:submersion/core/services/pdf_templates/pdf_template_builder.dart'; @@ -29,10 +33,16 @@ class PdfTemplatePadi extends PdfTemplateBuilder { required List dives, required PdfPageSize pageSize, required PdfDateFormatter dates, + required UnitFormatter units, String title = 'Dive Logbook', Map>? diveSignatures, List? certifications, Diver? diver, + // Accepted for the shared builder contract; this template does not chart + // profiles, show a portrait, or offer verification areas. + Map? profiles, + Uint8List? diverPhoto, + bool includeVerificationAreas = false, }) async { final pdf = pw.Document(theme: PdfFonts.instance.theme); final pageFormat = getPageFormat(pageSize); @@ -55,10 +65,10 @@ class PdfTemplatePadi extends PdfTemplateBuilder { // Certification cards page - highlight PADI certs if (certifications != null && certifications.isNotEmpty) { pdf.addPage( - pw.Page( + pw.MultiPage( pageFormat: pageFormat, margin: const pw.EdgeInsets.all(32), - build: (context) => PdfSharedComponents.buildCertificationCardsPage( + build: (context) => PdfSharedComponents.buildCertificationCardsBody( certifications: certifications, dates: dates, diver: diver, @@ -90,6 +100,7 @@ class PdfTemplatePadi extends PdfTemplateBuilder { _buildPadiDiveEntry( dive, dates: dates, + units: units, signatures: diveSignatures?[dive.id], ), pw.SizedBox(height: 8), @@ -218,6 +229,7 @@ class PdfTemplatePadi extends PdfTemplateBuilder { pw.Widget _buildPadiDiveEntry( Dive dive, { required PdfDateFormatter dates, + required UnitFormatter units, List? signatures, }) { final tank = dive.tanks.isNotEmpty ? dive.tanks.first : null; @@ -310,17 +322,14 @@ class PdfTemplatePadi extends PdfTemplateBuilder { // Dive data row pw.Row( children: [ - _buildPadiField( - 'Depth', - '${dive.maxDepth?.toStringAsFixed(1) ?? '-'}m', - ), + _buildPadiField('Depth', units.formatDepth(dive.maxDepth)), _buildPadiField( 'Time', '${pdfDiveDurationMinutes(dive)}min', ), _buildPadiField( 'Temp', - '${dive.waterTemp?.toStringAsFixed(0) ?? '-'}°C', + units.formatTemperature(dive.waterTemp), ), if (tank != null) _buildPadiField('Gas', tank.gasMix.name), ], @@ -335,13 +344,17 @@ class PdfTemplatePadi extends PdfTemplateBuilder { _buildPadiField( 'Vis', dive.visibilityMeters != null - ? '${dive.visibilityMeters!.toStringAsFixed(0)}m' + ? units.formatDistance(dive.visibilityMeters!) : (dive.visibility?.displayName ?? '-'), ), if (tank != null) ...[ _buildPadiField( 'Air', - '${tank.startPressure?.round() ?? '-'}-${tank.endPressure?.round() ?? '-'}', + pdfPressureRange( + units, + tank.startPressure, + tank.endPressure, + ), ), ], if (dive.waterType != null) diff --git a/lib/core/services/pdf_templates/pdf_template_professional.dart b/lib/core/services/pdf_templates/pdf_template_professional.dart deleted file mode 100644 index 5ee6433f7b..0000000000 --- a/lib/core/services/pdf_templates/pdf_template_professional.dart +++ /dev/null @@ -1,542 +0,0 @@ -import 'package:pdf/pdf.dart'; -import 'package:pdf/widgets.dart' as pw; - -import 'package:submersion/core/constants/pdf_templates.dart'; -import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; -import 'package:submersion/core/services/pdf_templates/pdf_fonts.dart'; -import 'package:submersion/core/services/pdf_templates/pdf_shared_components.dart'; -import 'package:submersion/core/services/pdf_templates/pdf_template_builder.dart'; -import 'package:submersion/features/certifications/domain/certification_title.dart'; -import 'package:submersion/features/certifications/domain/entities/certification.dart'; -import 'package:submersion/features/dive_log/domain/entities/dive.dart'; -import 'package:submersion/features/divers/domain/entities/diver.dart'; -import 'package:submersion/features/signatures/domain/entities/signature.dart'; - -/// Professional PDF template for instructors and divemasters. -/// -/// Displays 2 dives per page with large signature and stamp areas for -/// official verification. Suitable for presenting to dive agencies or -/// for professional dive logs. -class PdfTemplateProfessional extends PdfTemplateBuilder { - @override - PdfTemplate get templateType => PdfTemplate.professional; - - @override - Future> buildPdf({ - required List dives, - required PdfPageSize pageSize, - required PdfDateFormatter dates, - String title = 'Dive Logbook', - Map>? diveSignatures, - List? certifications, - Diver? diver, - }) async { - final pdf = pw.Document(theme: PdfFonts.instance.theme); - final pageFormat = getPageFormat(pageSize); - - // Cover page - pdf.addPage( - pw.Page( - pageFormat: pageFormat, - build: (context) => _buildCoverPage( - title: title, - diveCount: dives.length, - dates: dates, - diver: diver, - firstDiveDate: dives.isNotEmpty ? dives.last.dateTime : null, - lastDiveDate: dives.isNotEmpty ? dives.first.dateTime : null, - ), - ), - ); - - // Diver profile page - if (diver != null) { - pdf.addPage( - pw.Page( - pageFormat: pageFormat, - margin: const pw.EdgeInsets.all(40), - build: (context) => _buildDiverProfilePage( - diver: diver, - totalDives: dives.length, - dates: dates, - certifications: certifications, - ), - ), - ); - } - - // Certification cards page (if requested and available) - if (certifications != null && certifications.isNotEmpty) { - pdf.addPage( - pw.Page( - pageFormat: pageFormat, - margin: const pw.EdgeInsets.all(40), - build: (context) => PdfSharedComponents.buildCertificationCardsPage( - certifications: certifications, - dates: dates, - diver: diver, - accentColor: PdfColors.grey800, - ), - ), - ); - } - - // Dive log pages (2 dives per page for more space) - const divesPerPage = 2; - for (var i = 0; i < dives.length; i += divesPerPage) { - final pageDives = dives.skip(i).take(divesPerPage).toList(); - - pdf.addPage( - pw.Page( - pageFormat: pageFormat, - margin: const pw.EdgeInsets.all(40), - build: (context) => pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - // Page header - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text( - 'Professional Dive Log', - style: const pw.TextStyle( - fontSize: 12, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey700, - ), - ), - if (diver != null) - pw.Text( - diver.name, - style: const pw.TextStyle( - fontSize: 10, - color: PdfColors.grey600, - ), - ), - ], - ), - pw.Divider(color: PdfColors.grey400), - pw.SizedBox(height: 16), - // Dive entries - ...pageDives.expand( - (dive) => [ - _buildProfessionalDiveEntry( - dive, - dates: dates, - signatures: diveSignatures?[dive.id], - ), - pw.SizedBox(height: 24), - ], - ), - ], - ), - ), - ); - } - - return await pdf.save(); - } - - pw.Widget _buildCoverPage({ - required String title, - required int diveCount, - required PdfDateFormatter dates, - Diver? diver, - DateTime? firstDiveDate, - DateTime? lastDiveDate, - }) { - return pw.Center( - child: pw.Column( - mainAxisAlignment: pw.MainAxisAlignment.center, - children: [ - pw.Container( - padding: const pw.EdgeInsets.all(40), - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey800, width: 2), - ), - child: pw.Column( - children: [ - pw.Text( - 'PROFESSIONAL', - style: const pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - letterSpacing: 4, - color: PdfColors.grey600, - ), - ), - pw.SizedBox(height: 8), - pw.Text( - title.toUpperCase(), - style: const pw.TextStyle( - fontSize: 32, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.SizedBox(height: 20), - pw.Divider(color: PdfColors.grey400), - pw.SizedBox(height: 20), - if (diver != null) ...[ - pw.Text(diver.name, style: const pw.TextStyle(fontSize: 20)), - pw.SizedBox(height: 8), - ], - pw.Text( - '$diveCount Logged Dives', - style: const pw.TextStyle( - fontSize: 16, - color: PdfColors.grey700, - ), - ), - if (firstDiveDate != null && lastDiveDate != null) ...[ - pw.SizedBox(height: 8), - pw.Text( - '${dates.date(firstDiveDate)} - ${dates.date(lastDiveDate)}', - style: const pw.TextStyle( - fontSize: 12, - color: PdfColors.grey600, - ), - ), - ], - ], - ), - ), - pw.SizedBox(height: 40), - pw.Text( - 'Generated ${dates.dateTime(DateTime.now())}', - style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey500), - ), - ], - ), - ); - } - - pw.Widget _buildDiverProfilePage({ - required Diver diver, - required int totalDives, - required PdfDateFormatter dates, - List? certifications, - }) { - return pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'Diver Profile', - style: const pw.TextStyle( - fontSize: 20, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.SizedBox(height: 16), - pw.Divider(color: PdfColors.grey400), - pw.SizedBox(height: 24), - // Profile info - pw.Row( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - _buildProfileField('Name', diver.name), - if (diver.email != null) - _buildProfileField('Email', diver.email!), - _buildProfileField('Total Dives', '$totalDives'), - ], - ), - ), - pw.SizedBox(width: 40), - // Photo placeholder - pw.Container( - width: 100, - height: 120, - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey400), - ), - child: pw.Center( - child: pw.Text( - 'Photo', - style: const pw.TextStyle( - fontSize: 10, - color: PdfColors.grey400, - ), - ), - ), - ), - ], - ), - pw.SizedBox(height: 24), - // Certifications summary - if (certifications != null && certifications.isNotEmpty) ...[ - pw.Text( - 'Certifications', - style: const pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.SizedBox(height: 8), - ...certifications - .take(5) - .map( - (cert) => pw.Padding( - padding: const pw.EdgeInsets.only(bottom: 4), - child: pw.Row( - children: [ - pw.Container( - width: 4, - height: 4, - decoration: const pw.BoxDecoration( - color: PdfColors.grey600, - shape: pw.BoxShape.circle, - ), - ), - pw.SizedBox(width: 8), - // This bullet is a single line with no separate agency - // field, unlike the certifications section in - // PdfSharedComponents -- so the agency stays here. The - // title is derived so it is not printed twice. - pw.Text( - '${cert.agency.displayName} - ${certificationTitle(cert)}', - style: const pw.TextStyle(fontSize: 10), - ), - if (cert.issueDate != null) ...[ - pw.Text( - ' (${dates.date(cert.issueDate!)})', - style: const pw.TextStyle( - fontSize: 10, - color: PdfColors.grey600, - ), - ), - ], - ], - ), - ), - ), - if (certifications.length > 5) - pw.Text( - '... and ${certifications.length - 5} more', - style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey600), - ), - ], - ], - ); - } - - pw.Widget _buildProfileField(String label, String value) { - return pw.Padding( - padding: const pw.EdgeInsets.only(bottom: 12), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - label.toUpperCase(), - style: const pw.TextStyle( - fontSize: 8, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey600, - letterSpacing: 1, - ), - ), - pw.SizedBox(height: 2), - pw.Text(value, style: const pw.TextStyle(fontSize: 12)), - ], - ), - ); - } - - pw.Widget _buildProfessionalDiveEntry( - Dive dive, { - required PdfDateFormatter dates, - List? signatures, - }) { - final tank = dive.tanks.isNotEmpty ? dive.tanks.first : null; - final instructorSig = signatures - ?.where((s) => !s.isBuddySignature) - .firstOrNull; - final buddySig = signatures?.where((s) => s.isBuddySignature).firstOrNull; - - return pw.Container( - padding: const pw.EdgeInsets.all(16), - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey400), - borderRadius: pw.BorderRadius.circular(4), - ), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - // Header - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text( - 'DIVE #${dive.diveNumber ?? '-'}', - style: const pw.TextStyle( - fontSize: 16, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.Text( - dates.dateTime(dive.dateTime), - style: const pw.TextStyle(fontSize: 12), - ), - ], - ), - pw.SizedBox(height: 4), - pw.Text( - dive.site?.name ?? 'Unknown Site', - style: const pw.TextStyle(fontSize: 14, color: PdfColors.grey700), - ), - pw.SizedBox(height: 12), - pw.Divider(color: PdfColors.grey200), - pw.SizedBox(height: 12), - // Metrics grid - pw.Row( - children: [ - pw.Expanded( - child: pw.Column( - children: [ - _buildMetricRow( - 'Max Depth', - dive.maxDepth != null - ? '${dive.maxDepth!.toStringAsFixed(1)} m' - : '-', - ), - _buildMetricRow( - 'Avg Depth', - dive.avgDepth != null - ? '${dive.avgDepth!.toStringAsFixed(1)} m' - : '-', - ), - _buildMetricRow( - 'Duration', - dive.effectiveRuntime != null - ? '${dive.effectiveRuntime!.inMinutes} min' - : '-', - ), - ], - ), - ), - pw.Expanded( - child: pw.Column( - children: [ - _buildMetricRow( - 'Water Temp', - dive.waterTemp != null - ? '${dive.waterTemp!.toStringAsFixed(0)}°C' - : '-', - ), - _buildMetricRow( - 'Visibility', - // Measured distance from v144; pre-v144 dives fall back - // to their bucket label. - dive.visibilityMeters != null - ? '${dive.visibilityMeters!.toStringAsFixed(0)}m' - : (dive.visibility?.displayName ?? '-'), - ), - if (tank != null) - _buildMetricRow('Gas', tank.gasMix.name) - else - _buildMetricRow('Gas', '-'), - ], - ), - ), - ], - ), - // Notes - if (dive.notes.isNotEmpty) ...[ - pw.SizedBox(height: 12), - pw.Container( - width: double.infinity, - padding: const pw.EdgeInsets.all(8), - decoration: pw.BoxDecoration( - color: PdfColors.grey50, - borderRadius: pw.BorderRadius.circular(4), - ), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'NOTES', - style: const pw.TextStyle( - fontSize: 8, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey600, - ), - ), - pw.SizedBox(height: 4), - pw.Text( - dive.notes, - style: const pw.TextStyle(fontSize: 10), - maxLines: 4, - ), - ], - ), - ), - ], - pw.SizedBox(height: 16), - // Verification section - pw.Divider(color: PdfColors.grey200), - pw.SizedBox(height: 12), - pw.Text( - 'VERIFICATION', - style: const pw.TextStyle( - fontSize: 10, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey700, - ), - ), - pw.SizedBox(height: 12), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - // Instructor signature - PdfSharedComponents.buildLargeSignatureBlock( - label: 'Instructor / Verifier Signature', - signature: instructorSig, - width: 180, - height: 50, - ), - // Buddy signature - PdfSharedComponents.buildLargeSignatureBlock( - label: 'Buddy Signature', - signature: buddySig, - width: 140, - height: 50, - ), - // Stamp area - PdfSharedComponents.buildStampArea( - size: 70, - label: 'Official Stamp', - ), - ], - ), - ], - ), - ); - } - - pw.Widget _buildMetricRow(String label, String value) { - return pw.Padding( - padding: const pw.EdgeInsets.only(bottom: 6), - child: pw.Row( - children: [ - pw.SizedBox( - width: 80, - child: pw.Text( - label, - style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey600), - ), - ), - pw.Text( - value, - style: const pw.TextStyle( - fontSize: 10, - fontWeight: pw.FontWeight.bold, - ), - ), - ], - ), - ); - } -} diff --git a/lib/core/services/pdf_templates/pdf_template_simple.dart b/lib/core/services/pdf_templates/pdf_template_simple.dart index 3d34cf061c..4b91af0683 100644 --- a/lib/core/services/pdf_templates/pdf_template_simple.dart +++ b/lib/core/services/pdf_templates/pdf_template_simple.dart @@ -1,9 +1,14 @@ +import 'dart:typed_data'; + import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:submersion/core/constants/pdf_templates.dart'; import 'package:submersion/core/services/pdf_templates/pdf_date_formatter.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_profile_series.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/core/services/pdf_templates/pdf_fonts.dart'; +import 'package:submersion/core/services/pdf_templates/pdf_shared_components.dart'; import 'package:submersion/core/services/pdf_templates/pdf_template_builder.dart'; import 'package:submersion/features/certifications/domain/entities/certification.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; @@ -24,14 +29,49 @@ class PdfTemplateSimple extends PdfTemplateBuilder { required List dives, required PdfPageSize pageSize, required PdfDateFormatter dates, + required UnitFormatter units, String title = 'Dive Logbook', Map>? diveSignatures, List? certifications, Diver? diver, + // Accepted for the shared builder contract; this template does not chart + // profiles, show a portrait, or offer verification areas. + Map? profiles, + Uint8List? diverPhoto, + bool includeVerificationAreas = false, }) async { final pdf = pw.Document(theme: PdfFonts.instance.theme); final pageFormat = getPageFormat(pageSize); + // Key metrics up front (#1017). The compact table below stays untouched: + // Simple remains the dense option, it just no longer opens cold. + if (dives.isNotEmpty) { + pdf.addPage( + pw.Page( + pageFormat: pageFormat, + build: (context) => PdfSharedComponents.buildSummaryPage( + dives: dives, + dates: dates, + units: units, + ), + ), + ); + } + + if (certifications != null && certifications.isNotEmpty) { + pdf.addPage( + pw.MultiPage( + pageFormat: pageFormat, + margin: const pw.EdgeInsets.all(32), + build: (context) => PdfSharedComponents.buildCertificationCardsBody( + certifications: certifications, + dates: dates, + diver: diver, + ), + ), + ); + } + // Calculate dives per page based on page size final divesPerPage = pageSize == PdfPageSize.a4 ? 20 : 18; @@ -149,7 +189,7 @@ class PdfTemplateSimple extends PdfTemplateBuilder { ), _buildCell( dive.maxDepth != null - ? '${dive.maxDepth!.toStringAsFixed(1)}m' + ? units.formatDepth(dive.maxDepth) : '-', cellStyle, ), @@ -161,7 +201,7 @@ class PdfTemplateSimple extends PdfTemplateBuilder { ), _buildCell( dive.waterTemp != null - ? '${dive.waterTemp!.toStringAsFixed(0)}°C' + ? units.formatTemperature(dive.waterTemp) : '-', cellStyle, ), diff --git a/lib/core/services/storage/directory_size.dart b/lib/core/services/storage/directory_size.dart new file mode 100644 index 0000000000..1597f8da86 --- /dev/null +++ b/lib/core/services/storage/directory_size.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Recursively sums the bytes held by every file under [dir]. +/// +/// A directory that does not exist measures zero: that is a true measurement, +/// not a failure. A real I/O error (a permission denial, say) is allowed to +/// throw, because reporting a falsely low total is worse than showing the user +/// an error on the row that failed. A user who trusts a falsely low number acts +/// on it; a user who sees an error knows to look again. +Future measureDirectoryBytes(Directory dir) async { + if (!await dir.exists()) return 0; + var total = 0; + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is File) { + total += await entity.length(); + } + } + return total; +} + +/// Sums a known set of files, skipping any that are absent. +/// +/// Used for a database and its sidecars, where the `-wal` and `-shm` files +/// exist only while a connection is open. +Future measureFileGroupBytes(Iterable files) async { + var total = 0; + for (final file in files) { + if (await file.exists()) { + total += await file.length(); + } + } + return total; +} + +/// Sums the files directly inside [dir], never descending into subdirectories, +/// skipping any whose basename [exclude] returns true for. +/// +/// The Documents root needs this shape: exported files land loose at the top +/// level, while the database and the `Submersion/` subtree live alongside them +/// and must not be counted as exports. +Future measureLooseFilesBytes( + Directory dir, { + required bool Function(String name) exclude, +}) async { + if (!await dir.exists()) return 0; + var total = 0; + await for (final entity in dir.list(recursive: false, followLinks: false)) { + if (entity is! File) continue; + if (exclude(p.basename(entity.path))) continue; + total += await entity.length(); + } + return total; +} diff --git a/lib/core/services/storage/scratch_sweep.dart b/lib/core/services/storage/scratch_sweep.dart new file mode 100644 index 0000000000..556276bd9f --- /dev/null +++ b/lib/core/services/storage/scratch_sweep.dart @@ -0,0 +1,205 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'package:submersion/core/services/logger_service.dart'; + +/// SharedPreferences key holding the last sweep's epoch millis. +/// +/// Per-device by nature, so it lives in prefs rather than a synced column: +/// what this device has cleaned off its own disk is not news to a peer. +const String kScratchSweepStampKey = 'storage_scratch_sweep_last_run_ms'; + +/// Whether a scratch sweep is due. One run per day. +/// +/// A stamp far in the future can only come from a broken clock, and must not +/// suppress sweeping until real time catches up: the same defence as +/// `shouldAutoScan` and `shouldAutoVerify`. +bool shouldSweepScratch({ + required DateTime? lastSweptAt, + required DateTime now, +}) { + if (lastSweptAt == null) return true; + if (lastSweptAt.isAfter(now.add(const Duration(days: 1)))) return true; + return now.difference(lastSweptAt) >= const Duration(days: 1); +} + +/// What one sweep pass reclaimed. +class ScratchSweepReport { + const ScratchSweepReport({ + required this.filesDeleted, + required this.bytesReclaimed, + }); + + static const empty = ScratchSweepReport(filesDeleted: 0, bytesReclaimed: 0); + + final int filesDeleted; + final int bytesReclaimed; + + bool get isEmpty => filesDeleted == 0; + + ScratchSweepReport operator +(ScratchSweepReport other) => ScratchSweepReport( + filesDeleted: filesDeleted + other.filesDeleted, + bytesReclaimed: bytesReclaimed + other.bytesReclaimed, + ); +} + +/// Reclaims scratch files the app writes and never removes. +/// +/// Deliberately narrow. It touches only directories this app owns and fills +/// with derived or transient data, and every target is either regenerable or +/// already-consumed debris: +/// +/// - `/picked` holds full copies of SAF-picked files, including large +/// archives, made so an import can read a `content://` handle. Nothing has +/// ever deleted them. +/// - `media_cache/staging` holds files whose `put()` never completed. +/// - `media_cache/transcode` holds video renditions and `.tmp` debris that +/// `deleteTranscodeArtifacts` only removes for a video upload that reaches +/// `markDone`. An abandoned upload leaves a full-size video forever. +/// - The video and PDF thumbnail directories are keyed by content hash, so a +/// file that is edited or moved orphans its old thumbnail permanently and +/// there is no way to map a thumbnail back to a source to detect it. +/// +/// What it does NOT touch, and why: +/// +/// - The temp root itself. Other plugins write there, and this app's own share +/// files land loose among them under names it cannot distinguish. +/// - The Documents root. It holds the database and the user's exports, which +/// are visible in the Files app on iOS. +/// - The backups directory. Every file there is a full copy of the database. +/// - `media_cache/originals`, `thumbs` and `renditions`. Those are indexed and +/// governed by their own LRU caps, which age would fight rather than help. +/// +/// Ages are generous on purpose. A file being written concurrently must never +/// be swept mid-flight, and the cost of keeping debris one more day is a day +/// of disk, while the cost of deleting a live file is a broken import. +class StorageScratchSweep { + StorageScratchSweep({ + required Future Function() temporaryDirectory, + required Future Function() supportDirectory, + }) : _temporaryDirectory = temporaryDirectory, + _supportDirectory = supportDirectory; + + final Future Function() _temporaryDirectory; + final Future Function() _supportDirectory; + final _log = LoggerService.forClass(StorageScratchSweep); + + /// Long enough that an import or a staged write in flight is never caught. + static const Duration pickedMaxAge = Duration(hours: 24); + static const Duration stagingMaxAge = Duration(hours: 24); + + /// A week, because an upload retrying across days must keep its rendition. + static const Duration transcodeMaxAge = Duration(days: 7); + + /// A month. Thumbnails are pure derived artifacts and regenerate on demand, + /// so the only cost of reclaiming one early is rendering it again. + static const Duration thumbnailMaxAge = Duration(days: 30); + + static const String _appDir = 'Submersion'; + + /// Throws on an I/O failure the caller should see; individual unreadable + /// entries are skipped rather than aborting the pass. + Future run({DateTime? now}) async { + final at = now ?? DateTime.now(); + final temp = await _temporaryDirectory(); + final support = await _supportDirectory(); + + Directory appPath(List segments) => + Directory(p.joinAll([support.path, _appDir, ...segments])); + + var report = ScratchSweepReport.empty; + report += await _sweep( + Directory(p.join(temp.path, 'picked')), + pickedMaxAge, + at, + ); + report += await _sweep( + appPath(['media_cache', 'staging']), + stagingMaxAge, + at, + ); + report += await _sweep( + appPath(['media_cache', 'transcode']), + transcodeMaxAge, + at, + ); + report += await _sweep(appPath(['video_thumbnails']), thumbnailMaxAge, at); + report += await _sweep(appPath(['pdf_thumbnails']), thumbnailMaxAge, at); + + if (!report.isEmpty) { + _log.info( + 'Scratch sweep reclaimed ${report.bytesReclaimed} bytes ' + 'across ${report.filesDeleted} files', + ); + } + return report; + } + + /// Deletes files under [dir] last modified before [maxAge], then prunes the + /// directories it emptied. A directory that does not exist is a no-op. + Future _sweep( + Directory dir, + Duration maxAge, + DateTime now, + ) async { + if (!await dir.exists()) return ScratchSweepReport.empty; + + final cutoff = now.subtract(maxAge); + final emptied = {}; + var files = 0; + var bytes = 0; + + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is! File) continue; + try { + final stat = await entity.stat(); + if (!stat.modified.isBefore(cutoff)) continue; + final size = stat.size; + await entity.delete(); + files++; + bytes += size; + emptied.addAll(_ancestorsWithin(dir, entity.parent)); + } on FileSystemException { + // Skip an entry that vanished or is unreadable. A file being written + // right now is exactly the case worth losing rather than fighting. + continue; + } + } + + await _pruneEmptyDirectories(emptied); + return ScratchSweepReport(filesDeleted: files, bytesReclaimed: bytes); + } + + /// Every directory from [from] up to, but not including, [root]. + Iterable _ancestorsWithin(Directory root, Directory from) sync* { + final stop = p.canonicalize(root.path); + var current = p.canonicalize(from.path); + while (current != stop && p.isWithin(stop, current)) { + yield current; + current = p.dirname(current); + } + } + + /// Removes the directories this pass emptied, deepest first. The target + /// root itself is never a candidate: the writers expect it to exist. + /// + /// Only what the sweep emptied is considered. An empty directory it did not + /// empty is not abandoned scratch: `materializePickedFiles` creates a pick's + /// directory before it opens the destination file, so pruning on emptiness + /// alone could delete that directory out from under the writer and fail the + /// very import the age gates exist to protect. + Future _pruneEmptyDirectories(Set paths) async { + final ordered = paths.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + + for (final path in ordered) { + try { + final directory = Directory(path); + if (await directory.list().isEmpty) await directory.delete(); + } on FileSystemException { + continue; + } + } + } +} diff --git a/lib/core/services/storage/storage_category.dart b/lib/core/services/storage/storage_category.dart new file mode 100644 index 0000000000..352834b487 --- /dev/null +++ b/lib/core/services/storage/storage_category.dart @@ -0,0 +1,51 @@ +/// Where a storage category is shown on the usage page. +enum StorageGroup { appData, mediaCache, caches, backups, temporary, exports } + +/// Stable category ids. +/// +/// These key the l10n label, the size provider, and the reclaim policy that a +/// later slice adds. They are deliberately not derived from directory names: a +/// directory can be renamed without breaking a saved layout or a test, and an +/// id cannot. +abstract final class StorageCategoryId { + static const database = 'database'; + static const localCache = 'localCache'; + static const mediaCacheOriginals = 'mediaCacheOriginals'; + static const mediaCacheThumbs = 'mediaCacheThumbs'; + static const mediaCacheRenditions = 'mediaCacheRenditions'; + static const mediaCacheStaging = 'mediaCacheStaging'; + static const mediaCacheTranscode = 'mediaCacheTranscode'; + static const mapTiles = 'mapTiles'; + static const networkImages = 'networkImages'; + static const videoThumbnails = 'videoThumbnails'; + static const pdfThumbnails = 'pdfThumbnails'; + static const backups = 'backups'; + static const temporary = 'temporary'; + static const exports = 'exports'; +} + +/// One place on disk where the app accumulates bytes. +/// +/// Measurement cost is uneven by an order of magnitude across categories: the +/// media cache pools are a SUM over an index, the tile cache has a native size +/// call, and the rest need a recursive walk. Each category therefore brings its +/// own strategy rather than everything sharing one directory walk, and the page +/// renders each result as it arrives instead of being paced by the slowest. +class StorageCategory { + const StorageCategory({ + required this.id, + required this.group, + required this.measure, + }); + + /// One of [StorageCategoryId]. + final String id; + + final StorageGroup group; + + /// Returns the bytes held, or null when the category is structurally + /// unmeasurable on this platform or in this configuration. Null is not zero: + /// an Android SAF backup location has no directory to enumerate, and + /// reporting it as empty would tell the user their backups had vanished. + final Future Function() measure; +} diff --git a/lib/core/services/storage/storage_inventory.dart b/lib/core/services/storage/storage_inventory.dart new file mode 100644 index 0000000000..fa671c3597 --- /dev/null +++ b/lib/core/services/storage/storage_inventory.dart @@ -0,0 +1,213 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'package:submersion/core/services/storage/directory_size.dart'; +import 'package:submersion/core/services/storage/storage_category.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; + +/// Builds the list of places this app accumulates bytes on disk. +/// +/// Every external dependency arrives as a closure rather than being looked up +/// internally, so the whole inventory is testable without path_provider, a real +/// database, or an initialized FMTC store. +class StorageInventory { + StorageInventory({ + required Future Function() supportDirectory, + required Future Function() documentsDirectory, + required Future Function() temporaryDirectory, + required Future Function() databasePath, + required Future Function() backupsDirectoryPath, + required Future Function(MediaCacheKind kind) mediaCacheBytes, + required Future Function() mapTileKibibytes, + required Future Function() networkImageDirectory, + }) : _supportDirectory = supportDirectory, + _documentsDirectory = documentsDirectory, + _temporaryDirectory = temporaryDirectory, + _databasePath = databasePath, + _backupsDirectoryPath = backupsDirectoryPath, + _mediaCacheBytes = mediaCacheBytes, + _mapTileKibibytes = mapTileKibibytes, + _networkImageDirectory = networkImageDirectory; + + final Future Function() _supportDirectory; + final Future Function() _documentsDirectory; + final Future Function() _temporaryDirectory; + final Future Function() _databasePath; + final Future Function() _backupsDirectoryPath; + final Future Function(MediaCacheKind kind) _mediaCacheBytes; + final Future Function() _mapTileKibibytes; + final Future Function() _networkImageDirectory; + + static const _appDir = 'Submersion'; + static const _localCacheDb = 'submersion_local.db'; + static const _pickedDir = 'picked'; + + /// Order is display order on the usage page. + List get categories => [ + StorageCategory( + id: StorageCategoryId.database, + group: StorageGroup.appData, + measure: _measureDatabase, + ), + StorageCategory( + id: StorageCategoryId.localCache, + group: StorageGroup.appData, + measure: _measureLocalCache, + ), + StorageCategory( + id: StorageCategoryId.mediaCacheOriginals, + group: StorageGroup.mediaCache, + measure: () => _mediaCacheBytes(MediaCacheKind.original), + ), + StorageCategory( + id: StorageCategoryId.mediaCacheThumbs, + group: StorageGroup.mediaCache, + measure: () => _mediaCacheBytes(MediaCacheKind.thumb), + ), + StorageCategory( + id: StorageCategoryId.mediaCacheRenditions, + group: StorageGroup.mediaCache, + measure: () => _mediaCacheBytes(MediaCacheKind.rendition), + ), + // Staging and transcode are walked rather than read from the index: the + // LRU caps in MediaCacheStore are enforced only over rows in + // media_cache_entries, and neither subdirectory is indexed. Their bytes are + // invisible to the 3.25 GiB budget, which is precisely why they get rows. + StorageCategory( + id: StorageCategoryId.mediaCacheStaging, + group: StorageGroup.mediaCache, + measure: () => _measureSupportSubdirectory(['media_cache', 'staging']), + ), + StorageCategory( + id: StorageCategoryId.mediaCacheTranscode, + group: StorageGroup.mediaCache, + measure: () => _measureSupportSubdirectory(['media_cache', 'transcode']), + ), + StorageCategory( + id: StorageCategoryId.mapTiles, + group: StorageGroup.caches, + measure: _measureMapTiles, + ), + StorageCategory( + id: StorageCategoryId.networkImages, + group: StorageGroup.caches, + measure: _measureNetworkImages, + ), + StorageCategory( + id: StorageCategoryId.videoThumbnails, + group: StorageGroup.caches, + measure: () => _measureSupportSubdirectory(['video_thumbnails']), + ), + StorageCategory( + id: StorageCategoryId.pdfThumbnails, + group: StorageGroup.caches, + measure: () => _measureSupportSubdirectory(['pdf_thumbnails']), + ), + StorageCategory( + id: StorageCategoryId.backups, + group: StorageGroup.backups, + measure: _measureBackups, + ), + StorageCategory( + id: StorageCategoryId.temporary, + group: StorageGroup.temporary, + measure: _measureTemporary, + ), + StorageCategory( + id: StorageCategoryId.exports, + group: StorageGroup.exports, + measure: _measureExports, + ), + ]; + + Future _measureDatabase() async { + final path = await _databasePath(); + return measureFileGroupBytes([ + File(path), + File('$path-wal'), + File('$path-shm'), + ]); + } + + /// Includes the WAL sidecars for the same reason the main database does: + /// drift opens this file in WAL mode and the app holds it open for the whole + /// session, so `-wal` and `-shm` are present exactly when a user is looking + /// at this page. Counting only the base file would under-report it. + Future _measureLocalCache() async { + final support = await _supportDirectory(); + final path = p.join(support.path, _appDir, _localCacheDb); + return measureFileGroupBytes([ + File(path), + File('$path-wal'), + File('$path-shm'), + ]); + } + + Future _measureSupportSubdirectory(List segments) async { + final support = await _supportDirectory(); + return measureDirectoryBytes( + Directory(p.joinAll([support.path, _appDir, ...segments])), + ); + } + + /// FMTC reports kibibytes as a double. Converting here rather than at the + /// call site keeps every category's contract in bytes. + /// + /// Floored, not rounded. The difference is at most half a byte and the source + /// is only KiB-granular anyway, so this is about direction rather than + /// accuracy: every other category reports a figure that cannot exceed the + /// truth, and this one should not be the exception. + Future _measureMapTiles() async { + final kibibytes = await _mapTileKibibytes(); + if (kibibytes == null) return null; + return (kibibytes * 1024).floor(); + } + + Future _measureBackups() async { + final path = await _backupsDirectoryPath(); + if (path == null) return null; + return measureDirectoryBytes(Directory(path)); + } + + /// Walked here rather than delegated to CachedNetworkImageDiagnostics. + /// + /// That class's `cacheSize()` catches everything and returns 0, which is the + /// right call for the card it was written for but wrong here: a permission + /// failure would arrive as a successful "0 B" and let the header claim a + /// final total over a measurement that never happened. `measureDirectoryBytes` + /// keeps the distinction, returning 0 only when the directory genuinely is + /// not there and throwing when it cannot be read. + Future _measureNetworkImages() async => + measureDirectoryBytes(await _networkImageDirectory()); + + /// The picked/ subtree plus the loose share files at the temp root, never + /// the whole temp directory. + /// + /// A recursive walk of the root would double count: DefaultCacheManager keeps + /// the network image cache at `/libCachedImageData`, which already has + /// its own row, so walking the tree would add those bytes twice. It + /// would also sweep in temp subtrees belonging to plugins this app does not + /// own, which is not something a user can act on. + Future _measureTemporary() async { + final temp = await _temporaryDirectory(); + final picked = await measureDirectoryBytes( + Directory(p.join(temp.path, _pickedDir)), + ); + final loose = await measureLooseFilesBytes(temp, exclude: (_) => false); + return picked + loose; + } + + /// Loose files in the Documents root, which is where saveAndShareFile leaves + /// every export permanently. The database may or may not live here depending + /// on the configured location, so it is excluded by name either way, as are + /// its sidecars and every subdirectory. + Future _measureExports() async { + final documents = await _documentsDirectory(); + final dbName = p.basename(await _databasePath()); + return measureLooseFilesBytes( + documents, + exclude: (name) => name.startsWith(dbName), + ); + } +} diff --git a/lib/core/services/suunto_cloud/suunto_api_exception.dart b/lib/core/services/suunto_cloud/suunto_api_exception.dart new file mode 100644 index 0000000000..3a1baca816 --- /dev/null +++ b/lib/core/services/suunto_cloud/suunto_api_exception.dart @@ -0,0 +1,21 @@ +/// Thrown by [SuuntoCloudClient] operations against the undocumented +/// Suunto/Sports-Tracker cloud API (api.sports-tracker.com). +/// +/// Mirrors `CloudStorageException`'s `displayMessage` convention so callers +/// can surface [message] directly in a snackbar/dialog without a leading +/// `Exception:`/class-name prefix. +class SuuntoApiException implements Exception { + const SuuntoApiException(this.message, [this.cause]); + + final String message; + final Object? cause; + + /// True when the server rejected the current session key (HTTP 401): + /// the caller must clear the cached session and prompt for sign-in again. + bool get isSessionRejected => message.contains('session rejected'); + + String get displayMessage => cause == null ? message : '$message ($cause)'; + + @override + String toString() => 'SuuntoApiException: $displayMessage'; +} diff --git a/lib/core/services/suunto_cloud/suunto_cloud_client.dart b/lib/core/services/suunto_cloud/suunto_cloud_client.dart new file mode 100644 index 0000000000..d03e14cd1f --- /dev/null +++ b/lib/core/services/suunto_cloud/suunto_cloud_client.dart @@ -0,0 +1,364 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/suunto_cloud/suunto_api_exception.dart'; +import 'package:submersion/core/services/suunto_cloud/suunto_crypto.dart'; + +/// A single scuba/freediving workout summary from the `workouts` listing +/// endpoint -- not yet the full dive profile (see [SuuntoCloudClient.fetchSmlJson] +/// for that). +class SuuntoWorkoutSummary { + const SuuntoWorkoutSummary({ + required this.key, + required this.startTime, + required this.activityId, + this.maxDepth, + this.diveTimeSeconds, + this.totalTimeSeconds, + }); + + /// Opaque workout identifier used to fetch the full SML export. + final String key; + + /// Workout-level start time. Approximate -- the authoritative dive start + /// comes from the per-dive header's `DateTime` field once fetched. + final DateTime startTime; + + /// Sports-Tracker activity id (78 = scuba diving, 79 = freediving). + final int activityId; + + /// Maximum depth in meters, when present in the listing's + /// `DiveHeaderExtension`. Best-effort only, for progress display. + final double? maxDepth; + + /// Computer-reported bottom time in seconds, when present. + final int? diveTimeSeconds; + + /// Overall workout elapsed time in seconds. + final int? totalTimeSeconds; + + /// [diveTimeSeconds] when available, else [totalTimeSeconds]. + int? get durationSeconds => diveTimeSeconds ?? totalTimeSeconds; +} + +/// One page of the dive-workout listing, plus the cursor needed to ask for +/// the page after it. +class SuuntoDivePage { + const SuuntoDivePage({ + required this.dives, + required this.nextOffset, + required this.hasMore, + }); + + /// The page's dive workouts, newest first. Empty only when the account's + /// history is exhausted -- [SuuntoCloudClient.fetchDivePage] walks past + /// listing pages that hold no dives rather than returning them. + final List dives; + + /// The `offset` to hand [SuuntoCloudClient.fetchDivePage] for the next + /// page. Meaningless once [hasMore] is false. + final int nextOffset; + + /// Whether the server reported a full listing page, meaning there is more + /// history past this one. + final bool hasMore; +} + +/// Talks to an UNDOCUMENTED, UNOFFICIAL Suunto/Sports-Tracker cloud API +/// (api.sports-tracker.com). Using this against your own Suunto account may +/// violate Suunto's Terms of Service -- read them before using this. Use +/// only with your own account and your own data. +/// +/// A Dart port of suunto2subsurface's `SuuntoClient` +/// (https://github.com/dotanalon/suunto2subsurface), itself a C++ port of +/// suunto-export/export_suunto_dives.py's `SuuntoClient`. +class SuuntoCloudClient { + SuuntoCloudClient({http.Client? httpClient}) + : _http = httpClient ?? http.Client(); + + static const String _baseUrl = 'https://api.sports-tracker.com/apiserver/v1/'; + static const String _appVersionCode = '6008013'; + static const String _packageName = 'com.stt.android.suunto'; + + static const int _activityScubaDiving = 78; + static const int _activityFreeDiving = 79; + + final http.Client _http; + + String? sessionKey; + + bool get hasSession => sessionKey?.isNotEmpty ?? false; + + /// Throws [SuuntoApiException] on failure. On success, [sessionKey] is set + /// and returned. + Future login(String email, String password) async { + final totp = await SuuntoCrypto.generateTotp(email); + final signedParams = [ + MapEntry('l', email), + MapEntry('p', password), + MapEntry('totp', totp), + ]; + final signature = SuuntoCrypto.signParams('login2', signedParams); + + final saltBytes = Uint8List.fromList( + List.generate(16, (_) => Random.secure().nextInt(256)), + ); + final salt = base64Url.encode(saltBytes).replaceAll('=', ''); + + final formParams = [ + ...signedParams, + MapEntry('timestamp', DateTime.now().millisecondsSinceEpoch.toString()), + MapEntry('salt', salt), + MapEntry('signature', signature), + ]; + + final body = Uint8List.fromList(utf8.encode(_urlEncodeForm(formParams))); + final responseBytes = await _request( + 'POST', + 'login2', + body: body, + headers: const { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'x-login-email-verification-enabled': 'true', + }, + ); + + final session = + jsonDecode(utf8.decode(responseBytes)) as Map; + final key = session['sessionkey'] as String?; + if (key == null || key.isEmpty) { + throw const SuuntoApiException('login failed: no sessionkey in response'); + } + sessionKey = key; + return key; + } + + /// Cheap probe to check the current session key is still accepted by the + /// server, without pulling any real data. + Future verifySession() async { + try { + await _listWorkouts(sinceMs: 1 << 53, limit: 1, offset: 0); + return true; + } on SuuntoApiException { + return false; + } + } + + /// Fetches the page of dive-activity workouts (scuba diving / freediving) + /// at [offset], newest first. + /// + /// The Suunto/Sports-Tracker `workouts` listing is itself paginated + /// oldest-to-newest by offset. Fetching one page at a time -- rather than + /// buffering the diver's entire history before returning anything -- lets + /// a caller start acting on the newest dives while older pages are still + /// being fetched. Each page is additionally sorted newest first on its own + /// as a defensive measure, in case the server ever returns a page out of + /// order. + /// + /// The listing carries every activity type, and the dive filter runs + /// client-side, so a listing page can hold no dives at all. This walks + /// past such pages internally: a returned page is either non-empty or + /// genuinely the end of the account's history, and a caller never has to + /// tell "nothing on this page" apart from "nothing left". + /// + /// Exposed as an explicit cursor rather than only as [listDivesPaged] so + /// a caller can retry a single failed page. A `Stream` that has thrown is + /// terminated for good, and asking a terminated one for its next page + /// reports "no more pages" -- silently truncating the history at whatever + /// page happened to hit a transient network error. + Future fetchDivePage({ + int sinceMs = 0, + int offset = 0, + int pageSize = 15, + }) async { + var next = offset; + + while (true) { + final items = await _listWorkouts( + sinceMs: sinceMs, + limit: pageSize, + offset: next, + ); + if (items.isEmpty) { + return SuuntoDivePage( + dives: const [], + nextOffset: next, + hasMore: false, + ); + } + + final summaries = []; + for (final item in items) { + final activityId = (item['activityId'] as num?)?.toInt() ?? -1; + if (activityId == _activityScubaDiving || + activityId == _activityFreeDiving) { + summaries.add(_toWorkoutSummary(item, activityId)); + } + } + + final hasMore = items.length >= pageSize; + next += pageSize; + if (summaries.isNotEmpty || !hasMore) { + summaries.sort((a, b) => b.startTime.compareTo(a.startTime)); + return SuuntoDivePage( + dives: summaries, + nextOffset: next, + hasMore: hasMore, + ); + } + } + } + + /// Streams pages of dive-activity workouts, newest first, walking the + /// cursor [fetchDivePage] hands back until the history runs out. + Stream> listDivesPaged({ + int sinceMs = 0, + int pageSize = 15, + }) async* { + var offset = 0; + + while (true) { + final page = await fetchDivePage( + sinceMs: sinceMs, + offset: offset, + pageSize: pageSize, + ); + if (page.dives.isNotEmpty) yield page.dives; + if (!page.hasMore) break; + offset = page.nextOffset; + } + } + + /// Every dive-activity workout, newest first. A convenience wrapper over + /// [listDivesPaged] for callers that don't need progressive access to + /// pages as they arrive from the network. + Future> listDives({ + int sinceMs = 0, + int pageSize = 15, + }) async { + final dives = []; + await for (final page in listDivesPaged( + sinceMs: sinceMs, + pageSize: pageSize, + )) { + dives.addAll(page); + } + return dives; + } + + Future fetchSmlJson(String key) => + _request('GET', 'workouts/$key/sml'); + + SuuntoWorkoutSummary _toWorkoutSummary( + Map item, + int activityId, + ) { + final startMs = (item['startTime'] as num).toInt(); + final ext = _diveHeaderExtension(item); + return SuuntoWorkoutSummary( + key: item['key'] as String, + startTime: DateTime.fromMillisecondsSinceEpoch(startMs, isUtc: true), + activityId: activityId, + maxDepth: (ext?['maxDepth'] as num?)?.toDouble(), + diveTimeSeconds: (ext?['diveTime'] as num?)?.round(), + totalTimeSeconds: (item['totalTime'] as num?)?.round(), + ); + } + + /// The workout listing nests dive-specific summary fields (maxDepth, + /// diveTime) inside an `extensions` entry of type `DiveHeaderExtension`, + /// separate from the generic workout fields (startTime, totalTime). + Map? _diveHeaderExtension(Map item) { + final extensions = item['extensions'] as List? ?? const []; + for (final entry in extensions) { + final ext = entry as Map; + if (ext['type'] == 'DiveHeaderExtension') return ext; + } + return null; + } + + Future>> _listWorkouts({ + required int sinceMs, + required int limit, + required int offset, + }) async { + final path = 'workouts?since=$sinceMs&limit=$limit&offset=$offset'; + final bytes = await _request('GET', path); + final envelope = jsonDecode(utf8.decode(bytes)) as Map; + + final error = envelope['error']; + if (error != null) { + final err = error as Map; + throw SuuntoApiException( + 'API error ${err['code']}: ${err['description']}', + ); + } + + final payload = envelope['payload'] as List? ?? const []; + return payload.cast>(); + } + + Future _request( + String method, + String path, { + Uint8List? body, + Map headers = const {}, + }) async { + final uri = Uri.parse('$_baseUrl$path'); + final requestHeaders = { + 'User-Agent': '$_packageName/$_appVersionCode', + 'Accept-Language': 'en', + ...headers, + }; + final key = sessionKey; + if (key != null && key.isNotEmpty) { + requestHeaders['STTAuthorization'] = key; + } + + http.Response response; + try { + response = switch (method) { + 'GET' => + await _http + .get(uri, headers: requestHeaders) + .timeout(const Duration(seconds: 30)), + 'POST' => + await _http + .post(uri, headers: requestHeaders, body: body) + .timeout(const Duration(seconds: 30)), + _ => throw SuuntoApiException('unsupported HTTP method: $method'), + }; + } on SuuntoApiException { + rethrow; + } catch (e) { + throw SuuntoApiException('request failed', e); + } + + if (response.statusCode == 401) { + throw const SuuntoApiException('session rejected (401) -- login again'); + } + if (response.statusCode >= 400) { + final bytes = response.bodyBytes; + final snippet = bytes.length > 200 ? bytes.sublist(0, 200) : bytes; + throw SuuntoApiException( + 'HTTP ${response.statusCode}: ' + '${utf8.decode(snippet, allowMalformed: true)}', + ); + } + + return response.bodyBytes; + } +} + +String _urlEncodeForm(List> params) { + return params + .map( + (p) => + '${Uri.encodeQueryComponent(p.key)}=' + '${Uri.encodeQueryComponent(p.value)}', + ) + .join('&'); +} diff --git a/lib/core/services/suunto_cloud/suunto_crypto.dart b/lib/core/services/suunto_cloud/suunto_crypto.dart new file mode 100644 index 0000000000..bdcacb2871 --- /dev/null +++ b/lib/core/services/suunto_cloud/suunto_crypto.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:cryptography/cryptography.dart' as cryptography; + +/// Request-signing for the undocumented Suunto/Sports-Tracker cloud API +/// (api.sports-tracker.com). +/// +/// A Dart port of the reverse-engineered scheme published in the MIT-licensed +/// https://github.com/tajchert/suuntool (key material there is credited as +/// extracted from APK com.stt.android.suunto v6.8.13), itself ported to C++ +/// in https://github.com/dotanalon/suunto2subsurface. Using this against +/// your own Suunto account may violate Suunto's Terms of Service -- use only +/// with your own account and your own data. +class SuuntoCrypto { + const SuuntoCrypto._(); + + static const String _packageName = 'com.stt.android.suunto'; + static const String _totpObfuscationKey = 'Bh8nsTyCeC0Ql2drMen78awk84AE3ZxW'; + + // Base64-encoded, XOR-obfuscated key material (see _deriveObfuscatedSecret + // below). Source: github.com/tajchert/suuntool internal/auth/keys.go + static const String _loginKeyParts = + 'FBkubDYmN28bWVQLLTsWFxcmaRB' + 'fN2AqIBc/IRAoNgshbxgnOGUVGlU3LC0xL0AuXXXXMXY' + 'RWQ4zIi0PWz4hekc1QGNTPlciNhEKV1teYSIkDGYY'; + static const String _totpKeyParts = + 'FBkubDYmN28bWVQLLTsWWhI+NAtILCNlPQc5Y' + 'BgiMRYjKA99Jj4HHFIqLmomOFttBQchNzcZU0QrODcDWz4hekc1QGNTPlciNhEKGl5GPDkzFyVX'; + + static String deriveLoginSecret() => + _deriveObfuscatedSecret(_loginKeyParts, utf8.encode(_packageName)); + + static String deriveTotpMasterSecret() => + _deriveObfuscatedSecret(_totpKeyParts, utf8.encode(_totpObfuscationKey)); + + /// Reverses the base64 + lenient-UTF8-round-trip + XOR-cycle obfuscation + /// applied to the embedded key material. + /// + /// Mirrors Go's `utf8.DecodeRune`-based replacement of invalid UTF-8 + /// sequences with U+FFFD: Dart's `utf8.decode(..., allowMalformed: true)` + /// follows the same Unicode "maximal subpart" recommended practice, so this + /// matches byte-for-byte. + static String _deriveObfuscatedSecret(String base64Parts, List key) { + final raw = base64.decode(base64Parts); + final mid = utf8.encode(utf8.decode(raw, allowMalformed: true)); + final xored = _xorCycle(mid, key); + return utf8.decode(xored, allowMalformed: true); + } + + static Uint8List _xorCycle(List data, List key) { + final out = Uint8List(data.length); + for (var i = 0; i < data.length; i++) { + out[i] = data[i] ^ key[i % key.length]; + } + return out; + } + + /// Signs the `login2` request body. + /// + /// [params] must be in the same order as the form body the caller sends. + static String signParams(String path, List> params) { + final buffer = StringBuffer('POST&$path'); + for (final param in params) { + buffer.write('&${param.key}=${param.value}'); + } + buffer.write('&secret=${deriveLoginSecret()}'); + + final digest = sha256.convert(utf8.encode(buffer.toString())); + return base64Url.encode(digest.bytes).replaceAll('=', ''); + } + + /// Generates the current TOTP code for [salt] (typically the account + /// email). + /// + /// [nowMs] and [offsetMs] let tests pin the 30-second time step + /// deterministically; [nowMs] defaults to the real current time. + static Future generateTotp( + String salt, { + int? nowMs, + int offsetMs = 0, + }) async { + final master = deriveTotpMasterSecret(); + + // Go ranges over the string as Unicode code points ("runes"), not raw + // bytes -- iterating the UTF-16 code units of a Dart String matches that + // here since the master secret only ever contains BMP code points. + final pwd = Uint8List.fromList([ + for (var i = 0; i < master.length; i++) master.codeUnitAt(i) & 0xFF, + ]); + + final pbkdf2 = cryptography.Pbkdf2( + macAlgorithm: cryptography.Hmac.sha1(), + iterations: 100, + bits: 256, + ); + final derivedKey = await pbkdf2.deriveKey( + secretKey: cryptography.SecretKey(pwd), + nonce: utf8.encode(salt), + ); + final key = await derivedKey.extractBytes(); + + final counter = + ((nowMs ?? DateTime.now().millisecondsSinceEpoch) + offsetMs) ~/ 30000; + final counterBytes = ByteData(8)..setInt64(0, counter, Endian.big); + + final mac = Hmac(sha1, key).convert(counterBytes.buffer.asUint8List()); + final digest = mac.bytes; + + final offset = digest[digest.length - 1] & 0x0f; + final code = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + + return (code % 1000000).toString().padLeft(6, '0'); + } +} diff --git a/lib/core/services/suunto_cloud/suunto_dive_parser.dart b/lib/core/services/suunto_cloud/suunto_dive_parser.dart new file mode 100644 index 0000000000..3b086f9ea1 --- /dev/null +++ b/lib/core/services/suunto_cloud/suunto_dive_parser.dart @@ -0,0 +1,649 @@ +import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; + +/// A dive parsed from a Suunto export, plus the device identity fields +/// needed to resolve/create the owning [DiveComputer] record (kept separate +/// from [DownloadedDive], which has no computer-identity fields of its own). +class SuuntoParsedDive { + const SuuntoParsedDive({ + required this.dive, + this.deviceName, + this.serialNumber, + this.firmwareVersion, + }); + + final DownloadedDive dive; + + /// Suunto's internal device codename (e.g. "Vaasa"), already mapped to a + /// commercial product line name (e.g. "Suunto Nautic") for display. + final String? deviceName; + final String? serialNumber; + final String? firmwareVersion; +} + +/// Converts a normalized Suunto dive export (see [SuuntoSmlNormalizer]) into +/// a [DownloadedDive], the same shape a BLE/USB dive computer download +/// produces -- so the rest of the import pipeline (tanks, gas switches, +/// duplicate detection, consolidation) is shared with [DiveComputerAdapter]. +/// +/// A Dart port of the header/sample parsing in Subsurface's +/// `core/import-suunto-json.cpp` (`parse_header`/`parse_samples`/ +/// `parse_gases`), adapted to submersion's dive-computer-download model +/// instead of Subsurface's own `struct dive`. +class SuuntoDiveParser { + const SuuntoDiveParser._(); + + static SuuntoParsedDive parse({ + required Map header, + required List> samples, + }) { + final device = header['Device'] as Map?; + final deviceInternalName = device?['Name'] as String?; + final gasOffset = _gasOffsetFor(deviceInternalName, samples); + + final headerStart = _parseIso8601(header['DateTime'] as String?); + + final firstPass = _FirstPass.scan(samples); + final diveStartMs = firstPass.diveStartMs; + final startTime = diveStartMs != null + ? DateTime.fromMillisecondsSinceEpoch(diveStartMs, isUtc: true) + : (headerStart ?? DateTime.now().toUtc()); + + final profileResult = diveStartMs == null + ? const _ProfileResult(samples: [], gasSwitchOrder: [], lastDepth: 0) + : _buildProfile( + samples, + diveStartMs: diveStartMs, + temperatureReadings: firstPass.temperatureReadings, + gasOffset: gasOffset, + ); + + final depthObj = header['Depth'] as Map?; + var maxDepth = _asDouble(depthObj?['Max']) ?? 0; + if (maxDepth <= 0 && profileResult.samples.isNotEmpty) { + maxDepth = profileResult.samples + .map((s) => s.depth) + .reduce((a, b) => a > b ? a : b); + } + final avgDepth = + _asDouble(header['DepthAverage']) ?? _asDouble(depthObj?['Avg']); + + var durationSeconds = + (_asDouble(header['DiveTime']) ?? _asDouble(header['Duration']) ?? 0) + .round(); + if (durationSeconds <= 0 && profileResult.samples.isNotEmpty) { + durationSeconds = profileResult.samples.last.timeSeconds; + } + + final tempObj = header['Temperature'] as Map?; + final tempMaxK = _asDouble(tempObj?['Max']); + final tempMinK = _asDouble(tempObj?['Min']); + double? minTemperature; + double? maxTemperature; + // Suunto stores max/min reversed: "Min" is the higher Kelvin value + // (= warmer water), "Max" is the lower Kelvin value (= colder). + if (tempMaxK != null && tempMinK != null && tempMaxK > 0 && tempMinK > 0) { + minTemperature = _kelvinToCelsius( + tempMaxK < tempMinK ? tempMaxK : tempMinK, + ); + maxTemperature = _kelvinToCelsius( + tempMaxK > tempMinK ? tempMaxK : tempMinK, + ); + } + + final diving = header['Diving'] as Map?; + final gfLow = (diving?['GfLow'] as num?)?.round(); + final gfHigh = (diving?['GfHigh'] as num?)?.round(); + + final tanks = _buildTanks(diving, profileResult.gasSwitchOrder); + + final dive = DownloadedDive( + startTime: startTime, + durationSeconds: durationSeconds, + maxDepth: maxDepth, + avgDepth: avgDepth, + minTemperature: minTemperature, + maxTemperature: maxTemperature, + entryLatitude: firstPass.latitude, + entryLongitude: firstPass.longitude, + profile: profileResult.samples, + tanks: tanks, + gasSwitches: profileResult.gasSwitches, + gfLow: gfLow, + gfHigh: gfHigh, + decoAlgorithm: (gfLow != null && gfHigh != null) ? 'buhlmann' : null, + events: profileResult.events, + ); + + return SuuntoParsedDive( + dive: dive, + deviceName: _mapDeviceName(deviceInternalName), + serialNumber: device?['SerialNumber'] as String?, + firmwareVersion: + (device?['Info'] as Map?)?['SW'] as String?, + ); + } + + /// Suunto uses internal device codenames in the JSON; the commercial + /// product names are shown in submersion. + static String? _mapDeviceName(String? internalName) { + if (internalName == null || internalName.isEmpty) return null; + return _knownDeviceNames[internalName] ?? + // Falls back to "Suunto " for unknown devices. This covers + // the EON Core and EON Steel automatically. + 'Suunto $internalName'; + } + + /// Suunto device codenames whose commercial name and gas numbering are + /// known. Every entry here is a current-generation ("Seal") computer that + /// numbers its gases from 0; the EON family numbers from 1, and so does + /// anything else absent from this map unless its samples say otherwise + /// (see [_gasOffsetFor]). + static const Map _knownDeviceNames = { + 'Vaasa': 'Suunto Nautic', + 'Ylivieska': 'Suunto Nautic S', + 'Porvoo': 'Suunto Ocean', + }; + + /// The value to subtract from a sample's `GasNumber` to get a zero-based + /// cylinder index. + /// + /// Current-generation computers number gases from 0 and the EON family + /// from 1, with nothing in the header stating which. A codename allowlist + /// alone silently mis-assigns every gas on a device nobody has catalogued + /// yet -- a Suunto Nautic S, for instance, reports `Ylivieska`, which no + /// published list covers. + /// + /// So for an unrecognized codename the data gets a say, but only where it + /// is conclusive: a sample reporting `GasNumber` 0 can only come from a + /// zero-based device. A lowest observed number of 1 or more proves + /// nothing (a one-based dive may simply never have touched its first + /// cylinder), so those keep the EON default rather than guessing. + static int _gasOffsetFor( + String? internalName, + List> samples, + ) { + if (internalName != null && _knownDeviceNames.containsKey(internalName)) { + return 0; + } + return _lowestGasNumber(samples) == 0 ? 0 : 1; + } + + static int? _lowestGasNumber(List> samples) { + int? lowest; + void consider(num? gasNumber) { + if (gasNumber == null) return; + final value = gasNumber.toInt(); + if (lowest == null || value < lowest!) lowest = value; + } + + for (final sample in samples) { + for (final entry in (sample['Cylinders'] as List? ?? const [])) { + consider((entry as Map)['GasNumber'] as num?); + } + + final diveEvents = sample['DiveEvents'] as Map?; + final gasSwitch = diveEvents?['GasSwitch'] as Map?; + consider(gasSwitch?['GasNumber'] as num?); + + for (final entry in (sample['Events'] as List? ?? const [])) { + final gs = + (entry as Map)['GasSwitch'] + as Map?; + consider(gs?['GasNumber'] as num?); + } + } + return lowest; + } + + static List _buildTanks( + Map? diving, + List gasSwitchOrder, + ) { + final gases = (diving?['Gases'] as List? ?? const []) + .cast>(); + if (gases.isEmpty) return const []; + + // A dive with only one recorded gas never emits a gas-switch event (there + // is nothing to switch to/from), so gasSwitchOrder is empty for the + // overwhelmingly common single-gas case. Assign it straight to cylinder 0 + // rather than dropping the gas mix entirely. + final order = gases.length == 1 ? const [0] : gasSwitchOrder; + + final tanks = []; + for (var i = 0; i < gases.length && i < order.length; i++) { + final gas = gases[i]; + tanks.add( + DownloadedTank( + index: order[i], + // Suunto fractions (0.0-1.0), submersion percent (0-100). + o2Percent: (_asDouble(gas['Oxygen']) ?? 0) * 100, + hePercent: (_asDouble(gas['Helium']) ?? 0) * 100, + // Suunto m3, submersion liters. + volumeLiters: (_asDouble(gas['TankSize']) ?? 0) > 0 + ? _asDouble(gas['TankSize'])! * 1000 + : null, + // Suunto Pa, submersion bar. + startPressure: (_asDouble(gas['StartPressure']) ?? 0) > 0 + ? _asDouble(gas['StartPressure'])! / 100000 + : null, + endPressure: (_asDouble(gas['EndPressure']) ?? 0) > 0 + ? _asDouble(gas['EndPressure'])! / 100000 + : null, + ), + ); + } + return tanks; + } + + static _ProfileResult _buildProfile( + List> samples, { + required int diveStartMs, + required List<_TempReading> temperatureReadings, + required int gasOffset, + }) { + final profile = []; + final gasSwitches = []; + final events = []; + final gasSwitchOrder = []; + + var lastElapsedSecs = -1; + var lastDepth = 0.0; + + for (final sample in samples) { + final sampleMs = _parseTimestampMs(sample); + if (sampleMs == null) continue; + + // Truncates toward zero, matching the reference importer's int64 + // division for a (rare) sample that precedes the detected dive start. + final elapsedSecs = (sampleMs - diveStartMs) ~/ 1000; + + final depthValue = _asDouble(sample['Depth']); + if (depthValue != null && + elapsedSecs >= 0 && + elapsedSecs != lastElapsedSecs) { + lastElapsedSecs = elapsedSecs; + lastDepth = depthValue; + + final temperature = _findNearestTemperature( + temperatureReadings, + sampleMs, + ); + final ceiling = _asDouble(sample['Ceiling']); + final ndl = (sample['NoDecTime'] as num?)?.round(); + final tts = (sample['TimeToSurface'] as num?)?.round(); + + final pressures = _readCylinderPressures(sample, gasOffset); + + profile.add( + ProfileSample( + timeSeconds: elapsedSecs, + depth: depthValue, + temperature: temperature, + pressure: pressures.isEmpty ? null : pressures.first.pressureBar, + tankIndex: pressures.isEmpty ? null : pressures.first.tankIndex, + ndl: ndl, + ceiling: (ceiling ?? 0) > 0 ? ceiling : null, + tts: tts, + ), + ); + + // A dive computer with more than one live pressure transmitter + // reports every tank's pressure at the same instant; submersion's + // profile-sample model (mirroring how multi-transmitter downloads + // from real dive computers are represented) carries one + // pressure/tankIndex pair per row, so extra simultaneous readings + // ride along as additional minimal rows at the same timestamp. + for (final extra in pressures.skip(1)) { + profile.add( + ProfileSample( + timeSeconds: elapsedSecs, + depth: depthValue, + pressure: extra.pressureBar, + tankIndex: extra.tankIndex, + ), + ); + } + } + + final clampedElapsed = elapsedSecs < 0 ? 0 : elapsedSecs; + _collectGasSwitchOrder(sample, gasOffset, gasSwitchOrder); + _collectEvents( + sample, + gasOffset, + clampedElapsed, + lastDepth, + gasSwitches, + events, + ); + } + + return _ProfileResult( + samples: profile, + gasSwitchOrder: gasSwitchOrder, + gasSwitches: gasSwitches, + events: events, + lastDepth: lastDepth, + ); + } + + static void _collectGasSwitchOrder( + Map sample, + int gasOffset, + List order, + ) { + void record(int? gasNumber) { + if (gasNumber == null) return; + final gasIndex = gasNumber - gasOffset; + if (gasIndex < 0 || order.contains(gasIndex)) return; + order.add(gasIndex); + } + + final diveEvents = sample['DiveEvents'] as Map?; + final gasSwitch = diveEvents?['GasSwitch'] as Map?; + record((gasSwitch?['GasNumber'] as num?)?.toInt()); + + final eventsArray = sample['Events'] as List?; + if (eventsArray != null) { + for (final entry in eventsArray) { + final gs = + (entry as Map)['GasSwitch'] + as Map?; + record((gs?['GasNumber'] as num?)?.toInt()); + } + } + } + + static void _collectEvents( + Map sample, + int gasOffset, + int elapsedSecs, + double currentDepth, + List gasSwitches, + List events, + ) { + final diveEvents = sample['DiveEvents'] as Map?; + if (diveEvents != null) { + final gasSwitch = diveEvents['GasSwitch'] as Map?; + final gasNumber = (gasSwitch?['GasNumber'] as num?)?.toInt(); + if (gasNumber != null) { + gasSwitches.add( + GasSwitchEvent( + timeSeconds: elapsedSecs, + depth: currentDepth, + toTankIndex: gasNumber - gasOffset, + ), + ); + events.add( + DownloadedEvent(timeSeconds: elapsedSecs, type: 'gaschange'), + ); + } + + final state = diveEvents['State'] as Map?; + if (state?['Type'] == 'At Safety Stop') { + events.add( + DownloadedEvent(timeSeconds: elapsedSecs, type: 'safetystop'), + ); + } + } + + final eventsArray = sample['Events'] as List?; + if (eventsArray != null) { + for (final entry in eventsArray) { + final eo = entry as Map; + + final gasSwitch = eo['GasSwitch'] as Map?; + final gasNumber = (gasSwitch?['GasNumber'] as num?)?.toInt(); + if (gasNumber != null) { + gasSwitches.add( + GasSwitchEvent( + timeSeconds: elapsedSecs, + depth: currentDepth, + toTankIndex: gasNumber - gasOffset, + ), + ); + events.add( + DownloadedEvent(timeSeconds: elapsedSecs, type: 'gaschange'), + ); + } + + final notify = eo['Notify'] as Map?; + if (notify?['Active'] == true && notify?['Type'] == 'Safety Stop') { + events.add( + DownloadedEvent(timeSeconds: elapsedSecs, type: 'safetystop'), + ); + } + + final alarm = eo['Alarm'] as Map?; + if (alarm?['Active'] == true && alarm?['Type'] == 'Ascent Speed') { + events.add(DownloadedEvent(timeSeconds: elapsedSecs, type: 'ascent')); + } + } + } + } + + /// Reads every "Pressure"/"Pressure2"/"Pressure3"... transmitter reading + /// off a sample's `Cylinders` entries, in submersion units (bar). + static List<_PressureReading> _readCylinderPressures( + Map sample, + int gasOffset, + ) { + final cylinders = sample['Cylinders'] as List?; + if (cylinders == null) return const []; + + final readings = <_PressureReading>[]; + for (final entry in cylinders) { + final cyl = entry as Map; + final gasNumber = (cyl['GasNumber'] as num?)?.toInt() ?? 0; + var sensor = gasNumber - gasOffset; + + for (var t = 0; ; t++) { + final key = t == 0 ? 'Pressure' : 'Pressure${t + 1}'; + if (!cyl.containsKey(key)) break; + final value = cyl[key]; + if (value == null) { + sensor++; + continue; + } + final pressurePa = (value as num).toDouble(); + if (pressurePa > 0) { + readings.add(_PressureReading(sensor, pressurePa / 100000)); + } + sensor++; + } + } + return readings; + } + + static double? _findNearestTemperature( + List<_TempReading> readings, + int sampleMs, + ) { + double? bestKelvin; + var bestDiff = 1 << 62; + for (final reading in readings) { + final diff = (reading.timestampMs - sampleMs).abs(); + if (diff < bestDiff) { + bestDiff = diff; + bestKelvin = reading.kelvin; + } + } + if (bestKelvin == null || bestDiff >= 15000) return null; + return _kelvinToCelsius(bestKelvin); + } + + static double _kelvinToCelsius(double kelvin) => kelvin - 273.15; + + static double? _asDouble(dynamic value) => (value as num?)?.toDouble(); + + static int? _parseTimestampMs(Map sample) { + final iso = sample['TimeISO8601'] as String?; + final parsed = _parseIso8601(iso); + return parsed?.millisecondsSinceEpoch; + } + + /// Matches an ISO-8601 zone designator at the end of a timestamp: either a + /// literal `Z` or a numeric `+HH:MM` / `-HHMM` offset. Applied only to the + /// portion after the `T` so the date's own hyphens can never match. + static final RegExp _zoneDesignator = RegExp( + r'(?:(Z)|([+-])(\d{2}):?(\d{2}))$', + caseSensitive: false, + ); + + /// The UTC offset [value] declares, or null when it declares none. + static Duration? _declaredOffset(String value) { + final timeStart = value.indexOf(RegExp('[Tt]')); + if (timeStart < 0) return null; + final match = _zoneDesignator.firstMatch(value.substring(timeStart)); + if (match == null) return null; + if (match.group(1) != null) return Duration.zero; + final sign = match.group(2) == '-' ? -1 : 1; + return Duration( + hours: sign * int.parse(match.group(3)!), + minutes: sign * int.parse(match.group(4)!), + ); + } + + /// Parses a Suunto timestamp into the diver's **local wall clock, flagged + /// as UTC** -- submersion's storage convention for dive times (see + /// `parsedDiveToDownloadedDive`, which stamps libdivecomputer's local + /// date/time fields with `DateTime.utc`). + /// + /// Suunto writes the dive computer's local time together with its offset + /// (`2026-08-20T15:27:23.140+02:00`), so the offset has to be re-applied + /// rather than resolved away: resolving it to a true UTC instant files a + /// diver in UTC+2 an hour-shifted dive (15:27 lands at 13:27), which is the + /// long-standing "dives import hours off" complaint against Suunto JSON + /// imports. Subsurface's `import-suunto-json.cpp`, which this parser is a + /// port of, uses `parse_iso8601_local_ms()` for the same reason. + /// + /// A timestamp with no offset at all is taken at face value; without this + /// its fields would be shifted by whatever zone the *importing machine* + /// happens to sit in. A `Z` designator means Suunto recorded UTC and there + /// is no local offset to recover, so the wall clock is the UTC time. + /// + /// Elapsed sample times are unaffected either way: they are differences + /// between two timestamps that carry the same offset. + static DateTime? _parseIso8601(String? value) { + if (value == null || value.isEmpty) return null; + try { + final parsed = DateTime.parse(value); + final offset = _declaredOffset(value); + if (offset == null) { + return DateTime.utc( + parsed.year, + parsed.month, + parsed.day, + parsed.hour, + parsed.minute, + parsed.second, + parsed.millisecond, + parsed.microsecond, + ); + } + return parsed.add(offset); + } on FormatException { + return null; + } + } +} + +class _TempReading { + const _TempReading(this.timestampMs, this.kelvin); + final int timestampMs; + final double kelvin; +} + +class _PressureReading { + const _PressureReading(this.tankIndex, this.pressureBar); + final int tankIndex; + final double pressureBar; +} + +/// First pass over the raw samples: collects temperature readings (matched +/// to depth samples by nearest timestamp later), the dive-active start time, +/// and an entry GPS fix, without building any profile rows yet. +class _FirstPass { + const _FirstPass({ + required this.temperatureReadings, + required this.diveStartMs, + this.latitude, + this.longitude, + }); + + final List<_TempReading> temperatureReadings; + final int? diveStartMs; + final double? latitude; + final double? longitude; + + static _FirstPass scan(List> samples) { + final temperatureReadings = <_TempReading>[]; + int? diveStartMs; + double? latitude; + double? longitude; + + for (final sample in samples) { + final temp = SuuntoDiveParser._asDouble(sample['Temperature']); + if (temp != null && temp > 0) { + final ms = SuuntoDiveParser._parseTimestampMs(sample); + if (ms != null) temperatureReadings.add(_TempReading(ms, temp)); + } + + if (diveStartMs == null) { + // Nautic signals dive start via DiveEvents.DiveStatus. + final diveEvents = sample['DiveEvents'] as Map?; + if (diveEvents?['DiveStatus'] == true) { + diveStartMs = SuuntoDiveParser._parseTimestampMs(sample); + } + + // EON signals dive start via Events[].State "Dive Active". + if (diveStartMs == null) { + final eventsArray = sample['Events'] as List?; + if (eventsArray != null) { + for (final entry in eventsArray) { + final state = + (entry as Map)['State'] + as Map?; + if (state?['Active'] == true && state?['Type'] == 'Dive Active') { + diveStartMs = SuuntoDiveParser._parseTimestampMs(sample); + break; + } + } + } + } + } + + if (latitude == null) { + final origin = sample['DiveRouteOrigin'] as Map?; + final lat = SuuntoDiveParser._asDouble(origin?['Latitude']); + final lon = SuuntoDiveParser._asDouble(origin?['Longitude']); + if (lat != null && lon != null && (lat != 0 || lon != 0)) { + latitude = lat; + longitude = lon; + } + } + } + + return _FirstPass( + temperatureReadings: temperatureReadings, + diveStartMs: diveStartMs, + latitude: latitude, + longitude: longitude, + ); + } +} + +class _ProfileResult { + const _ProfileResult({ + required this.samples, + required this.gasSwitchOrder, + this.gasSwitches = const [], + this.events = const [], + required this.lastDepth, + }); + + final List samples; + final List gasSwitchOrder; + final List gasSwitches; + final List events; + final double lastDepth; +} diff --git a/lib/core/services/suunto_cloud/suunto_session_store.dart b/lib/core/services/suunto_cloud/suunto_session_store.dart new file mode 100644 index 0000000000..5811e7e0df --- /dev/null +++ b/lib/core/services/suunto_cloud/suunto_session_store.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:submersion/core/services/secure_storage/fallback_secure_storage.dart'; + +/// A cached Suunto cloud session: the session key plus the account email +/// shown in the sign-in UI. The password is never persisted -- only this +/// resulting session token is cached, so the diver doesn't have to sign in +/// again next time. +class SuuntoSessionData { + const SuuntoSessionData({required this.email, required this.sessionKey}); + + final String email; + final String sessionKey; + + Map toJson() => {'email': email, 'sessionKey': sessionKey}; + + factory SuuntoSessionData.fromJson(Map json) => + SuuntoSessionData( + email: json['email'] as String, + sessionKey: json['sessionKey'] as String, + ); +} + +/// Persists the Suunto cloud session as a single JSON blob in the platform +/// keychain, mirroring `DropboxAuthStore`: one blob keeps load/save atomic; +/// nothing touches SharedPreferences or the database. +/// +/// A corrupt blob is left in place rather than deleted, so a transient +/// decode bug cannot destroy credentials; [save] simply overwrites it. +class SuuntoSessionStore { + SuuntoSessionStore({FlutterSecureStorage? storage}) + : _storage = FallbackSecureStorage(storage ?? const FlutterSecureStorage()); + + final FallbackSecureStorage _storage; + + static const String storageKey = 'suunto_cloud_session'; + + /// The stored session, or null when unset or the stored blob is corrupt. + Future load() async { + final raw = await _storage.read(key: storageKey); + if (raw == null) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + if (decoded['sessionKey'] is! String || decoded['email'] is! String) { + return null; + } + return SuuntoSessionData.fromJson(decoded); + } on FormatException { + return null; + } on TypeError { + return null; + } + } + + Future save(SuuntoSessionData data) => + _storage.write(key: storageKey, value: jsonEncode(data.toJson())); + + Future clear() => _storage.delete(key: storageKey); +} diff --git a/lib/core/services/suunto_cloud/suunto_sml_normalizer.dart b/lib/core/services/suunto_cloud/suunto_sml_normalizer.dart new file mode 100644 index 0000000000..c5de952f81 --- /dev/null +++ b/lib/core/services/suunto_cloud/suunto_sml_normalizer.dart @@ -0,0 +1,168 @@ +import 'package:submersion/core/services/suunto_cloud/suunto_api_exception.dart'; + +/// A dive's header (summary fields) and time-series samples, in the same +/// shape the Suunto app's own "export as JSON" share feature produces (the +/// `DeviceLog.Header` / `DeviceLog.Samples` shape). +class SuuntoDiveExport { + const SuuntoDiveExport({required this.header, required this.samples}); + + final Map header; + final List> samples; +} + +/// Normalizes a Suunto dive export -- either the Suunto *cloud* API's `sml` +/// shape (what [SuuntoCloudClient.fetchSmlJson] downloads) or the Suunto +/// *app*'s own manual "export as JSON" `DeviceLog` shape -- into a single +/// (header, samples) pair. +/// +/// A Dart port of suunto2subsurface's `SuuntoConverter::convertBytes` +/// (https://github.com/dotanalon/suunto2subsurface), minus the JSON +/// re-serialization step that existed there only to hand the result back to +/// Subsurface's own DeviceLog-shaped file parser. +class SuuntoSmlNormalizer { + const SuuntoSmlNormalizer._(); + + static const int _suuntoActivityScuba = 51; + + /// Throws [SuuntoApiException] if [json] isn't a recognizable dive export + /// (neither cloud 'sml' nor app 'DeviceLog' shape, or not a dive activity). + static SuuntoDiveExport parse(Map json) { + final normalized = _normalizeCloudJson(json); + + final Map header; + final List> samples; + if (normalized != null) { + header = normalized.header; + samples = normalized.samples; + } else { + final deviceLog = json['DeviceLog'] as Map? ?? const {}; + header = deviceLog['Header'] as Map? ?? const {}; + samples = ((deviceLog['Samples'] as List?) ?? const []) + .cast>(); + } + + if (header.isEmpty) { + throw const SuuntoApiException( + "Suunto JSON: could not find dive header (checked both cloud 'sml' " + "and app-export 'DeviceLog' shapes)", + ); + } + + final activityType = (header['ActivityType'] as num?)?.toInt() ?? 0; + if (activityType != _suuntoActivityScuba) { + throw SuuntoApiException( + 'Suunto JSON: not a dive activity (ActivityType=$activityType)', + ); + } + + return SuuntoDiveExport(header: header, samples: samples); + } + + /// The Suunto *cloud* API's 'sml' export nests everything under + /// `Summary`/`Data` .Samples[].Attributes['suunto/sml'][type] instead of + /// a flat `DeviceLog.Header` / `DeviceLog.Samples[]`: + /// - `Summary.Samples[]` with an `Attributes['suunto/sml']['Header']` + /// entry and an `Attributes['suunto/sml']['DiveHeader']` entry (which + /// holds the real Gases list, as plain percentages, not the 0.0-1.0 + /// fractions the DeviceLog shape uses). + /// - `Data.Samples[]` each wraps the per-instant fields (Depth, Ceiling, + /// NoDecTime, Cylinders, DiveEvents, Events, Temperature, ...) one level + /// deeper, in `Attributes['suunto/sml']['Sample']`. + /// + /// Returns null if [root] isn't cloud-shaped (no Summary/Data keys). + static SuuntoDiveExport? _normalizeCloudJson(Map root) { + if (!root.containsKey('Summary') || !root.containsKey('Data')) { + return null; + } + + final headerVal = _summaryType(root, 'Header'); + if (headerVal is! Map) return null; + final header = Map.from(headerVal); + + final diveHeaderVal = _summaryType(root, 'DiveHeader'); + final diveHeader = diveHeaderVal is Map + ? Map.from(diveHeaderVal) + : {}; + final gases = (diveHeader['Gases'] as List? ?? const []) + .cast>(); + final usedGases = gases + .where((g) => (_asDouble(g['TankSize']) ?? 0) > 0) + .toList(); + + final diving = {}; + if (usedGases.isNotEmpty) { + diving['Gases'] = usedGases + .map( + (g) => { + 'Oxygen': (_asDouble(g['Oxygen']) ?? 0) / 100.0, + 'Helium': (_asDouble(g['Helium']) ?? 0) / 100.0, + 'TankSize': _asDouble(g['TankSize']) ?? 0, + 'TankFillPressure': _fixPressureUnit( + _asDouble(g['TankFillPressure']) ?? 0, + ), + 'StartPressure': _fixPressureUnit( + _asDouble(g['StartPressure']) ?? 0, + ), + 'EndPressure': _fixPressureUnit(_asDouble(g['EndPressure']) ?? 0), + }, + ) + .toList(); + } + // GfLow/GfHigh aren't part of the app's own DeviceLog schema, but the + // dive parser reads them from here so a single normalized export can + // carry the gradient factors without a paired FIT. + if (diveHeader['LowGf'] != null && diveHeader['HighGf'] != null) { + diving['GfLow'] = diveHeader['LowGf']; + diving['GfHigh'] = diveHeader['HighGf']; + } + if (diving.isNotEmpty) header['Diving'] = diving; + + final samples = >[]; + final dataSamples = + (root['Data'] as Map?)?['Samples'] as List? ?? + const []; + for (final item in dataSamples) { + final itemMap = item as Map; + final attributes = itemMap['Attributes'] as Map?; + final sml = attributes?['suunto/sml'] as Map?; + if (sml == null || !sml.containsKey('Sample')) continue; + final flat = Map.from( + sml['Sample'] as Map, + ); + flat['TimeISO8601'] = itemMap['TimeISO8601']; + samples.add(flat); + } + + return SuuntoDiveExport(header: header, samples: samples); + } + + /// Looks up a `Summary.Samples[].Attributes['suunto/sml'][typeName]` block. + static dynamic _summaryType(Map root, String typeName) { + final summarySamples = + (root['Summary'] as Map?)?['Samples'] + as List? ?? + const []; + for (final entry in summarySamples) { + final attributes = + (entry as Map)['Attributes'] + as Map?; + final sml = attributes?['suunto/sml'] as Map?; + if (sml != null && sml.containsKey(typeName)) return sml[typeName]; + } + return null; + } + + /// Some dives report a gas's TankFillPressure/StartPressure/EndPressure + /// already in bar instead of Pa -- seen on a real multi-gas dive where gas + /// 0 correctly had 23200000 (Pa, == 232 bar) but gas 1 (a tank with no + /// paired pressure transmitter) had a bare 232. No real dive pressure is + /// ever this small in Pa, so treat anything in that range as already-bar + /// and rescale it, rather than importing it as a near-zero working + /// pressure. + static double _fixPressureUnit(double value) { + if (value > 0 && value < 10000) return value * 100000; + return value; + } + + static double? _asDouble(dynamic value) => (value as num?)?.toDouble(); +} diff --git a/lib/core/services/sync/changeset_log/base_apply_progress.dart b/lib/core/services/sync/changeset_log/base_apply_progress.dart new file mode 100644 index 0000000000..cb2d9f40a3 --- /dev/null +++ b/lib/core/services/sync/changeset_log/base_apply_progress.dart @@ -0,0 +1,76 @@ +import 'package:submersion/core/services/logger_service.dart'; + +/// Maps the three file passes of a peer base apply onto one 0.0 to 1.0 +/// fraction (each pass is a third) and logs when each pass completes, so a +/// debug log of a large adoption shows steady progress instead of minutes of +/// silence (issue #1421). +/// +/// Progress never moves backwards. If the worker-isolate apply fails part way +/// and the caller falls back to the inline apply, [restart] folds the three +/// inline passes into whatever share of the bar is still unspent. +class BaseApplyProgress { + BaseApplyProgress(this._totalBytes, this._onProgress); + + static const int _passes = 3; + + final int _totalBytes; + final void Function(double fraction)? _onProgress; + final LoggerService _log = LoggerService.forClass(BaseApplyProgress); + final Stopwatch _stopwatch = Stopwatch()..start(); + Duration _passStarted = Duration.zero; + int _pass = 0; + double _floor = 0.0; + double _last = 0.0; + + /// Marks the start of zero-based [pass]; the previous pass is complete. + void beginPass(int pass) { + _logPassDone(); + _pass = pass; + _emit(_pass / _passes); + } + + /// Bytes of the file consumed so far in the current pass (inline path). + void consumed(int bytes) => this.bytes(bytes, _totalBytes); + + /// Bytes of the file consumed so far in the current pass (worker path). + void bytes(int consumed, int total) { + final within = total <= 0 ? 1.0 : (consumed / total).clamp(0.0, 1.0); + _emit((_pass + within) / _passes); + } + + /// The apply is starting over from pass 1 (worker failed, inline fallback). + /// Everything reported so far becomes the floor; the restarted passes fill + /// the remainder, so the bar keeps moving and never rewinds. + void restart() { + _log.info( + 'Peer base apply restarting from pass 1 after ' + '${_stopwatch.elapsed.inSeconds}s', + ); + _floor = _last; + _pass = 0; + _passStarted = _stopwatch.elapsed; + } + + void done() { + _logPassDone(); + _emit(1.0); + } + + /// Logs the pass that just ended with its own duration and the running + /// total, then starts timing the next one. + void _logPassDone() { + final now = _stopwatch.elapsed; + _log.info( + 'Peer base pass ${_pass + 1}/$_passes done in ' + '${(now - _passStarted).inSeconds}s (${now.inSeconds}s total)', + ); + _passStarted = now; + } + + void _emit(double fraction) { + final scaled = _floor + (1.0 - _floor) * fraction; + if (scaled < _last) return; + _last = scaled; + _onProgress?.call(scaled); + } +} diff --git a/lib/core/services/sync/changeset_log/base_json_stream_reader.dart b/lib/core/services/sync/changeset_log/base_json_stream_reader.dart index c16458c606..415835483d 100644 --- a/lib/core/services/sync/changeset_log/base_json_stream_reader.dart +++ b/lib/core/services/sync/changeset_log/base_json_stream_reader.dart @@ -37,10 +37,14 @@ class BaseJsonStreamReader { String? _section; String? _table; - final BytesBuilder _key = BytesBuilder(copy: false); + // Copying builders on purpose: the non-copying BytesBuilder allocates a + // one-element list per addByte, which on a multi-hundred-MB base is one + // allocation per captured byte. The copying builder writes into a grown + // buffer instead (issue #1421). + final BytesBuilder _key = BytesBuilder(); bool _keyEscaped = false; - final BytesBuilder _cap = BytesBuilder(copy: false); + final BytesBuilder _cap = BytesBuilder(); int _capDepth = 0; bool _capInString = false; bool _capEscaped = false; @@ -75,7 +79,28 @@ class BaseJsonStreamReader { _want = wantRows ?? (_, _) => true; await for (final chunk in bytes) { - for (final c in chunk) { + final data = chunk is Uint8List ? chunk : Uint8List.fromList(chunk); + final n = data.length; + var i = 0; + while (i < n) { + // Fast path: inside a string body nothing but a quote or a backslash + // can change state, so take the whole run in one slice instead of one + // state-machine step per byte. Blob-bearing rows are almost entirely + // string body, and a skipped table's bytes are not copied at all. + if (_inStringBody) { + var j = i; + while (j < n) { + final b = data[j]; + if (b == _quote || b == _backslash) break; + j++; + } + if (j > i && _capKind != _Cap.skip) { + _cap.add(Uint8List.sublistView(data, i, j)); + } + i = j; + if (i >= n) break; + } + final c = data[i++]; var consumed = false; while (!consumed) { final r = _consume(c); @@ -110,6 +135,19 @@ class BaseJsonStreamReader { } } + bool get _inStringBody => + (_state == _S.capture || + _state == _S.captureArray || + _state == _S.captureSection) && + _capInString && + !_capEscaped; + + /// Appends a captured byte unless this capture is being skipped, in which + /// case nothing is retained (the bytes are discarded at finish anyway). + void _capAdd(int c) { + if (_capKind != _Cap.skip) _cap.addByte(c); + } + _Step _consume(int c) { switch (_state) { case _S.awaitTopOpen: @@ -256,7 +294,7 @@ class BaseJsonStreamReader { _Step _captureByte(int c) { if (_capInString) { - _cap.addByte(c); + _capAdd(c); if (_capEscaped) { _capEscaped = false; } else if (c == _backslash) { @@ -270,13 +308,13 @@ class BaseJsonStreamReader { if (c == _quote) { _capStarted = true; _capInString = true; - _cap.addByte(c); + _capAdd(c); return const _Step(true); } if (c == _lbrace || c == _lbracket) { _capStarted = true; _capDepth++; - _cap.addByte(c); + _capAdd(c); return const _Step(true); } if (c == _rbrace || c == _rbracket) { @@ -286,7 +324,7 @@ class BaseJsonStreamReader { return _finishCapture(consume: false); } _capDepth--; - _cap.addByte(c); + _capAdd(c); if (_capDepth == 0) return _finishCapture(consume: true); return const _Step(true); } @@ -303,14 +341,13 @@ class BaseJsonStreamReader { return const _Step(true); // leading/interior structural whitespace } _capStarted = true; - _cap.addByte(c); + _capAdd(c); return const _Step(true); } _Step _finishCapture({required bool consume}) { final kind = _capKind; final bytes = (kind == _Cap.skip) ? null : _cap.takeBytes(); - if (kind == _Cap.skip) _cap.clear(); // Transition to the parent state. if (_state == _S.capture) { diff --git a/lib/core/services/sync/changeset_log/base_parse_client.dart b/lib/core/services/sync/changeset_log/base_parse_client.dart index 23528c42fc..1aa0970fef 100644 --- a/lib/core/services/sync/changeset_log/base_parse_client.dart +++ b/lib/core/services/sync/changeset_log/base_parse_client.dart @@ -17,13 +17,25 @@ class BaseParseException implements Exception { /// decoded rows back, pull-backpressured (one ≤500-row batch per [nextDataBatch]). /// Operations are sequential — one in flight at a time. class BaseParseClient { - BaseParseClient._(this._isolate, this._toWorker, this._fromWorker, this._sub); + BaseParseClient._( + this._isolate, + this._toWorker, + this._fromWorker, + this._sub, + this.onProgress, + ); final Isolate _isolate; final SendPort _toWorker; final ReceivePort _fromWorker; final StreamSubscription _sub; + /// Receives the worker's byte-progress heartbeats: bytes of the file + /// consumed so far in the current pass, and the file's total length. + /// Settable after [spawn] so a caller can attach it to a client it obtained + /// through an injected spawn hook. + void Function(int bytes, int total)? onProgress; + // Buffered request/response mailbox: messages that arrive before a reader is // waiting are queued, so a batch sent before the first [nextDataBatch] pull is // never lost. @@ -40,9 +52,13 @@ class BaseParseClient { /// handshake within [handshakeTimeout]. The port, subscription, and isolate /// it created are torn down before the error propagates. /// + /// [onProgress] receives the worker's byte-progress heartbeats (bytes of the + /// file consumed so far in the current pass, and the file's total length). + /// /// [entryPoint] and [handshakeTimeout] are injectable for tests only. static Future spawn( String filePath, { + void Function(int bytes, int total)? onProgress, @visibleForTesting void Function(List) entryPoint = baseParseWorkerMain, @visibleForTesting Duration handshakeTimeout = const Duration(seconds: 10), @@ -99,7 +115,13 @@ class BaseParseClient { errorsAreFatal: false, ); final toWorker = await ready.future; - client = BaseParseClient._(isolate, toWorker, fromWorker, sub); + client = BaseParseClient._( + isolate, + toWorker, + fromWorker, + sub, + onProgress, + ); } catch (_) { // Spawn failed, the worker errored before handshake, or it timed out: // tear down so we never leak the port/subscription/isolate, then rethrow @@ -128,15 +150,29 @@ class BaseParseClient { } } - /// Per-message backstop: after the handshake, a worker can still die or hang + /// Inactivity backstop: after the handshake, a worker can still die or hang /// without delivering an `onError` (e.g. an OS OOM kill), which would leave a /// waiting pull blocked forever and hang the whole sync. Time out and surface /// a [BaseParseException] so the caller (readScalarsAndDeletions / /// nextDataBatch) falls back to the inline parser. Injectable for tests. + /// + /// This bounds worker SILENCE, not reply latency. A single reply (pass 1 over + /// a 730 MB base, or a data batch that must skip a giant table first) can + /// legitimately take minutes; the worker's progress heartbeats restart this + /// window each time they arrive, so only a worker that has stopped reading + /// the file trips it (issue #1421). @visibleForTesting static Duration messageTimeout = const Duration(seconds: 60); - Future> _nextMessage() { + Future> _nextMessage() async { + while (true) { + final m = await _nextRawMessage(); + if (m['type'] != 'progress') return m; + onProgress?.call(m['bytes'] as int, m['total'] as int); + } + } + + Future> _nextRawMessage() { if (_queue.isNotEmpty) return Future.value(_queue.removeAt(0)); final c = Completer>(); _waiters.add(c); diff --git a/lib/core/services/sync/changeset_log/base_parse_worker.dart b/lib/core/services/sync/changeset_log/base_parse_worker.dart index 8eaf89708b..64236df293 100644 --- a/lib/core/services/sync/changeset_log/base_parse_worker.dart +++ b/lib/core/services/sync/changeset_log/base_parse_worker.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:isolate'; import 'package:submersion/core/services/sync/changeset_log/base_json_stream_reader.dart'; +import 'package:submersion/core/services/sync/changeset_log/byte_progress_stream.dart'; /// Isolate entrypoint for the base-file parse worker. /// @@ -17,8 +18,15 @@ import 'package:submersion/core/services/sync/changeset_log/base_json_stream_rea /// {'type': 'ready', 'port': SendPort} (handshake) /// {'type': 'deletions', 'exportedAt': int, 'rows': List} /// {'type': 'batch', 'rows': List, 'done': bool} +/// {'type': 'progress', 'bytes': int, 'total': int} (liveness heartbeat) /// {'type': 'error', 'message': String} /// +/// A progress message is sent every few MiB of file consumed during a pass +/// and once at its end. It is not a reply: the client treats it as proof the +/// worker is alive and restarts its inactivity timeout, so a pass over a +/// multi-hundred-MB base can legitimately take minutes without being mistaken +/// for a dead worker (issue #1421). +/// /// [args] is `[mainSendPort, filePath]` (Isolate.spawn passes one message). void baseParseWorkerMain(List args) { final mainSendPort = args[0] as SendPort; @@ -45,12 +53,22 @@ void baseParseWorkerMain(List args) { 'message': e.toString(), }); + final total = File(filePath).lengthSync(); + Stream> openWithProgress() => withByteProgress( + File(filePath).openRead(), + onProgress: (consumed) => mainSendPort.send({ + 'type': 'progress', + 'bytes': consumed, + 'total': total, + }), + ); + Future runDeletions() async { try { var exportedAt = 0; final rows = >[]; await BaseJsonStreamReader().parse( - File(filePath).openRead(), + openWithProgress(), onScalar: (key, raw) async { if (key == 'exportedAt') { exportedAt = (jsonDecode(utf8.decode(raw)) as num?)?.toInt() ?? 0; @@ -86,7 +104,7 @@ void baseParseWorkerMain(List args) { try { var batch = >[]; await BaseJsonStreamReader().parse( - File(filePath).openRead(), + openWithProgress(), wantRows: (section, table) => section == 'data' && tables.contains(table), onRow: (section, table, rowBytes) async { diff --git a/lib/core/services/sync/changeset_log/base_part_file_sink.dart b/lib/core/services/sync/changeset_log/base_part_file_sink.dart index b805740c16..42c3a0edb0 100644 --- a/lib/core/services/sync/changeset_log/base_part_file_sink.dart +++ b/lib/core/services/sync/changeset_log/base_part_file_sink.dart @@ -26,6 +26,7 @@ class BasePartFileSink { required String? wholeChecksum, required List partChecksums, required Future Function(int index) downloadPart, + void Function(int downloaded, int total)? onPartDownloaded, }) async { final dir = await _tempDir(); final path = basePartTempPath(dir.path, name, _uuid.v4()); @@ -51,6 +52,7 @@ class BasePartFileSink { } wholeInput.add(part); out.add(part); + onPartDownloaded?.call(i + 1, partCount); } } catch (_) { ok = false; diff --git a/lib/core/services/sync/changeset_log/byte_progress_stream.dart b/lib/core/services/sync/changeset_log/byte_progress_stream.dart new file mode 100644 index 0000000000..0e61250e88 --- /dev/null +++ b/lib/core/services/sync/changeset_log/byte_progress_stream.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +/// Default reporting granularity for [withByteProgress]: every 4 MiB consumed. +const int defaultByteProgressInterval = 4 << 20; + +/// Passes [source] through unchanged while reporting the running byte count +/// to [onProgress]: once every [interval] bytes and once more when the stream +/// ends, so the final report always equals the total bytes consumed even for +/// a stream shorter than one interval. +/// +/// Used by the base-file parse passes to turn a long, otherwise silent scan +/// into a stream of liveness ticks for the isolate client and the sync +/// progress bar (issue #1421). +Stream> withByteProgress( + Stream> source, { + required void Function(int consumed) onProgress, + int interval = defaultByteProgressInterval, +}) async* { + var consumed = 0; + var nextReport = interval; + await for (final chunk in source) { + consumed += chunk.length; + if (consumed >= nextReport) { + onProgress(consumed); + nextReport = consumed + interval; + } + yield chunk; + } + onProgress(consumed); +} diff --git a/lib/core/services/sync/changeset_log/changeset_reader.dart b/lib/core/services/sync/changeset_log/changeset_reader.dart index 2e133495ee..7b504644bd 100644 --- a/lib/core/services/sync/changeset_log/changeset_reader.dart +++ b/lib/core/services/sync/changeset_log/changeset_reader.dart @@ -1,5 +1,9 @@ +import 'dart:typed_data'; + import 'package:submersion/core/database/database.dart'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; import 'package:submersion/core/services/sync/sync_data_serializer.dart'; import 'package:submersion/core/services/sync/changeset_log/base_part_file_sink.dart'; import 'package:submersion/core/services/sync/changeset_log/changeset_codec.dart'; @@ -12,6 +16,12 @@ import 'package:submersion/core/services/sync/changeset_log/sync_manifest.dart'; /// so the merge stays the single source of truth. typedef ApplyPayload = Future Function(SyncPayload payload); +/// Progress of a peer base download: [downloaded] of [total] parts have landed +/// for the base named by [manifest]. Lets the caller keep the progress bar +/// moving through a multi-hundred-MB adoption (issue #1421). +typedef BaseDownloadProgress = + void Function(SyncManifest manifest, int downloaded, int total); + /// Applies a base that has been streamed to a local temp [filePath]. The real /// implementation streams the file through the merge in bounded memory; see /// SyncService._applyRemoteBaseFile. @@ -27,6 +37,8 @@ class ChangesetReadResult { this.skippedPeerNames = const {}, this.newerSchemaPeerDeviceIds = const {}, this.newerSchemaPeerNames = const {}, + this.readFailedPeerDeviceIds = const {}, + this.readFailedPeerNames = const {}, this.retiredPeerIds = const {}, this.retiredPeerHasFiles = false, }); @@ -58,6 +70,18 @@ class ChangesetReadResult { /// published one, keyed by device id. Same fallback contract as /// [skippedPeerNames]: absent means the UI shows a short id label. final Map newerSchemaPeerNames; + + /// Peers whose read threw partway through this pull (a provider error, a + /// malformed manifest or changeset, an apply failure, ...). Their cursors + /// stayed put, so the next sync retries them -- but the caller must surface + /// them rather than reporting a misleadingly clean sync. + final Set readFailedPeerDeviceIds; + + /// Display names for the entries in [readFailedPeerDeviceIds] whose + /// manifests were parsed (and named the device) before the failure. Same + /// fallback contract as [skippedPeerNames]: absent means the UI shows a + /// short id label. + final Map readFailedPeerNames; final Set retiredPeerIds; /// True when a retired peer still has non-marker files in the bucket (a @@ -76,6 +100,7 @@ class ChangesetReader { final ChangesetCodec _codec; final PeerCursorStore _peerCursors; final BasePartFileSink _baseSink; + static final _log = LoggerService.forClass(ChangesetReader); Future pull({ required CloudStorageProvider provider, @@ -86,6 +111,7 @@ class ChangesetReader { String? currentEpochId, int localSchemaVersion = AppDatabase.currentSchemaVersion, List? preListedFiles, + BaseDownloadProgress? onBaseDownloadProgress, }) async { final providerId = provider.providerId; // [preListedFiles] lets the caller reuse a listing it just made (the @@ -122,11 +148,16 @@ class ChangesetReader { final skippedPeerNames = {}; final newerSchemaPeerDeviceIds = {}; final newerSchemaPeerNames = {}; + final readFailedPeerDeviceIds = {}; + final readFailedPeerNames = {}; var peersProcessed = 0; var payloadsApplied = 0; for (final peerId in peerIds) { + // Captured as soon as the manifest parses, so the catch below can name + // the peer even when the failure comes later in the read. + String? peerName; try { // A retired peer's files are being deleted; never merge from them and // never advance a cursor against them. @@ -137,6 +168,10 @@ class ChangesetReader { await provider.downloadFile(manifestFile.id), ); peerManifests.add(manifest); + final manifestName = manifest.deviceName; + if (manifestName != null && manifestName.isNotEmpty) { + peerName = manifestName; + } // Stale-epoch filter: once this device is on a library epoch, a peer // stamped with a different epoch (including an unstamped legacy peer) @@ -180,20 +215,47 @@ class ChangesetReader { // Cold-start, or lapped by the peer's compaction: adopt the base. final baseSeq = manifest.baseSeq; if (baseSeq != null && lastApplied < baseSeq) { + // A base adoption can run for minutes on a large library, so log + // its shape and timing: a debug log that goes silent here is + // otherwise indistinguishable from a hang. + _log.info( + 'Adopting base seq $baseSeq from peer $peerId' + '${peerName != null ? ' ($peerName)' : ''}: ' + '${manifest.basePartCount ?? 0} parts, ' + '${manifest.baseBytes ?? 0} bytes ' + '(cursor at $lastApplied, head ${manifest.headSeq})', + ); + final stopwatch = Stopwatch()..start(); final path = await _fetchBaseToFile( provider, peerId, manifest, byName, + onPartDownloaded: onBaseDownloadProgress == null + ? null + : (downloaded, total) => + onBaseDownloadProgress(manifest, downloaded, total), ); if (path == null) { + _log.warning( + 'Base seq $baseSeq from peer $peerId is incomplete or corrupt; ' + 'will retry next sync', + ); continue; // missing or corrupt base -> transient, retry next sync } + _log.info( + 'Base seq $baseSeq from peer $peerId assembled in ' + '${stopwatch.elapsed.inSeconds}s; applying', + ); try { await applyBaseFile(path, manifest); } finally { await _baseSink.deleteQuietly(path); } + _log.info( + 'Base seq $baseSeq from peer $peerId applied ' + '(${stopwatch.elapsed.inSeconds}s total)', + ); payloadsApplied++; appliedThrough = baseSeq; baseSeqApplied = baseSeq; @@ -209,9 +271,19 @@ class ChangesetReader { for (var seq = appliedThrough + 1; seq <= manifest.headSeq; seq++) { final csFile = byName[ChangesetLogLayout.changesetName(peerId, seq)]; if (csFile == null) break; // gap -> apply what we have, retry later - final cs = _codec.decodeChangeset( - await provider.downloadFile(csFile.id), - ); + final Uint8List csBytes; + try { + csBytes = await provider.downloadFile(csFile.id); + } on EnvelopeCorruptException { + // Same standing as the checksum failure below, so the same + // handling: break rather than throw, which reaches the cursor + // upsert and records the seqs already applied. Throwing here + // would unwind to the per-peer catch, which skips that upsert, + // so every later sync would re-download and re-apply this + // peer's whole backlog and still never advance past this seq. + break; + } + final cs = _codec.decodeChangeset(csBytes); if (!_codec.serializer.validateChecksum(cs)) { break; // corrupt changeset -> stop; a fixed sync retries from here } @@ -232,9 +304,21 @@ class ChangesetReader { appliedHlcHigh: appliedHlc, ); } - } catch (_) { + } catch (e, stackTrace) { // One bad peer must not block the others; its cursor stays put so the - // next sync retries it. + // next sync retries it. Record the failure so the caller can surface + // it instead of reporting a misleadingly clean sync. + readFailedPeerDeviceIds.add(peerId); + if (peerName != null) { + readFailedPeerNames[peerId] = peerName; + } + _log.warning( + 'Failed to read peer $peerId' + '${peerName != null ? ' ($peerName)' : ''}; ' + 'cursor kept for retry next sync', + error: e, + stackTrace: stackTrace, + ); continue; } } @@ -247,6 +331,8 @@ class ChangesetReader { skippedPeerNames: skippedPeerNames, newerSchemaPeerDeviceIds: newerSchemaPeerDeviceIds, newerSchemaPeerNames: newerSchemaPeerNames, + readFailedPeerDeviceIds: readFailedPeerDeviceIds, + readFailedPeerNames: readFailedPeerNames, retiredPeerIds: retiredPeerIds, retiredPeerHasFiles: retiredPeerHasFiles, ); @@ -268,8 +354,9 @@ class ChangesetReader { CloudStorageProvider provider, String peerId, SyncManifest manifest, - Map byName, - ) { + Map byName, { + void Function(int downloaded, int total)? onPartDownloaded, + }) { final baseSeq = manifest.baseSeq!; final partCount = manifest.basePartCount ?? 0; // A manifest that names a base (baseSeq set) but no parts is malformed -- @@ -287,6 +374,7 @@ class ChangesetReader { if (pf == null) return null; return provider.downloadFile(pf.id); }, + onPartDownloaded: onPartDownloaded, ); } } diff --git a/lib/core/services/sync/changeset_log/changeset_writer.dart b/lib/core/services/sync/changeset_log/changeset_writer.dart index 4b6b72b68b..05494d4c5a 100644 --- a/lib/core/services/sync/changeset_log/changeset_writer.dart +++ b/lib/core/services/sync/changeset_log/changeset_writer.dart @@ -34,6 +34,7 @@ class ChangesetWriter { this._publishState, { this.compactionByteRatio = 0.30, this.compactionMaxChangesets = 200, + this.maxChangesetSeriesBlobBytes = 16 * 1024 * 1024, this.heartbeatMaxAgeMillis = SyncLiveness.heartbeatMaxAgeMillis, ResumableBasePublishStore? resumableStore, }) : _resumable = resumableStore ?? ResumableBasePublishStore(); @@ -44,6 +45,14 @@ class ChangesetWriter { final ResumableBasePublishStore _resumable; final double compactionByteRatio; final int compactionMaxChangesets; + + /// How many bytes of packed series blob an in-memory changeset may carry + /// before this publishes a streamed base instead. See + /// [SyncDataSerializer.pendingSeriesBlobBytes]: the changeset payload holds + /// base64 and its JSON encoding at once, so the peak is several times this, + /// while the base path streams to a temp file and is bounded whatever the + /// library size. + final int maxChangesetSeriesBlobBytes; final int heartbeatMaxAgeMillis; Future publish({ @@ -85,8 +94,27 @@ class ChangesetWriter { // there is nothing to append to, so cold-start a fresh base. `state` still // recovers the seq counter (knownHeadSeq) so the new base never reuses a // number. - final hasBase = !forceBase && ownManifest?.baseSeq != null; final watermark = ownManifest?.publishedHlcHigh ?? state?.publishedHlcHigh; + final baseInCloud = ownManifest?.baseSeq != null; + final adoptedMarker = + state != null && state.baseSeq == null && watermark != null; + // The in-memory changeset cannot carry an unbounded amount of packed + // series blob: it holds base64 and its JSON encoding at once, which is + // the OOM the streamed base path exists to avoid (#358). The v182 + // migration stamps EVERY packed row with one freshly issued HLC, so the + // first changeset after the upgrade would select the entire packed + // corpus in one payload. When the delta is that big, publish a base + // instead: same content, streamed to a temp file and slice-uploaded, and + // afterwards the watermark covers the corpus so ordinary changesets + // resume. A forced base already takes that path, so this only decides + // between the two when the caller did not. + final oversizedDelta = + !forceBase && + (baseInCloud || adoptedMarker) && + await _serializer.pendingSeriesBlobBytes(watermark) > + maxChangesetSeriesBlobBytes; + final publishBase = forceBase || oversizedDelta; + final hasBase = !publishBase && baseInCloud; // The post-adopt marker (a publish-state row with a null baseSeq): this // device's library IS the adopted epoch the peers already published, so // publishing its own base would redundantly re-upload the whole library -- @@ -102,12 +130,7 @@ class ChangesetWriter { // exact full-upload/OOM this path avoids, #358). maxRowHlc() is null at // adopt for an empty library (the base below no-ops) or one whose rows // all predate HLC stamping (one streamed base publish, safely bounded). - final adoptedNoBase = - !forceBase && - !hasBase && - state != null && - state.baseSeq == null && - watermark != null; + final adoptedNoBase = !publishBase && !hasBase && adoptedMarker; final newSeq = knownHeadSeq + 1; final now = DateTime.now().millisecondsSinceEpoch; @@ -149,7 +172,7 @@ class ChangesetWriter { // replaces an over-claiming `publishedHlcHigh` with the truth (null), // which is what stops the stale-restore loop for a device rewound to an // empty library. Peers apply an empty base as a no-op (upsert + LWW). - if (rowCount == 0 && deletions.isEmpty && !forceBase) { + if (rowCount == 0 && deletions.isEmpty && !publishBase) { try { await File(base.path).delete(); } catch (_) {} @@ -221,7 +244,7 @@ class ChangesetWriter { // a whole library's worth. Prune them exactly as compaction does (see // _pruneSupersededBelow: best-effort, readers self-heal from the new // base). An ordinary cold-start has nothing below it to prune. - if (forceBase) { + if (publishBase) { await _pruneSupersededBelow(provider, folderId, deviceId, seq); } // Only now, with the manifest committed, is the export spent. Failing diff --git a/lib/core/services/sync/changeset_log/local_only_tombstone_gc.dart b/lib/core/services/sync/changeset_log/local_only_tombstone_gc.dart new file mode 100644 index 0000000000..bbe0eea485 --- /dev/null +++ b/lib/core/services/sync/changeset_log/local_only_tombstone_gc.dart @@ -0,0 +1,120 @@ +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/sync/changeset_log/peer_cursor_store.dart'; +import 'package:submersion/core/services/sync/changeset_log/publish_state_store.dart'; +import 'package:submersion/core/services/sync/changeset_log/sync_liveness.dart'; + +/// Every durable trace that this device has taken part in a sync, read once +/// so the GC decision is a pure function of them. +/// +/// Sign-out clears only the provider and the remote file id, so the other +/// four survive it and keep a formerly synced device from purging tombstones +/// its cloud log still owes a peer. The user-facing Reset Sync State wipes the +/// cursor and publish rows and the last sync time but leaves the accepted +/// epoch, which is why the epoch is read as well. +class SyncHistoryEvidence { + const SyncHistoryEvidence({ + required this.providerConfigured, + required this.hasLastSyncTime, + required this.hasPeerCursors, + required this.hasPublishState, + required this.hasAcceptedEpoch, + }); + + /// A cloud provider is connected. The cloud path owns GC in that case: it + /// computes the fleet-acked horizon from live peer manifests at the tail of + /// every successful sync. + final bool providerConfigured; + + /// `sync_metadata.lastSyncTimestamp` is set. + final bool hasLastSyncTime; + + /// At least one `sync_peer_cursors` row: this device consumed a peer's log. + final bool hasPeerCursors; + + /// At least one `local_publish_states` row: this device published a base or + /// adopted one. The only trace a single-device cloud library leaves. + final bool hasPublishState; + + /// `sync_metadata.lastAcceptedEpochId` is set. + final bool hasAcceptedEpoch; + + bool get hasEverSynced => + hasLastSyncTime || hasPeerCursors || hasPublishState || hasAcceptedEpoch; + + /// Tombstones may be purged past the floor only on a device that has no + /// provider connected AND has never synced. A never-synced device has no + /// peer that could hold the deleted rows, and a device joining a library + /// later adopts the cloud's base rather than replaying this log. A device + /// that has synced and then went local-only must keep its tombstones: the + /// peers it left behind still need them to converge when it returns. + bool get allowsLocalOnlyGc => !providerConfigured && !hasEverSynced; + + @override + String toString() => + 'SyncHistoryEvidence(provider: $providerConfigured, ' + 'lastSync: $hasLastSyncTime, peerCursors: $hasPeerCursors, ' + 'publishState: $hasPublishState, epoch: $hasAcceptedEpoch)'; +} + +/// Reaches tombstone GC on a device that never syncs. +/// +/// `SyncRepository.clearAcknowledgedDeletions` is otherwise called from +/// exactly one place, the tail of a successful cloud sync, so a library that +/// never configured a provider keeps every tombstone forever. This runs the +/// same deletion with the same 30-day floor and a null horizon, which is the +/// branch the cloud path already takes when no live peer constrains it +/// (`TombstoneGcDecision.unbounded()`): a never-synced device is that case +/// with an empty fleet. +/// +/// Runs on every launch, unawaited, in the shape of `MediaOrphanBacklogSweep`: +/// the probe is one metadata read and two indexed existence checks, and the +/// delete is a no-op on a library with nothing past the floor. +/// +/// Known residual: a user who runs Reset Sync State and then signs out erases +/// every trace in [SyncHistoryEvidence], so a reconnect more than 30 days +/// later could resurrect rows deleted in between. That pair of actions is +/// already a deliberate cold start of the sync transport. +class LocalOnlyTombstoneGc { + LocalOnlyTombstoneGc({ + required SyncRepository syncRepository, + required PeerCursorStore peerCursors, + required PublishStateStore publishStates, + }) : _syncRepository = syncRepository, + _peerCursors = peerCursors, + _publishStates = publishStates; + + final SyncRepository _syncRepository; + final PeerCursorStore _peerCursors; + final PublishStateStore _publishStates; + final _log = LoggerService.forClass(LocalOnlyTombstoneGc); + + /// One metadata read and two indexed existence checks. The three metadata + /// signals come off a single row rather than through the repository's + /// per-field accessors, each of which would re-read it. + Future gatherEvidence() async { + final metadata = await _syncRepository.getOrCreateMetadata(); + return SyncHistoryEvidence( + providerConfigured: metadata.syncProvider != null, + hasLastSyncTime: metadata.lastSyncTimestamp != null, + hasPeerCursors: await _peerCursors.hasAny(), + hasPublishState: await _publishStates.hasAny(), + hasAcceptedEpoch: metadata.lastAcceptedEpochId != null, + ); + } + + /// Returns whether a purge ran. Throws on repository failure so the caller + /// can log it; the next launch simply runs again. + Future run({DateTime? now}) async { + final evidence = await gatherEvidence(); + if (!evidence.allowsLocalOnlyGc) return false; + + final at = now ?? DateTime.now(); + await _syncRepository.clearAcknowledgedDeletions( + upToHlc: null, + floorCutoffMillis: at.millisecondsSinceEpoch - SyncLiveness.gcFloorMillis, + ); + _log.info('Local-only tombstone GC ran: $evidence'); + return true; + } +} diff --git a/lib/core/services/sync/changeset_log/peer_cursor_store.dart b/lib/core/services/sync/changeset_log/peer_cursor_store.dart index 9e45e61e70..dcacff8347 100644 --- a/lib/core/services/sync/changeset_log/peer_cursor_store.dart +++ b/lib/core/services/sync/changeset_log/peer_cursor_store.dart @@ -48,6 +48,18 @@ class PeerCursorStore { ); } + /// Whether any peer cursor exists for any provider. A cursor is written the + /// first time this device consumes a peer's log, so a true here is proof + /// the device has synced against at least one other device. + Future hasAny() async { + final row = + await (_db.selectOnly(_db.syncPeerCursors) + ..addColumns([_db.syncPeerCursors.peerDeviceId]) + ..limit(1)) + .getSingleOrNull(); + return row != null; + } + /// Drop every cursor for [provider] -- used on backend switch and on /// stale-restore recovery so the device re-pulls each peer from scratch. Future resetForProvider(String provider) async { diff --git a/lib/core/services/sync/changeset_log/publish_state_store.dart b/lib/core/services/sync/changeset_log/publish_state_store.dart index 4482ff9a0f..dffb5abe29 100644 --- a/lib/core/services/sync/changeset_log/publish_state_store.dart +++ b/lib/core/services/sync/changeset_log/publish_state_store.dart @@ -57,6 +57,19 @@ class PublishStateStore { ); } + /// Whether a publish row exists for any provider. Written by the first + /// base publish or adopt, so a true here is proof this device has synced + /// even when it never had a peer (a single-device cloud library leaves no + /// peer cursor, only this row). + Future hasAny() async { + final row = + await (_db.selectOnly(_db.localPublishStates) + ..addColumns([_db.localPublishStates.provider]) + ..limit(1)) + .getSingleOrNull(); + return row != null; + } + /// Drop publish state for [provider] -- used on backend switch so the device /// republishes a base as if new on the new backend. Future resetForProvider(String provider) async { diff --git a/lib/core/services/sync/crypto/sync_envelope.dart b/lib/core/services/sync/crypto/sync_envelope.dart index 2a8d04dfa0..7c55e55d08 100644 --- a/lib/core/services/sync/crypto/sync_envelope.dart +++ b/lib/core/services/sync/crypto/sync_envelope.dart @@ -6,6 +6,7 @@ import 'package:cryptography/cryptography.dart'; import 'package:uuid/uuid_value.dart'; import 'package:submersion/core/services/sync/crypto/crypto_errors.dart'; +import 'package:submersion/core/utils/bounded_inflate.dart'; /// Single-shot SBE1 envelope: the byte form of every encrypted sync file. /// @@ -17,6 +18,21 @@ abstract final class SyncEnvelope { static const int _headerLength = 4 + 16 + 1 + 12; static const int _flagGzip = 0x01; + /// The largest plaintext an envelope will gzip on seal or inflate to on + /// open. + /// + /// Derived from what a sync payload can legitimately be, not from a + /// round number. The only structurally bounded kind is a base part, cut + /// to exactly `BaseChunker.defaultPartSize` (8 MiB), so even a 648 MB + /// library publishes as 78 small envelopes; this leaves that path 32x of + /// headroom. The unbounded kind is a changeset, which is built, + /// JSON-encoded, gzipped and encrypted whole in memory on the writer and + /// decoded whole in memory on the reader. A plaintext past a few hundred + /// megabytes therefore cannot be applied even if it inflates, so the cap + /// refuses only payloads that were already fatal, and turns an OOM kill + /// into a transient-stop error. + static const int defaultMaxPlaintextBytes = 256 * 1024 * 1024; + static final AesGcm _aesGcm = AesGcm.with256bits(); static bool hasMagic(List bytes) => @@ -40,11 +56,18 @@ abstract final class SyncEnvelope { required String libraryKeyId, required String filename, bool compress = true, + int maxPlaintextBytes = defaultMaxPlaintextBytes, List? nonceForTest, }) async { var payload = plaintext; var flags = 0; - if (compress) { + // A payload past the cap is stored uncompressed rather than refused: a + // device must never write an envelope [open] would reject, and only the + // gzip flag makes that possible. The upload is larger, but the write + // path stays total and nothing is stranded. Reaching this needs a + // single changeset of a quarter gigabyte, which the in-memory encode + // above it would already be struggling with. + if (compress && plaintext.length <= maxPlaintextBytes) { final gz = Uint8List.fromList(gzip.encode(plaintext)); if (gz.length < plaintext.length) { payload = gz; @@ -74,6 +97,7 @@ abstract final class SyncEnvelope { required SecretKey dataKey, required String expectedLibraryKeyId, required String filename, + int maxPlaintextBytes = defaultMaxPlaintextBytes, }) async { if (!hasMagic(envelope) || envelope.length < _headerLength + 16) { throw const EnvelopeCorruptException('Not an SBE1 envelope'); @@ -106,7 +130,39 @@ abstract final class SyncEnvelope { ); } if ((flags & _flagGzip) != 0) { - return Uint8List.fromList(gzip.decode(payload)); + // Bounded and chunked. gzip.decode is a single native call that has + // already allocated the whole body by the time it returns, so a + // length check after the fact buys nothing; the guard has to abort + // from inside the inflate. + // + // AES-GCM ran first, so reaching here means the payload authenticated + // under the library data key: the residual threat is a peer inside + // the trust boundary, not a network attacker. The gzip flag itself is + // read from header byte 20, which is outside both the ciphertext and + // the AAD and so is not authenticated at all, which is what lets a + // keyless attacker turn any file into "not a gzip stream". + // + // The blob cap matches the body cap. For anything this seal wrote + // that is free: the flag is only set when the gzip came out strictly + // smaller than its plaintext, so the blob is always the shorter of + // the two and the body cap is the one that binds. A foreign writer + // is not bound by that, so a blob over the cap is refused on its + // length even if it would have inflated to something under it. That + // is deliberate. Such an envelope is itself a quarter-gigabyte file, + // and the alternative is copying it whole into the native filter + // before the first chunk comes back. + try { + return inflateBounded( + payload, + decoder: gzip.decoder, + maxBytes: maxPlaintextBytes, + maxBlobBytes: maxPlaintextBytes, + ); + } on BoundedInflateException catch (e) { + throw EnvelopeCorruptException( + 'Envelope payload rejected: ${e.message}', + ); + } } return Uint8List.fromList(payload); } diff --git a/lib/core/services/sync/sync_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index 90276fff3c..85763345b0 100644 --- a/lib/core/services/sync/sync_data_serializer.dart +++ b/lib/core/services/sync/sync_data_serializer.dart @@ -8,9 +8,15 @@ import 'package:uuid/uuid.dart'; import 'package:submersion/core/data/repositories/sync_repository.dart'; import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/database/legacy_sample_staging.dart'; +import 'package:submersion/core/database/profile_series_pack.dart'; import 'package:submersion/core/services/sync/changeset_log/sync_temp_dir.dart'; import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_codec_exception.dart'; +import 'package:submersion/features/dive_log/domain/codecs/profile_series_summary.dart'; +import 'package:submersion/features/dive_log/domain/codecs/tank_pressure_series_codec.dart'; /// Sync data format version for compatibility checking const int syncFormatVersion = 2; @@ -216,6 +222,11 @@ class SyncData { final List> divers; final List> diverSettings; final List> dives; + + /// Inbound only since v182: older peers still send row-per-sample arrays; + /// they apply into the legacy tables and are packed into series by + /// [SyncDataSerializer.packLegacySamples]. Never exported, absent from + /// [toJson]. final List> diveProfiles; final List> diveTanks; final List> diveEquipment; @@ -269,6 +280,11 @@ class SyncData { final List> diveRoles; final List> tankPresets; final List> diveComputers; + + /// Inbound only since v182: older peers still send row-per-sample arrays; + /// they apply into the legacy tables and are packed into series by + /// [SyncDataSerializer.packLegacySamples]. Never exported, absent from + /// [toJson]. final List> tankPressureProfiles; final List> tideRecords; final List> settings; @@ -283,10 +299,13 @@ class SyncData { final List> diveCustomFields; final List> diveDataSources; final List> siteSpecies; + final List> mediaSpecies; final List> siteFeatures; final List> csvPresets; final List> viewConfigs; final List> fieldPresets; + final List> diveProfileSeries; + final List> tankPressureSeries; const SyncData({ this.divers = const [], @@ -359,17 +378,19 @@ class SyncData { this.diveCustomFields = const [], this.diveDataSources = const [], this.siteSpecies = const [], + this.mediaSpecies = const [], this.siteFeatures = const [], this.csvPresets = const [], this.viewConfigs = const [], this.fieldPresets = const [], + this.diveProfileSeries = const [], + this.tankPressureSeries = const [], }); Map toJson() => { 'divers': divers, 'diverSettings': diverSettings, 'dives': dives, - 'diveProfiles': diveProfiles, 'diveTanks': diveTanks, 'diveEquipment': diveEquipment, 'diveWeights': diveWeights, @@ -422,7 +443,6 @@ class SyncData { 'diveRoles': diveRoles, 'tankPresets': tankPresets, 'diveComputers': diveComputers, - 'tankPressureProfiles': tankPressureProfiles, 'tideRecords': tideRecords, 'settings': settings, 'species': species, @@ -436,10 +456,13 @@ class SyncData { 'diveCustomFields': diveCustomFields, 'diveDataSources': diveDataSources, 'siteSpecies': siteSpecies, + 'mediaSpecies': mediaSpecies, 'siteFeatures': siteFeatures, 'csvPresets': csvPresets, 'viewConfigs': viewConfigs, 'fieldPresets': fieldPresets, + 'diveProfileSeries': diveProfileSeries, + 'tankPressureSeries': tankPressureSeries, }; factory SyncData.fromJson(Map json) { @@ -516,10 +539,13 @@ class SyncData { diveCustomFields: _parseList(json['diveCustomFields']), diveDataSources: _parseList(json['diveDataSources']), siteSpecies: _parseList(json['siteSpecies']), + mediaSpecies: _parseList(json['mediaSpecies']), siteFeatures: _parseList(json['siteFeatures']), csvPresets: _parseList(json['csvPresets']), viewConfigs: _parseList(json['viewConfigs']), fieldPresets: _parseList(json['fieldPresets']), + diveProfileSeries: _parseList(json['diveProfileSeries']), + tankPressureSeries: _parseList(json['tankPressureSeries']), ); } @@ -530,6 +556,38 @@ class SyncData { } /// Service for serializing and deserializing sync data +/// Bytes of packed series blob one base-export page may hold. +/// +/// The streamed base pages every other table at a row count, which bounds +/// memory only while a row is small. A packed series row is not: a 1 Hz +/// hour-long dive is tens of kilobytes, and a page holds the blobs AND the +/// base64 the JSON carries, so 2,000 of them is hundreds of megabytes on +/// the very path that exists to keep a large library from materialising +/// whole (#358). Four mebibytes of blob is roughly 5.5 MB of base64 and a +/// page that stays in the tens of megabytes on any library. +const int kBaseBlobPageBytes = 4 * 1024 * 1024; + +/// The prefix of [rows] whose blob bytes fit in [budget]. +/// +/// Always at least one row, however large: a row over the budget on its own +/// still has to move or the export never advances past it. +/// +/// Top-level and public so the budgeting rule can be tested without an +/// export; nothing outside this file and its test should need it. +List<({String id, int bytes})> idsWithinBlobBudget( + List<({String id, int bytes})> rows, + int budget, +) { + final taken = <({String id, int bytes})>[]; + var total = 0; + for (final row in rows) { + if (taken.isNotEmpty && total + row.bytes > budget) break; + taken.add(row); + total += row.bytes; + } + return taken; +} + class SyncDataSerializer { AppDatabase get _db => DatabaseService.instance.database; final _log = LoggerService.forClass(SyncDataSerializer); @@ -647,10 +705,9 @@ class SyncDataSerializer { }) > get _baseTables => [ - (key: 'divers', table: _db.divers, blob: false, full: null), + (key: 'divers', table: _db.divers, blob: true, full: null), (key: 'diverSettings', table: _db.diverSettings, blob: false, full: null), (key: 'dives', table: _db.dives, blob: false, full: null), - (key: 'diveProfiles', table: _db.diveProfiles, blob: false, full: null), (key: 'diveTanks', table: _db.diveTanks, blob: false, full: null), ( key: 'diveEquipment', @@ -711,7 +768,7 @@ class SyncDataSerializer { blob: false, full: null, ), - (key: 'buddies', table: _db.buddies, blob: false, full: null), + (key: 'buddies', table: _db.buddies, blob: true, full: null), (key: 'mediaStores', table: _db.mediaStores, blob: false, full: null), ( key: 'connectedAccounts', @@ -857,12 +914,6 @@ class SyncDataSerializer { ), (key: 'tankPresets', table: _db.tankPresets, blob: false, full: null), (key: 'diveComputers', table: _db.diveComputers, blob: false, full: null), - ( - key: 'tankPressureProfiles', - table: _db.tankPressureProfiles, - blob: false, - full: null, - ), (key: 'tideRecords', table: _db.tideRecords, blob: false, full: null), ( key: 'settings', @@ -918,6 +969,7 @@ class SyncDataSerializer { full: null, ), (key: 'siteSpecies', table: _db.siteSpecies, blob: false, full: null), + (key: 'mediaSpecies', table: _db.mediaSpecies, blob: false, full: null), (key: 'siteFeatures', table: _db.siteFeatures, blob: false, full: null), (key: 'csvPresets', table: _db.csvPresets, blob: false, full: null), (key: 'viewConfigs', table: _db.viewConfigs, blob: false, full: null), @@ -927,6 +979,18 @@ class SyncDataSerializer { blob: false, full: () => _exportFieldPresets(null), ), + ( + key: 'diveProfileSeries', + table: _db.diveProfileSeries, + blob: true, + full: null, + ), + ( + key: 'tankPressureSeries', + table: _db.tankPressureSeries, + blob: true, + full: null, + ), ]; /// Test seam: the base table order, asserted equal to SyncData.toJson keys so @@ -934,6 +998,95 @@ class SyncDataSerializer { static List get debugBaseTableKeys => SyncDataSerializer()._baseTables.map((t) => t.key).toList(); + /// One keyset page of a BLOB table, bounded by [maxBytes] of blob rather + /// than by a row count. Reads the ids and blob lengths first (no blob + /// leaves SQLite for that), takes the prefix that fits, then loads only + /// those rows. + /// + /// Returns an empty list at the end of the table, which is how the caller + /// knows to stop: a short page here means the budget was reached, not that + /// the rows ran out. + Future>> _pageBlobTableByBytes( + TableInfo table, { + required String? cursor, + required int limit, + required int maxBytes, + }) async { + final name = table.actualTableName; + // Every BLOB column of the table, from the schema rather than a name + // this file would have to keep in step: `blob: true` marks any table + // carrying one (a diver avatar, a data source fingerprint), not only + // the packed series. + // Intersected with what the table actually has: a database shaped by a + // parallel branch can be missing a column this build declares, and the + // row pager below tolerates that until a row needs mapping. Naming the + // column in SQL would fail even on an empty table. + final actual = { + for (final r + in await _db.customSelect('PRAGMA table_info("$name")').get()) + r.read('name'), + }; + final blobColumns = [ + for (final c in table.$columns) + if (c.type == DriftSqlType.blob && actual.contains(c.name)) c.name, + ]; + if (blobColumns.isEmpty) { + return _pageBaseTableById( + table, + cursor: cursor, + limit: limit, + blob: true, + ); + } + final sizeExpr = blobColumns + .map((c) => 'COALESCE(LENGTH("$c"), 0)') + .join(' + '); + final sizeRows = cursor == null + ? await _db + .customSelect( + 'SELECT id, $sizeExpr AS n FROM "$name" ORDER BY id LIMIT ?', + variables: [Variable.withInt(limit)], + ) + .get() + : await _db + .customSelect( + 'SELECT id, $sizeExpr AS n FROM "$name" WHERE id > ? ' + 'ORDER BY id LIMIT ?', + variables: [ + Variable.withString(cursor), + Variable.withInt(limit), + ], + ) + .get(); + if (sizeRows.isEmpty) return const []; + final take = idsWithinBlobBudget([ + for (final r in sizeRows) + (id: r.read('id'), bytes: r.readNullable('n') ?? 0), + ], maxBytes); + final last = take.last.id; + final rows = cursor == null + ? await _db + .customSelect( + 'SELECT * FROM "$name" WHERE id <= ? ORDER BY id', + variables: [Variable.withString(last)], + ) + .get() + : await _db + .customSelect( + 'SELECT * FROM "$name" WHERE id > ? AND id <= ? ORDER BY id', + variables: [ + Variable.withString(cursor), + Variable.withString(last), + ], + ) + .get(); + return [ + for (final r in rows) + (table.map(r.data) as dynamic).toJson(serializer: _syncBlobSerializer) + as Map, + ]; + } + /// One keyset page (`id > cursor`, ascending, up to [limit]) of an id-PK /// table, as JSON rows identical to the table's own `toJson` (BLOB serializer /// applied for BLOB tables). O(n) total across pages; never loads the whole @@ -984,6 +1137,7 @@ class SyncDataSerializer { String? uploadNonce, int? seq, int pageSize = 2000, + int blobPageBytes = kBaseBlobPageBytes, DateTime Function() now = DateTime.now, Future Function()? tempDir, }) async { @@ -1045,17 +1199,27 @@ class SyncDataSerializer { if (spec.table != null) { String? cursor; while (true) { - final rows = await _pageBaseTableById( - spec.table!, - cursor: cursor, - limit: pageSize, - blob: spec.blob, - ); + // A blob page is short when it hit its byte budget, not when the + // table ran out, so it pages until an empty one comes back. + final rows = spec.blob + ? await _pageBlobTableByBytes( + spec.table!, + cursor: cursor, + limit: pageSize, + maxBytes: blobPageBytes, + ) + : await _pageBaseTableById( + spec.table!, + cursor: cursor, + limit: pageSize, + blob: false, + ); + if (rows.isEmpty) break; for (final row in rows) { await emit(row); } - if (rows.length < pageSize) break; cursor = rows.last['id'] as String; + if (!spec.blob && rows.length < pageSize) break; } } else { for (final row in await spec.full!()) { @@ -1164,10 +1328,6 @@ class SyncDataSerializer { () => _exportDiverSettings(hlcSince), ), dives: await _safeExport('dives', () => _exportDives(hlcSince)), - diveProfiles: await _safeExport( - 'diveProfiles', - () => _exportDiveProfiles(hlcSince), - ), diveTanks: await _safeExport( 'diveTanks', () => _exportDiveTanks(hlcSince), @@ -1358,10 +1518,6 @@ class SyncDataSerializer { 'diveComputers', () => _exportDiveComputers(hlcSince), ), - tankPressureProfiles: await _safeExport( - 'tankPressureProfiles', - () => _exportTankPressureProfiles(hlcSince), - ), tideRecords: await _safeExport( 'tideRecords', () => _exportTideRecords(hlcSince), @@ -1408,6 +1564,10 @@ class SyncDataSerializer { 'siteSpecies', () => _exportSiteSpecies(hlcSince), ), + mediaSpecies: await _safeExport( + 'mediaSpecies', + () => _exportMediaSpecies(hlcSince), + ), siteFeatures: await _safeExport( 'siteFeatures', () => _exportSiteFeatures(hlcSince), @@ -1424,9 +1584,27 @@ class SyncDataSerializer { 'fieldPresets', () => _exportFieldPresets(hlcSince), ), + diveProfileSeries: await _safeExport( + 'diveProfileSeries', + () => _exportDiveProfileSeries(hlcSince), + ), + tankPressureSeries: await _safeExport( + 'tankPressureSeries', + () => _exportTankPressureSeries(hlcSince), + ), ); } + /// Packs legacy row-per-sample rows that an older peer's changeset staged + /// (the real `dive_profiles` / `tank_pressure_profiles` tables are gone as + /// of v183; see `legacy_sample_staging.dart`) into series rows. Dives that + /// already have a series are left alone: the peer is held below the floor + /// and will migrate its own rows when it upgrades. + Future packLegacySamples() => packStagedLegacyRows(_db); + + /// True when a previous apply left rows staged that it could not place. + Future hasStagedLegacySamples() => hasStagedLegacyRows(_db); + /// Convert payload to JSON string String serializePayload(SyncPayload payload) { return jsonEncode(payload.toJson()); @@ -1547,7 +1725,7 @@ class SyncDataSerializer { final row = await (_db.select( _db.divers, )..where((t) => t.id.equals(recordId))).getSingleOrNull(); - return row?.toJson(); + return row?.toJson(serializer: _syncBlobSerializer); case 'diverSettings': final row = await (_db.select( _db.diverSettings, @@ -1558,11 +1736,6 @@ class SyncDataSerializer { _db.dives, )..where((t) => t.id.equals(recordId))).getSingleOrNull(); return row?.toJson(); - case 'diveProfiles': - final row = await (_db.select( - _db.diveProfiles, - )..where((t) => t.id.equals(recordId))).getSingleOrNull(); - return row?.toJson(); case 'diveTanks': final row = await (_db.select( _db.diveTanks, @@ -1642,7 +1815,7 @@ class SyncDataSerializer { final row = await (_db.select( _db.buddies, )..where((t) => t.id.equals(recordId))).getSingleOrNull(); - return row?.toJson(); + return row?.toJson(serializer: _syncBlobSerializer); case 'mediaStores': final row = await (_db.select( _db.mediaStores, @@ -1834,11 +2007,6 @@ class SyncDataSerializer { _db.diveComputers, )..where((t) => t.id.equals(recordId))).getSingleOrNull(); return row == null ? null : _withoutDeviceLocalFields(row.toJson()); - case 'tankPressureProfiles': - final row = await (_db.select( - _db.tankPressureProfiles, - )..where((t) => t.id.equals(recordId))).getSingleOrNull(); - return row?.toJson(); case 'tideRecords': final row = await (_db.select( _db.tideRecords, @@ -1904,6 +2072,11 @@ class SyncDataSerializer { _db.siteSpecies, )..where((t) => t.id.equals(recordId))).getSingleOrNull(); return row?.toJson(); + case 'mediaSpecies': + final row = await (_db.select( + _db.mediaSpecies, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(); case 'siteFeatures': final row = await (_db.select( _db.siteFeatures, @@ -1924,6 +2097,16 @@ class SyncDataSerializer { _db.fieldPresets, )..where((t) => t.id.equals(recordId))).getSingleOrNull(); return row?.toJson(); + case 'diveProfileSeries': + final row = await (_db.select( + _db.diveProfileSeries, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(serializer: _syncBlobSerializer); + case 'tankPressureSeries': + final row = await (_db.select( + _db.tankPressureSeries, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(serializer: _syncBlobSerializer); } return null; } @@ -1958,7 +2141,9 @@ class SyncDataSerializer { final rows = await (_db.select( _db.divers, )..where((t) => t.id.isIn(idList))).get(); - return {for (final r in rows) r.id: r.toJson()}; + return { + for (final r in rows) r.id: r.toJson(serializer: _syncBlobSerializer), + }; case 'diverSettings': final rows = await (_db.select( _db.diverSettings, @@ -2013,7 +2198,9 @@ class SyncDataSerializer { final rows = await (_db.select( _db.buddies, )..where((t) => t.id.isIn(idList))).get(); - return {for (final r in rows) r.id: r.toJson()}; + return { + for (final r in rows) r.id: r.toJson(serializer: _syncBlobSerializer), + }; case 'mediaStores': final rows = await (_db.select( _db.mediaStores, @@ -2188,6 +2375,20 @@ class SyncDataSerializer { _db.settings, )..where((t) => t.key.isIn(idList))).get(); return {for (final r in rows) r.key: r.toJson()}; + case 'diveProfileSeries': + final rows = await (_db.select( + _db.diveProfileSeries, + )..where((t) => t.id.isIn(idList))).get(); + return { + for (final r in rows) r.id: r.toJson(serializer: _syncBlobSerializer), + }; + case 'tankPressureSeries': + final rows = await (_db.select( + _db.tankPressureSeries, + )..where((t) => t.id.isIn(idList))).get(); + return { + for (final r in rows) r.id: r.toJson(serializer: _syncBlobSerializer), + }; default: // Clockless / composite-key entities are never fetched by the merge; // fall back to per-id reads so the method is total and correct. @@ -2367,6 +2568,31 @@ class SyncDataSerializer { ); } + /// Applies one incoming `dive_dive_types` row. + /// + /// `dive_dive_types` carries a unique index on (dive, type) since v178, and + /// its primary key is a surrogate uuid minted per device. A peer's row for a + /// pair this device already has is therefore NOT a primary-key conflict: + /// `insertOnConflictUpdate` targets the PK, misses, hits the index, and + /// throws SqliteException(2067) -- failing the whole merge. + /// + /// An empty `target` means plain `ON CONFLICT DO NOTHING`, which absorbs a + /// conflict on ANY uniqueness constraint. Dropping the peer's row loses + /// nothing: the pair is the entire meaning of a junction row, and this table + /// carries no other mutable column. Before that index existed, keeping both + /// rows is exactly how one dive came to show the same type twice on every + /// device in the fleet (issue #1360). + Future _applyDiveDiveTypeRecord(DiveDiveType record) async { + await _db + .into(_db.diveDiveTypes) + .insert( + record, + onConflict: DoNothing<$DiveDiveTypesTable, DiveDiveType>( + target: const [], + ), + ); + } + /// Applies one incoming record. /// /// HLC-bearing entities (`entityHasUpdatedAt == true`) apply via @@ -2405,7 +2631,12 @@ class SyncDataSerializer { case 'divers': await _db .into(_db.divers) - .insertOnConflictUpdate(Diver.fromJson(data).toCompanion(false)); + .insertOnConflictUpdate( + Diver.fromJson( + data, + serializer: _syncBlobSerializer, + ).toCompanion(false), + ); return; case 'diverSettings': await _db @@ -2422,9 +2653,11 @@ class SyncDataSerializer { .insertOnConflictUpdate(Dive.fromJson(data).toCompanion(false)); return; case 'diveProfiles': - await _db - .into(_db.diveProfiles) - .insertOnConflictUpdate(DiveProfile.fromJson(data)); + // The row-per-sample tables are gone (v183): an older peer's row + // stages in a TEMP table instead and is packed into a series after + // the merge (SyncService._packLegacySamplesIfPresent). + await ensureLegacyStagingTables(_db); + await stageLegacyProfileRows(_db, [data]); return; case 'diveTanks': await _db @@ -2510,7 +2743,12 @@ class SyncDataSerializer { case 'buddies': await _db .into(_db.buddies) - .insertOnConflictUpdate(Buddy.fromJson(data).toCompanion(false)); + .insertOnConflictUpdate( + Buddy.fromJson( + data, + serializer: _syncBlobSerializer, + ).toCompanion(false), + ); return; case 'mediaStores': await _db @@ -2724,9 +2962,7 @@ class SyncDataSerializer { await _applyDiveTagRecord(DiveTag.fromJson(_withTagAlias(data))); return; case 'diveDiveTypes': - await _db - .into(_db.diveDiveTypes) - .insertOnConflictUpdate(DiveDiveType.fromJson(data)); + await _applyDiveDiveTypeRecord(DiveDiveType.fromJson(data)); return; case 'diveTypes': await _db @@ -2755,9 +2991,10 @@ class SyncDataSerializer { ); return; case 'tankPressureProfiles': - await _db - .into(_db.tankPressureProfiles) - .insertOnConflictUpdate(TankPressureProfile.fromJson(data)); + // See the 'diveProfiles' case above: stages into a TEMP table and + // packs after the merge. + await ensureLegacyStagingTables(_db); + await stageLegacyTankRows(_db, [data]); return; case 'tideRecords': await _db @@ -2847,6 +3084,13 @@ class SyncDataSerializer { SiteSpecy.fromJson(_withTimestampDefaults(data)), ); return; + case 'mediaSpecies': + await _db + .into(_db.mediaSpecies) + .insertOnConflictUpdate( + MediaSpecy.fromJson(_withTimestampDefaults(data)), + ); + return; case 'siteFeatures': await _db .into(_db.siteFeatures) @@ -2879,6 +3123,185 @@ class SyncDataSerializer { FieldPreset.fromJson(_withTimestampDefaults(data)), ); return; + case 'diveProfileSeries': + // Parsed inside a try for the reason the batch path documents: a + // truncated base64 `samples` string throws FormatException and a + // missing key throws TypeError, both before the soundness filter + // can run. resolveConflict and the adopt/restore loop call this + // with no per-record catch, so an unguarded parse turns one + // malformed peer record into a failed restore. `on Object` because + // [data] is untrusted wire data from a peer. + final DiveProfileSeriesRow row; + try { + row = DiveProfileSeriesRow.fromJson( + data, + serializer: _syncBlobSerializer, + ); + // Inside the same guard as the parse, matching the batch path: + // the check only catches ProfileSeriesCodecException today, and + // resolveConflict and the adopt/restore loop call this with no + // per-record catch, so a decoder that ever leaked another error + // would take down a whole restore rather than one record. + if (!_profileSeriesBlobIsSound(row)) return; + } on Object catch (e) { + _log.warning('Skipping a malformed diveProfileSeries record: $e'); + return; + } + await _db.into(_db.diveProfileSeries).insertOnConflictUpdate(row); + return; + case 'tankPressureSeries': + // See the diveProfileSeries case above: same per-record try, same + // reason. + final TankPressureSeriesRow row; + try { + row = TankPressureSeriesRow.fromJson( + data, + serializer: _syncBlobSerializer, + ); + // Inside the same guard as the parse, matching the batch path: + // the check only catches ProfileSeriesCodecException today, and + // resolveConflict and the adopt/restore loop call this with no + // per-record catch, so a decoder that ever leaked another error + // would take down a whole restore rather than one record. + if (!_tankSeriesBlobIsSound(row)) return; + } on Object catch (e) { + _log.warning('Skipping a malformed tankPressureSeries record: $e'); + return; + } + await _db.into(_db.tankPressureSeries).insertOnConflictUpdate(row); + return; + } + } + + /// Compares one header scalar against the value the blob decodes to, by + /// bit pattern rather than with `!=`. + /// + /// `!=` reports -0.0 as equal to 0.0, so a header that genuinely + /// disagrees with its blob at that value would pass the very check that + /// exists to catch a tampered one. It also reports NaN as different from + /// itself, which would be the wrong answer for a header that matches its + /// blob exactly; [_profileSeriesHeaderIsStorable] rejects a non-finite + /// header before this ever has to decide, for a different reason. + static bool _headerDoubleDiffers(double fromBlob, double fromHeader) => + fromBlob.compareTo(fromHeader) != 0; + + /// False when a scalar the row carries cannot be stored in its column. + /// + /// `max_depth`, `first_depth` and `last_depth` are NOT NULL REAL columns + /// and SQLite stores a non-finite double as NULL, so an infinite or NaN + /// header fails the insert. That insert is one batch for every series + /// record in the payload and runs inside the merge transaction, so one + /// such row would take down the whole batch rather than itself: this + /// filter drops it here, where a skip costs one record and one log line. + /// + /// Non-finite depths are a real input class ([ProfileSeriesSummary.of] + /// seeds `maxDepth` from the first sample with a `>` that never + /// overwrites a NaN seed, and the data-quality builder filters on + /// `depth.isFinite`), even though no peer can send one through JSON, + /// which encodes neither NaN nor infinity. + static bool _profileSeriesHeaderIsStorable(DiveProfileSeriesRow row) => + row.maxDepth.isFinite && + row.firstDepth.isFinite && + row.lastDepth.isFinite; + + /// A peer's packed samples are decoded once before they are written so a + /// corrupt or truncated blob never reaches the readers. Returns false (and + /// logs) when the blob does not decode or when the summary computed from + /// the decoded samples disagrees with any scalar the row carries. + /// + /// The scalars are recomputed rather than only compared by count because + /// seven SQL consumers read them directly without ever decoding the blob + /// (deco classification, runtime fallback, quality neighbours and more): + /// a row whose header was tampered with, or corrupted independently of + /// the blob, would otherwise write scalars that disagree with the samples + /// they claim to summarize. + bool _profileSeriesBlobIsSound(DiveProfileSeriesRow row) { + if (!_profileSeriesHeaderIsStorable(row)) { + _log.warning( + 'Skipping diveProfileSeries ${row.id}: a non-finite depth scalar ' + 'cannot be stored in a NOT NULL REAL column', + ); + return false; + } + try { + final decoded = const ProfileSeriesCodec().decode(row.samples); + final summary = ProfileSeriesSummary.of(decoded); + if (summary.sampleCount != row.sampleCount || + summary.startTimestamp != row.startTimestamp || + summary.endTimestamp != row.endTimestamp || + _headerDoubleDiffers(summary.maxDepth, row.maxDepth) || + _headerDoubleDiffers(summary.firstDepth, row.firstDepth) || + _headerDoubleDiffers(summary.lastDepth, row.lastDepth) || + summary.hasDecoType != row.hasDecoType || + summary.hasDecoStop != row.hasDecoStop || + summary.hasPositiveCeiling != row.hasPositiveCeiling) { + _log.warning( + 'Skipping diveProfileSeries ${row.id}: the header scalars do not ' + 'match the summary the blob decodes to', + ); + return false; + } + return true; + } on UnknownSeriesVersionException catch (e) { + // Forward compatibility, not corruption: a codec version above + // everything this build knows is a series a NEWER peer wrote, and the + // rules for raising the compatibility floor do not classify adding a + // codec version as breaking, so no floor holds that peer back. The + // samples are fine and this device will read them once it updates; + // discarding the row at the door would lose them for good, because + // nothing re-requests a record the sender believes it delivered. The + // row is stored with the scalars the peer computed (this build cannot + // recompute them without the field table), and every local reader + // skips a blob it cannot decode. + if (e.isForwardVersion) { + _log.info( + 'Storing diveProfileSeries ${row.id} written by a newer codec ' + '(version ${e.blobVersion}); it reads once this device updates', + ); + return true; + } + _log.warning('Skipping diveProfileSeries ${row.id}: $e'); + return false; + } on ProfileSeriesCodecException catch (e) { + _log.warning('Skipping diveProfileSeries ${row.id}: $e'); + return false; + } + } + + /// The tank twin of [_profileSeriesBlobIsSound]. + /// + /// [TankPressureSeriesSummary] carries only counts and timestamps, so every + /// scalar compared here is an int; a double scalar added later belongs in + /// [_headerDoubleDiffers] rather than behind a plain `!=`. + bool _tankSeriesBlobIsSound(TankPressureSeriesRow row) { + try { + final decoded = const TankPressureSeriesCodec().decode(row.samples); + final summary = TankPressureSeriesSummary.of(decoded); + if (summary.sampleCount != row.sampleCount || + summary.startTimestamp != row.startTimestamp || + summary.endTimestamp != row.endTimestamp) { + _log.warning( + 'Skipping tankPressureSeries ${row.id}: the header scalars do not ' + 'match the summary the blob decodes to', + ); + return false; + } + return true; + } on UnknownSeriesVersionException catch (e) { + // See _profileSeriesBlobIsSound: a newer codec version is stored, not + // discarded. + if (e.isForwardVersion) { + _log.info( + 'Storing tankPressureSeries ${row.id} written by a newer codec ' + '(version ${e.blobVersion}); it reads once this device updates', + ); + return true; + } + _log.warning('Skipping tankPressureSeries ${row.id}: $e'); + return false; + } on ProfileSeriesCodecException catch (e) { + _log.warning('Skipping tankPressureSeries ${row.id}: $e'); + return false; } } @@ -2908,7 +3331,14 @@ class SyncDataSerializer { await _db.batch( (b) => b.insertAllOnConflictUpdate( _db.divers, - records.map((r) => Diver.fromJson(r).toCompanion(false)).toList(), + records + .map( + (r) => Diver.fromJson( + r, + serializer: _syncBlobSerializer, + ).toCompanion(false), + ) + .toList(), ), ); return; @@ -2935,12 +3365,10 @@ class SyncDataSerializer { ); return; case 'diveProfiles': - await _db.batch( - (b) => b.insertAllOnConflictUpdate( - _db.diveProfiles, - records.map((r) => DiveProfile.fromJson(r)).toList(), - ), - ); + // See upsertRecord's 'diveProfiles' case: stages into a TEMP table + // and packs after the merge. + await ensureLegacyStagingTables(_db); + await stageLegacyProfileRows(_db, records); return; case 'diveTanks': await _db.batch( @@ -3072,7 +3500,14 @@ class SyncDataSerializer { await _db.batch( (b) => b.insertAllOnConflictUpdate( _db.buddies, - records.map((r) => Buddy.fromJson(r).toCompanion(false)).toList(), + records + .map( + (r) => Buddy.fromJson( + r, + serializer: _syncBlobSerializer, + ).toCompanion(false), + ) + .toList(), ), ); return; @@ -3404,10 +3839,15 @@ class SyncDataSerializer { ); return; case 'diveDiveTypes': + // DoNothing, not insertAllOnConflictUpdate: see + // [_applyDiveDiveTypeRecord]. await _db.batch( - (b) => b.insertAllOnConflictUpdate( + (b) => b.insertAll( _db.diveDiveTypes, records.map((r) => DiveDiveType.fromJson(r)).toList(), + onConflict: DoNothing<$DiveDiveTypesTable, DiveDiveType>( + target: const [], + ), ), ); return; @@ -3452,12 +3892,10 @@ class SyncDataSerializer { ); return; case 'tankPressureProfiles': - await _db.batch( - (b) => b.insertAllOnConflictUpdate( - _db.tankPressureProfiles, - records.map((r) => TankPressureProfile.fromJson(r)).toList(), - ), - ); + // See upsertRecord's 'tankPressureProfiles' case: stages into a TEMP + // table and packs after the merge. + await ensureLegacyStagingTables(_db); + await stageLegacyTankRows(_db, records); return; case 'tideRecords': await _db.batch( @@ -3583,6 +4021,16 @@ class SyncDataSerializer { ), ); return; + case 'mediaSpecies': + await _db.batch( + (b) => b.insertAllOnConflictUpdate( + _db.mediaSpecies, + records + .map((r) => MediaSpecy.fromJson(_withTimestampDefaults(r))) + .toList(), + ), + ); + return; case 'siteFeatures': await _db.batch( (b) => b.insertAllOnConflictUpdate( @@ -3631,6 +4079,51 @@ class SyncDataSerializer { ), ); return; + case 'diveProfileSeries': + // Each record is parsed inside its own try: a truncated base64 + // `samples` string throws FormatException, a missing key throws + // TypeError, and either one, eagerly parsed before the soundness + // filter, used to fail the whole batch instead of just that record. + // `on Object` is deliberate here: [r] is untrusted wire data from a + // peer, so any parse failure it can provoke must be caught, not + // just the two shapes seen so far. + final rows = []; + for (final r in records) { + try { + final row = DiveProfileSeriesRow.fromJson( + r, + serializer: _syncBlobSerializer, + ); + if (_profileSeriesBlobIsSound(row)) rows.add(row); + } on Object catch (e) { + _log.warning('Skipping a malformed diveProfileSeries record: $e'); + } + } + if (rows.isEmpty) return; + await _db.batch( + (b) => b.insertAllOnConflictUpdate(_db.diveProfileSeries, rows), + ); + return; + case 'tankPressureSeries': + // See the diveProfileSeries case above: same per-record try, same + // reason. + final rows = []; + for (final r in records) { + try { + final row = TankPressureSeriesRow.fromJson( + r, + serializer: _syncBlobSerializer, + ); + if (_tankSeriesBlobIsSound(row)) rows.add(row); + } on Object catch (e) { + _log.warning('Skipping a malformed tankPressureSeries record: $e'); + } + } + if (rows.isEmpty) return; + await _db.batch( + (b) => b.insertAllOnConflictUpdate(_db.tankPressureSeries, rows), + ); + return; default: throw ArgumentError('upsertRecords: unknown entityType $entityType'); } @@ -3798,8 +4291,6 @@ class SyncDataSerializer { return plain(_db.diveDiveTypes, _db.diveDiveTypes.id); case 'diveBuddies': return plain(_db.diveBuddies, _db.diveBuddies.id); - case 'diveProfiles': - return plain(_db.diveProfiles, _db.diveProfiles.id); case 'diveProfileEvents': return plain(_db.diveProfileEvents, _db.diveProfileEvents.id); case 'diveSafetyReviews': @@ -3818,6 +4309,8 @@ class SyncDataSerializer { return plain(_db.diveDataSources, _db.diveDataSources.id); case 'siteSpecies': return plain(_db.siteSpecies, _db.siteSpecies.id); + case 'mediaSpecies': + return plain(_db.mediaSpecies, _db.mediaSpecies.id); case 'siteFeatures': return plain(_db.siteFeatures, _db.siteFeatures.id); case 'csvPresets': @@ -3826,8 +4319,6 @@ class SyncDataSerializer { return plain(_db.viewConfigs, _db.viewConfigs.id); case 'fieldPresets': return plain(_db.fieldPresets, _db.fieldPresets.id); - case 'tankPressureProfiles': - return plain(_db.tankPressureProfiles, _db.tankPressureProfiles.id); case 'tideRecords': return plain(_db.tideRecords, _db.tideRecords.id); case 'sightings': @@ -3844,6 +4335,10 @@ class SyncDataSerializer { return plain(_db.media, _db.media.id); case 'mediaSmartAlbums': return plain(_db.mediaSmartAlbums, _db.mediaSmartAlbums.id); + case 'diveProfileSeries': + return plain(_db.diveProfileSeries, _db.diveProfileSeries.id); + case 'tankPressureSeries': + return plain(_db.tankPressureSeries, _db.tankPressureSeries.id); default: // Fail loud: a synced entity without a case here would silently // enumerate zero local ids, so streaming adopt would never delete its @@ -4027,8 +4522,6 @@ class SyncDataSerializer { return _db.diveDiveTypes; case 'diveBuddies': return _db.diveBuddies; - case 'diveProfiles': - return _db.diveProfiles; case 'diveProfileEvents': return _db.diveProfileEvents; case 'diveSafetyReviews': @@ -4047,6 +4540,8 @@ class SyncDataSerializer { return _db.diveDataSources; case 'siteSpecies': return _db.siteSpecies; + case 'mediaSpecies': + return _db.mediaSpecies; case 'siteFeatures': return _db.siteFeatures; case 'csvPresets': @@ -4055,8 +4550,6 @@ class SyncDataSerializer { return _db.viewConfigs; case 'fieldPresets': return _db.fieldPresets; - case 'tankPressureProfiles': - return _db.tankPressureProfiles; case 'tideRecords': return _db.tideRecords; case 'sightings': @@ -4073,6 +4566,10 @@ class SyncDataSerializer { return _db.media; case 'mediaSmartAlbums': return _db.mediaSmartAlbums; + case 'diveProfileSeries': + return _db.diveProfileSeries; + case 'tankPressureSeries': + return _db.tankPressureSeries; default: throw ArgumentError.value( entityType, @@ -4082,7 +4579,14 @@ class SyncDataSerializer { } } + // 'diveProfiles' / 'tankPressureProfiles' have no local row-per-sample + // table left to delete from (v183), but a peer below the floor does still + // tombstone its own rows, and a copy of one can be sitting in the receive + // shim's staging table waiting for a dive that has not arrived. Packing + // it later would resurrect what the peer deleted, so the tombstone clears + // it there. Everything else falls through the switch with no default. Future deleteRecord(String entityType, String recordId) async { + await deleteStagedLegacyRow(_db, entityType, recordId); switch (entityType) { case 'divers': await (_db.delete( @@ -4097,11 +4601,6 @@ class SyncDataSerializer { case 'dives': await (_db.delete(_db.dives)..where((t) => t.id.equals(recordId))).go(); return; - case 'diveProfiles': - await (_db.delete( - _db.diveProfiles, - )..where((t) => t.id.equals(recordId))).go(); - return; case 'diveTanks': await (_db.delete( _db.diveTanks, @@ -4363,11 +4862,6 @@ class SyncDataSerializer { _db.diveComputers, )..where((t) => t.id.equals(recordId))).go(); return; - case 'tankPressureProfiles': - await (_db.delete( - _db.tankPressureProfiles, - )..where((t) => t.id.equals(recordId))).go(); - return; case 'tideRecords': await (_db.delete( _db.tideRecords, @@ -4433,6 +4927,11 @@ class SyncDataSerializer { _db.siteSpecies, )..where((t) => t.id.equals(recordId))).go(); return; + case 'mediaSpecies': + await (_db.delete( + _db.mediaSpecies, + )..where((t) => t.id.equals(recordId))).go(); + return; case 'siteFeatures': await (_db.delete( _db.siteFeatures, @@ -4453,6 +4952,16 @@ class SyncDataSerializer { _db.fieldPresets, )..where((t) => t.id.equals(recordId))).go(); return; + case 'diveProfileSeries': + await (_db.delete( + _db.diveProfileSeries, + )..where((t) => t.id.equals(recordId))).go(); + return; + case 'tankPressureSeries': + await (_db.delete( + _db.tankPressureSeries, + )..where((t) => t.id.equals(recordId))).go(); + return; } } @@ -4470,7 +4979,8 @@ class SyncDataSerializer { query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); } final rows = await query.get(); - return rows.map((r) => r.toJson()).toList(); + // Divers carry the profile photo BLOB; base64-encode it. + return rows.map((r) => r.toJson(serializer: _syncBlobSerializer)).toList(); } Future>> _exportDiverSettings( @@ -4496,26 +5006,6 @@ class SyncDataSerializer { return rows.map((r) => r.toJson()).toList(); } - Future>> _exportDiveProfiles( - String? hlcSince, - ) async { - // Profile points don't have updatedAt, export all for modified dives - if (hlcSince != null) { - final modifiedDives = await (_db.select( - _db.dives, - )..where((t) => t.hlc.isBiggerThanValue(hlcSince))).get(); - final diveIds = modifiedDives.map((d) => d.id).toSet(); - if (diveIds.isEmpty) return []; - - final rows = await (_db.select( - _db.diveProfiles, - )..where((t) => t.diveId.isIn(diveIds))).get(); - return rows.map((r) => r.toJson()).toList(); - } - final rows = await _db.select(_db.diveProfiles).get(); - return rows.map((r) => r.toJson()).toList(); - } - Future>> _exportDiveTanks(String? hlcSince) async { // Similar to profiles, export for modified dives if (hlcSince != null) { @@ -4738,7 +5228,8 @@ class SyncDataSerializer { query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); } final rows = await query.get(); - return rows.map((r) => r.toJson()).toList(); + // Buddies carry the profile photo BLOB; base64-encode it. + return rows.map((r) => r.toJson(serializer: _syncBlobSerializer)).toList(); } Future>> _exportMediaStores( @@ -5059,6 +5550,70 @@ class SyncDataSerializer { return rows.map((r) => r.toJson(serializer: _syncBlobSerializer)).toList(); } + /// The stored size, in bytes, of the packed sample blobs an incremental + /// changeset would carry above [hlcSince] (everything when it is null). + /// + /// The changeset export builds its whole payload in memory, base64 and + /// `jsonEncode` alive at once, which the base path deliberately avoids by + /// streaming to a temp file. These two entities are the only ones whose + /// rows carry a large blob AND can all move at once: the v182 migration + /// stamps every packed row with one freshly issued HLC, so the first + /// changeset after the upgrade would otherwise select the entire packed + /// corpus into a single unstreamed payload. [ChangesetWriter] asks this + /// first and publishes a streamed base instead when the answer is too big. + /// + /// `length()` on a blob column reads the record header, not the payload, + /// so this costs a scan of two small tables and no blob reads. + Future pendingSeriesBlobBytes(String? hlcSince) async { + var total = 0; + for (final table in const ['dive_profile_series', 'tank_pressure_series']) { + // Guarded per table: _assertProfileSeriesSchema waits for each series + // table's foreign key parents, so a partially built database can reach + // a publish without one. + final exists = await _db + .customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(table)], + ) + .get(); + if (exists.isEmpty) continue; + final row = await _db + .customSelect( + 'SELECT COALESCE(SUM(LENGTH(samples)), 0) AS n FROM $table' + '${hlcSince == null ? '' : ' WHERE hlc > ?'}', + variables: hlcSince == null + ? const [] + : [Variable(hlcSince)], + ) + .getSingle(); + total += row.read('n'); + } + return total; + } + + Future>> _exportDiveProfileSeries( + String? hlcSince, + ) async { + final query = _db.select(_db.diveProfileSeries); + if (hlcSince != null) { + query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); + } + final rows = await query.get(); + // The packed samples BLOB rides as base64, like gps_tracks.points. + return rows.map((r) => r.toJson(serializer: _syncBlobSerializer)).toList(); + } + + Future>> _exportTankPressureSeries( + String? hlcSince, + ) async { + final query = _db.select(_db.tankPressureSeries); + if (hlcSince != null) { + query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); + } + final rows = await query.get(); + return rows.map((r) => r.toJson(serializer: _syncBlobSerializer)).toList(); + } + Future>> _exportDivePlans(String? hlcSince) async { final query = _db.select(_db.divePlans); if (hlcSince != null) { @@ -5196,25 +5751,6 @@ class SyncDataSerializer { return copy; } - Future>> _exportTankPressureProfiles( - String? hlcSince, - ) async { - if (hlcSince != null) { - final modifiedDives = await (_db.select( - _db.dives, - )..where((t) => t.hlc.isBiggerThanValue(hlcSince))).get(); - final diveIds = modifiedDives.map((d) => d.id).toSet(); - if (diveIds.isEmpty) return []; - - final rows = await (_db.select( - _db.tankPressureProfiles, - )..where((t) => t.diveId.isIn(diveIds))).get(); - return rows.map((r) => r.toJson()).toList(); - } - final rows = await _db.select(_db.tankPressureProfiles).get(); - return rows.map((r) => r.toJson()).toList(); - } - Future>> _exportTideRecords( String? hlcSince, ) async { @@ -5344,15 +5880,60 @@ class SyncDataSerializer { final siteIds = modifiedSites.map((s) => s.id).toSet(); if (siteIds.isEmpty) return []; - final rows = await (_db.select( - _db.siteSpecies, - )..where((t) => t.siteId.isIn(siteIds))).get(); - return rows.map((r) => r.toJson()).toList(); + return _childRowsOf( + siteIds, + (chunk) => (_db.select( + _db.siteSpecies, + )..where((t) => t.siteId.isIn(chunk))).get(), + ); } final rows = await _db.select(_db.siteSpecies).get(); return rows.map((r) => r.toJson()).toList(); } + /// Rows of a clockless child table for [parentIds], fetched in chunks so + /// a large changeset (every photo modified since the cursor, say) cannot + /// overflow SQLite's bound-variable limit in one `IN (...)`. + Future>> _childRowsOf( + Set parentIds, + Future> Function(List chunk) select, + ) async { + const idChunk = 900; + final ids = parentIds.toList(); + final out = >[]; + for (var i = 0; i < ids.length; i += idChunk) { + final end = i + idChunk < ids.length ? i + idChunk : ids.length; + for (final r in await select(ids.sublist(i, end))) { + out.add(r.toJson()); + } + } + return out; + } + + /// `media_species` has no clock of its own, so an incremental export + /// ships the tags of every photo whose `media.hlc` advanced; a full export + /// ships the table. + Future>> _exportMediaSpecies( + String? hlcSince, + ) async { + if (hlcSince != null) { + final modifiedMedia = await (_db.select( + _db.media, + )..where((t) => t.hlc.isBiggerThanValue(hlcSince))).get(); + final mediaIds = modifiedMedia.map((m) => m.id).toSet(); + if (mediaIds.isEmpty) return []; + + return _childRowsOf( + mediaIds, + (chunk) => (_db.select( + _db.mediaSpecies, + )..where((t) => t.mediaId.isIn(chunk))).get(), + ); + } + final rows = await _db.select(_db.mediaSpecies).get(); + return rows.map((r) => r.toJson()).toList(); + } + Future>> _exportCsvPresets(String? hlcSince) async { final query = _db.select(_db.csvPresets); if (hlcSince != null) { @@ -5728,6 +6309,11 @@ class SyncDataSerializer { // v161: seed it so payloads predating the column hydrate instead of // throwing in DiverSetting.fromJson. 'defaultShowO2CellMv': false, + // v177: GTR settings; seed them so payloads predating the columns + // hydrate instead of throwing in DiverSetting.fromJson. + 'defaultShowGtr': false, + 'defaultGtrSource': 1, + 'gtrReservePressure': 50.0, // v166: seed it so payloads predating the column hydrate instead of // throwing in DiverSetting.fromJson (issue #1187). 'placeNameLanguage': 'en', diff --git a/lib/core/services/sync/sync_initializer.dart b/lib/core/services/sync/sync_initializer.dart index 28e1bcc152..287dc7324b 100644 --- a/lib/core/services/sync/sync_initializer.dart +++ b/lib/core/services/sync/sync_initializer.dart @@ -145,6 +145,22 @@ class SyncInitializer { sentinelDeviceId != deviceId; if (restoreDetected) { + // A database with no sync history has no baseline to rewind and no + // stale tombstones to clear, so there is nothing for a rebaseline to + // repair: this is a fresh install, not a restore. Preserving the + // anchored id here would be actively harmful: this install would then + // own every file the previous install published, so its own cloud + // library would list as "ours" rather than a peer's and the account + // would read as empty. Keep the identity we minted and re-point the + // anchors at it instead. + if (!await _syncRepository.hasSyncHistory()) { + _log.info( + 'Anchors name a previous install but this database has never ' + 'synced; keeping the freshly minted identity', + ); + await _establishAnchors(deviceId); + return DeviceIdentityStatus.freshInstall; + } _log.warning( 'On-disk database is not the one we last wrote (restore/overwrite ' 'detected); re-baselining sync and restoring the live identity', @@ -388,13 +404,21 @@ class SyncInitializer { CloudStorageProvider provider, ) async { final deviceId = await _syncRepository.getDeviceId(); + return (await _changesetLogFiles( + provider, + )).where((f) => ChangesetLogLayout.deviceIdOf(f.name) != deviceId).toList(); + } + + /// Every changeset-log artifact on the account, this device's own included. + /// One listing, so callers that need both halves of the split can take the + /// snapshot once instead of paying a second network round trip. + Future> _changesetLogFiles( + CloudStorageProvider provider, + ) async { final files = await provider.listFiles( namePattern: ChangesetLogLayout.prefix, ); - return files - .where((f) => ChangesetLogLayout.isOurs(f.name)) - .where((f) => ChangesetLogLayout.deviceIdOf(f.name) != deviceId) - .toList(); + return files.where((f) => ChangesetLogLayout.isOurs(f.name)).toList(); } /// What a peer listing says about this account, in one round trip. @@ -402,6 +426,52 @@ class SyncInitializer { CloudStorageProvider provider, ) async => classifyPeerFiles(await peerLogFiles(provider)); + /// What the account holds, from the point of view of an install that has no + /// library of its own yet, i.e. the setup wizard's Connect step. + /// + /// Differs from [peerLibraryState] in one case: the listing is empty of + /// PEER files but the account does hold a changeset log under OUR OWN device + /// id. That happens whenever an install inherits an earlier install's + /// identity, which is routine on macOS, where replacing the .app leaves + /// ~/Library/Preferences, and with it the device-id anchor, in place. Every + /// file the earlier install published then reads as "ours", the account + /// lists as empty, and the wizard offers Start Fresh over a live library. + /// + /// The files can only be pulled as a peer's, so the fix is to stop claiming + /// the retired identity: mint a fresh one and re-classify. Guarded on + /// [localLibraryIsEmpty] because a device that still holds the library it + /// published must keep the identity that published it, or it orphans its own + /// log. Nothing is deleted either way. + Future firstContactLibraryState( + CloudStorageProvider provider, { + required bool localLibraryIsEmpty, + }) async { + // One listing serves every branch below, including the re-classification + // after an identity swap: the Connect step waits on this, and listFiles is + // a network round trip on every real provider. + final files = await _changesetLogFiles(provider); + final deviceId = await _syncRepository.getDeviceId(); + bool isOurOwn(CloudFileInfo f) => + ChangesetLogLayout.deviceIdOf(f.name) == deviceId; + + final state = classifyPeerFiles(files.where((f) => !isOurOwn(f)).toList()); + if (state != PeerLibraryState.none || !localLibraryIsEmpty) return state; + + final ours = files.where(isOurOwn).toList(); + if (classifyPeerFiles(ours) == PeerLibraryState.none) return state; + + _log.warning( + 'The account holds a library under this install\'s own device id while ' + 'it has no library locally; adopting a fresh identity so those files ' + 'can be pulled as a peer\'s', + ); + await adoptFreshIdentity(); + // The id just minted is brand new, so no file in the snapshot can carry + // it: every one of them belongs to a peer now, and re-classifying is + // local work. + return classifyPeerFiles(files); + } + /// Classifies a peer listing. Static and visible for testing for the same /// reason as SyncNotifier.skippedPeerLabels: it is pure, and the rule /// deserves tests that do not need a provider or a database. @@ -462,6 +532,14 @@ enum DeviceIdentityStatus { /// re-baselined and the live identity restored. rebaselined, + /// The anchors named a previous install, but the on-disk database has no + /// sync history at all: a fresh install on a machine whose preferences + /// outlived the old one (routine on macOS, where replacing the .app leaves + /// ~/Library/Preferences untouched). This install keeps the identity it + /// minted and the anchors are re-pointed at it, so the previous install's + /// cloud files stay visible as a peer's. + freshInstall, + /// The reconcile could not run (e.g. the metadata lookup failed). Launch /// continues regardless. error, diff --git a/lib/core/services/sync/sync_service.dart b/lib/core/services/sync/sync_service.dart index 287c749172..5ab5872c60 100644 --- a/lib/core/services/sync/sync_service.dart +++ b/lib/core/services/sync/sync_service.dart @@ -13,9 +13,11 @@ import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.da import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/sync/conflict_reference.dart'; +import 'package:submersion/core/services/sync/changeset_log/base_apply_progress.dart'; import 'package:submersion/core/services/sync/changeset_log/base_json_stream_reader.dart'; import 'package:submersion/core/services/sync/changeset_log/base_parse_client.dart'; import 'package:submersion/core/services/sync/changeset_log/base_part_file_sink.dart'; +import 'package:submersion/core/services/sync/changeset_log/byte_progress_stream.dart'; import 'package:submersion/core/services/sync/changeset_log/changeset_codec.dart'; import 'package:submersion/core/services/sync/changeset_log/sync_temp_dir.dart'; import 'package:submersion/core/services/sync/changeset_log/changeset_log_layout.dart'; @@ -83,6 +85,15 @@ class SyncResult { /// [skippedPeerNames]. final Map newerSchemaPeerNames; + /// Peers whose changeset read threw during the pull. Their cursors stayed + /// put, so the next sync retries them; the UI names them so the user can + /// see whose data did not merge this run. + final Set readFailedPeerDeviceIds; + + /// Display names for [readFailedPeerDeviceIds], same contract as + /// [skippedPeerNames]. + final Map readFailedPeerNames; + /// Set with [SyncResultStatus.awaitingAdoption]: the cloud library was /// replaced under this marker's epoch and the user must adopt (or defer) /// before any sync can proceed. @@ -99,6 +110,8 @@ class SyncResult { this.skippedPeerNames = const {}, this.newerSchemaPeerDeviceIds = const {}, this.newerSchemaPeerNames = const {}, + this.readFailedPeerDeviceIds = const {}, + this.readFailedPeerNames = const {}, this.replaceMarker, }); @@ -314,8 +327,16 @@ class SyncService { /// Test seam: apply a base from a local file through the streaming merge. @visibleForTesting Future<({int recordsApplied, int conflictsFound, int recordsFailed})> - debugApplyBaseFile(String filePath, {DateTime? lastSync}) async { - final r = await _applyRemoteBaseFile(filePath, lastSync); + debugApplyBaseFile( + String filePath, { + DateTime? lastSync, + void Function(double fraction)? onProgress, + }) async { + final r = await _applyRemoteBaseFile( + filePath, + lastSync, + onProgress: onProgress, + ); return ( recordsApplied: r.recordsApplied, conflictsFound: r.conflictsFound, @@ -596,8 +617,32 @@ class SyncService { conflictsFound += r.conflictsFound; recordsFailed += r.recordsFailed; }, + // A peer's base can run to hundreds of megabytes and take minutes + // to download and import. Spread it across the pull's share of the + // bar (0.4 to 0.8) so the user sees it advancing instead of reading a + // motionless bar as a hang and killing the app, which restarts the + // whole adoption from part 0 (issue #1421). + onBaseDownloadProgress: (manifest, downloaded, total) => + _reportProgress( + SyncPhase.downloading, + 0.4 + 0.15 * (downloaded / total), + _l10n.settings_cloudSync_progress_downloadingLibrary( + downloaded, + total, + ), + ), applyBaseFile: (path, manifest) async { - final r = await _applyRemoteBaseFile(path, lastSyncTime); + final r = await _applyRemoteBaseFile( + path, + lastSyncTime, + onProgress: (fraction) => _reportProgress( + SyncPhase.downloading, + 0.55 + 0.25 * fraction, + _l10n.settings_cloudSync_progress_importingLibrary( + (fraction * 100).round(), + ), + ), + ); recordsSynced += r.recordsApplied; conflictsFound += r.conflictsFound; recordsFailed += r.recordsFailed; @@ -798,6 +843,8 @@ class SyncService { skippedPeerNames: pullResult.skippedPeerNames, newerSchemaPeerDeviceIds: pullResult.newerSchemaPeerDeviceIds, newerSchemaPeerNames: pullResult.newerSchemaPeerNames, + readFailedPeerDeviceIds: pullResult.readFailedPeerDeviceIds, + readFailedPeerNames: pullResult.readFailedPeerNames, ); } on TimeoutException { _log.warning('Sync timed out'); @@ -1149,7 +1196,19 @@ class SyncService { // skipped below and the live row wins (clearing any local tombstone in the // merge heals a peer that already dropped the row on the buggy build). final contradictedByEntity = >{}; - final liveByType = remotePayload.data.toJson(); + // toJson omits the inbound-only legacy sample entities, so they are + // folded back in from the typed fields fromJson already parsed them + // into, the way the adopt path does. Without them a peer below v183 + // that publishes a row both live and tombstoned (consolidation undo + // re-inserts the snapshot's rows while the old tombstones are still in + // its deletion_log) has the tombstone win: these entities carry no + // updatedAt, so _mergeEntity's local-deletion guard has no "newer than + // the deletion" escape and skips the live row entirely. + final liveByType = { + ...remotePayload.data.toJson(), + 'diveProfiles': remotePayload.data.diveProfiles, + 'tankPressureProfiles': remotePayload.data.tankPressureProfiles, + }; for (final delEntry in remotePayload.deletions.entries) { final deletedIds = {for (final d in delEntry.value) d.id}; if (deletedIds.isEmpty) continue; @@ -1380,10 +1439,10 @@ class SyncService { hasUpdatedAt: false, ), (type: 'gasSwitches', records: data.gasSwitches, hasUpdatedAt: false), - // Extra entities added in the SyncData expansion. Four are + // Extra entities added in the SyncData expansion. Five are // append-only and use the blind-upsert merge path (no updatedAt // column: diveCustomFields, diveDataSources, siteSpecies, - // fieldPresets). Two carry updatedAt and use the standard + // mediaSpecies, fieldPresets). Two carry updatedAt and use the standard // conflict-detection path (csvPresets, viewConfigs). FK ordering // is handled by the deferred-FK transaction wrapping this loop. ( @@ -1396,7 +1455,22 @@ class SyncService { records: data.diveDataSources, hasUpdatedAt: false, ), + ( + type: 'diveProfileSeries', + records: data.diveProfileSeries, + hasUpdatedAt: true, + ), + ( + type: 'tankPressureSeries', + records: data.tankPressureSeries, + hasUpdatedAt: true, + ), (type: 'siteSpecies', records: data.siteSpecies, hasUpdatedAt: false), + ( + type: 'mediaSpecies', + records: data.mediaSpecies, + hasUpdatedAt: false, + ), ( type: 'siteFeatures', records: data.siteFeatures, @@ -1519,6 +1593,11 @@ class SyncService { recordsFailed += result.recordsFailed; } + await _packLegacySamplesIfPresent( + data.diveProfiles.isNotEmpty || data.tankPressureProfiles.isNotEmpty, + parentsArrived: data.dives.isNotEmpty || data.diveTanks.isNotEmpty, + ); + // Integrity backstop: applying a remote deletion of a parent can leave a // local row pointing at it via a non-cascading FK, which would fail the // deferred-FK COMMIT and abort the whole sync. Clear or delete any such @@ -1533,6 +1612,64 @@ class SyncService { ); } + /// v182 receive-side tolerance: after a merge (changeset OR base file) has + /// applied any inbound-only legacy row-per-sample rows, pack them into + /// series for whichever dives don't already have one. A no-op (and no log) + /// when this payload carried none AND nothing is still staged, which is + /// the common case once every peer has upgraded. + /// + /// The staging check is what makes the retry this method's own doc + /// promises real. A staged row whose dive had not arrived is kept for the + /// next apply, and the payload that finally brings that dive need not + /// carry legacy rows of its own: gating on the payload alone let the rows + /// sit until the next payload happened to carry legacy rows of its own. + /// + /// Runs inside the caller's deferred-FK merge transaction, so a throw here + /// must never escape: it would roll back the whole payload while leaving + /// the changeset reader's cursor advanced, and the next sync would neither + /// replay nor retry the payload, wedging the peer permanently. The legacy + /// rows this call packs are already applied and durable in + /// `legacy_sample_staging.dart`'s staging tables by the time it runs, and + /// [SyncDataSerializer.packLegacySamples] leaves them staged on a throw + /// (it only empties the staging tables after a successful pack), so + /// nothing is lost by deferring: the next apply in this session (another + /// changeset, base file, or adopt) packs the same staged rows. The + /// staging tables are ordinary tables, not TEMP, so that retry survives + /// an app restart: the changeset cursor that would offer these rows again + /// is committed durably, and a staging that evaporated with the + /// connection made the promise false exactly when it mattered. + Future _packLegacySamplesIfPresent( + bool anyLegacyRowsApplied, { + bool parentsArrived = false, + }) async { + if (!anyLegacyRowsApplied) { + // A staged row the last pass could not place is blocked on a parent + // (its dive, or a pressure row's tank). Only a payload that brought + // one can unblock it, so a payload that brought neither cannot, and + // re-running the whole pack on every apply to find that out is the + // difference between a bounded retry and a permanent per-apply cost + // for one row that may never be placeable. + if (!parentsArrived) return; + if (!await _serializer.hasStagedLegacySamples()) return; + } + try { + final packed = await _serializer.packLegacySamples(); + _log.info( + 'Packed legacy sample rows from a peer: ' + '${packed.profileSeries} profile series, ' + '${packed.tankSeries} tank series, ' + '${packed.skippedAlreadyPacked} dives already packed here', + ); + } catch (e, st) { + _log.warning( + 'Packing legacy sample rows from a peer failed; the rows stay ' + 'staged and the next sync apply in this session retries them', + error: e, + stackTrace: st, + ); + } + } + /// Apply a base that was streamed to a local temp [filePath], in bounded /// memory. Three forward passes over the file: /// 1. header `exportedAt` + the full `deletions` map (both small), @@ -1543,24 +1680,54 @@ class SyncService { /// main isolate in BasePartFileSink.assemble -- folding it in is a deferred /// follow-up) and falls back to the inline path on any worker failure, so a /// broken isolate degrades to old behaviour and never fails or corrupts sync. + /// + /// [onProgress] receives a 0.0 to 1.0 fraction spanning the three passes + /// (each pass is one third), with a final 1.0 once the transaction commits. Future<_MergeResult> _applyRemoteBaseFile( String filePath, - DateTime? localLastSync, - ) async { + DateTime? localLastSync, { + void Function(double fraction)? onProgress, + }) async { + final totalBytes = await File(filePath).length(); + final stopwatch = Stopwatch()..start(); + _log.info('Applying peer base: $totalBytes bytes'); + final progress = BaseApplyProgress(totalBytes, onProgress); BaseParseClient? client; + _MergeResult result; try { client = await baseParseClientSpawn(filePath); - return await _applyRemoteBaseFileViaWorker(client, localLastSync); + client.onProgress = progress.bytes; + result = await _applyRemoteBaseFileViaWorker( + client, + localLastSync, + progress, + ); } catch (e, st) { _log.warning( 'Base-apply worker failed; falling back to inline', error: e, stackTrace: st, ); - return _applyRemoteBaseFileInline(filePath, localLastSync); + // Kill the (possibly wedged, memory-hungry) worker BEFORE the inline + // passes start competing with it for memory and file I/O. + await client?.dispose(); + client = null; + progress.restart(); + result = await _applyRemoteBaseFileInline( + filePath, + localLastSync, + progress, + ); } finally { await client?.dispose(); } + progress.done(); + _log.info( + 'Peer base applied in ${stopwatch.elapsed.inSeconds}s: ' + '${result.recordsApplied} applied, ${result.conflictsFound} conflicts, ' + '${result.recordsFailed} failed', + ); + return result; } /// Worker-backed base apply: the file read + JSON parse run in [client]'s @@ -1571,6 +1738,7 @@ class SyncService { Future<_MergeResult> _applyRemoteBaseFileViaWorker( BaseParseClient client, DateTime? localLastSync, + BaseApplyProgress progress, ) async { final lastSyncMs = localLastSync?.millisecondsSinceEpoch; @@ -1595,10 +1763,11 @@ class SyncService { final parentUpdatedAt = >{}; final contradictedByEntity = >{}; final pass2Tables = { - for (final table in entityHasUpdatedAt.keys) + for (final table in _baseApplyEntityFlags.keys) if (parentTypes.contains(table) || deletionIds.containsKey(table)) table, }; + progress.beginPass(1); client.startDataRows(pass2Tables); List<({String table, Map row})>? p2batch; while ((p2batch = await client.nextDataBatch()) != null) { @@ -1607,7 +1776,8 @@ class SyncService { final rec = r.row; final id = _recordIdForEntity(table, rec); if (id == null) continue; - if (parentTypes.contains(table) && entityHasUpdatedAt[table] == true) { + if (parentTypes.contains(table) && + _baseApplyEntityFlags[table] == true) { final u = _extractUpdatedAtMillis(rec); if (u != null) (parentUpdatedAt[table] ??= {})[id] = u; } @@ -1653,6 +1823,11 @@ class SyncService { const batchSize = 500; String? currentTable; var batch = >[]; + // v182 receive-side tolerance: true once a legacy diveProfiles / + // tankPressureProfiles row has streamed through, so the packer only + // runs when this base actually carried some. + var sawLegacySamples = false; + var sawParents = false; Future flush() async { final table = currentTable; @@ -1660,7 +1835,7 @@ class SyncService { final r = await _mergeEntity( entityType: table, records: batch, - hasUpdatedAt: entityHasUpdatedAt[table] ?? false, + hasUpdatedAt: _baseApplyEntityFlags[table] ?? false, lastSyncMs: lastSyncMs, pendingRecordIds: pendingByEntity[table] ?? const {}, allTombstones: tombstonesByEntity, @@ -1673,7 +1848,8 @@ class SyncService { batch = >[]; } - client.startDataRows(entityHasUpdatedAt.keys.toSet()); + progress.beginPass(2); + client.startDataRows(_baseApplyEntityFlags.keys.toSet()); List<({String table, Map row})>? p3batch; while ((p3batch = await client.nextDataBatch()) != null) { for (final r in p3batch!) { @@ -1681,12 +1857,23 @@ class SyncService { await flush(); currentTable = r.table; } + if (inboundOnlyLegacyEntities.containsKey(r.table)) { + sawLegacySamples = true; + } + if (r.table == 'dives' || r.table == 'diveTanks') { + sawParents = true; + } batch.add(r.row); if (batch.length >= batchSize) await flush(); } } await flush(); + await _packLegacySamplesIfPresent( + sawLegacySamples, + parentsArrived: sawParents, + ); + await _serializer.repairDanglingForeignKeys(); return _MergeResult( recordsApplied: recordsApplied, @@ -1703,15 +1890,20 @@ class SyncService { Future<_MergeResult> _applyRemoteBaseFileInline( String filePath, DateTime? localLastSync, + BaseApplyProgress progress, ) async { final file = File(filePath); final lastSyncMs = localLastSync?.millisecondsSinceEpoch; + // The inline parse blocks the UI isolate, so its heartbeats are what + // keep the bar moving; the fallback runs the whole file three times. + Stream> openWithProgress() => + withByteProgress(file.openRead(), onProgress: progress.consumed); // ---- Pass 1: exportedAt + deletions ---- var baseExportedAt = 0; final deletions = >{}; await BaseJsonStreamReader().parse( - file.openRead(), + openWithProgress(), onScalar: (key, raw) async { if (key == 'exportedAt') { baseExportedAt = (jsonDecode(utf8.decode(raw)) as num?)?.toInt() ?? 0; @@ -1746,17 +1938,19 @@ class SyncService { }; final parentUpdatedAt = >{}; final contradictedByEntity = >{}; + progress.beginPass(1); await BaseJsonStreamReader().parse( - file.openRead(), + openWithProgress(), wantRows: (section, table) => section == 'data' && - entityHasUpdatedAt.containsKey(table) && + _baseApplyEntityFlags.containsKey(table) && (parentTypes.contains(table) || deletionIds.containsKey(table)), onRow: (section, table, rowBytes) async { final rec = jsonDecode(utf8.decode(rowBytes)) as Map; final id = _recordIdForEntity(table, rec); if (id == null) return; - if (parentTypes.contains(table) && entityHasUpdatedAt[table] == true) { + if (parentTypes.contains(table) && + _baseApplyEntityFlags[table] == true) { final u = _extractUpdatedAtMillis(rec); if (u != null) (parentUpdatedAt[table] ??= {})[id] = u; } @@ -1808,6 +2002,11 @@ class SyncService { const batchSize = 500; String? currentTable; var batch = >[]; + // v182 receive-side tolerance: true once a legacy diveProfiles / + // tankPressureProfiles row has streamed through, so the packer only + // runs when this base actually carried some. + var sawLegacySamples = false; + var sawParents = false; Future flush() async { final table = currentTable; @@ -1815,7 +2014,7 @@ class SyncService { final r = await _mergeEntity( entityType: table, records: batch, - hasUpdatedAt: entityHasUpdatedAt[table] ?? false, + hasUpdatedAt: _baseApplyEntityFlags[table] ?? false, lastSyncMs: lastSyncMs, pendingRecordIds: pendingByEntity[table] ?? const {}, allTombstones: tombstonesByEntity, @@ -1828,21 +2027,33 @@ class SyncService { batch = >[]; } + progress.beginPass(2); await BaseJsonStreamReader().parse( - file.openRead(), + openWithProgress(), wantRows: (section, table) => - section == 'data' && entityHasUpdatedAt.containsKey(table), + section == 'data' && _baseApplyEntityFlags.containsKey(table), onRow: (section, table, rowBytes) async { if (table != currentTable) { await flush(); currentTable = table; } + if (inboundOnlyLegacyEntities.containsKey(table)) { + sawLegacySamples = true; + } + if (table == 'dives' || table == 'diveTanks') { + sawParents = true; + } batch.add(jsonDecode(utf8.decode(rowBytes)) as Map); if (batch.length >= batchSize) await flush(); }, ); await flush(); + await _packLegacySamplesIfPresent( + sawLegacySamples, + parentsArrived: sawParents, + ); + await _serializer.repairDanglingForeignKeys(); return _MergeResult( recordsApplied: recordsApplied, @@ -1883,8 +2094,8 @@ class SyncService { // protected from a stale remote tombstone even on append-only child // tables that have a createdAt. // Clockless child tables with neither updatedAt nor createdAt - // (dive_profiles, dive_tanks, tank_pressure_profiles, sightings) have - // no age signal: the uuid-keyed ones regenerate with fresh ids on + // (dive_tanks and sightings, and the retired dive_profiles / + // tank_pressure_profiles before them) have no age signal: the uuid-keyed ones regenerate with fresh ids on // re-import, so a stale tombstone won't match a current row. The // composite-natural-key junctions (dive_equipment, equipment_set_items) // WOULD match a re-inserted row, but the contradicted-key skip above @@ -1957,9 +2168,10 @@ class SyncService { /// Per-entity "has an updatedAt column" flag, mirroring the `mergeOrder` /// records in [_applyRemotePayloadInner]. The streaming base apply - /// ([_applyRemoteBaseFile]) uses it for (a) conflict-detection behavior in - /// [_mergeEntity] and (b) the set of entity tables it applies (a table absent - /// from these keys is silently skipped on base import). + /// ([_applyRemoteBaseFile]) uses it (via [_baseApplyEntityFlags], which + /// also folds in [inboundOnlyLegacyEntities]) for (a) conflict-detection + /// behavior in [_mergeEntity] and (b) the set of entity tables it applies + /// (a table absent from that union is silently skipped on base import). /// /// MUST list every [SyncData] entity: a structural test /// (`entityHasUpdatedAt covers exactly the SyncData entities`) asserts the @@ -2017,7 +2229,6 @@ class SyncService { 'diveTags': false, 'diveDiveTypes': false, 'diveBuddies': false, - 'diveProfiles': false, 'diveProfileEvents': false, 'diveSafetyReviews': false, 'diveSafetyFindings': false, @@ -2027,11 +2238,11 @@ class SyncService { 'diveCustomFields': false, 'diveDataSources': false, 'siteSpecies': false, + 'mediaSpecies': false, 'siteFeatures': true, 'csvPresets': true, 'viewConfigs': true, 'fieldPresets': false, - 'tankPressureProfiles': false, 'tideRecords': false, 'sightings': false, 'certifications': true, @@ -2044,8 +2255,39 @@ class SyncService { 'mediaStores': false, 'connectedAccounts': true, 'mediaSubscriptions': true, + 'diveProfileSeries': true, + 'tankPressureSeries': true, }; + /// v182 receive-side tolerance: legacy row-per-sample entities an older + /// peer still sends. Parsed and applied when such a peer's data arrives, + /// whether as a changeset (via `mergeOrder` in [_applyRemotePayloadInner]) + /// or as a base file (unioned into the table set the base-file apply path + /// reads off the wire, see [_baseApplyEntityFlags]), then packed into + /// series by [SyncDataSerializer.packLegacySamples]. Deliberately absent + /// from [entityHasUpdatedAt] and from [SyncData.toJson]: never exported, + /// and kept out of the structural test that pins `entityHasUpdatedAt` to + /// the `SyncData` entity set. Removed together with the legacy tables in + /// plan 2e. + @visibleForTesting + static const Map inboundOnlyLegacyEntities = { + 'diveProfiles': false, + 'tankPressureProfiles': false, + }; + + /// Table set the base-file apply path (`_applyRemoteBaseFile*`) and the + /// replace-adopt streaming path (`_adoptApplyStreaming`) read off the wire + /// (and, for adopt, clear before re-inserting): every merge-applied entity + /// ([entityHasUpdatedAt]) plus the inbound-only legacy entities + /// ([inboundOnlyLegacyEntities]). A table absent from this union is + /// silently skipped on base import (mirrors [entityHasUpdatedAt]'s own doc + /// comment); kept separate from [entityHasUpdatedAt] itself so that map's + /// structural test is unaffected. + static final Map _baseApplyEntityFlags = Map.unmodifiable({ + ...entityHasUpdatedAt, + ...inboundOnlyLegacyEntities, + }); + /// Every synced child -> parent FK whose parent can be deleted (and thus /// tombstoned in the deletion log). Used by [_mergeEntity] to keep a peer's /// live child from dangling its FK against a parent we have deleted -- which @@ -2073,11 +2315,6 @@ class SyncService { (field: 'relatedDiveId', parent: 'dives', nullable: true), (field: 'computerId', parent: 'diveComputers', nullable: true), ], - 'diveProfiles': [ - (field: 'diveId', parent: 'dives', nullable: false), - (field: 'computerId', parent: 'diveComputers', nullable: true), - (field: 'sourceId', parent: 'diveDataSources', nullable: true), - ], // v175 gear twins: a peer's live computer whose gear item we deleted // locally would otherwise dangle this FK and abort the whole sync at // COMMIT. Nullable, so the computer survives with the reference cleared. @@ -2120,13 +2357,19 @@ class SyncService { 'diveSafetyFindings': [(field: 'diveId', parent: 'dives', nullable: false)], 'gasSwitches': [(field: 'diveId', parent: 'dives', nullable: false)], 'diveCustomFields': [(field: 'diveId', parent: 'dives', nullable: false)], - 'tankPressureProfiles': [ + 'tideRecords': [(field: 'diveId', parent: 'dives', nullable: false)], + 'diveDataSources': [ (field: 'diveId', parent: 'dives', nullable: false), (field: 'computerId', parent: 'diveComputers', nullable: true), ], - 'tideRecords': [(field: 'diveId', parent: 'dives', nullable: false)], - 'diveDataSources': [ + 'diveProfileSeries': [ + (field: 'diveId', parent: 'dives', nullable: false), + (field: 'computerId', parent: 'diveComputers', nullable: true), + (field: 'sourceId', parent: 'diveDataSources', nullable: true), + ], + 'tankPressureSeries': [ (field: 'diveId', parent: 'dives', nullable: false), + (field: 'tankId', parent: 'diveTanks', nullable: false), (field: 'computerId', parent: 'diveComputers', nullable: true), ], 'sightings': [ @@ -2136,12 +2379,17 @@ class SyncService { 'media': [ (field: 'diveId', parent: 'dives', nullable: true), (field: 'siteId', parent: 'diveSites', nullable: true), + (field: 'equipmentId', parent: 'equipment', nullable: true), (field: 'signerId', parent: 'buddies', nullable: true), ], 'siteSpecies': [ (field: 'siteId', parent: 'diveSites', nullable: false), (field: 'speciesId', parent: 'species', nullable: false), ], + 'mediaSpecies': [ + (field: 'mediaId', parent: 'media', nullable: false), + (field: 'speciesId', parent: 'species', nullable: false), + ], 'siteFeatures': [(field: 'siteId', parent: 'diveSites', nullable: false)], 'liveaboardDetails': [(field: 'tripId', parent: 'trips', nullable: false)], 'itineraryDays': [(field: 'tripId', parent: 'trips', nullable: false)], @@ -2408,6 +2656,14 @@ class SyncService { // Drift's batch is all-or-nothing, so a failure fails every row it would // have applied -- move those from applied to failed, mirroring the per-row // catch above. + // + // The two inbound-only legacy sample entities are the exception to the + // all-or-nothing part: their upsert goes to the staging tables of + // legacy_sample_staging.dart, which insert in chunks, so a throw can + // leave earlier chunks staged. Counting the whole batch failed is still + // right, and the partial stage is harmless: the staged rows are + // pack that reads it is idempotent, and a retry restages the same ids + // over the same rows. if (toUpsert.isNotEmpty) { try { await _serializer.upsertRecords(entityType, toUpsert); @@ -2677,20 +2933,48 @@ class SyncService { _log.info('Local sync state repaired'); } - /// Best-effort sweep of leftover streaming-base temp files (base export - /// base export `ssv1_base_*.json` and assembled `ssv1_*.base` / `ssv1_adopt_*` - /// parts) from the app temp dir. Every sync temp file is prefixed `ssv1_`, so + /// How recently a sync temp file must have been touched to be spared as + /// "probably still being written" rather than swept as a leftover. Mirrors + /// `ResumableBasePublishStore._orphanGrace`, for the same reason. + static const Duration _tempFileGrace = Duration(minutes: 5); + + /// Best-effort sweep of leftover streaming-base temp files (base exports + /// `ssv1_base_*.json` and assembled `ssv1_*.base` / `ssv1_adopt_*` parts) + /// from the app temp dir. Every sync temp file is prefixed `ssv1_`, so /// the sweep matches ONLY that prefix -- never an unrelated app temp file /// (the dir is a shared, general-purpose temp location). Failure is logged /// and ignored; a stale temp file is harmless. - Future deleteLeftoverBaseTempFiles() async { + /// + /// Skips anything touched within [_tempFileGrace]. The prefix keeps the + /// sweep off files this app did not write, but NOT off files another + /// instance of it is writing right now: under `flutter test` + /// [resolveSyncTempDir] falls back to the machine-wide + /// `Directory.systemTemp`, so every concurrent test process shares one + /// directory and a base export in flight in one of them sits next to this + /// sweep running in another. Deleting it fails that publish, which surfaces + /// as a sync returning non-success in a test file that touched no sync code + /// at all. The uuid in each name prevents collisions but cannot help here, + /// because the sweep matches on prefix rather than on a name it chose. A + /// leftover worth reaping is by definition from a run that already ended, so + /// it is old; a true orphan younger than the grace is simply reclaimed by + /// the next sweep. + /// + /// [tempDir] is injectable so tests can sweep a private directory instead of + /// the shared one -- otherwise this test would be the very hazard it covers. + Future deleteLeftoverBaseTempFiles({ + @visibleForTesting Future Function()? tempDir, + }) async { try { - final dir = await resolveSyncTempDir(); + final dir = await (tempDir?.call() ?? resolveSyncTempDir()); + final cutoff = DateTime.now().subtract(_tempFileGrace); await for (final entity in dir.list(followLinks: false)) { if (entity is! File) continue; final name = entity.uri.pathSegments.last; if (name.startsWith('ssv1_')) { try { + // Inside the try: the file can vanish between the listing and the + // stat, which is exactly what a concurrent sweep looks like. + if ((await entity.lastModified()).isAfter(cutoff)) continue; await entity.delete(); } catch (_) { // best effort @@ -3358,8 +3642,15 @@ class SyncService { // Replace semantics: clear every synced table, then insert the cloud union // (latest export wins). Equivalent to the old upsert-then-delete-not-in- // cloud, but needs no in-RAM id set to diff against, so adopt memory stays - // bounded regardless of library size (#358 adopt OOM). - for (final entity in entityHasUpdatedAt.keys) { + // bounded regardless of library size (#358 adopt OOM). The legacy sample + // entities are still applied from a not-yet-upgraded peer's point of view + // (v182 receive-side tolerance) below, but there is no local table to + // clear for them any more (v183 dropped `dive_profiles` / + // `tank_pressure_profiles`; an inbound row now stages in a per-connection + // staging table instead), so the clear loop skips them; the series tables, + // which [entityHasUpdatedAt] already lists, get no special treatment. + for (final entity in _baseApplyEntityFlags.keys) { + if (inboundOnlyLegacyEntities.containsKey(entity)) continue; await _serializer.deleteAllRecords(entity); } @@ -3386,20 +3677,44 @@ class SyncService { for (final c in changesets) (at: c.exportedAt, file: null, changeset: c), ]..sort((a, b) => a.at.compareTo(b.at)); + // v182 receive-side tolerance: true once a legacy diveProfiles / + // tankPressureProfiles row has been applied from any unit, so the + // packer only runs when the adopted library actually carried some. + var sawLegacySamples = false; + var sawParents = false; + for (final unit in units) { final changeset = unit.changeset; if (changeset != null) { - for (final entry in changeset.data.toJson().entries) { - if (!entityHasUpdatedAt.containsKey(entry.key)) continue; - await applyBatch( - entry.key, - (entry.value as List).cast>(), - ); + // changeset.data.toJson() omits diveProfiles/tankPressureProfiles + // (inbound only, never re-exported through toJson), so they are + // folded back in from the typed fields fromJson already parsed them + // into, matching the raw wire shape a not-yet-upgraded peer sends. + final changesetData = { + ...changeset.data.toJson(), + 'diveProfiles': changeset.data.diveProfiles, + 'tankPressureProfiles': changeset.data.tankPressureProfiles, + }; + for (final entry in changesetData.entries) { + if (!_baseApplyEntityFlags.containsKey(entry.key)) continue; + final records = (entry.value as List).cast>(); + if (records.isNotEmpty && + inboundOnlyLegacyEntities.containsKey(entry.key)) { + sawLegacySamples = true; + } + if (records.isNotEmpty && + (entry.key == 'dives' || entry.key == 'diveTanks')) { + sawParents = true; + } + await applyBatch(entry.key, records); } continue; } - // Base file: stream `data` rows, batching 500 per table. + // Base file: stream `data` rows, batching 500 per table. Read directly + // off disk, so a not-yet-upgraded peer's raw diveProfiles / + // tankPressureProfiles rows are present here regardless of what this + // build's own SyncData.toJson would emit. const batchSize = 500; String? currentTable; var batch = >[]; @@ -3413,12 +3728,18 @@ class SyncService { await BaseJsonStreamReader().parse( File(unit.file!).openRead(), wantRows: (section, table) => - section == 'data' && entityHasUpdatedAt.containsKey(table), + section == 'data' && _baseApplyEntityFlags.containsKey(table), onRow: (section, table, rowBytes) async { if (table != currentTable) { await flush(); currentTable = table; } + if (inboundOnlyLegacyEntities.containsKey(table)) { + sawLegacySamples = true; + } + if (table == 'dives' || table == 'diveTanks') { + sawParents = true; + } batch.add(jsonDecode(utf8.decode(rowBytes)) as Map); if (batch.length >= batchSize) await flush(); }, @@ -3426,6 +3747,11 @@ class SyncService { await flush(); } + await _packLegacySamplesIfPresent( + sawLegacySamples, + parentsArrived: sawParents, + ); + await _serializer.repairDanglingForeignKeys(); } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart deleted file mode 100644 index 46a4681811..0000000000 --- a/lib/core/theme/app_theme.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:submersion/core/theme/full_themes/submersion_theme.dart'; - -class AppTheme { - AppTheme._(); - - static ThemeData get light => submersionLight; - - static ThemeData get dark => submersionDark; -} diff --git a/lib/core/theme/feature_accent_colors.dart b/lib/core/theme/feature_accent_colors.dart index d60a83f583..9e627a6987 100644 --- a/lib/core/theme/feature_accent_colors.dart +++ b/lib/core/theme/feature_accent_colors.dart @@ -19,6 +19,10 @@ class FeatureAccentColors extends ThemeExtension { 'sites': Color(0xFF388E3C), 'trips': Color(0xFF7B1FA2), 'media': Color(0xFF6A1B9A), + // Lime is the one hue the other nav accents leave free: green is + // sites and teal is statistics, so a kelp tone keeps Species + // distinguishable from both at icon size. + 'species': Color(0xFF827717), // Orange and amber are the two hues that go illegible first on a light // surface, so these are deepened past their Material 700 shades to keep // every light-mode accent at or above the 3:1 WCAG contrast ratio for @@ -57,6 +61,7 @@ class FeatureAccentColors extends ThemeExtension { 'sites': Color(0xFF81C784), 'trips': Color(0xFFBA68C8), 'media': Color(0xFFCE93D8), + 'species': Color(0xFFDCE775), 'equipment': Color(0xFFFFB74D), 'buddies': Color(0xFFF06292), 'dive-centers': Color(0xFFBCAAA4), diff --git a/lib/features/dive_log/presentation/widgets/profile_chart_viewport.dart b/lib/core/ui/chart_viewport.dart similarity index 84% rename from lib/features/dive_log/presentation/widgets/profile_chart_viewport.dart rename to lib/core/ui/chart_viewport.dart index 5a542010dc..764ca3ec03 100644 --- a/lib/features/dive_log/presentation/widgets/profile_chart_viewport.dart +++ b/lib/core/ui/chart_viewport.dart @@ -1,7 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -/// Immutable description of the dive profile chart's visible window, expressed +/// Immutable description of a chart's visible window, expressed /// as normalized fractions [0,1] of the total data range. Resolution- and /// data-independent, so the anchor math is unit-testable with plain numbers. /// @@ -9,20 +9,16 @@ import 'package:flutter/widgets.dart'; /// (`offsetY == 0` is the surface). The window spans `1/zoom` of each axis, so /// both offsets are valid in `[0, 1 - 1/zoom]`. @immutable -class ProfileChartViewport { +class ChartViewport { final double zoom; // >= 1.0 final double offsetX; final double offsetY; - const ProfileChartViewport({ - this.zoom = 1, - this.offsetX = 0, - this.offsetY = 0, - }); + const ChartViewport({this.zoom = 1, this.offsetX = 0, this.offsetY = 0}); static const double minZoom = 1.0; static const double maxZoom = 10.0; - static const ProfileChartViewport reset = ProfileChartViewport(); + static const ChartViewport reset = ChartViewport(); bool get isZoomed => zoom > 1.0; double get visibleWidth => 1.0 / zoom; @@ -31,13 +27,13 @@ class ProfileChartViewport { /// Zoom by [factor] (>1 = in, <1 = out) keeping the data point under the /// focal point fixed. [focalX]/[focalY] are fractions (0..1) of the visible /// plot area under the cursor/pinch (0 = left/top edge). - ProfileChartViewport zoomedAt(double focalX, double focalY, double factor) { + ChartViewport zoomedAt(double focalX, double focalY, double factor) { final newZoom = (zoom * factor).clamp(minZoom, maxZoom); if (newZoom == zoom) return this; final anchorX = offsetX + focalX / zoom; // data fraction under focus, before final anchorY = offsetY + focalY / zoom; - return ProfileChartViewport( + return ChartViewport( zoom: newZoom, offsetX: anchorX - focalX / newZoom, // keep it under focus, after offsetY: anchorY - focalY / newZoom, @@ -45,15 +41,15 @@ class ProfileChartViewport { } /// Pan by a normalized delta (fractions of the total range). - ProfileChartViewport pannedBy(double dx, double dy) => ProfileChartViewport( + ChartViewport pannedBy(double dx, double dy) => ChartViewport( zoom: zoom, offsetX: offsetX + dx, offsetY: offsetY + dy, )._clamped(); - ProfileChartViewport _clamped() { + ChartViewport _clamped() { final maxOff = 1.0 - 1.0 / zoom; - return ProfileChartViewport( + return ChartViewport( zoom: zoom, offsetX: offsetX.clamp(0.0, maxOff), offsetY: offsetY.clamp(0.0, maxOff), @@ -81,7 +77,7 @@ class ProfileChartViewport { ); } -/// What a drag/scale event should do on the profile chart. +/// What a drag/scale event should do on an interactive chart. enum ChartDragIntent { pan, scrub, zoomPan, none } /// Decides the meaning of an in-progress gesture from the active pointer kind, diff --git a/lib/core/util/wall_clock_utc.dart b/lib/core/util/wall_clock_utc.dart index d70462928f..aa7e668393 100644 --- a/lib/core/util/wall_clock_utc.dart +++ b/lib/core/util/wall_clock_utc.dart @@ -14,6 +14,9 @@ /// but should match wall-clock-UTC dive times. /// * [fromWallClockUtc] — the inverse, for reading a stored wall-clock-UTC /// value back into the local `DateTime` the UI works in. +/// * [wallClockUtcDayStart] gives the start of a calendar day in +/// wall-clock-UTC, for comparing a date the user picked against stored +/// wall-clock values. library; /// Matches a trailing `Z` or `+hh:mm` / `-hh:mm` / `+hhmm` / `-hhmm` offset @@ -90,3 +93,16 @@ DateTime fromWallClockUtc(DateTime wallClockUtc) { wallClockUtc.millisecond, ); } + +/// Start of [date]'s calendar day in wall-clock-UTC, discarding any +/// time-of-day component and any timezone offset [date] carries. +/// +/// This is the bridge a date the diver *picked* has to cross before it can be +/// compared against a stored wall-clock-UTC timestamp (issue #1368). Pickers +/// and preset builders produce LOCAL `DateTime`s, whose +/// `millisecondsSinceEpoch` is local midnight shifted by the device's UTC +/// offset; comparing that raw against wall-clock-UTC values moved the day +/// boundary by that offset. Reading only the calendar components makes the +/// result identical whichever flavor the caller holds. +DateTime wallClockUtcDayStart(DateTime date) => + DateTime.utc(date.year, date.month, date.day); diff --git a/lib/core/utils/bounded_inflate.dart b/lib/core/utils/bounded_inflate.dart new file mode 100644 index 0000000000..ba84b4f745 --- /dev/null +++ b/lib/core/utils/bounded_inflate.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// A compressed blob was refused: too long, not a stream, or it expanded +/// past the cap its caller allowed. +/// +/// Callers translate this into whatever their own layer calls malformed +/// input, so a bad blob never escapes as a private runtime type. +class BoundedInflateException implements Exception { + final String message; + + const BoundedInflateException(this.message); + + @override + String toString() => 'BoundedInflateException: $message'; +} + +/// Inflates a compressed stream, refusing anything that expands past +/// [maxBytes]. +/// +/// [decoder] chooses the framing, so the same guard covers zlib +/// (`zlib.decoder`) and gzip (`gzip.decoder`). The decoder runs chunked, so +/// a compression bomb is abandoned at the first chunk over the cap rather +/// than after the whole body is in memory. +/// +/// Throws [BoundedInflateException] if [bytes] is longer than +/// [maxBlobBytes], is not a stream [decoder] accepts, or inflates past +/// [maxBytes]. Errors that are not malformed input, an [ArgumentError] from +/// a bad cap among them, are left to surface. +/// +/// Not a completeness check. Both framings accept a truncated stream, +/// returning the bytes they managed to inflate with no error, and a stream +/// missing only its trailer returns its body in full, so the checksum is +/// never verified. Both were measured. Callers must frame their own +/// payload. +Uint8List inflateBounded( + List bytes, { + required Converter, List> decoder, + required int maxBytes, + required int maxBlobBytes, +}) { + if (maxBytes < 0) { + throw ArgumentError.value(maxBytes, 'maxBytes', 'must not be negative'); + } + if (maxBlobBytes < 0) { + throw ArgumentError.value( + maxBlobBytes, + 'maxBlobBytes', + 'must not be negative', + ); + } + // Before the conversion, not inside the sink: the filter copies the whole + // input natively before it emits a first chunk, so a sink-side check + // never sees an oversized blob. + if (bytes.length > maxBlobBytes) { + throw BoundedInflateException( + 'blob of ${bytes.length} byte(s) exceeds the $maxBlobBytes allowed', + ); + } + final sink = _BoundedByteSink(maxBytes); + try { + final input = decoder.startChunkedConversion(sink); + input + ..add(bytes) + ..close(); + } on FormatException catch (e) { + throw BoundedInflateException( + 'not a valid compressed stream: ${e.message}', + ); + } + return sink.takeBytes(); +} + +/// Collects inflated chunks and throws as soon as they pass [_maxBytes]. +class _BoundedByteSink implements Sink> { + _BoundedByteSink(this._maxBytes); + + final int _maxBytes; + final BytesBuilder _builder = BytesBuilder(copy: false); + + @override + void add(List chunk) { + if (_builder.length + chunk.length > _maxBytes) { + // Thrown from inside the inflate filter's own add, which unwinds it. + throw BoundedInflateException( + 'inflated body exceeds the $_maxBytes byte(s) allowed', + ); + } + _builder.add(chunk); + } + + @override + void close() {} + + Uint8List takeBytes() => _builder.takeBytes(); +} diff --git a/lib/core/utils/byte_format.dart b/lib/core/utils/byte_format.dart new file mode 100644 index 0000000000..e4f43c950c --- /dev/null +++ b/lib/core/utils/byte_format.dart @@ -0,0 +1,17 @@ +/// Formats a byte count for display. +/// +/// Bytes are not a diver-facing unit, so this deliberately ignores the active +/// diver's unit settings: a megabyte is a megabyte in metric and imperial +/// alike. Sizes are a floor, since a source that reports no size counts zero. +String formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + if (bytes < 1024) return '$bytes B'; + const units = ['KB', 'MB', 'GB', 'TB']; + var value = bytes / 1024; + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return '${value.toStringAsFixed(value >= 10 ? 0 : 1)} ${units[unit]}'; +} diff --git a/lib/core/utils/deco_dive_detector.dart b/lib/core/utils/deco_dive_detector.dart new file mode 100644 index 0000000000..900547f40d --- /dev/null +++ b/lib/core/utils/deco_dive_detector.dart @@ -0,0 +1,73 @@ +/// A single profile sample's decompression-relevant fields, independent of +/// any particular profile-point entity so this detector can be shared by +/// every import path (file import, dive computer download, cloud import) +/// without those features depending on each other. +class DecoDiveSample { + final double depth; + final int? ndl; + final double? ceiling; + final int? decoType; + final int? tts; + + const DecoDiveSample({ + required this.depth, + this.ndl, + this.ceiling, + this.decoType, + this.tts, + }); +} + +/// Detects whether a dive incurred a decompression obligation, based on the +/// profile samples and events a source provided. +/// +/// Many import sources (dive computer downloads, Suunto/Shearwater cloud +/// exports, Subsurface, UDDF, FIT) carry no explicit dive type, so imported +/// dives used to default to "recreational" even when the profile clearly +/// shows mandatory deco. Callers use this detector to default such dives to +/// the built-in "technical" type instead. +class DecoDiveDetector { + DecoDiveDetector._(); + + /// Samples this close to the surface are ignored for the exhausted-NDL + /// signal: some computers zero out NDL on surface samples. + static const double _surfaceDepthMeters = 1.0; + + /// `decoType` value meaning a mandatory deco stop (0=NDL, 1=safety stop, + /// 2=deco stop, 3=deep stop). + static const int _decoStopSampleType = 2; + + /// Parser event names that prove a deco obligation. Matches the event + /// names consumed by the UDDF importer's profile-event persistence. + static const Set _decoEventTypes = {'decoStopStart', 'decoViolation'}; + + /// Returns true when the profile or events show a decompression + /// obligation: a positive deco ceiling, a deco stop sample, an exhausted + /// NDL with time-to-surface remaining while at depth, or a deco stop / + /// deco violation event. + static bool isDecoDive({ + required Iterable samples, + List>? eventMaps, + }) { + for (final sample in samples) { + if ((sample.ceiling ?? 0) > 0) return true; + if (sample.decoType == _decoStopSampleType) return true; + final inDecoByNdl = + sample.ndl == 0 && + (sample.tts ?? 0) > 0 && + sample.depth > _surfaceDepthMeters; + if (inDecoByNdl) return true; + } + + if (eventMaps != null) { + for (final event in eventMaps) { + final eventType = event['eventType']; + if (eventType is String && _decoEventTypes.contains(eventType)) { + return true; + } + } + } + + return false; + } +} diff --git a/lib/core/utils/geo_math.dart b/lib/core/utils/geo_math.dart index bff8032696..750dc66237 100644 --- a/lib/core/utils/geo_math.dart +++ b/lib/core/utils/geo_math.dart @@ -41,6 +41,14 @@ double metersPerDegreeLongitude(double latitudeDeg) { return 111320.0 * math.max(cosLat, 0.01); } +/// Meters per degree of latitude, treated as locally constant (the true +/// value varies ~110.6-111.7 km from equator to pole, but every consumer +/// here only needs a flat local-tangent-plane approximation over +/// city-to-region-sized areas). The single shared constant so a terrain +/// mesh's own degree-to-meter conversion and any source grid reprojected +/// into that mesh (e.g. swissBATHY3D's LV95 tiles) can never drift apart. +const double metersPerDegreeLatitude = 110540.0; + /// Offset of [to] relative to [from] in local east-north meters /// (distance + bearing decomposition — the same math the spatial scene /// uses for exit fixes). diff --git a/lib/core/utils/lv95_transform.dart b/lib/core/utils/lv95_transform.dart new file mode 100644 index 0000000000..85585e4c95 --- /dev/null +++ b/lib/core/utils/lv95_transform.dart @@ -0,0 +1,66 @@ +/// LV95 easting/northing (meters), a.k.a. CH1903+. +class Lv95Coordinates { + final double easting; + final double northing; + + const Lv95Coordinates(this.easting, this.northing); +} + +/// Approximate WGS84 <-> LV95 (CH1903+) conversion using swisstopo's +/// published approximation formulas ("Näherungsformeln für die Transformation +/// zwischen Schweizer Projektionskoordinaten und WGS84", swisstopo). +/// Accuracy is about 1 m within Switzerland — no external service needed. +/// +/// Not valid outside the vicinity of Switzerland. +class Lv95Transform { + const Lv95Transform._(); + + /// WGS84 (degrees) -> LV95 (meters). + static Lv95Coordinates fromWgs84(double latitudeDeg, double longitudeDeg) { + final phiSec = latitudeDeg * 3600.0; + final lambdaSec = longitudeDeg * 3600.0; + final phi = (phiSec - 169028.66) / 10000.0; + final lambda = (lambdaSec - 26782.5) / 10000.0; + + final easting = + 2600072.37 + + 211455.93 * lambda - + 10938.51 * lambda * phi - + 0.36 * lambda * phi * phi - + 44.54 * lambda * lambda * lambda; + final northing = + 1200147.07 + + 308807.95 * phi + + 3745.25 * lambda * lambda + + 76.63 * phi * phi - + 194.56 * lambda * lambda * phi + + 119.79 * phi * phi * phi; + + return Lv95Coordinates(easting, northing); + } + + /// LV95 (meters) -> WGS84 (degrees). + static ({double latitude, double longitude}) toWgs84( + double easting, + double northing, + ) { + final y = (easting - 2600000.0) / 1000000.0; + final x = (northing - 1200000.0) / 1000000.0; + + final lambda = + 2.6779094 + + 4.728982 * y + + 0.791484 * y * x + + 0.1306 * y * x * x - + 0.0436 * y * y * y; + final phi = + 16.9023892 + + 3.238272 * x - + 0.270978 * y * y - + 0.002528 * x * x - + 0.0447 * y * y * x - + 0.0140 * x * x * x; + + return (longitude: lambda * 100.0 / 36.0, latitude: phi * 100.0 / 36.0); + } +} diff --git a/lib/core/utils/unit_formatter.dart b/lib/core/utils/unit_formatter.dart index 02f37bd561..d55bd7216f 100644 --- a/lib/core/utils/unit_formatter.dart +++ b/lib/core/utils/unit_formatter.dart @@ -323,10 +323,15 @@ class UnitFormatter { String formatHeight(double? cm) { if (cm == null) return '--'; if (heightIsMetric) return '${cm.round()} cm'; + final split = cmToFeetInches(cm); + return '${split.feet}\' ${split.inches}"'; + } + + /// Split a stored height (centimeters) into whole feet and inches, + /// rounding to the nearest inch and carrying 12 inches into the next foot. + ({int feet, int inches}) cmToFeetInches(double cm) { final totalInches = (cm / _cmPerInch).round(); - final feet = totalInches ~/ 12; - final inches = totalInches % 12; - return '$feet\' $inches"'; + return (feet: totalInches ~/ 12, inches: totalInches % 12); } /// Build a stored height (centimeters) from imperial feet and inches. @@ -504,6 +509,46 @@ class UnitFormatter { static String weekdayMonthDayPattern(DateFormatPreference dateFormat) => 'EEE, ${monthDayPattern(dateFormat)}'; + /// Narrow all-numeric month and day, in [dateFormat]'s order and with its + /// own separator: 'M/d', 'd/M', 'M-d' or 'd.M'. + /// + /// For the cramped surfaces that cannot afford a spelled month, above all a + /// chart axis, where a wide label is what crowds the ticks. Derived from the + /// pattern rather than from [DateFormatPreference.isDayFirst], which is also + /// true for the ISO preference: reading that flag alone hands an ISO diver a + /// day-first '8/10' and drops the dotted preference's dots. + static String numericMonthDayPattern(DateFormatPreference dateFormat) => + dateFormat.pattern + // The year and the separator that binds it to its neighbour. + .replaceFirst(RegExp(r'y+[^A-Za-z]+'), '') + .replaceFirst(RegExp(r'[^A-Za-z]+y+$'), '') + // Narrowest numeric form of whichever tokens are left. + .replaceFirst(RegExp(r'M+'), 'M') + .replaceFirst(RegExp(r'd+'), 'd') + // A space or comma is what the spelled-out preferences separate + // with, and neither reads as a date once the month is a digit. + .replaceFirst(RegExp(r'[,\s]+'), '/'); + + /// `DateFormat` pattern for a bare month and year in [dateFormat]'s shape. + /// + /// Derived by dropping the day token and the separator that binds it to its + /// neighbour, so every preference keeps its own punctuation and ordering: + /// 'MM/dd/yyyy' and 'dd/MM/yyyy' both collapse to 'MM/yyyy', 'yyyy-MM-dd' to + /// 'yyyy-MM', and the spelled-out forms to 'MMM yyyy'. + static String monthYearPattern(DateFormatPreference dateFormat) => dateFormat + .pattern + // A day followed by its separator: 'dd/', 'd ', 'd, '. + .replaceFirst(RegExp(r'd+[^A-Za-z]+'), '') + // Or a trailing day preceded by its separator: '-dd'. + .replaceFirst(RegExp(r'[^A-Za-z]+d+$'), ''); + + /// Format month and year only, in the diver's date shape. + /// Example: "Mar 2026", "03/2026" or "2026-03" + String formatMonthYear(DateTime? dateTime) { + if (dateTime == null) return '--'; + return DateFormat(monthYearPattern(settings.dateFormat)).format(dateTime); + } + /// Format month and day only (respects day-first vs month-first preference) /// Example: "Jan 15" or "15 Jan" String formatMonthDay(DateTime? dateTime) { diff --git a/lib/features/auto_update/data/services/linux_install_method_reader.dart b/lib/features/auto_update/data/services/linux_install_method_reader.dart new file mode 100644 index 0000000000..8b8f8cae22 --- /dev/null +++ b/lib/features/auto_update/data/services/linux_install_method_reader.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:submersion/features/auto_update/domain/entities/linux_install_method.dart'; + +/// Reads the install-method marker that the Linux packages ship. +/// +/// The marker sits beside the executable at /usr/lib/submersion/INSTALL_METHOD. +/// The tarball ships no marker, so its absence is the tarball case rather than +/// an error. +class LinuxInstallMethodReader { + const LinuxInstallMethodReader({required this.executablePath}); + + final String executablePath; + + LinuxInstallMethod read() { + try { + final marker = File('${File(executablePath).parent.path}/INSTALL_METHOD'); + if (!marker.existsSync()) return LinuxInstallMethod.tarball; + return switch (marker.readAsStringSync().trim()) { + 'deb' => LinuxInstallMethod.deb, + 'rpm' => LinuxInstallMethod.rpm, + _ => LinuxInstallMethod.tarball, + }; + } on FileSystemException { + return LinuxInstallMethod.tarball; + } + } +} diff --git a/lib/features/auto_update/domain/entities/linux_install_method.dart b/lib/features/auto_update/domain/entities/linux_install_method.dart new file mode 100644 index 0000000000..f660d454b1 --- /dev/null +++ b/lib/features/auto_update/domain/entities/linux_install_method.dart @@ -0,0 +1,19 @@ +/// How this Linux copy of Submersion was installed. +/// +/// A packaged install is upgraded by the system package manager, so the in-app +/// updater must not offer a tarball that would shadow the packaged copy. The +/// marker file is written into the package by +/// scripts/release/stage_linux_package.py. +enum LinuxInstallMethod { + /// Installed from a .deb; upgraded with apt. + deb, + + /// Installed from an .rpm; upgraded with dnf. + rpm, + + /// Unpacked from the tarball; upgraded by downloading a new tarball. + tarball; + + /// Whether a system package manager owns this install. + bool get isPackaged => this != LinuxInstallMethod.tarball; +} diff --git a/lib/features/auto_update/domain/linux_upgrade_command.dart b/lib/features/auto_update/domain/linux_upgrade_command.dart new file mode 100644 index 0000000000..0df0e67793 --- /dev/null +++ b/lib/features/auto_update/domain/linux_upgrade_command.dart @@ -0,0 +1,26 @@ +import 'package:submersion/features/auto_update/domain/entities/linux_install_method.dart'; + +/// Path probed to tell an openSUSE host from a Fedora or RHEL one. +const _zypperPath = '/usr/bin/zypper'; + +/// The command that upgrades a packaged install, or null when no package +/// manager owns it. +/// +/// The install method alone does not determine the command. One `.rpm` +/// installs on Fedora, RHEL, and openSUSE alike, but openSUSE's package +/// manager is zypper and it does not ship dnf by default, so telling an +/// openSUSE user to run dnf gives them a command that does not exist. The +/// host is probed rather than assumed. +/// +/// [exists] is injected so this is testable without a real filesystem. +String? resolveUpgradeCommand( + LinuxInstallMethod method, { + required bool Function(String path) exists, +}) => switch (method) { + LinuxInstallMethod.deb => 'sudo apt upgrade submersion', + LinuxInstallMethod.rpm => + exists(_zypperPath) + ? 'sudo zypper update submersion' + : 'sudo dnf upgrade submersion', + LinuxInstallMethod.tarball => null, +}; diff --git a/lib/features/auto_update/presentation/providers/update_providers.dart b/lib/features/auto_update/presentation/providers/update_providers.dart index ebee0a3570..38d8d8e46f 100644 --- a/lib/features/auto_update/presentation/providers/update_providers.dart +++ b/lib/features/auto_update/presentation/providers/update_providers.dart @@ -6,8 +6,11 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/app_version.dart'; import 'package:submersion/features/auto_update/data/repositories/update_preferences.dart'; import 'package:submersion/features/auto_update/data/services/github_update_service.dart'; +import 'package:submersion/features/auto_update/data/services/linux_install_method_reader.dart'; import 'package:submersion/features/auto_update/data/services/sparkle_update_service.dart'; import 'package:submersion/features/auto_update/data/services/update_service.dart'; +import 'package:submersion/features/auto_update/domain/entities/linux_install_method.dart'; +import 'package:submersion/features/auto_update/domain/linux_upgrade_command.dart'; import 'package:submersion/features/auto_update/domain/entities/release_channel.dart'; import 'package:submersion/features/auto_update/domain/entities/update_channel.dart'; import 'package:submersion/features/auto_update/domain/entities/update_status.dart'; @@ -48,6 +51,28 @@ String get _platformSuffix { /// Whether the current platform uses the Sparkle/WinSparkle engine. bool get _useSparkleEngine => Platform.isMacOS || Platform.isWindows; +/// How this Linux copy was installed. +/// +/// A provider rather than an inline Platform check, matching +/// isLinuxPlatformProvider in sync_providers.dart, so widget tests can simulate +/// each install method on any CI host. Non-Linux platforms report tarball: they +/// have their own update engines and never consult this. +final linuxInstallMethodProvider = Provider((ref) { + if (!Platform.isLinux) return LinuxInstallMethod.tarball; + return LinuxInstallMethodReader( + executablePath: Platform.resolvedExecutable, + ).read(); +}); + +/// The package-manager command that upgrades this install, or null when no +/// package manager owns it (a tarball install, or any non-Linux platform). +final linuxUpgradeCommandProvider = Provider((ref) { + return resolveUpgradeCommand( + ref.watch(linuxInstallMethodProvider), + exists: (path) => File(path).existsSync(), + ); +}); + /// Update preferences provider. final updatePreferencesProvider = Provider((ref) { final prefs = ref.watch(sharedPreferencesProvider); diff --git a/lib/features/auto_update/presentation/widgets/update_banner.dart b/lib/features/auto_update/presentation/widgets/update_banner.dart index 255b88cab6..eef5248b4d 100644 --- a/lib/features/auto_update/presentation/widgets/update_banner.dart +++ b/lib/features/auto_update/presentation/widgets/update_banner.dart @@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/auto_update/domain/entities/update_status.dart'; import 'package:submersion/features/auto_update/presentation/providers/update_providers.dart'; +import 'package:submersion/features/auto_update/presentation/widgets/update_banner_actions.dart'; import 'package:submersion/l10n/l10n_extension.dart'; class UpdateBanner extends ConsumerStatefulWidget { @@ -44,15 +45,11 @@ class _UpdateBannerState extends ConsumerState { style: theme.textTheme.bodyMedium, ), actions: [ - if (downloadUrl != null) - TextButton( - onPressed: () => _openDownload(downloadUrl), - child: Text(context.l10n.autoUpdate_banner_download), - ), - IconButton( - icon: const Icon(Icons.close, size: 18), - tooltip: context.l10n.common_action_dismiss, - onPressed: () => setState(() => _dismissed = true), + UpdateBannerActions( + upgradeCommand: ref.watch(linuxUpgradeCommandProvider), + downloadUrl: downloadUrl, + onDownload: _openDownload, + onDismiss: () => setState(() => _dismissed = true), ), ], ); diff --git a/lib/features/auto_update/presentation/widgets/update_banner_actions.dart b/lib/features/auto_update/presentation/widgets/update_banner_actions.dart new file mode 100644 index 0000000000..80fc63cf08 --- /dev/null +++ b/lib/features/auto_update/presentation/widgets/update_banner_actions.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The action area of [UpdateBanner]. +/// +/// A packaged Linux install is upgraded by the system package manager, so this +/// shows the command rather than a download button: the app knows about the +/// release before the package manager has told the user, but it is not the +/// thing that should install it. Offering the tarball there would hand the user +/// an archive that shadows the packaged copy. +/// +/// Takes the resolved [upgradeCommand] rather than an install method, because +/// the command depends on the host as well as the package format: see +/// resolveUpgradeCommand in domain/linux_upgrade_command.dart. A non-null +/// command means a package manager owns this install. +/// +/// Split out of [UpdateBanner] so this behavior can be tested without +/// constructing UpdateStatusNotifier, whose constructor schedules a delayed +/// check that would leak a pending timer into every widget test. +class UpdateBannerActions extends StatelessWidget { + const UpdateBannerActions({ + super.key, + required this.upgradeCommand, + required this.downloadUrl, + required this.onDownload, + required this.onDismiss, + }); + + /// The package-manager command that upgrades this install, or null when no + /// package manager owns it. + final String? upgradeCommand; + final String? downloadUrl; + final ValueChanged onDownload; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final command = upgradeCommand; + final url = downloadUrl; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (command != null) + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SelectableText( + context.l10n.autoUpdate_banner_packageManagerHint(command), + style: theme.textTheme.bodySmall, + ), + ), + ) + else if (url != null) + TextButton( + onPressed: () => onDownload(url), + child: Text(context.l10n.autoUpdate_banner_download), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + tooltip: context.l10n.common_action_dismiss, + onPressed: onDismiss, + ), + ], + ); + } +} diff --git a/lib/features/backup/data/services/backup_attribution.dart b/lib/features/backup/data/services/backup_attribution.dart new file mode 100644 index 0000000000..bae060ba3c --- /dev/null +++ b/lib/features/backup/data/services/backup_attribution.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +/// Which device wrote a backup file. +enum BackupOwnership { + /// Written by this device. Safe to offer for deletion once its history + /// record is gone. + thisDevice, + + /// Written by a different device. Never offer to delete it: in a shared + /// backup folder this may be that device's only copy. + otherDevice, + + /// No attribution in the name. Every backup written before attribution + /// shipped is in this state, and so is anything that is not one of ours. + unattributed, +} + +/// Marker separating the timestamp from the device tag. +/// +/// Two underscores, because the timestamp itself contains single ones and the +/// historic name is `submersion_backup__