diff --git a/docs/superpowers/plans/2026-09-19-explore-phase1-core-dives.md b/docs/superpowers/plans/2026-09-19-explore-phase1-core-dives.md new file mode 100644 index 0000000000..7fb05f854e --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-explore-phase1-core-dives.md @@ -0,0 +1,5840 @@ +# Explore Phase 1 (Core plus Dives) 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:** A diver types a sentence on a new Explore page, an onboard platform model turns it into a small clause list, and pure Dart compiles that into the existing dive filter, rendered as editable chips, a count, up to three charts and the matching dives, with handoffs into the dive list and Statistics. + +**Architecture:** A `MethodChannel` package (`submersion_nl`) wraps Apple Foundation Models and the ML Kit GenAI Prompt API and only ever sees the sentence. A pure-Dart query model validates the model's JSON; a pure-Dart compiler grounds units against the diver's settings, resolves names against a local `NameIndex` by Dice similarity, and lowers to `DiveFilterState`. Four new filter axes (water temperature, visibility, water type, species) are threaded through the three existing filter paths with a parity test. The page drives a third filter provider and reuses the existing list tile, filter sheet and chart widgets. + +**Tech Stack:** Flutter 3.47 / Dart ^3.10, Riverpod (StateProvider, FutureProvider, StateNotifier), Drift (main DB and local cache DB), fl_chart via `DiveTrendChart`, Swift (FoundationModels, iOS 26 / macOS 26), Kotlin (`com.google.mlkit:genai-prompt`). + +**Spec:** `docs/superpowers/specs/2026-09-19-explore-natural-language-search-design.md` + +## Global Constraints + +- No em-dashes, en-dashes as punctuation, or double hyphens anywhere: code, comments, ARB strings, commit messages, this plan's outputs. +- No mention of Claude, Claude Code or Anthropic in any commit, file or PR text. +- No emojis in code, comments or docs. +- Every new ARB key goes into all 11 files: `app_ar, de, en, es, fr, he, hu, it, nl, pt, zh`. `app_en.arb` is alphabetical; the others are feature-grouped, so anchor inserts on a neighbouring key. `test/l10n/arb_parity_test.dart` enforces presence. Run `flutter gen-l10n` after editing ARBs; the generated `lib/l10n/arb/app_localizations*.dart` files are checked in. +- Every new `DiveFilterState` axis must land in THREE places: `buildFilteredDiveIdSubquery` (`lib/features/statistics/data/dive_filter_sql.dart`), `DiveRepositoryImpl._buildFilterWhereClauses` (`lib/features/dive_log/data/repositories/dive_repository_impl.dart`), and `DiveFilterState.apply`, with a parity test. +- A provider that reads a table must self-invalidate on that table's change tick (`test/architecture/provider_change_tick_test.dart` enforces it). Use `ref.invalidateSelfWhen(stream)` from `lib/core/providers/ref_invalidate_on_change.dart`. +- Storage units are metric: metres, celsius, bar. Convert on input with `UnitFormatter` helpers (`depthToMeters`, `temperatureToCelsius`) and display with `convertDepth`, `convertTemperature`, `depthSymbol`, `temperatureSymbol`. +- Dive detail navigation uses `context.push('/dives/$id')`, never `go` (issue 647). +- Files stay under 800 lines; split by responsibility. +- Run `dart format .` before every commit. Run `flutter analyze` on the whole project before the final commit (infos are fatal in CI). +- Run tests per file (`flutter test `), never the whole suite more than once at the end. A piped `flutter test | grep` hides the exit code; run it unpiped. +- The worktree is `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/on-device-nlang-search-2cdf94` on branch `ericgriffin/on-device-nlang-search-2cdf94`. Run `git submodule update --init --recursive && flutter pub get` once before the first task. Stage explicit paths; never `git add -A`. +- Commit after every task with a conventional message (`feat(explore): ...`, `test(explore): ...`). Do not push and do not open a PR; a GitHub issue must be opened first and the PR body must say `Refs #`. + +--- + +## File Structure + +New feature directory `lib/features/explore/`: + +| File | Responsibility | +| --- | --- | +| `domain/query_model.dart` | `ParsedQuery` and its enums; JSON validation (`ParsedQuery.fromJson`), `QuerySchemaException`, `kQuerySchemaVersion`. | +| `domain/dive_field_catalog.dart` | `ExploreDiveField` enum with JSON names, `FieldDimension`, `FieldSpec`, `DiveFieldCatalog.spec(field)`. | +| `domain/unit_grounding.dart` | `UnitPrefs` record and `groundToMetric(...)`. | +| `domain/time_grammar.dart` | `parseTimeText(text, now:)` to a calendar-date range. | +| `domain/name_index.dart` | `NameEntry`, `NameTarget`, `NameIndex`. | +| `domain/entity_resolver.dart` | `Resolution` sealed class, `resolveMention(...)`. | +| `domain/chart_selection.dart` | `ChartKind`, `ChartRequest`, `selectCharts(...)`. | +| `domain/compiled_query.dart` | `CompiledQuery`, `QueryChip`, `ChipPayload` sealed class, `UnresolvedMention`. | +| `domain/query_compiler.dart` | `CompilerContext`, `QueryCompiler.compile(...)`. | +| `domain/nl_engine.dart` | `NlEngine` interface, `NlAvailability`, `NlError`, `NlException`, `NlPrompt`. | +| `data/channel_nl_engine.dart` | `ChannelNlEngine` over `SubmersionNl`. | +| `data/name_index_builder.dart` | `NameIndexBuilder.build(...)` from the entity repositories. | +| `data/explore_repository.dart` | `ExploreRepository.diveCountBySite(filter)` (stats-scoped). | +| `data/recent_query_repository.dart` | `RecentQueryRepository` over the local cache DB. | +| `presentation/providers/explore_gate_providers.dart` | `explorePlatformSupportedProvider`, `exploreAvailabilityProvider`, `exploreEnabledProvider`, `nlEngineProvider`. | +| `presentation/providers/explore_providers.dart` | `exploreFilterProvider`, `nameIndexProvider`, `ExploreQueryNotifier`, `exploreQueryProvider`, results, count, chart data, recent queries. | +| `presentation/pages/explore_page.dart` | The page: field, rows, count, charts, results, handoffs. | +| `presentation/widgets/explore_chip_rows.dart` | Understood row and needs-attention row. | +| `presentation/widgets/explore_charts.dart` | Chart cards from `ChartRequest`s. | +| `presentation/widgets/explore_results_list.dart` | Results via `CompactDiveListTile`. | +| `presentation/chip_labeler.dart` | `ChipLabeler` turning a `ChipPayload` into a localized label. | + +New package `packages/submersion_nl/` with `pubspec.yaml`, `lib/submersion_nl.dart`, `darwin/Classes/SubmersionNlPlugin.swift`, `darwin/submersion_nl.podspec`, `android/build.gradle`, `android/src/main/AndroidManifest.xml`, `android/src/main/kotlin/app/submersion/nl/SubmersionNlPlugin.kt`. + +Modified files: `dive_filter_state.dart`, `dive_filter_sql.dart`, `dive_repository_impl.dart`, `dive_providers.dart` (ticks and `orderedDiveIdsProvider`), `dive_filter_sheet.dart`, `dive_list_content.dart` (chip bar), `local_cache_database.dart`, `app_router.dart`, `dive_list_page.dart`, `app_shortcuts.dart`, `pubspec.yaml`, the 11 ARB files. + +--- + +### Task 1: Query model and JSON validation + +**Files:** +- Create: `lib/features/explore/domain/query_model.dart` +- Test: `test/features/explore/domain/query_model_test.dart` + +**Interfaces:** +- Produces: `const int kQuerySchemaVersion = 1;` `enum QuerySubject { dives, equipment, sites, buddies, species, trips, centers }` `enum ClauseOp { lt, lte, gt, gte, eq, between, inList, not }` (JSON names `lt, lte, gt, gte, eq, between, in, not`) `enum ClauseUnit { m, ft, c, f, bar, psi, min, lMin, cuftMin }` (JSON `m, ft, c, f, bar, psi, min, l_min, cuft_min`) `enum MentionKind { site, place, species, gear, buddy, tag, center, trip, computer }` `class QueryClause { String field; ClauseOp op; Object value; ClauseUnit? unit; String text; }` `class QueryMention { MentionKind kind; String text; }` `class QueryTime { String text; }` `class ParsedQuery { int schemaVersion; QuerySubject subject; List clauses; List mentions; QueryTime? time; List unplaced; factory ParsedQuery.fromJson(Map); Map toJson(); ParsedQuery withoutClause(int i); ParsedQuery withoutMention(int i); ParsedQuery withoutTime(); }` `class QuerySchemaException implements Exception { String message; }` + +- [ ] **Step 1: Write the failing test** + +```dart +// test/features/explore/domain/query_model_test.dart +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +void main() { + Map sample() => { + 'schemaVersion': 1, + 'subject': 'dives', + 'clauses': [ + {'field': 'depth', 'op': 'gt', 'value': 20, 'unit': 'm', 'text': 'below 20m'}, + {'field': 'visibility', 'op': 'gt', 'value': 20, 'unit': 'm', 'text': 'viz over 20m'}, + ], + 'mentions': [ + {'kind': 'species', 'text': 'turtles'}, + {'kind': 'place', 'text': 'Bonaire'}, + ], + 'time': null, + 'unplaced': [], + }; + + test('parses the sample sentence payload', () { + final q = ParsedQuery.fromJson(sample()); + expect(q.schemaVersion, kQuerySchemaVersion); + expect(q.subject, QuerySubject.dives); + expect(q.clauses, hasLength(2)); + expect(q.clauses.first.field, 'depth'); + expect(q.clauses.first.op, ClauseOp.gt); + expect(q.clauses.first.value, 20); + expect(q.clauses.first.unit, ClauseUnit.m); + expect(q.mentions[1].kind, MentionKind.place); + expect(q.time, isNull); + expect(q.unplaced, isEmpty); + }); + + test('round-trips through toJson', () { + final q = ParsedQuery.fromJson(sample()); + expect(ParsedQuery.fromJson(q.toJson()).toJson(), q.toJson()); + expect(jsonEncode(q.toJson()), contains('"gt"')); + }); + + test('a between clause carries two numbers', () { + final json = sample() + ..['clauses'] = [ + {'field': 'depth', 'op': 'between', 'value': [10, 20], 'unit': 'm', 'text': '10 to 20m'}, + ]; + final q = ParsedQuery.fromJson(json); + expect(q.clauses.single.value, [10, 20]); + }); + + test('the JSON name of inList is in', () { + final json = sample() + ..['clauses'] = [ + {'field': 'waterType', 'op': 'in', 'value': ['salt'], 'text': 'salt water'}, + ]; + expect(ParsedQuery.fromJson(json).clauses.single.op, ClauseOp.inList); + }); + + test('rejects a wrong schema version', () { + expect( + () => ParsedQuery.fromJson(sample()..['schemaVersion'] = 2), + throwsA(isA()), + ); + }); + + test('rejects an unknown op, unit, kind or subject', () { + expect( + () => ParsedQuery.fromJson(sample()..['subject'] = 'boats'), + throwsA(isA()), + ); + final badOp = sample()..['clauses'] = [{'field': 'depth', 'op': 'near', 'value': 1, 'text': 'x'}]; + expect(() => ParsedQuery.fromJson(badOp), throwsA(isA())); + final badUnit = sample()..['clauses'] = [{'field': 'depth', 'op': 'gt', 'value': 1, 'unit': 'furlong', 'text': 'x'}]; + expect(() => ParsedQuery.fromJson(badUnit), throwsA(isA())); + final badKind = sample()..['mentions'] = [{'kind': 'boat', 'text': 'x'}]; + expect(() => ParsedQuery.fromJson(badKind), throwsA(isA())); + }); + + test('rejects a clause missing text or value', () { + final noText = sample()..['clauses'] = [{'field': 'depth', 'op': 'gt', 'value': 1}]; + expect(() => ParsedQuery.fromJson(noText), throwsA(isA())); + final noValue = sample()..['clauses'] = [{'field': 'depth', 'op': 'gt', 'text': 'x'}]; + expect(() => ParsedQuery.fromJson(noValue), throwsA(isA())); + }); + + test('string-encoded values from a constrained decoder are coerced', () { + // The Apple schema declares value as a string (one type per property), + // so numbers, lists and booleans may arrive quoted. + final json = sample() + ..['clauses'] = [ + {'field': 'depth', 'op': 'gt', 'value': '20', 'unit': 'none', 'text': 'a'}, + {'field': 'depth', 'op': 'between', 'value': '[10, 20]', 'text': 'b'}, + {'field': 'favorite', 'op': 'eq', 'value': 'true', 'text': 'c'}, + {'field': 'waterType', 'op': 'in', 'value': '["salt","fresh"]', 'text': 'd'}, + ]; + final q = ParsedQuery.fromJson(json); + expect(q.clauses[0].value, 20); + expect(q.clauses[0].unit, isNull); + expect(q.clauses[1].value, [10, 20]); + expect(q.clauses[2].value, true); + expect(q.clauses[3].value, ['salt', 'fresh']); + }); + + test('missing optional lists default to empty', () { + final q = ParsedQuery.fromJson({'schemaVersion': 1, 'subject': 'dives'}); + expect(q.clauses, isEmpty); + expect(q.mentions, isEmpty); + expect(q.unplaced, isEmpty); + }); + + test('withoutClause and withoutMention drop by index', () { + final q = ParsedQuery.fromJson(sample()); + expect(q.withoutClause(0).clauses.single.field, 'visibility'); + expect(q.withoutMention(1).mentions.single.kind, MentionKind.species); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/explore/domain/query_model_test.dart` +Expected: FAIL, compile error `Target of URI doesn't exist`. + +- [ ] **Step 3: Write the model** + +```dart +// lib/features/explore/domain/query_model.dart +/// The JSON contract between the native model adapter and the compiler. +/// +/// Schema version 1. Every payload the adapter returns is validated here +/// before anything reads it; a mismatch is a [QuerySchemaException], never a +/// guess. Pure Dart, no Flutter imports. +library; + +import 'dart:convert'; + +const int kQuerySchemaVersion = 1; + +enum QuerySubject { dives, equipment, sites, buddies, species, trips, centers } + +enum ClauseOp { + lt('lt'), + lte('lte'), + gt('gt'), + gte('gte'), + eq('eq'), + between('between'), + inList('in'), + not('not'); + + final String jsonName; + const ClauseOp(this.jsonName); +} + +enum ClauseUnit { + m('m'), + ft('ft'), + c('c'), + f('f'), + bar('bar'), + psi('psi'), + min('min'), + lMin('l_min'), + cuftMin('cuft_min'); + + final String jsonName; + const ClauseUnit(this.jsonName); +} + +enum MentionKind { site, place, species, gear, buddy, tag, center, trip, computer } + +class QuerySchemaException implements Exception { + final String message; + const QuerySchemaException(this.message); + @override + String toString() => 'QuerySchemaException: $message'; +} + +class QueryClause { + final String field; + final ClauseOp op; + + /// A num, a String, a bool (flags), a List of num (between) or a List of + /// String (in). + final Object value; + final ClauseUnit? unit; + final String text; + + const QueryClause({ + required this.field, + required this.op, + required this.value, + this.unit, + required this.text, + }); + + Map toJson() => { + 'field': field, + 'op': op.jsonName, + 'value': value, + if (unit != null) 'unit': unit!.jsonName, + 'text': text, + }; +} + +class QueryMention { + final MentionKind kind; + final String text; + const QueryMention({required this.kind, required this.text}); + Map toJson() => {'kind': kind.name, 'text': text}; +} + +class QueryTime { + final String text; + const QueryTime(this.text); + Map toJson() => {'text': text}; +} + +class ParsedQuery { + final int schemaVersion; + final QuerySubject subject; + final List clauses; + final List mentions; + final QueryTime? time; + final List unplaced; + + const ParsedQuery({ + this.schemaVersion = kQuerySchemaVersion, + required this.subject, + this.clauses = const [], + this.mentions = const [], + this.time, + this.unplaced = const [], + }); + + factory ParsedQuery.fromJson(Map json) { + final version = json['schemaVersion']; + if (version != kQuerySchemaVersion) { + throw QuerySchemaException( + 'schemaVersion $version, expected $kQuerySchemaVersion', + ); + } + final subject = _enumByName(QuerySubject.values, json['subject'], 'subject'); + final clauses = []; + for (final raw in _list(json['clauses'], 'clauses')) { + final map = _map(raw, 'clause'); + final field = map['field']; + final text = map['text']; + if (field is! String || field.isEmpty) { + throw const QuerySchemaException('clause.field missing'); + } + if (text is! String) throw const QuerySchemaException('clause.text missing'); + final value = _coerceValue(map['value']); + if (value == null) throw const QuerySchemaException('clause.value missing'); + if (value is! num && value is! String && value is! bool && value is! List) { + throw QuerySchemaException('clause.value has type ${value.runtimeType}'); + } + final op = _byJsonName(ClauseOp.values, (o) => o.jsonName, map['op'], 'op'); + final rawUnit = map['unit']; + final unit = rawUnit == null || rawUnit == 'none' || rawUnit == '' + ? null + : _byJsonName(ClauseUnit.values, (u) => u.jsonName, rawUnit, 'unit'); + clauses.add(QueryClause(field: field, op: op, value: value, unit: unit, text: text)); + } + final mentions = []; + for (final raw in _list(json['mentions'], 'mentions')) { + final map = _map(raw, 'mention'); + final text = map['text']; + if (text is! String || text.isEmpty) { + throw const QuerySchemaException('mention.text missing'); + } + mentions.add( + QueryMention( + kind: _enumByName(MentionKind.values, map['kind'], 'kind'), + text: text, + ), + ); + } + final rawTime = json['time']; + QueryTime? time; + if (rawTime != null) { + final text = _map(rawTime, 'time')['text']; + if (text is String && text.trim().isNotEmpty) time = QueryTime(text); + } + final unplaced = _list(json['unplaced'], 'unplaced') + .whereType() + .where((s) => s.trim().isNotEmpty) + .toList(); + return ParsedQuery( + subject: subject, + clauses: clauses, + mentions: mentions, + time: time, + unplaced: unplaced, + ); + } + + Map toJson() => { + 'schemaVersion': schemaVersion, + 'subject': subject.name, + 'clauses': clauses.map((c) => c.toJson()).toList(), + 'mentions': mentions.map((m) => m.toJson()).toList(), + 'time': time?.toJson(), + 'unplaced': unplaced, + }; + + ParsedQuery withoutClause(int index) => ParsedQuery( + schemaVersion: schemaVersion, + subject: subject, + clauses: [for (var i = 0; i < clauses.length; i++) if (i != index) clauses[i]], + mentions: mentions, + time: time, + unplaced: unplaced, + ); + + ParsedQuery withoutMention(int index) => ParsedQuery( + schemaVersion: schemaVersion, + subject: subject, + clauses: clauses, + mentions: [for (var i = 0; i < mentions.length; i++) if (i != index) mentions[i]], + time: time, + unplaced: unplaced, + ); + + ParsedQuery withoutTime() => ParsedQuery( + schemaVersion: schemaVersion, + subject: subject, + clauses: clauses, + mentions: mentions, + unplaced: unplaced, + ); +} + +/// A constrained decoder with one type per property may quote a number, a +/// list or a boolean. Unquote what parses; leave real strings alone. +Object? _coerceValue(Object? raw) { + if (raw is! String) return raw; + final s = raw.trim(); + if (s == 'true') return true; + if (s == 'false') return false; + final n = num.tryParse(s); + if (n != null) return n; + if (s.startsWith('[') && s.endsWith(']')) { + try { + final decoded = jsonDecode(s); + if (decoded is List) return decoded; + } on FormatException { + return raw; + } + } + return raw; +} + +List _list(Object? raw, String name) { + if (raw == null) return const []; + if (raw is List) return raw; + throw QuerySchemaException('$name is not a list'); +} + +Map _map(Object? raw, String name) { + if (raw is Map) return raw.cast(); + throw QuerySchemaException('$name is not an object'); +} + +T _enumByName(List values, Object? raw, String name) { + for (final v in values) { + if (v.name == raw) return v; + } + throw QuerySchemaException('unknown $name: $raw'); +} + +T _byJsonName(List values, String Function(T) jsonName, Object? raw, String name) { + for (final v in values) { + if (jsonName(v) == raw) return v; + } + throw QuerySchemaException('unknown $name: $raw'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/features/explore/domain/query_model_test.dart` +Expected: PASS (9 tests). + +- [ ] **Step 5: Commit** + +```bash +dart format lib/features/explore test/features/explore +git add lib/features/explore/domain/query_model.dart test/features/explore/domain/query_model_test.dart +git commit -m "feat(explore): parsed query model with schema validation" +``` + +--- + +### Task 2: Dive field catalog and unit grounding + +**Files:** +- Create: `lib/features/explore/domain/dive_field_catalog.dart` +- Create: `lib/features/explore/domain/unit_grounding.dart` +- Test: `test/features/explore/domain/dive_field_catalog_test.dart` +- Test: `test/features/explore/domain/unit_grounding_test.dart` + +**Interfaces:** +- Consumes: `DepthUnit`, `TemperatureUnit`, `PressureUnit` from `lib/core/constants/units.dart`; `ClauseUnit`, `ClauseOp` from Task 1. +- Produces: `enum FieldDimension { depth, temperature, pressure, minutes, percent, count, none }` `enum FieldValueType { number, enumName, flag }` `enum ExploreDiveField` with `jsonName` (values: `depth, avgDepth, bottomTime, waterTemp, airTemp, visibility, rating, o2, diveNumber, waterType, diveMode, entryMethod, currentStrength, favorite, deco, noBuddy, weekday, diveType`) `class FieldSpec { FieldDimension dimension; FieldValueType valueType; Set ops; List? enumValues; }` `abstract final class DiveFieldCatalog { static ExploreDiveField? parse(String jsonName); static FieldSpec spec(ExploreDiveField f); static List get jsonNames; }` `typedef UnitPrefs = ({DepthUnit depth, TemperatureUnit temperature, PressureUnit pressure});` `double groundToMetric(num value, ClauseUnit? unit, FieldDimension dimension, UnitPrefs prefs)`. + +- [ ] **Step 1: Write the failing tests** + +```dart +// test/features/explore/domain/dive_field_catalog_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +void main() { + test('every field parses by its JSON name and back', () { + for (final f in ExploreDiveField.values) { + expect(DiveFieldCatalog.parse(f.jsonName), f); + } + expect(DiveFieldCatalog.parse('bogus'), isNull); + expect(DiveFieldCatalog.jsonNames, hasLength(ExploreDiveField.values.length)); + }); + + test('depth is a numeric depth field with ordering ops', () { + final spec = DiveFieldCatalog.spec(ExploreDiveField.depth); + expect(spec.dimension, FieldDimension.depth); + expect(spec.valueType, FieldValueType.number); + expect(spec.ops, containsAll([ClauseOp.gt, ClauseOp.lt, ClauseOp.between])); + expect(spec.ops, isNot(contains(ClauseOp.inList))); + }); + + test('waterType is an enum field accepting eq, in and not', () { + final spec = DiveFieldCatalog.spec(ExploreDiveField.waterType); + expect(spec.valueType, FieldValueType.enumName); + expect(spec.enumValues, ['salt', 'fresh', 'brackish']); + expect(spec.ops, {ClauseOp.eq, ClauseOp.inList, ClauseOp.not}); + }); + + test('favorite, deco and noBuddy are flags accepting eq only', () { + for (final f in [ExploreDiveField.favorite, ExploreDiveField.deco, ExploreDiveField.noBuddy]) { + expect(DiveFieldCatalog.spec(f).valueType, FieldValueType.flag); + expect(DiveFieldCatalog.spec(f).ops, {ClauseOp.eq}); + } + }); +} +``` + +```dart +// test/features/explore/domain/unit_grounding_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; +import 'package:submersion/features/explore/domain/unit_grounding.dart'; + +void main() { + const metric = (depth: DepthUnit.meters, temperature: TemperatureUnit.celsius, pressure: PressureUnit.bar); + const imperial = (depth: DepthUnit.feet, temperature: TemperatureUnit.fahrenheit, pressure: PressureUnit.psi); + + test('an explicit unit wins over the diver preference', () { + expect(groundToMetric(20, ClauseUnit.m, FieldDimension.depth, imperial), 20); + expect(groundToMetric(66, ClauseUnit.ft, FieldDimension.depth, metric), closeTo(20.1, 0.05)); + expect(groundToMetric(50, ClauseUnit.f, FieldDimension.temperature, metric), 10); + expect(groundToMetric(3000, ClauseUnit.psi, FieldDimension.pressure, metric), closeTo(206.8, 0.1)); + }); + + test('a bare number takes the diver preference for the dimension', () { + expect(groundToMetric(20, null, FieldDimension.depth, metric), 20); + expect(groundToMetric(20, null, FieldDimension.depth, imperial), closeTo(6.1, 0.01)); + expect(groundToMetric(60, null, FieldDimension.temperature, imperial), closeTo(15.56, 0.01)); + expect(groundToMetric(200, null, FieldDimension.pressure, metric), 200); + }); + + test('dimensionless fields ignore any unit', () { + expect(groundToMetric(4, ClauseUnit.m, FieldDimension.count, metric), 4); + expect(groundToMetric(45, ClauseUnit.f, FieldDimension.minutes, imperial), 45); + expect(groundToMetric(32, null, FieldDimension.percent, imperial), 32); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/explore/domain/dive_field_catalog_test.dart test/features/explore/domain/unit_grounding_test.dart` +Expected: FAIL, compile errors on missing files. + +- [ ] **Step 3: Write the catalog and grounding** + +```dart +// lib/features/explore/domain/dive_field_catalog.dart +import 'package:submersion/features/explore/domain/query_model.dart'; + +/// What a numeric clause measures, so bare numbers can take the diver's unit. +enum FieldDimension { depth, temperature, pressure, minutes, percent, count, none } + +enum FieldValueType { number, enumName, flag } + +/// The fields the native schema constrains a dive clause to (schema v1). +/// +/// Named `ExploreDiveField` because `DiveField` is the table-column enum. +enum ExploreDiveField { + depth('depth'), + avgDepth('avgDepth'), + bottomTime('bottomTime'), + waterTemp('waterTemp'), + airTemp('airTemp'), + visibility('visibility'), + rating('rating'), + o2('o2'), + diveNumber('diveNumber'), + waterType('waterType'), + diveMode('diveMode'), + entryMethod('entryMethod'), + currentStrength('currentStrength'), + favorite('favorite'), + deco('deco'), + noBuddy('noBuddy'), + weekday('weekday'), + diveType('diveType'); + + final String jsonName; + const ExploreDiveField(this.jsonName); +} + +class FieldSpec { + final FieldDimension dimension; + final FieldValueType valueType; + final Set ops; + final List? enumValues; + + const FieldSpec({ + required this.dimension, + required this.valueType, + required this.ops, + this.enumValues, + }); +} + +const Set _ordering = { + ClauseOp.lt, + ClauseOp.lte, + ClauseOp.gt, + ClauseOp.gte, + ClauseOp.eq, + ClauseOp.between, +}; +const Set _membership = {ClauseOp.eq, ClauseOp.inList, ClauseOp.not}; + +abstract final class DiveFieldCatalog { + static const Map _specs = { + ExploreDiveField.depth: FieldSpec(dimension: FieldDimension.depth, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.avgDepth: FieldSpec(dimension: FieldDimension.depth, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.bottomTime: FieldSpec(dimension: FieldDimension.minutes, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.waterTemp: FieldSpec(dimension: FieldDimension.temperature, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.airTemp: FieldSpec(dimension: FieldDimension.temperature, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.visibility: FieldSpec(dimension: FieldDimension.depth, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.rating: FieldSpec(dimension: FieldDimension.count, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.o2: FieldSpec(dimension: FieldDimension.percent, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.diveNumber: FieldSpec(dimension: FieldDimension.count, valueType: FieldValueType.number, ops: _ordering), + ExploreDiveField.waterType: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.enumName, ops: _membership, enumValues: ['salt', 'fresh', 'brackish']), + ExploreDiveField.diveMode: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.enumName, ops: _membership, enumValues: ['oc', 'ccr', 'scr', 'gauge']), + ExploreDiveField.entryMethod: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.enumName, ops: _membership, enumValues: ['shore', 'boat', 'backRoll', 'giantStride', 'seatedEntry', 'ladder', 'platform', 'jetty', 'other']), + ExploreDiveField.currentStrength: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.enumName, ops: _membership, enumValues: ['none', 'light', 'moderate', 'strong']), + ExploreDiveField.favorite: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.flag, ops: {ClauseOp.eq}), + ExploreDiveField.deco: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.flag, ops: {ClauseOp.eq}), + ExploreDiveField.noBuddy: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.flag, ops: {ClauseOp.eq}), + ExploreDiveField.weekday: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.enumName, ops: _membership, enumValues: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']), + ExploreDiveField.diveType: FieldSpec(dimension: FieldDimension.none, valueType: FieldValueType.enumName, ops: {ClauseOp.eq}), + }; + + static ExploreDiveField? parse(String jsonName) { + for (final f in ExploreDiveField.values) { + if (f.jsonName == jsonName) return f; + } + return null; + } + + static FieldSpec spec(ExploreDiveField field) => _specs[field]!; + + static List get jsonNames => + ExploreDiveField.values.map((f) => f.jsonName).toList(growable: false); +} +``` + +```dart +// lib/features/explore/domain/unit_grounding.dart +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +/// The diver's unit choices the compiler needs; built from AppSettings by the +/// provider so this file stays free of the settings layer. +typedef UnitPrefs = ({ + DepthUnit depth, + TemperatureUnit temperature, + PressureUnit pressure, +}); + +/// Converts a clause value to storage units (metres, celsius, bar). +/// +/// An explicit [unit] wins. A bare number on a dimensioned field takes the +/// diver's unit for that dimension. Dimensionless fields return the value +/// unchanged whatever [unit] says. +double groundToMetric( + num value, + ClauseUnit? unit, + FieldDimension dimension, + UnitPrefs prefs, +) { + final v = value.toDouble(); + switch (dimension) { + case FieldDimension.depth: + final from = switch (unit) { + ClauseUnit.m => DepthUnit.meters, + ClauseUnit.ft => DepthUnit.feet, + _ => prefs.depth, + }; + return from.convert(v, DepthUnit.meters); + case FieldDimension.temperature: + final from = switch (unit) { + ClauseUnit.c => TemperatureUnit.celsius, + ClauseUnit.f => TemperatureUnit.fahrenheit, + _ => prefs.temperature, + }; + return from.convert(v, TemperatureUnit.celsius); + case FieldDimension.pressure: + final from = switch (unit) { + ClauseUnit.bar => PressureUnit.bar, + ClauseUnit.psi => PressureUnit.psi, + _ => prefs.pressure, + }; + return from.convert(v, PressureUnit.bar); + case FieldDimension.minutes: + case FieldDimension.percent: + case FieldDimension.count: + case FieldDimension.none: + return v; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/features/explore/domain/dive_field_catalog_test.dart test/features/explore/domain/unit_grounding_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +dart format lib/features/explore test/features/explore +git add lib/features/explore/domain/dive_field_catalog.dart lib/features/explore/domain/unit_grounding.dart test/features/explore/domain/dive_field_catalog_test.dart test/features/explore/domain/unit_grounding_test.dart +git commit -m "feat(explore): dive field catalog and unit grounding" +``` + +--- +### Task 3: Time grammar + +**Files:** +- Create: `lib/features/explore/domain/time_grammar.dart` +- Test: `test/features/explore/domain/time_grammar_test.dart` + +**Interfaces:** +- Produces: `typedef DateRange = ({DateTime? start, DateTime? end});` `DateRange? parseTimeText(String text, {required DateTime now})`. Dates are plain calendar `DateTime(y, m, d)` values, which is what `DiveFilterState.startDate` and `endDate` expect (they read only year, month, day). Returns null when the grammar does not accept the text. + +Accepted shapes (case-insensitive, English only; the model normalizes other languages into these words because the prompt asks it to): a four-digit year `2023`; month-year `may 2023` or `2023-05`; `this year`, `last year`, `this month`, `last month`; `last N days|weeks|months|years` and `past N ...`; `since 2022` and `since may 2023`; `before 2022`; an ISO date `2023-05-14`; `YYYY-MM-DD to YYYY-MM-DD`. + +- [ ] **Step 1: Write the failing test** + +```dart +// test/features/explore/domain/time_grammar_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/explore/domain/time_grammar.dart'; + +void main() { + final now = DateTime(2026, 9, 19); + + test('a bare year covers the whole year', () { + final r = parseTimeText('2023', now: now)!; + expect(r.start, DateTime(2023, 1, 1)); + expect(r.end, DateTime(2023, 12, 31)); + }); + + test('month year and ISO month cover the month', () { + for (final text in ['May 2023', 'may 2023', '2023-05']) { + final r = parseTimeText(text, now: now)!; + expect(r.start, DateTime(2023, 5, 1), reason: text); + expect(r.end, DateTime(2023, 5, 31), reason: text); + } + }); + + test('relative periods anchor on now', () { + expect(parseTimeText('this year', now: now), (start: DateTime(2026, 1, 1), end: DateTime(2026, 12, 31))); + expect(parseTimeText('last year', now: now), (start: DateTime(2025, 1, 1), end: DateTime(2025, 12, 31))); + expect(parseTimeText('this month', now: now), (start: DateTime(2026, 9, 1), end: DateTime(2026, 9, 30))); + expect(parseTimeText('last month', now: now), (start: DateTime(2026, 8, 1), end: DateTime(2026, 8, 31))); + }); + + test('last N units count back from today inclusive', () { + expect(parseTimeText('last 30 days', now: now), (start: DateTime(2026, 8, 20), end: DateTime(2026, 9, 19))); + expect(parseTimeText('past 2 weeks', now: now), (start: DateTime(2026, 9, 5), end: DateTime(2026, 9, 19))); + expect(parseTimeText('last 6 months', now: now), (start: DateTime(2026, 3, 19), end: DateTime(2026, 9, 19))); + expect(parseTimeText('last 2 years', now: now), (start: DateTime(2024, 9, 19), end: DateTime(2026, 9, 19))); + }); + + test('since and before are open ended', () { + expect(parseTimeText('since 2022', now: now), (start: DateTime(2022, 1, 1), end: null)); + expect(parseTimeText('since May 2023', now: now), (start: DateTime(2023, 5, 1), end: null)); + expect(parseTimeText('before 2022', now: now), (start: null, end: DateTime(2021, 12, 31))); + }); + + test('ISO dates and ranges', () { + expect(parseTimeText('2023-05-14', now: now), (start: DateTime(2023, 5, 14), end: DateTime(2023, 5, 14))); + expect(parseTimeText('2023-05-01 to 2023-05-14', now: now), (start: DateTime(2023, 5, 1), end: DateTime(2023, 5, 14))); + }); + + test('anything else is null', () { + expect(parseTimeText('when the water was warm', now: now), isNull); + expect(parseTimeText('', now: now), isNull); + expect(parseTimeText('99999', now: now), isNull); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/explore/domain/time_grammar_test.dart` +Expected: FAIL, missing file. + +- [ ] **Step 3: Write the grammar** + +```dart +// lib/features/explore/domain/time_grammar.dart +/// A small deterministic grammar over the model's own words for a period. +/// +/// The model is asked to phrase time in one of these shapes; anything else +/// becomes an unplaced chip rather than a guessed range. Pure Dart. +library; + +typedef DateRange = ({DateTime? start, DateTime? end}); + +const _months = { + 'january': 1, 'jan': 1, 'february': 2, 'feb': 2, 'march': 3, 'mar': 3, + 'april': 4, 'apr': 4, 'may': 5, 'june': 6, 'jun': 6, 'july': 7, 'jul': 7, + 'august': 8, 'aug': 8, 'september': 9, 'sep': 9, 'sept': 9, 'october': 10, + 'oct': 10, 'november': 11, 'nov': 11, 'december': 12, 'dec': 12, +}; + +final _year = RegExp(r'^(\d{4})$'); +final _isoMonth = RegExp(r'^(\d{4})-(\d{2})$'); +final _isoDate = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$'); +final _isoRange = RegExp(r'^(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})$'); +final _monthYear = RegExp(r'^([a-z]+)\s+(\d{4})$'); +final _lastN = RegExp(r'^(?:last|past)\s+(\d{1,3})\s+(day|week|month|year)s?$'); +final _since = RegExp(r'^since\s+(.+)$'); +final _before = RegExp(r'^before\s+(.+)$'); + +DateRange? parseTimeText(String text, {required DateTime now}) { + final t = text.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + if (t.isEmpty) return null; + final today = DateTime(now.year, now.month, now.day); + + DateRange? year(int y) => + y < 1900 || y > 2200 ? null : (start: DateTime(y, 1, 1), end: DateTime(y, 12, 31)); + DateRange? month(int y, int m) => m < 1 || m > 12 || y < 1900 || y > 2200 + ? null + : (start: DateTime(y, m, 1), end: DateTime(y, m + 1, 0)); + + var m = _year.firstMatch(t); + if (m != null) return year(int.parse(m[1]!)); + m = _isoMonth.firstMatch(t); + if (m != null) return month(int.parse(m[1]!), int.parse(m[2]!)); + m = _isoRange.firstMatch(t); + if (m != null) { + final a = _parseIso(m[1]!); + final b = _parseIso(m[2]!); + if (a == null || b == null || b.isBefore(a)) return null; + return (start: a, end: b); + } + m = _isoDate.firstMatch(t); + if (m != null) { + final d = _parseIso(t); + return d == null ? null : (start: d, end: d); + } + m = _monthYear.firstMatch(t); + if (m != null) { + final mo = _months[m[1]!]; + return mo == null ? null : month(int.parse(m[2]!), mo); + } + switch (t) { + case 'this year': + return year(today.year); + case 'last year': + return year(today.year - 1); + case 'this month': + return month(today.year, today.month); + case 'last month': + return month(today.year, today.month - 1 == 0 ? 12 : today.month - 1) + .let((r) => today.month == 1 ? month(today.year - 1, 12) : r); + } + m = _lastN.firstMatch(t); + if (m != null) { + final n = int.parse(m[1]!); + final start = switch (m[2]!) { + 'day' => today.subtract(Duration(days: n)), + 'week' => today.subtract(Duration(days: 7 * n)), + 'month' => DateTime(today.year, today.month - n, today.day), + _ => DateTime(today.year - n, today.month, today.day), + }; + return (start: start, end: today); + } + m = _since.firstMatch(t); + if (m != null) { + final inner = parseTimeText(m[1]!, now: now); + return inner?.start == null ? null : (start: inner!.start, end: null); + } + m = _before.firstMatch(t); + if (m != null) { + final inner = parseTimeText(m[1]!, now: now); + if (inner?.start == null) return null; + return (start: null, end: inner!.start!.subtract(const Duration(days: 1))); + } + return null; +} + +DateTime? _parseIso(String s) { + final m = _isoDate.firstMatch(s); + if (m == null) return null; + final y = int.parse(m[1]!), mo = int.parse(m[2]!), d = int.parse(m[3]!); + if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; + final date = DateTime(y, mo, d); + return date.month == mo ? date : null; +} + +extension on T { + R let(R Function(T) f) => f(this); +} +``` + +Simplify the `last month` case if the `let` extension reads awkwardly: compute `final prev = today.month == 1 ? month(today.year - 1, 12) : month(today.year, today.month - 1); return prev;` and delete the extension. The tests are the contract, not this exact shape. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/features/explore/domain/time_grammar_test.dart` +Expected: PASS. Note `last 30 days` from 2026-09-19 is 2026-08-20 because `today.subtract(30 days)` lands there; the test pins that. + +- [ ] **Step 5: Commit** + +```bash +dart format lib/features/explore test/features/explore +git add lib/features/explore/domain/time_grammar.dart test/features/explore/domain/time_grammar_test.dart +git commit -m "feat(explore): deterministic time grammar" +``` + +--- + +### Task 4: Name index and entity resolver + +**Files:** +- Create: `lib/features/explore/domain/name_index.dart` +- Create: `lib/features/explore/domain/entity_resolver.dart` +- Test: `test/features/explore/domain/entity_resolver_test.dart` + +**Interfaces:** +- Consumes: `normalize`, `diceCoefficient` from `lib/core/text/fuzzy_match.dart`; `MentionKind` from Task 1. +- Produces: + - `enum NameTarget { siteId, sitePlace, speciesId, equipmentId, attrChoice, buddyId, legacyBuddyName, tagId, centerId, tripId, computerId }` + - `class NameEntry { MentionKind kind; String label; List ids; NameTarget target; String? attrKey; String? attrChoice; int rank; }` where `rank` orders match groups within a kind (place: 0 country, 1 region, 2 island, 3 city, 4 site name; gear: 0 item name, 1 brand plus model, 2 attribute choice; species: 0 localized, 1 English, 2 scientific; others 0). `ids` is one id for entity targets and the list of site ids for `sitePlace`. + - `class NameIndex { List entries; const NameIndex(this.entries); static const empty = NameIndex([]); Iterable forKind(MentionKind kind); }` + - `sealed class Resolution` with `Resolved(NameEntry entry, double score)`, `Ambiguous(List candidates)`, `Unresolved()`. + - `Resolution resolveMention(QueryMention mention, NameIndex index, {double threshold = 0.75, double tieGap = 0.05})`. `place` mentions also search `site` entries at rank 4; `site` mentions also search `sitePlace` entries. Matching walks rank groups in order and stops at the first group with a score at or above threshold. Within the winning group, if the top two scores are within `tieGap` and map to different ids, the result is `Ambiguous` with up to five candidates in score order; entries with identical `ids` are deduplicated (a species matched by both its localized and English label is one candidate). + +- [ ] **Step 1: Write the failing test** + +```dart +// test/features/explore/domain/entity_resolver_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/explore/domain/entity_resolver.dart'; +import 'package:submersion/features/explore/domain/name_index.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +void main() { + NameEntry e(MentionKind k, String label, List ids, NameTarget t, {int rank = 0, String? attrKey, String? attrChoice}) => + NameEntry(kind: k, label: label, ids: ids, target: t, rank: rank, attrKey: attrKey, attrChoice: attrChoice); + + final index = NameIndex([ + e(MentionKind.place, 'Bonaire', ['s1', 's2', 's3'], NameTarget.sitePlace, rank: 0), + e(MentionKind.place, 'Netherlands Antilles', ['s1', 's2', 's3'], NameTarget.sitePlace, rank: 1), + e(MentionKind.site, 'Salt Pier', ['s1'], NameTarget.siteId), + e(MentionKind.site, 'Something Special', ['s2'], NameTarget.siteId), + e(MentionKind.site, '1000 Steps', ['s3'], NameTarget.siteId), + e(MentionKind.species, 'Green Turtle', ['sp1'], NameTarget.speciesId, rank: 0), + e(MentionKind.species, 'Green Turtle', ['sp1'], NameTarget.speciesId, rank: 1), + e(MentionKind.species, 'Chelonia mydas', ['sp1'], NameTarget.speciesId, rank: 2), + e(MentionKind.species, 'Hawksbill Turtle', ['sp2'], NameTarget.speciesId, rank: 0), + e(MentionKind.gear, 'Apeks MTX-R', ['g1'], NameTarget.equipmentId, rank: 0), + e(MentionKind.gear, 'Trilaminate', [], NameTarget.attrChoice, rank: 2, attrKey: 'shell_material', attrChoice: 'trilaminate'), + e(MentionKind.buddy, 'Sarah Jones', ['b1'], NameTarget.buddyId), + e(MentionKind.buddy, 'Sara Johnson', ['b2'], NameTarget.buddyId), + ]); + + Resolution r(MentionKind k, String text) => resolveMention(QueryMention(kind: k, text: text), index); + + test('an exact place resolves to the site id set', () { + final res = r(MentionKind.place, 'Bonaire') as Resolved; + expect(res.entry.target, NameTarget.sitePlace); + expect(res.entry.ids, ['s1', 's2', 's3']); + expect(res.score, 1.0); + }); + + test('a place that only matches a site name falls through to sites', () { + final res = r(MentionKind.place, 'salt pier') as Resolved; + expect(res.entry.target, NameTarget.siteId); + expect(res.entry.ids, ['s1']); + }); + + test('diacritics and case are ignored', () { + final res = r(MentionKind.site, 'SALT PIÉR') as Resolved; + expect(res.entry.ids, ['s1']); + }); + + test('a species matched by several labels is one candidate', () { + final res = r(MentionKind.species, 'green turtle'); + expect(res, isA()); + expect((res as Resolved).entry.ids, ['sp1']); + }); + + test('turtles alone is ambiguous between the two turtles', () { + // Dice of "turtles" against "green turtle" and "hawksbill turtle" is well + // below 0.75, so this is Unresolved, not Ambiguous. The compiler surfaces + // it with candidates; the resolver is strict. + expect(r(MentionKind.species, 'turtles'), isA()); + }); + + test('two near-equal buddies are ambiguous with both candidates', () { + final res = r(MentionKind.buddy, 'Sara Jones'); + expect(res, isA()); + final ids = (res as Ambiguous).candidates.map((c) => c.ids.single).toSet(); + expect(ids, {'b1', 'b2'}); + }); + + test('gear resolves an attribute choice when no item matches', () { + final res = r(MentionKind.gear, 'trilaminate suit'); + expect(res, isA()); + final entry = (res as Resolved).entry; + expect(entry.target, NameTarget.attrChoice); + expect(entry.attrKey, 'shell_material'); + expect(entry.attrChoice, 'trilaminate'); + }); + + test('nothing similar is unresolved', () { + expect(r(MentionKind.gear, 'submarine'), isA()); + expect(resolveMention(const QueryMention(kind: MentionKind.tag, text: 'night'), NameIndex.empty), isA()); + }); +} +``` + +Check the `trilaminate suit` case: `diceCoefficient('trilaminate suit', 'trilaminate')` is 2*10/(15+10) = 0.80, above the threshold. If it lands below on the real bigram count, lower the test input to `trilaminate`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/explore/domain/entity_resolver_test.dart` +Expected: FAIL, missing files. + +- [ ] **Step 3: Write the index and resolver** + +```dart +// lib/features/explore/domain/name_index.dart +import 'package:submersion/features/explore/domain/query_model.dart'; + +/// What a matched label lowers to. +enum NameTarget { + siteId, + sitePlace, + speciesId, + equipmentId, + attrChoice, + buddyId, + legacyBuddyName, + tagId, + centerId, + tripId, + computerId, +} + +/// One label the diver's data offers for matching, and what it maps to. +class NameEntry { + final MentionKind kind; + final String label; + + /// One id for an entity target; every site id under a place; empty for an + /// attribute choice or a legacy buddy name (the label itself is the value). + final List ids; + final NameTarget target; + final String? attrKey; + final String? attrChoice; + + /// Match-group order within a kind; lower ranks are tried first. + final int rank; + + const NameEntry({ + required this.kind, + required this.label, + required this.ids, + required this.target, + this.rank = 0, + this.attrKey, + this.attrChoice, + }); + + /// Identity for deduplication: the same ids under the same target. + String get identity => + '${target.name}:${ids.join(',')}:${attrKey ?? ''}:${attrChoice ?? ''}:${target == NameTarget.legacyBuddyName ? label : ''}'; +} + +class NameIndex { + final List entries; + const NameIndex(this.entries); + static const empty = NameIndex([]); + + Iterable forKind(MentionKind kind) => + entries.where((e) => e.kind == kind); +} +``` + +```dart +// lib/features/explore/domain/entity_resolver.dart +import 'package:submersion/core/text/fuzzy_match.dart'; +import 'package:submersion/features/explore/domain/name_index.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +sealed class Resolution { + const Resolution(); +} + +class Resolved extends Resolution { + final NameEntry entry; + final double score; + const Resolved(this.entry, this.score); +} + +class Ambiguous extends Resolution { + final List candidates; + const Ambiguous(this.candidates); +} + +class Unresolved extends Resolution { + const Unresolved(); +} + +/// Resolves one mention against the diver's own names by Dice similarity. +/// +/// Walks the kind's rank groups in order and stops at the first group with a +/// score at or above [threshold]. A top pair within [tieGap] that maps to +/// different identities is [Ambiguous]; nothing at threshold is [Unresolved]. +/// A `place` mention falls through to site names; a `site` mention falls +/// through to places, so "Bonaire" and "Salt Pier" both work under either. +Resolution resolveMention( + QueryMention mention, + NameIndex index, { + double threshold = 0.75, + double tieGap = 0.05, +}) { + final query = normalize(mention.text); + if (query.isEmpty) return const Unresolved(); + + final groups = >[]; + void addGroups(MentionKind kind) { + final byRank = >{}; + for (final e in index.forKind(kind)) { + byRank.putIfAbsent(e.rank, () => []).add(e); + } + final ranks = byRank.keys.toList()..sort(); + for (final r in ranks) { + groups.add(byRank[r]!); + } + } + + addGroups(mention.kind); + if (mention.kind == MentionKind.place) addGroups(MentionKind.site); + if (mention.kind == MentionKind.site) addGroups(MentionKind.place); + + for (final group in groups) { + final scored = <(NameEntry, double)>[]; + for (final e in group) { + final s = diceCoefficient(query, normalize(e.label)); + if (s >= threshold) scored.add((e, s)); + } + if (scored.isEmpty) continue; + scored.sort((a, b) => b.$2.compareTo(a.$2)); + // Collapse labels that lower to the same thing (a species by three names). + final seen = {}; + final distinct = <(NameEntry, double)>[]; + for (final s in scored) { + if (seen.add(s.$1.identity)) distinct.add(s); + } + if (distinct.length >= 2 && distinct[0].$2 - distinct[1].$2 <= tieGap) { + return Ambiguous(distinct.take(5).map((s) => s.$1).toList()); + } + return Resolved(distinct.first.$1, distinct.first.$2); + } + return const Unresolved(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/features/explore/domain/entity_resolver_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +dart format lib/features/explore test/features/explore +git add lib/features/explore/domain/name_index.dart lib/features/explore/domain/entity_resolver.dart test/features/explore/domain/entity_resolver_test.dart +git commit -m "feat(explore): name index and fuzzy entity resolver" +``` + +--- +### Task 5: New filter axes on `DiveFilterState` + +**Files:** +- Modify: `lib/features/dive_log/domain/models/dive_filter_state.dart` (fields after `equipmentAttrConditions`, constructor, `hasActiveFilters`, `copyWith`, `apply`) +- Test: `test/features/dive_log/domain/models/dive_filter_state_test.dart` (extend `_makeDive` and add a group) + +**Interfaces:** +- Produces on `DiveFilterState`: `double? minWaterTemp, maxWaterTemp` (celsius), `double? minVisibility, maxVisibility` (metres, against `visibilityMeters`), `List waterTypes` (OR), `List speciesIds` (OR, any sighting), `List siteIds` (OR, set form of `siteId`); `bool get readsSightings => speciesIds.isNotEmpty;` and `copyWith` params plus `clearMinWaterTemp, clearMaxWaterTemp, clearMinVisibility, clearMaxVisibility, clearWaterTypes, clearSpeciesIds, clearSiteIds`. + +- [ ] **Step 1: Write the failing tests** + +Extend `_makeDive` in `test/features/dive_log/domain/models/dive_filter_state_test.dart` with four optional parameters and pass them to the `Dive` constructor: + +```dart + double? waterTemp, + double? visibilityMeters, + WaterType? waterType, + List sightings = const [], + String? siteId, +``` + +and in the `Dive(...)` call replace `sightings: const [],` with `sightings: sightings,` and add `waterTemp: waterTemp, visibilityMeters: visibilityMeters, waterType: waterType,` and `site: siteId == null ? null : DiveSite(id: siteId, name: siteId),` (check the `DiveSite` constructor's required parameters in `lib/features/dive_sites/domain/entities/dive_site.dart` and pass the minimum). Add imports for `WaterType` (`lib/core/constants/enums.dart`), `MarineSighting` (already in `dive.dart`) and `DiveSite`. + +Add this group at the end of `main()`: + +```dart + group('phase 1 explore axes', () { + MarineSighting s(String speciesId) => MarineSighting( + id: 'sight-$speciesId', + speciesId: speciesId, + speciesName: speciesId, + count: 1, + notes: '', + ); + + test('water temperature bounds exclude null and out-of-range dives', () { + final dives = [ + _makeDive(id: 'cold', waterTemp: 8), + _makeDive(id: 'warm', waterTemp: 27), + _makeDive(id: 'none'), + ]; + expect(const DiveFilterState(maxWaterTemp: 15).apply(dives).map((d) => d.id), ['cold']); + expect(const DiveFilterState(minWaterTemp: 20).apply(dives).map((d) => d.id), ['warm']); + }); + + test('visibility bounds read visibilityMeters only', () { + final dives = [ + _makeDive(id: 'clear', visibilityMeters: 30), + _makeDive(id: 'murky', visibilityMeters: 4), + _makeDive(id: 'none'), + ]; + expect(const DiveFilterState(minVisibility: 20).apply(dives).map((d) => d.id), ['clear']); + expect(const DiveFilterState(maxVisibility: 5).apply(dives).map((d) => d.id), ['murky']); + }); + + test('water types OR within the axis', () { + final dives = [ + _makeDive(id: 'salt', waterType: WaterType.salt), + _makeDive(id: 'fresh', waterType: WaterType.fresh), + _makeDive(id: 'none'), + ]; + expect( + const DiveFilterState(waterTypes: [WaterType.salt, WaterType.fresh]).apply(dives).map((d) => d.id), + ['salt', 'fresh'], + ); + }); + + test('species ids match any sighting', () { + final dives = [ + _makeDive(id: 'turtle', sightings: [s('sp_green_turtle')]), + _makeDive(id: 'shark', sightings: [s('sp_nurse_shark')]), + _makeDive(id: 'none'), + ]; + expect( + const DiveFilterState(speciesIds: ['sp_green_turtle', 'sp_hawksbill_turtle']).apply(dives).map((d) => d.id), + ['turtle'], + ); + }); + + test('site ids match any listed site and AND with siteId', () { + final dives = [_makeDive(id: 'a', siteId: 's1'), _makeDive(id: 'b', siteId: 's2'), _makeDive(id: 'c')]; + expect(const DiveFilterState(siteIds: ['s1', 's2']).apply(dives).map((d) => d.id), ['a', 'b']); + expect(const DiveFilterState(siteIds: ['s1', 's2'], siteId: 's2').apply(dives).map((d) => d.id), ['b']); + }); + + test('the new axes count as active and clear through copyWith', () { + const f = DiveFilterState(minWaterTemp: 1, maxVisibility: 2, waterTypes: [WaterType.salt], speciesIds: ['x'], siteIds: ['s']); + expect(f.hasActiveFilters, isTrue); + expect(f.readsSightings, isTrue); + final cleared = f.copyWith( + clearMinWaterTemp: true, + clearMaxVisibility: true, + clearWaterTypes: true, + clearSpeciesIds: true, + clearSiteIds: true, + ); + expect(cleared.hasActiveFilters, isFalse); + expect(cleared.readsSightings, isFalse); + }); + }); +``` + +- [ ] **Step 2: Run the test file to verify the new group fails** + +Run: `flutter test test/features/dive_log/domain/models/dive_filter_state_test.dart` +Expected: FAIL, `No named parameter with the name 'minWaterTemp'`. + +- [ ] **Step 3: Add the axes** + +In `dive_filter_state.dart` add `import 'package:submersion/core/constants/enums.dart';`. After the `equipmentAttrConditions` field add: + +```dart + /// Water temperature bounds in celsius against `dives.water_temp`. A dive + /// with no recorded temperature never matches a set bound. + final double? minWaterTemp; + final double? maxWaterTemp; + + /// Visibility bounds in metres against `dives.visibility_meters` only; the + /// legacy `visibility` bucket column is read-only and ignored. + final double? minVisibility; + final double? maxVisibility; + + /// Water types to keep (OR within the axis), matched on `dives.water_type`. + final List waterTypes; + + /// Species ids: keep dives with a sighting of ANY listed species. + final List speciesIds; + + /// Site ids (OR within the axis), the set form of [siteId]; both apply when + /// both are set. Explore lowers a place mention ("Bonaire") to this. + final List siteIds; +``` + +Add to the constructor: `this.minWaterTemp, this.maxWaterTemp, this.minVisibility, this.maxVisibility, this.waterTypes = const [], this.speciesIds = const [], this.siteIds = const [],`. + +After `readsBuddyLinks` add: + +```dart + /// Whether a filter reads the `sightings` junction, which changes without a + /// `dives` write, so a list filtered this way must follow that table too. + bool get readsSightings => speciesIds.isNotEmpty; +``` + +Extend `hasActiveFilters` with `|| minWaterTemp != null || maxWaterTemp != null || minVisibility != null || maxVisibility != null || waterTypes.isNotEmpty || speciesIds.isNotEmpty || siteIds.isNotEmpty`. + +Extend `copyWith` with the seven value parameters and seven `clear*` flags, following the existing pattern exactly, for example: + +```dart + minWaterTemp: clearMinWaterTemp ? null : (minWaterTemp ?? this.minWaterTemp), + waterTypes: clearWaterTypes ? const [] : (waterTypes ?? this.waterTypes), + speciesIds: clearSpeciesIds ? const [] : (speciesIds ?? this.speciesIds), + siteIds: clearSiteIds ? const [] : (siteIds ?? this.siteIds), +``` + +In `apply`, after the `siteId` check add: + +```dart + if (siteIds.isNotEmpty && !siteIds.contains(dive.site?.id)) { + return false; + } +``` + +and after the `maxDepth` check add: + +```dart + if (minWaterTemp != null && + (dive.waterTemp == null || dive.waterTemp! < minWaterTemp!)) { + return false; + } + if (maxWaterTemp != null && + (dive.waterTemp == null || dive.waterTemp! > maxWaterTemp!)) { + return false; + } + if (minVisibility != null && + (dive.visibilityMeters == null || + dive.visibilityMeters! < minVisibility!)) { + return false; + } + if (maxVisibility != null && + (dive.visibilityMeters == null || + dive.visibilityMeters! > maxVisibility!)) { + return false; + } + if (waterTypes.isNotEmpty && + (dive.waterType == null || !waterTypes.contains(dive.waterType))) { + return false; + } + if (speciesIds.isNotEmpty && + !dive.sightings.any((s) => speciesIds.contains(s.speciesId))) { + return false; + } +``` + +- [ ] **Step 4: Run the test file** + +Run: `flutter test test/features/dive_log/domain/models/dive_filter_state_test.dart` +Expected: PASS, including every pre-existing test. + +- [ ] **Step 5: Commit** + +```bash +dart format lib/features/dive_log/domain/models/dive_filter_state.dart test/features/dive_log/domain/models/dive_filter_state_test.dart +git add lib/features/dive_log/domain/models/dive_filter_state.dart test/features/dive_log/domain/models/dive_filter_state_test.dart +git commit -m "feat(dive-log): water temp, visibility, water type, species and site-set filter axes" +``` + +--- + +### Task 6: The two SQL paths, their change ticks, and the parity test + +**Files:** +- Modify: `lib/features/statistics/data/dive_filter_sql.dart` (inside `buildFilteredDiveIdSubquery`, after the `maxDepth` block and after the `siteId` block) +- Modify: `lib/features/dive_log/data/repositories/dive_repository_impl.dart` (`_buildFilterWhereClauses`, plus a new `watchSightingsFilterChanges()` stream next to `watchEquipmentAttrFilterChanges` at about line 220) +- Modify: `lib/features/dive_log/presentation/providers/dive_providers.dart` (`orderedDiveIdsProvider` at about line 166 and the paginator's `_followFilterTicks`) +- Test: `test/features/dive_log/data/repositories/dive_repository_explore_axes_filter_test.dart` + +**Interfaces:** +- Consumes: the Task 5 axes. +- Produces: `Stream watchSightingsFilterChanges()` on `DiveRepository` (dives plus sightings, debounced like its siblings). + +- [ ] **Step 1: Write the failing parity test** + +```dart +// test/features/dive_log/data/repositories/dive_repository_explore_axes_filter_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/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/statistics/data/dive_filter_sql.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Statistics, the paginated list and its count must select the same dives +/// for every phase 1 Explore axis (the three-path rule). +void main() { + late AppDatabase db; + late DiveRepository repo; + final now = DateTime(2026, 6, 1).millisecondsSinceEpoch; + + setUp(() async { + db = await setUpTestDatabase(); + repo = DiveRepository(); + }); + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future insertDive( + String id, { + double? waterTemp, + double? visibilityMeters, + WaterType? waterType, + String? siteId, + }) => db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + waterTemp: Value(waterTemp), + visibilityMeters: Value(visibilityMeters), + waterType: Value(waterType?.name), + siteId: Value(siteId), + ), + ); + + Future insertSite(String id) => db + .into(db.diveSites) + .insert( + DiveSitesCompanion( + id: Value(id), + name: Value(id), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + + Future insertSpecies(String id) => db + .into(db.species) + .insert( + SpeciesCompanion( + id: Value(id), + commonName: Value(id), + category: Value(SpeciesCategory.reptile.name), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + + Future insertSighting(String diveId, String speciesId) => db + .into(db.sightings) + .insert( + SightingsCompanion( + id: Value('$diveId-$speciesId'), + diveId: Value(diveId), + speciesId: Value(speciesId), + ), + ); + + Future> statisticsIds(DiveFilterState filter) async { + final q = buildFilteredDiveIdSubquery(filter); + final rows = await db + .customSelect(q.subquery, variables: q.params.map((p) => Variable(p)).toList()) + .get(); + return rows.map((r) => r.read('id')).toSet(); + } + + Future> listIds(DiveFilterState filter) async => + (await repo.getDiveSummaries(filter: filter)).map((s) => s.id).toSet(); + + Future expectParity(DiveFilterState filter, Set expected) async { + expect(await statisticsIds(filter), expected, reason: 'statistics'); + expect(await listIds(filter), expected, reason: 'list'); + expect(await repo.getDiveCount(filter: filter), expected.length, reason: 'count'); + } + + test('water temperature bounds', () async { + await insertDive('cold', waterTemp: 8); + await insertDive('warm', waterTemp: 27); + await insertDive('none'); + await expectParity(const DiveFilterState(maxWaterTemp: 15), {'cold'}); + await expectParity(const DiveFilterState(minWaterTemp: 20), {'warm'}); + await expectParity(const DiveFilterState(minWaterTemp: 5, maxWaterTemp: 30), {'cold', 'warm'}); + }); + + test('visibility bounds', () async { + await insertDive('clear', visibilityMeters: 30); + await insertDive('murky', visibilityMeters: 4); + await insertDive('none'); + await expectParity(const DiveFilterState(minVisibility: 20), {'clear'}); + await expectParity(const DiveFilterState(maxVisibility: 5), {'murky'}); + }); + + test('water types', () async { + await insertDive('salt', waterType: WaterType.salt); + await insertDive('fresh', waterType: WaterType.fresh); + await insertDive('none'); + await expectParity(const DiveFilterState(waterTypes: [WaterType.salt]), {'salt'}); + await expectParity(const DiveFilterState(waterTypes: [WaterType.salt, WaterType.fresh]), {'salt', 'fresh'}); + }); + + test('species ids match any sighting', () async { + await insertSpecies('turtle'); + await insertSpecies('shark'); + await insertDive('t'); + await insertDive('s'); + await insertDive('n'); + await insertSighting('t', 'turtle'); + await insertSighting('s', 'shark'); + await expectParity(const DiveFilterState(speciesIds: ['turtle', 'ray']), {'t'}); + await expectParity(const DiveFilterState(speciesIds: ['turtle', 'shark']), {'t', 's'}); + }); + + test('site id set, alone and with siteId', () async { + await insertSite('s1'); + await insertSite('s2'); + await insertDive('a', siteId: 's1'); + await insertDive('b', siteId: 's2'); + await insertDive('c'); + await expectParity(const DiveFilterState(siteIds: ['s1', 's2']), {'a', 'b'}); + await expectParity(const DiveFilterState(siteIds: ['s1', 's2'], siteId: 's2'), {'b'}); + }); + + test('the sightings tick fires on a sighting write alone', () async { + await insertSpecies('turtle'); + await insertDive('t'); + final ticks = []; + final sub = repo.watchSightingsFilterChanges().listen(ticks.add); + await insertSighting('t', 'turtle'); + await Future.delayed(DiveRepository.changeTickDebounce * 2); + await sub.cancel(); + expect(ticks, isNotEmpty); + }); +} +``` + +If `SpeciesCategory.reptile` does not exist, use any value from the enum in `lib/core/constants/enums.dart`. If `DiveSitesCompanion` or `SpeciesCompanion` require more non-null columns, add them with the same `now` stamps; the compile error names them. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_explore_axes_filter_test.dart` +Expected: FAIL. The first assertion fails on `statistics` because the subquery is empty for the new axis (an empty subquery is `''` and `customSelect('')` errors) and `watchSightingsFilterChanges` does not exist. + +- [ ] **Step 3: Add the Statistics SQL** + +In `buildFilteredDiveIdSubquery`, after the `siteId` block: + +```dart + if (filter.siteIds.isNotEmpty) { + final ph = List.filled(filter.siteIds.length, '?').join(', '); + conditions.add('site_id IN ($ph)'); + params.addAll(filter.siteIds); + } +``` + +After the `maxDepth` block: + +```dart + // Water temperature and visibility: null excluded when a bound is set, + // mirroring depth and DiveFilterState.apply. + if (filter.minWaterTemp != null) { + conditions.add('water_temp IS NOT NULL AND water_temp >= ?'); + params.add(filter.minWaterTemp); + } + if (filter.maxWaterTemp != null) { + conditions.add('water_temp IS NOT NULL AND water_temp <= ?'); + params.add(filter.maxWaterTemp); + } + if (filter.minVisibility != null) { + conditions.add('visibility_meters IS NOT NULL AND visibility_meters >= ?'); + params.add(filter.minVisibility); + } + if (filter.maxVisibility != null) { + conditions.add('visibility_meters IS NOT NULL AND visibility_meters <= ?'); + params.add(filter.maxVisibility); + } + if (filter.waterTypes.isNotEmpty) { + final ph = List.filled(filter.waterTypes.length, '?').join(', '); + conditions.add('water_type IN ($ph)'); + params.addAll(filter.waterTypes.map((w) => w.name)); + } + // Species: any sighting of a listed species. + if (filter.speciesIds.isNotEmpty) { + final ph = List.filled(filter.speciesIds.length, '?').join(', '); + conditions.add( + 'id IN (SELECT dive_id FROM sightings WHERE species_id IN ($ph))', + ); + params.addAll(filter.speciesIds); + } +``` + +- [ ] **Step 4: Add the list SQL and the tick** + +In `_buildFilterWhereClauses`, after the `siteId` clause: + +```dart + if (filter.siteIds.isNotEmpty) { + final placeholders = List.filled(filter.siteIds.length, '?').join(', '); + clauses.add('d.site_id IN ($placeholders)'); + for (final siteId in filter.siteIds) { + args.add(Variable(siteId)); + } + } +``` + +After the `maxDepth` clause: + +```dart + // Null excluded when a bound is set, in step with Statistics and apply(). + if (filter.minWaterTemp != null) { + clauses.add('d.water_temp IS NOT NULL AND d.water_temp >= ?'); + args.add(Variable(filter.minWaterTemp!)); + } + if (filter.maxWaterTemp != null) { + clauses.add('d.water_temp IS NOT NULL AND d.water_temp <= ?'); + args.add(Variable(filter.maxWaterTemp!)); + } + if (filter.minVisibility != null) { + clauses.add('d.visibility_meters IS NOT NULL AND d.visibility_meters >= ?'); + args.add(Variable(filter.minVisibility!)); + } + if (filter.maxVisibility != null) { + clauses.add('d.visibility_meters IS NOT NULL AND d.visibility_meters <= ?'); + args.add(Variable(filter.maxVisibility!)); + } + if (filter.waterTypes.isNotEmpty) { + final placeholders = List.filled(filter.waterTypes.length, '?').join(', '); + clauses.add('d.water_type IN ($placeholders)'); + for (final w in filter.waterTypes) { + args.add(Variable(w.name)); + } + } + if (filter.speciesIds.isNotEmpty) { + final placeholders = List.filled(filter.speciesIds.length, '?').join(', '); + clauses.add( + 'EXISTS (SELECT 1 FROM sightings sg ' + 'WHERE sg.dive_id = d.id AND sg.species_id IN ($placeholders))', + ); + for (final speciesId in filter.speciesIds) { + args.add(Variable(speciesId)); + } + } +``` + +Note the existing depth clauses in this method do not carry `IS NOT NULL`; SQLite's `NULL >= ?` is already false, so behaviour matches. Keep the explicit form for the new axes so the intent is readable. + +Next to `watchEquipmentAttrFilterChanges` add: + +```dart + /// Change tick for a list filtered by species ([DiveFilterState.speciesIds]): + /// a sighting is written without a `dives` write, so the dives tick alone + /// would leave the list stale. + Stream watchSightingsFilterChanges() => _db + .tableUpdates( + TableUpdateQuery.allOf([ + TableUpdateQuery.onTable(_db.dives), + TableUpdateQuery.onTable(_db.sightings), + ]), + ) + .debounce(changeTickDebounce); +``` + +In `dive_providers.dart`, `orderedDiveIdsProvider`: after the attribute-condition block add + +```dart + // A species filter makes the query read sightings. + if (filter.readsSightings) { + ref.invalidateSelfWhen(repository.watchSightingsFilterChanges()); + } +``` + +In `PaginatedDiveListNotifier`, find `_attrFilterTick` (a `_FilterTickFollower`) and add a sibling `_sightingsFilterTick = _FilterTickFollower(() => _repository.watchSightingsFilterChanges(), loadFirstPage)` declared the same way, cancelled in the same `onDispose`, and driven in `_followFilterTicks(filter)` with `_sightingsFilterTick.follow(filter.readsSightings)` next to the attribute call (read `_FilterTickFollower` at the bottom of the file for its method name; it is `follow(bool)` or equivalent, copy the attribute line). Subscribing only while the axis is set is deliberate: many test fakes `implements DiveRepository` and would hit `noSuchMethod` on an unconditional subscription. + +- [ ] **Step 5: Run the parity test and the neighbours** + +Run: `flutter test test/features/dive_log/data/repositories/dive_repository_explore_axes_filter_test.dart test/features/statistics/data/dive_filter_sql_test.dart test/features/dive_log/data/repositories/dive_repository_equipment_attr_filter_test.dart test/architecture/provider_change_tick_test.dart test/architecture/repository_tick_stream_test.dart` +Expected: PASS. If `repository_tick_stream_test.dart` lists tick streams by name, add `watchSightingsFilterChanges` where it expects new streams to be registered (read its failure message). + +- [ ] **Step 6: Commit** + +```bash +dart format lib/features/statistics/data/dive_filter_sql.dart lib/features/dive_log/data/repositories/dive_repository_impl.dart lib/features/dive_log/presentation/providers/dive_providers.dart test/features/dive_log/data/repositories/dive_repository_explore_axes_filter_test.dart +git add lib/features/statistics/data/dive_filter_sql.dart lib/features/dive_log/data/repositories/dive_repository_impl.dart lib/features/dive_log/presentation/providers/dive_providers.dart test/features/dive_log/data/repositories/dive_repository_explore_axes_filter_test.dart +git commit -m "feat(dive-log): evaluate the explore axes in Statistics and the paginated list with parity" +``` + +--- +### Task 7: Compiled query, chart selection and the compiler + +**Files:** +- Create: `lib/features/explore/domain/compiled_query.dart` +- Create: `lib/features/explore/domain/chart_selection.dart` +- Create: `lib/features/explore/domain/query_compiler.dart` +- Test: `test/features/explore/domain/query_compiler_test.dart` +- Test: `test/features/explore/domain/chart_selection_test.dart` + +**Interfaces:** +- Consumes: Tasks 1 to 5. `EquipmentAttrCondition` (`lib/features/equipment/domain/models/equipment_attr_condition.dart`), `WaterType` (`lib/core/constants/enums.dart`). +- Produces: + - `enum ChipRef { clause, mention, time }` and `class QueryChip { ChipRef ref; int index; ChipPayload payload; }` (index into `ParsedQuery.clauses` or `mentions`; 0 for time). + - `sealed class ChipPayload` with `ClauseChip(ExploreDiveField field, ClauseOp op, Object value, FieldDimension dimension)` where `value` is already metric (double, List, String or List), `MentionChip(MentionKind kind, NameEntry entry)`, `TimeChip(DateTime? start, DateTime? end)`. + - `class UnresolvedMention { int index; QueryMention mention; List candidates; }` + - `class UnplacedItem { String text; String? reason; }` + - `enum ChartKind { divesOverTime, depthTrend, waterTempTrend, bottomTimeTrend, entityCounts }` `class ChartRequest { ChartKind kind; MentionKind? entityKind; }` `List selectCharts({required List numericFields, required Map resolvedEntityCounts})` capped at three, in the order: dives over time, depth, water temp, bottom time, then entity counts for kinds with more than one resolved id. + - `class CompiledQuery { DiveFilterState filter; List chips; List unresolved; List unplaced; List charts; }` + - `class CompilerContext { UnitPrefs units; NameIndex names; DateTime now; }` + - `abstract final class QueryCompiler { static CompiledQuery compile(ParsedQuery query, CompilerContext ctx); }` + +Lowering table (clause field to filter axis): + +| Field | op lt/lte | op gt/gte | eq | between | in / not | +| --- | --- | --- | --- | --- | --- | +| depth | maxDepth | minDepth | min = max = v | min, max | unplaced | +| avgDepth | unplaced (no axis; reason `noAxis`) | | | | | +| bottomTime | maxBottomTimeMinutes | minBottomTimeMinutes | both | both | unplaced | +| waterTemp | maxWaterTemp | minWaterTemp | both | both | unplaced | +| airTemp | unplaced (`noAxis`) | | | | | +| visibility | maxVisibility | minVisibility | both | both | unplaced | +| rating | unplaced for lt | minRating | minRating | minRating = low | unplaced | +| o2 | maxO2Percent | minO2Percent | both | both | unplaced | +| diveNumber | unplaced (`noAxis`) | | | | | +| waterType | | | waterTypes = [v] | | in: list; not: complement of the enum | +| diveMode, entryMethod, currentStrength | unplaced (`noAxis`) in phase 1 | | | | | +| favorite | | | eq true: favoritesOnly | | | +| deco | | | eq true/false: decoOnly | | | +| noBuddy | | | eq true: noBuddyOnly | | | +| weekday | | | weekdays = [n] | | in: list (mon=1 ... sun=7); not: complement | +| diveType | | | mention-like: resolve against `MentionKind.tag`? No: diveType names are not in the NameIndex in phase 1, so eq is unplaced with reason `noAxis` | | | + +`lt` and `gt` are treated as `lte` and `gte` after grounding (the filter axes are inclusive); the chip keeps the original op for display. Values are rounded to two decimals after grounding. + +Mention lowering by `NameTarget`: `siteId` and `sitePlace` append to `siteIds` (a single resolved site also goes into `siteIds`, never `siteId`, so several places OR together); `speciesId` appends to `speciesIds`; `equipmentId` appends to `equipmentIds`; `attrChoice` appends `EquipmentAttrCondition(key: attrKey, choices: {attrChoice})`; `buddyId` sets `buddyId` (a second buddy becomes `buddyNameFilter` comma-joined with the first resolved buddy's label, which ANDs, matching the existing semantics); `legacyBuddyName` appends to `buddyNameFilter`; `tagId` appends to `tagIds`; `centerId` sets `diveCenterId`; `tripId` sets `tripId`; `computerId` sets `computerId`. + +Sanity rules: a `between` with reversed bounds is swapped; depth below 0 or above 350 m, temperature outside -5 to 45 C, visibility below 0, rating outside 1 to 5, o2 outside 1 to 100 are unplaced with reason `outOfRange`; a non-numeric value on a number field, a non-string on an enum field, a value not in `enumValues`, or an op not in `spec.ops` is unplaced with reason `invalid`. + +- [ ] **Step 1: Write the failing tests** + +```dart +// test/features/explore/domain/chart_selection_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/explore/domain/chart_selection.dart'; +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +void main() { + test('always starts with dives over time', () { + final charts = selectCharts(numericFields: const [], resolvedEntityCounts: const {}); + expect(charts.map((c) => c.kind), [ChartKind.divesOverTime]); + }); + + test('adds one trend per numeric field in catalog order, capped at three', () { + final charts = selectCharts( + numericFields: const [ExploreDiveField.bottomTime, ExploreDiveField.depth, ExploreDiveField.waterTemp, ExploreDiveField.rating], + resolvedEntityCounts: const {MentionKind.place: 3}, + ); + expect(charts.map((c) => c.kind), [ChartKind.divesOverTime, ChartKind.depthTrend, ChartKind.waterTempTrend]); + }); + + test('entity counts appear only for kinds with several ids', () { + final charts = selectCharts( + numericFields: const [], + resolvedEntityCounts: const {MentionKind.place: 3, MentionKind.species: 1}, + ); + expect(charts, hasLength(2)); + expect(charts[1].kind, ChartKind.entityCounts); + expect(charts[1].entityKind, MentionKind.place); + }); +} +``` + +```dart +// test/features/explore/domain/query_compiler_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/features/explore/domain/chart_selection.dart'; +import 'package:submersion/features/explore/domain/compiled_query.dart'; +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/name_index.dart'; +import 'package:submersion/features/explore/domain/query_compiler.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +void main() { + const metric = (depth: DepthUnit.meters, temperature: TemperatureUnit.celsius, pressure: PressureUnit.bar); + const imperial = (depth: DepthUnit.feet, temperature: TemperatureUnit.fahrenheit, pressure: PressureUnit.psi); + final now = DateTime(2026, 9, 19); + + final names = NameIndex([ + const NameEntry(kind: MentionKind.place, label: 'Bonaire', ids: ['s1', 's2'], target: NameTarget.sitePlace), + const NameEntry(kind: MentionKind.species, label: 'Green Turtle', ids: ['sp_green_turtle'], target: NameTarget.speciesId), + const NameEntry(kind: MentionKind.species, label: 'Hawksbill Turtle', ids: ['sp_hawksbill_turtle'], target: NameTarget.speciesId), + const NameEntry(kind: MentionKind.gear, label: 'Trilaminate', ids: [], target: NameTarget.attrChoice, rank: 2, attrKey: 'shell_material', attrChoice: 'trilaminate'), + const NameEntry(kind: MentionKind.buddy, label: 'Sarah Jones', ids: ['b1'], target: NameTarget.buddyId), + ]); + + CompiledQuery compile(ParsedQuery q, {UnitPrefs units = metric}) => + QueryCompiler.compile(q, CompilerContext(units: units, names: names, now: now)); + + ParsedQuery turtlesQuery() => ParsedQuery.fromJson({ + 'schemaVersion': 1, + 'subject': 'dives', + 'clauses': [ + {'field': 'depth', 'op': 'gt', 'value': 20, 'unit': 'm', 'text': 'below 20m'}, + {'field': 'visibility', 'op': 'gt', 'value': 20, 'unit': 'm', 'text': 'viz over 20m'}, + ], + 'mentions': [ + {'kind': 'species', 'text': 'green turtle'}, + {'kind': 'place', 'text': 'Bonaire'}, + ], + 'unplaced': [], + }); + + test('the turtles sentence compiles to depth, visibility, species and sites', () { + final c = compile(turtlesQuery()); + expect(c.filter.minDepth, 20); + expect(c.filter.minVisibility, 20); + expect(c.filter.speciesIds, ['sp_green_turtle']); + expect(c.filter.siteIds, ['s1', 's2']); + expect(c.filter.siteId, isNull); + expect(c.chips, hasLength(4)); + expect(c.unresolved, isEmpty); + expect(c.unplaced, isEmpty); + expect(c.charts.map((x) => x.kind), [ChartKind.divesOverTime, ChartKind.depthTrend, ChartKind.entityCounts]); + expect(c.charts.last.entityKind, MentionKind.place); + }); + + test('a bare number takes the diver unit and the chip keeps the metric value', () { + final q = ParsedQuery.fromJson({ + 'schemaVersion': 1, + 'subject': 'dives', + 'clauses': [ + {'field': 'depth', 'op': 'lt', 'value': 60, 'text': 'shallower than 60'}, + {'field': 'waterTemp', 'op': 'lt', 'value': 60, 'text': 'colder than 60'}, + ], + }); + final c = compile(q, units: imperial); + expect(c.filter.maxDepth, closeTo(18.29, 0.01)); + expect(c.filter.maxWaterTemp, closeTo(15.56, 0.01)); + final chip = c.chips.first.payload as ClauseChip; + expect(chip.field, ExploreDiveField.depth); + expect(chip.op, ClauseOp.lt); + expect(chip.value, closeTo(18.29, 0.01)); + expect(chip.dimension, FieldDimension.depth); + }); + + test('between, water types, flags, weekdays and time lower correctly', () { + final q = ParsedQuery.fromJson({ + 'schemaVersion': 1, + 'subject': 'dives', + 'clauses': [ + {'field': 'depth', 'op': 'between', 'value': [30, 10], 'unit': 'm', 'text': '10 to 30m'}, + {'field': 'waterType', 'op': 'not', 'value': 'salt', 'text': 'not in the sea'}, + {'field': 'favorite', 'op': 'eq', 'value': true, 'text': 'favourite'}, + {'field': 'deco', 'op': 'eq', 'value': false, 'text': 'no deco'}, + {'field': 'weekday', 'op': 'in', 'value': ['sat', 'sun'], 'text': 'weekends'}, + {'field': 'bottomTime', 'op': 'gte', 'value': 45, 'text': 'over 45 minutes'}, + ], + 'time': {'text': 'last year'}, + }); + final c = compile(q); + expect(c.filter.minDepth, 10); + expect(c.filter.maxDepth, 30); + expect(c.filter.waterTypes, [WaterType.fresh, WaterType.brackish]); + expect(c.filter.favoritesOnly, isTrue); + expect(c.filter.decoOnly, isFalse); + expect(c.filter.weekdays, [6, 7]); + expect(c.filter.minBottomTimeMinutes, 45); + expect(c.filter.startDate, DateTime(2025, 1, 1)); + expect(c.filter.endDate, DateTime(2025, 12, 31)); + expect(c.chips.whereType().where((x) => x.ref == ChipRef.time), hasLength(1)); + expect(c.unplaced, isEmpty); + }); + + test('a flag with a boolean value is accepted; favorite eq false is unplaced', () { + final q = ParsedQuery.fromJson({ + 'schemaVersion': 1, + 'subject': 'dives', + 'clauses': [ + {'field': 'favorite', 'op': 'eq', 'value': 'false', 'text': 'not favourite'}, + ], + }); + final c = compile(q); + expect(c.filter.favoritesOnly, isNull); + expect(c.unplaced.single.text, 'not favourite'); + }); + + test('gear attribute mentions become attribute conditions', () { + final q = ParsedQuery.fromJson({ + 'schemaVersion': 1, + 'subject': 'dives', + 'mentions': [ + {'kind': 'gear', 'text': 'trilaminate'}, + {'kind': 'buddy', 'text': 'Sarah Jones'}, + ], + }); + final c = compile(q); + expect(c.filter.equipmentAttrConditions.single.key, 'shell_material'); + expect(c.filter.equipmentAttrConditions.single.choices, {'trilaminate'}); + expect(c.filter.buddyId, 'b1'); + }); + + test('unknown fields, bad ops, out-of-range values and unknown time are unplaced with reasons', () { + final q = ParsedQuery.fromJson({ + 'schemaVersion': 1, + 'subject': 'dives', + 'clauses': [ + {'field': 'salinity', 'op': 'gt', 'value': 3, 'text': 'salty'}, + {'field': 'depth', 'op': 'in', 'value': [1, 2], 'text': 'depth in'}, + {'field': 'depth', 'op': 'gt', 'value': 900, 'unit': 'm', 'text': 'below 900m'}, + {'field': 'waterTemp', 'op': 'gt', 'value': 'warm', 'text': 'warm'}, + {'field': 'avgDepth', 'op': 'gt', 'value': 10, 'text': 'avg over 10'}, + ], + 'time': {'text': 'when the water was warm'}, + 'unplaced': ['maybe'], + }); + final c = compile(q); + expect(c.filter.hasActiveFilters, isFalse); + expect(c.unplaced.map((u) => u.text), ['salty', 'depth in', 'below 900m', 'warm', 'avg over 10', 'when the water was warm', 'maybe']); + expect(c.unplaced.map((u) => u.reason), ['unknownField', 'invalid', 'outOfRange', 'invalid', 'noAxis', 'unknownTime', null]); + }); + + test('an unresolved mention carries candidates and lowers nothing', () { + final q = ParsedQuery.fromJson({ + 'schemaVersion': 1, + 'subject': 'dives', + 'mentions': [{'kind': 'species', 'text': 'turtles'}], + }); + final c = compile(q); + expect(c.filter.speciesIds, isEmpty); + expect(c.unresolved.single.mention.text, 'turtles'); + expect(c.unresolved.single.candidates.map((e) => e.label), containsAll(['Green Turtle', 'Hawksbill Turtle'])); + }); + + test('a non-dive subject is one unplaced item and an empty filter', () { + final q = ParsedQuery.fromJson({'schemaVersion': 1, 'subject': 'equipment'}); + final c = compile(q); + expect(c.filter.hasActiveFilters, isFalse); + expect(c.unplaced.single.reason, 'subjectNotSupported'); + }); +} +``` + +For the unresolved-with-candidates case the compiler asks the resolver for candidates below the threshold: add an optional `int candidatesBelowThreshold` behaviour by calling `resolveMention` first and, on `Unresolved`, computing the top five entries of that kind by Dice score with score above 0.3 as `UnresolvedMention.candidates`. Implement that scoring in the compiler with `diceCoefficient` and `normalize` directly. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/explore/domain/query_compiler_test.dart test/features/explore/domain/chart_selection_test.dart` +Expected: FAIL, missing files. + +- [ ] **Step 3: Write the output types and chart selection** + +```dart +// lib/features/explore/domain/compiled_query.dart +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/explore/domain/chart_selection.dart'; +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/name_index.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +enum ChipRef { clause, mention, time } + +sealed class ChipPayload { + const ChipPayload(); +} + +/// A lowered clause. [value] is in storage units: a double, a List of +/// double (between), a String or a List of String (enum fields). +class ClauseChip extends ChipPayload { + final ExploreDiveField field; + final ClauseOp op; + final Object value; + final FieldDimension dimension; + const ClauseChip({ + required this.field, + required this.op, + required this.value, + required this.dimension, + }); +} + +class MentionChip extends ChipPayload { + final MentionKind kind; + final NameEntry entry; + const MentionChip({required this.kind, required this.entry}); +} + +class TimeChip extends ChipPayload { + final DateTime? start; + final DateTime? end; + const TimeChip({this.start, this.end}); +} + +/// One chip on the understood row. Removing it drops [ref] at [index] from +/// the ParsedQuery and recompiles; the query is the editable state. +class QueryChip { + final ChipRef ref; + final int index; + final ChipPayload payload; + const QueryChip({required this.ref, required this.index, required this.payload}); +} + +class UnresolvedMention { + final int index; + final QueryMention mention; + final List candidates; + const UnresolvedMention({ + required this.index, + required this.mention, + required this.candidates, + }); +} + +/// A word or clause the compiler could not place. [reason] is one of +/// `unknownField`, `invalid`, `outOfRange`, `noAxis`, `unknownTime`, +/// `subjectNotSupported`, or null for a word the model itself left over. +class UnplacedItem { + final String text; + final String? reason; + const UnplacedItem(this.text, {this.reason}); +} + +class CompiledQuery { + final DiveFilterState filter; + final List chips; + final List unresolved; + final List unplaced; + final List charts; + const CompiledQuery({ + required this.filter, + required this.chips, + required this.unresolved, + required this.unplaced, + required this.charts, + }); +} +``` + +```dart +// lib/features/explore/domain/chart_selection.dart +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +enum ChartKind { divesOverTime, depthTrend, waterTempTrend, bottomTimeTrend, entityCounts } + +class ChartRequest { + final ChartKind kind; + final MentionKind? entityKind; + const ChartRequest(this.kind, {this.entityKind}); +} + +const int kMaxExploreCharts = 3; + +/// Rule-based chart choice: the model never picks charts. +List selectCharts({ + required List numericFields, + required Map resolvedEntityCounts, +}) { + final out = [const ChartRequest(ChartKind.divesOverTime)]; + const trends = { + ExploreDiveField.depth: ChartKind.depthTrend, + ExploreDiveField.waterTemp: ChartKind.waterTempTrend, + ExploreDiveField.bottomTime: ChartKind.bottomTimeTrend, + }; + for (final entry in trends.entries) { + if (numericFields.contains(entry.key)) out.add(ChartRequest(entry.value)); + } + for (final kind in MentionKind.values) { + if ((resolvedEntityCounts[kind] ?? 0) > 1) { + out.add(ChartRequest(ChartKind.entityCounts, entityKind: kind)); + } + } + return out.take(kMaxExploreCharts).toList(); +} +``` + +- [ ] **Step 4: Write the compiler** + +```dart +// lib/features/explore/domain/query_compiler.dart +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/text/fuzzy_match.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/equipment/domain/models/equipment_attr_condition.dart'; +import 'package:submersion/features/explore/domain/chart_selection.dart'; +import 'package:submersion/features/explore/domain/compiled_query.dart'; +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/entity_resolver.dart'; +import 'package:submersion/features/explore/domain/name_index.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; +import 'package:submersion/features/explore/domain/time_grammar.dart'; +import 'package:submersion/features/explore/domain/unit_grounding.dart'; + +class CompilerContext { + final UnitPrefs units; + final NameIndex names; + final DateTime now; + const CompilerContext({required this.units, required this.names, required this.now}); +} + +const _weekdayNumbers = {'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5, 'sat': 6, 'sun': 7}; + +/// Deterministic lowering of a [ParsedQuery] to a [DiveFilterState]. +/// +/// The model chose the words; everything about units, names, ids and ranges +/// is decided here, so a canned JSON payload fully specifies the outcome. +abstract final class QueryCompiler { + static CompiledQuery compile(ParsedQuery query, CompilerContext ctx) { + final chips = []; + final unresolved = []; + final unplaced = []; + var filter = const DiveFilterState(); + + if (query.subject != QuerySubject.dives) { + return CompiledQuery( + filter: filter, + chips: const [], + unresolved: const [], + unplaced: [ + UnplacedItem(query.subject.name, reason: 'subjectNotSupported'), + for (final w in query.unplaced) UnplacedItem(w), + ], + charts: const [], + ); + } + + final numericFields = []; + for (var i = 0; i < query.clauses.length; i++) { + final c = query.clauses[i]; + final r = _lowerClause(c, filter, ctx.units); + if (r.error != null) { + unplaced.add(UnplacedItem(c.text, reason: r.error)); + continue; + } + filter = r.filter!; + chips.add(QueryChip(ref: ChipRef.clause, index: i, payload: r.chip!)); + if (r.chip!.dimension != FieldDimension.none) numericFields.add(r.chip!.field); + } + + final entityIdCounts = >{}; + final buddyLabels = []; + for (var i = 0; i < query.mentions.length; i++) { + final m = query.mentions[i]; + final res = resolveMention(m, ctx.names); + switch (res) { + case Resolved(:final entry): + filter = _lowerMention(entry, filter, buddyLabels); + chips.add(QueryChip(ref: ChipRef.mention, index: i, payload: MentionChip(kind: m.kind, entry: entry))); + entityIdCounts.putIfAbsent(m.kind, () => {}).addAll(entry.ids); + case Ambiguous(:final candidates): + unresolved.add(UnresolvedMention(index: i, mention: m, candidates: candidates)); + case Unresolved(): + unresolved.add(UnresolvedMention(index: i, mention: m, candidates: _nearest(m, ctx.names))); + } + } + + if (query.time != null) { + final range = parseTimeText(query.time!.text, now: ctx.now); + if (range == null) { + unplaced.add(UnplacedItem(query.time!.text, reason: 'unknownTime')); + } else { + filter = filter.copyWith(startDate: range.start, endDate: range.end); + chips.add(QueryChip(ref: ChipRef.time, index: 0, payload: TimeChip(start: range.start, end: range.end))); + } + } + + for (final w in query.unplaced) { + unplaced.add(UnplacedItem(w)); + } + + return CompiledQuery( + filter: filter, + chips: chips, + unresolved: unresolved, + unplaced: unplaced, + charts: selectCharts( + numericFields: numericFields, + resolvedEntityCounts: {for (final e in entityIdCounts.entries) e.key: e.value.length}, + ), + ); + } + + static List _nearest(QueryMention m, NameIndex names) { + final q = normalize(m.text); + final scored = <(NameEntry, double)>[]; + for (final e in names.forKind(m.kind)) { + final s = diceCoefficient(q, normalize(e.label)); + if (s > 0.3) scored.add((e, s)); + } + scored.sort((a, b) => b.$2.compareTo(a.$2)); + final seen = {}; + return [for (final s in scored) if (seen.add(s.$1.identity)) s.$1].take(5).toList(); + } + + static ({DiveFilterState? filter, ClauseChip? chip, String? error}) _lowerClause( + QueryClause c, + DiveFilterState f, + UnitPrefs units, + ) { + final field = DiveFieldCatalog.parse(c.field); + if (field == null) return (filter: null, chip: null, error: 'unknownField'); + final spec = DiveFieldCatalog.spec(field); + if (!spec.ops.contains(c.op)) return (filter: null, chip: null, error: 'invalid'); + + switch (spec.valueType) { + case FieldValueType.number: + return _lowerNumber(c, field, spec, f, units); + case FieldValueType.flag: + final v = c.value; + final on = v == true || (v is String && v.toLowerCase() == 'true'); + final off = v == false || (v is String && v.toLowerCase() == 'false'); + if (!on && !off) return (filter: null, chip: null, error: 'invalid'); + DiveFilterState? next; + switch (field) { + case ExploreDiveField.favorite: + if (on) next = f.copyWith(favoritesOnly: true); + case ExploreDiveField.noBuddy: + if (on) next = f.copyWith(noBuddyOnly: true); + case ExploreDiveField.deco: + next = f.copyWith(decoOnly: on); + default: + break; + } + if (next == null) return (filter: null, chip: null, error: 'invalid'); + return (filter: next, chip: ClauseChip(field: field, op: c.op, value: on, dimension: FieldDimension.none), error: null); + case FieldValueType.enumName: + final values = c.value is List ? (c.value as List).whereType().toList() : [if (c.value is String) c.value as String]; + if (values.isEmpty) return (filter: null, chip: null, error: 'invalid'); + final allowed = spec.enumValues; + if (allowed == null || values.any((v) => !allowed.contains(v))) { + return (filter: null, chip: null, error: allowed == null ? 'noAxis' : 'invalid'); + } + final chosen = c.op == ClauseOp.not ? allowed.where((v) => !values.contains(v)).toList() : values; + switch (field) { + case ExploreDiveField.waterType: + final types = chosen.map((v) => WaterType.values.byName(v)).toList(); + return (filter: f.copyWith(waterTypes: [...f.waterTypes, ...types]), chip: ClauseChip(field: field, op: c.op, value: values, dimension: FieldDimension.none), error: null); + case ExploreDiveField.weekday: + final days = chosen.map((v) => _weekdayNumbers[v]!).toList(); + return (filter: f.copyWith(weekdays: [...f.weekdays, ...days]), chip: ClauseChip(field: field, op: c.op, value: values, dimension: FieldDimension.none), error: null); + default: + return (filter: null, chip: null, error: 'noAxis'); + } + } + } + + static ({DiveFilterState? filter, ClauseChip? chip, String? error}) _lowerNumber( + QueryClause c, + ExploreDiveField field, + FieldSpec spec, + DiveFilterState f, + UnitPrefs units, + ) { + double ground(num v) => double.parse(groundToMetric(v, c.unit, spec.dimension, units).toStringAsFixed(2)); + double? lo; + double? hi; + Object chipValue; + if (c.op == ClauseOp.between) { + final raw = c.value; + if (raw is! List || raw.length != 2 || raw.any((v) => v is! num)) { + return (filter: null, chip: null, error: 'invalid'); + } + var a = ground(raw[0] as num); + var b = ground(raw[1] as num); + if (b < a) (a, b) = (b, a); + lo = a; + hi = b; + chipValue = [a, b]; + } else { + final raw = c.value; + if (raw is! num) return (filter: null, chip: null, error: 'invalid'); + final v = ground(raw); + chipValue = v; + switch (c.op) { + case ClauseOp.lt: + case ClauseOp.lte: + hi = v; + case ClauseOp.gt: + case ClauseOp.gte: + lo = v; + case ClauseOp.eq: + lo = v; + hi = v; + default: + return (filter: null, chip: null, error: 'invalid'); + } + } + if (!_inRange(field, lo) || !_inRange(field, hi)) { + return (filter: null, chip: null, error: 'outOfRange'); + } + final chip = ClauseChip(field: field, op: c.op, value: chipValue, dimension: spec.dimension); + switch (field) { + case ExploreDiveField.depth: + return (filter: f.copyWith(minDepth: lo, maxDepth: hi), chip: chip, error: null); + case ExploreDiveField.waterTemp: + return (filter: f.copyWith(minWaterTemp: lo, maxWaterTemp: hi), chip: chip, error: null); + case ExploreDiveField.visibility: + return (filter: f.copyWith(minVisibility: lo, maxVisibility: hi), chip: chip, error: null); + case ExploreDiveField.o2: + return (filter: f.copyWith(minO2Percent: lo, maxO2Percent: hi), chip: chip, error: null); + case ExploreDiveField.bottomTime: + return (filter: f.copyWith(minBottomTimeMinutes: lo?.round(), maxBottomTimeMinutes: hi?.round()), chip: chip, error: null); + case ExploreDiveField.rating: + if (lo == null) return (filter: null, chip: null, error: 'invalid'); + return (filter: f.copyWith(minRating: lo.round()), chip: chip, error: null); + default: + return (filter: null, chip: null, error: 'noAxis'); + } + } + + static bool _inRange(ExploreDiveField field, double? v) { + if (v == null) return true; + return switch (field) { + ExploreDiveField.depth || ExploreDiveField.avgDepth => v >= 0 && v <= 350, + ExploreDiveField.waterTemp || ExploreDiveField.airTemp => v >= -5 && v <= 45, + ExploreDiveField.visibility => v >= 0 && v <= 200, + ExploreDiveField.rating => v >= 1 && v <= 5, + ExploreDiveField.o2 => v >= 1 && v <= 100, + ExploreDiveField.bottomTime => v >= 0 && v <= 24 * 60, + _ => v >= 0, + }; + } + + static DiveFilterState _lowerMention(NameEntry e, DiveFilterState f, List buddyLabels) { + switch (e.target) { + case NameTarget.siteId: + case NameTarget.sitePlace: + return f.copyWith(siteIds: [...f.siteIds, ...e.ids]); + case NameTarget.speciesId: + return f.copyWith(speciesIds: [...f.speciesIds, ...e.ids]); + case NameTarget.equipmentId: + return f.copyWith(equipmentIds: [...f.equipmentIds, ...e.ids]); + case NameTarget.attrChoice: + return f.copyWith( + equipmentAttrConditions: [ + ...f.equipmentAttrConditions, + EquipmentAttrCondition(key: e.attrKey!, choices: {e.attrChoice!}), + ], + ); + case NameTarget.buddyId: + if (f.buddyId == null && buddyLabels.isEmpty) { + buddyLabels.add(e.label); + return f.copyWith(buddyId: e.ids.single); + } + buddyLabels.add(e.label); + return f.copyWith(buddyNameFilter: buddyLabels.join(', ')); + case NameTarget.legacyBuddyName: + buddyLabels.add(e.label); + return f.copyWith(buddyNameFilter: buddyLabels.join(', ')); + case NameTarget.tagId: + return f.copyWith(tagIds: [...f.tagIds, ...e.ids]); + case NameTarget.centerId: + return f.copyWith(diveCenterId: e.ids.single); + case NameTarget.tripId: + return f.copyWith(tripId: e.ids.single); + case NameTarget.computerId: + return f.copyWith(computerId: e.ids.single); + } + } +} +``` + +Note on `deco`: `decoOnly: on` passes `false` through `copyWith`, which the existing `copyWith` treats as a value (not a clear), so `decoOnly` becomes false as the test expects. + +- [ ] **Step 5: Run the tests** + +Run: `flutter test test/features/explore/domain/query_compiler_test.dart test/features/explore/domain/chart_selection_test.dart` +Expected: PASS. The `depth in` case fails on `spec.ops` (`invalid`), `salinity` on `unknownField`, `900 m` on `outOfRange`, `warm` on a non-numeric value (`invalid`), `avgDepth` on `noAxis`. + +- [ ] **Step 6: Commit** + +```bash +dart format lib/features/explore test/features/explore +git add lib/features/explore/domain/compiled_query.dart lib/features/explore/domain/chart_selection.dart lib/features/explore/domain/query_compiler.dart test/features/explore/domain/query_compiler_test.dart test/features/explore/domain/chart_selection_test.dart +git commit -m "feat(explore): compile a parsed query into the dive filter, chips and chart requests" +``` + +--- +### Task 8: Engine contract, prompt, and the `submersion_nl` Dart package + +**Files:** +- Create: `lib/features/explore/domain/nl_engine.dart` +- Create: `packages/submersion_nl/pubspec.yaml` +- Create: `packages/submersion_nl/lib/submersion_nl.dart` +- Create: `lib/features/explore/data/channel_nl_engine.dart` +- Modify: `pubspec.yaml` (add `submersion_nl: path: packages/submersion_nl` under `submersion_ocr`) +- Test: `test/features/explore/domain/nl_prompt_test.dart` +- Test: `test/features/explore/data/channel_nl_engine_test.dart` + +**Interfaces:** +- Produces: + - `enum NlAvailability { available, deviceNotEligible, notEnabled, modelNotReady, downloadable, downloading, unsupportedLocale, unsupportedPlatform }` + - `enum NlError { unsupportedLocale, contextExceeded, guardrail, refusal, decodingFailure, modelNotReady, quotaExceeded, schemaMismatch, unknown }` + - `class NlException implements Exception { NlError error; String? message; }` + - `abstract class NlEngine { Future availability(String localeTag); Future prepare(); Stream download(); Future compile(String sentence, {required String localeTag}); }` + - `abstract final class NlPrompt { static String instructions(); static Map vocabulary(); }` where `vocabulary()` is `{'schemaVersion': 1, 'subjects': [...], 'fields': DiveFieldCatalog.jsonNames, 'ops': [...], 'units': [...], 'mentionKinds': [...]}` and is passed to the native side for constrained decoding. + - Package `SubmersionNl` with static methods `availability(String localeTag) -> Future`, `prepare(String instructions, Map vocabulary) -> Future`, `download() -> Stream` (an `EventChannel('submersion_nl/download')`), `compile(String sentence, String localeTag) -> Future`. Method names on the channel `submersion_nl`: `availability`, `prepare`, `compile`. Native error codes: `unsupported_locale`, `context_exceeded`, `guardrail`, `refusal`, `decoding_failure`, `model_not_ready`, `quota_exceeded`, `schema_mismatch`. + - `class ChannelNlEngine implements NlEngine` mapping `PlatformException.code` to `NlError` and `MissingPluginException` to `NlAvailability.unsupportedPlatform`. + +The prompt is fixed English text under 900 words. It states the JSON shape, lists the fields with a one-line meaning each, gives the op and unit vocabularies, tells the model to put entity names in `mentions` with a kind, to phrase `time.text` in the accepted shapes (`2023`, `May 2023`, `last year`, `last 30 days`, `since 2022`, `before 2022`, an ISO date, or `A to B`), to put anything it cannot place into `unplaced`, to never invent ids, and it ends with three worked examples in English, one of which is the turtles sentence and one the trilaminate sentence (whose SAC and final-stop clauses land in `unplaced` in phase 1, which the example shows explicitly). + +- [ ] **Step 1: Write the failing tests** + +```dart +// test/features/explore/domain/nl_prompt_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/nl_engine.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +void main() { + test('the prompt names every catalog field and stays inside the budget', () { + final text = NlPrompt.instructions(); + for (final name in DiveFieldCatalog.jsonNames) { + expect(text, contains(name), reason: name); + } + expect(text, contains('"schemaVersion": 1')); + expect(text, contains('unplaced')); + // Roughly 4 characters per token; the budget is 2,500 tokens for + // instructions plus schema, so the text itself stays under 7,000 chars. + expect(text.length, lessThan(7000)); + expect(text, isNot(contains('\u2014'))); + }); + + test('the vocabulary mirrors the Dart enums', () { + final v = NlPrompt.vocabulary(); + expect(v['schemaVersion'], kQuerySchemaVersion); + expect(v['fields'], DiveFieldCatalog.jsonNames); + expect(v['ops'], ClauseOp.values.map((o) => o.jsonName).toList()); + expect(v['units'], ClauseUnit.values.map((u) => u.jsonName).toList()); + expect(v['mentionKinds'], MentionKind.values.map((k) => k.name).toList()); + expect(v['subjects'], QuerySubject.values.map((s) => s.name).toList()); + }); +} +``` + +```dart +// test/features/explore/data/channel_nl_engine_test.dart +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/explore/data/channel_nl_engine.dart'; +import 'package:submersion/features/explore/domain/nl_engine.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('submersion_nl'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test('maps availability strings', () async { + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'availability'); + expect(call.arguments, {'locale': 'en-US'}); + return 'unsupportedLocale'; + }); + expect(await ChannelNlEngine().availability('en-US'), NlAvailability.unsupportedLocale); + }); + + test('a missing plugin is an unsupported platform, not an error', () async { + messenger.setMockMethodCallHandler(channel, (call) async => throw MissingPluginException()); + expect(await ChannelNlEngine().availability('en-US'), NlAvailability.unsupportedPlatform); + }); + + test('compile returns the JSON string and maps native error codes', () async { + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'compile'); + expect(call.arguments, {'sentence': 'turtles', 'locale': 'en-US'}); + return '{"schemaVersion":1,"subject":"dives"}'; + }); + expect(await ChannelNlEngine().compile('turtles', localeTag: 'en-US'), '{"schemaVersion":1,"subject":"dives"}'); + + messenger.setMockMethodCallHandler(channel, (call) async => throw PlatformException(code: 'context_exceeded', message: 'too long')); + await expectLater( + () => ChannelNlEngine().compile('x', localeTag: 'en-US'), + throwsA(isA().having((e) => e.error, 'error', NlError.contextExceeded)), + ); + + messenger.setMockMethodCallHandler(channel, (call) async => throw PlatformException(code: 'weird')); + await expectLater( + () => ChannelNlEngine().compile('x', localeTag: 'en-US'), + throwsA(isA().having((e) => e.error, 'error', NlError.unknown)), + ); + }); + + test('prepare sends the instructions and vocabulary once', () async { + Map? sent; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'prepare') sent = call.arguments as Map; + return null; + }); + await ChannelNlEngine().prepare(); + expect(sent!['instructions'], NlPrompt.instructions()); + expect((sent!['vocabulary'] as Map)['schemaVersion'], 1); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/explore/domain/nl_prompt_test.dart test/features/explore/data/channel_nl_engine_test.dart` +Expected: FAIL, missing files. + +- [ ] **Step 3: Write the engine contract and prompt** + +```dart +// lib/features/explore/domain/nl_engine.dart +import 'package:submersion/features/explore/domain/dive_field_catalog.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; + +enum NlAvailability { + available, + deviceNotEligible, + notEnabled, + modelNotReady, + downloadable, + downloading, + unsupportedLocale, + unsupportedPlatform, +} + +enum NlError { + unsupportedLocale, + contextExceeded, + guardrail, + refusal, + decodingFailure, + modelNotReady, + quotaExceeded, + schemaMismatch, + unknown, +} + +class NlException implements Exception { + final NlError error; + final String? message; + const NlException(this.error, [this.message]); + @override + String toString() => 'NlException(${error.name}${message == null ? '' : ': $message'})'; +} + +/// The only thing the app asks of an on-device model: availability, a warm +/// session, and one sentence in, one JSON string out. It never sees dive data. +abstract class NlEngine { + Future availability(String localeTag); + Future prepare(); + Stream download(); + Future compile(String sentence, {required String localeTag}); +} + +/// The fixed prompt. Identical on every device and free of the diver's data, +/// so behaviour is reproducible and the 4K context is never at risk. +abstract final class NlPrompt { + static Map vocabulary() => { + 'schemaVersion': kQuerySchemaVersion, + 'subjects': QuerySubject.values.map((s) => s.name).toList(), + 'fields': DiveFieldCatalog.jsonNames, + 'ops': ClauseOp.values.map((o) => o.jsonName).toList(), + 'units': ClauseUnit.values.map((u) => u.jsonName).toList(), + 'mentionKinds': MentionKind.values.map((k) => k.name).toList(), + }; + + static String instructions() => ''' +You turn one sentence about a scuba diver's logbook into a JSON object. Reply with JSON only. + +Shape: +{"schemaVersion": 1, "subject": "dives", "clauses": [...], "mentions": [...], "time": null or {"text": "..."}, "unplaced": [...]} + +subject is one of: dives, equipment, sites, buddies, species, trips, centers. Use "dives" unless the sentence clearly asks for another kind of thing. + +A clause is {"field", "op", "value", "unit", "text"}. text is the words of the sentence the clause came from. Fields: +depth: maximum depth of the dive. avgDepth: average depth. bottomTime: minutes of bottom time. waterTemp: water temperature. airTemp: air temperature. visibility: underwater visibility distance. rating: 1 to 5 stars. o2: oxygen percent of the gas. diveNumber: the dive's number. waterType: salt, fresh or brackish. diveMode: oc, ccr, scr or gauge. entryMethod: shore, boat, backRoll, giantStride, seatedEntry, ladder, platform, jetty or other. currentStrength: none, light, moderate or strong. favorite: true. deco: true or false. noBuddy: true. weekday: mon, tue, wed, thu, fri, sat, sun. diveType: the name of a dive type. + +op is one of: lt, lte, gt, gte, eq, between, in, not. "below 20m" on depth means deeper, so op gt. "shallower than" means op lt. between takes value [low, high]. in takes a list. not excludes one value. +unit is one of: m, ft, c, f, bar, psi, min, l_min, cuft_min. Omit unit when the sentence gives none; never convert numbers. + +A mention is {"kind", "text"} for a named thing: kind is site, place (country, region, island or town), species (an animal), gear (an item, brand, model or material such as trilaminate), buddy (a person), tag, center (a dive shop or operator), trip, or computer. Copy the words as written. Never invent identifiers. + +time is {"text": "..."} using only these shapes: "2023", "May 2023", "this year", "last year", "this month", "last month", "last 30 days", "last 2 weeks", "last 6 months", "since 2022", "before 2022", "2023-05-14", "2023-05-01 to 2023-05-14". Otherwise leave time null and put the words in unplaced. + +Anything you cannot place goes into unplaced as the exact words. Do not guess. Do not add fields that are not listed. + +Example 1 +Sentence: Turtles below 20m in Bonaire with viz over 20m +{"schemaVersion":1,"subject":"dives","clauses":[{"field":"depth","op":"gt","value":20,"unit":"m","text":"below 20m"},{"field":"visibility","op":"gt","value":20,"unit":"m","text":"viz over 20m"}],"mentions":[{"kind":"species","text":"Turtles"},{"kind":"place","text":"Bonaire"}],"time":null,"unplaced":[]} + +Example 2 +Sentence: Show cold-water dives using my trilaminate suit where SAC increased after 20 minutes and the final stop was unstable +{"schemaVersion":1,"subject":"dives","clauses":[{"field":"waterTemp","op":"lt","value":15,"unit":"c","text":"cold-water"}],"mentions":[{"kind":"gear","text":"trilaminate suit"}],"time":null,"unplaced":["SAC increased after 20 minutes","the final stop was unstable"]} + +Example 3 +Sentence: favourite night dives with Sarah last year deeper than 60 +{"schemaVersion":1,"subject":"dives","clauses":[{"field":"favorite","op":"eq","value":true,"text":"favourite"},{"field":"depth","op":"gt","value":60,"text":"deeper than 60"}],"mentions":[{"kind":"tag","text":"night"},{"kind":"buddy","text":"Sarah"}],"time":{"text":"last year"},"unplaced":[]} +'''; +} +``` + +The `cold-water` example deliberately shows the model choosing a threshold; the compiler grounds `15 c` regardless of the diver's units because the unit is explicit. Keep that example, it teaches the model to attach a unit when it introduces a number. + +- [ ] **Step 4: Write the package** + +`packages/submersion_nl/pubspec.yaml`: + +```yaml +name: submersion_nl +description: On-device natural-language query compilation for Submersion. Apple Foundation Models on iOS/macOS, ML Kit GenAI Prompt API on Android. +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.10.0 + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + plugin: + platforms: + android: + package: app.submersion.nl + pluginClass: SubmersionNlPlugin + ios: + pluginClass: SubmersionNlPlugin + sharedDarwinSource: true + macos: + pluginClass: SubmersionNlPlugin + sharedDarwinSource: true +``` + +`packages/submersion_nl/lib/submersion_nl.dart`: + +```dart +import 'package:flutter/services.dart'; + +/// Thin channel wrapper. Typing and error mapping live in the app layer +/// (ChannelNlEngine), matching the submersion_ocr precedent. +class SubmersionNl { + static const MethodChannel _channel = MethodChannel('submersion_nl'); + static const EventChannel _download = EventChannel('submersion_nl/download'); + + /// One of: available, deviceNotEligible, notEnabled, modelNotReady, + /// downloadable, downloading, unsupportedLocale. + static Future availability(String localeTag) async { + final raw = await _channel.invokeMethod('availability', {'locale': localeTag}); + return raw ?? 'modelNotReady'; + } + + /// Warms a session with the fixed instructions and the schema vocabulary. + static Future prepare(String instructions, Map vocabulary) => + _channel.invokeMethod('prepare', {'instructions': instructions, 'vocabulary': vocabulary}); + + /// Download progress 0..1 (Android only; Apple emits nothing and completes). + static Stream download() => + _download.receiveBroadcastStream().map((e) => (e as num).toDouble()); + + /// Returns the JSON text the model produced. Throws PlatformException with + /// one of the documented codes on failure. + static Future compile(String sentence, String localeTag) async { + final raw = await _channel.invokeMethod('compile', {'sentence': sentence, 'locale': localeTag}); + if (raw == null) throw PlatformException(code: 'decoding_failure', message: 'empty response'); + return raw; + } +} +``` + +Add to the app `pubspec.yaml` right after the `submersion_ocr` entry: + +```yaml + submersion_nl: + path: packages/submersion_nl +``` + +Run `flutter pub get`. + +- [ ] **Step 5: Write the channel engine** + +```dart +// lib/features/explore/data/channel_nl_engine.dart +import 'package:flutter/services.dart'; +import 'package:submersion_nl/submersion_nl.dart'; + +import 'package:submersion/features/explore/domain/nl_engine.dart'; + +class ChannelNlEngine implements NlEngine { + @override + Future availability(String localeTag) async { + try { + final raw = await SubmersionNl.availability(localeTag); + return NlAvailability.values.firstWhere( + (a) => a.name == raw, + orElse: () => NlAvailability.modelNotReady, + ); + } on MissingPluginException { + return NlAvailability.unsupportedPlatform; + } on PlatformException catch (e) { + throw _map(e); + } + } + + @override + Future prepare() async { + try { + await SubmersionNl.prepare(NlPrompt.instructions(), NlPrompt.vocabulary()); + } on MissingPluginException { + // Nothing to warm on a platform without an adapter. + } on PlatformException catch (e) { + throw _map(e); + } + } + + @override + Stream download() => SubmersionNl.download(); + + @override + Future compile(String sentence, {required String localeTag}) async { + try { + return await SubmersionNl.compile(sentence, localeTag); + } on MissingPluginException { + throw const NlException(NlError.unknown, 'no adapter on this platform'); + } on PlatformException catch (e) { + throw _map(e); + } + } + + static NlException _map(PlatformException e) { + final error = switch (e.code) { + 'unsupported_locale' => NlError.unsupportedLocale, + 'context_exceeded' => NlError.contextExceeded, + 'guardrail' => NlError.guardrail, + 'refusal' => NlError.refusal, + 'decoding_failure' => NlError.decodingFailure, + 'model_not_ready' => NlError.modelNotReady, + 'quota_exceeded' => NlError.quotaExceeded, + 'schema_mismatch' => NlError.schemaMismatch, + _ => NlError.unknown, + }; + return NlException(error, e.message); + } +} +``` + +- [ ] **Step 6: Run the tests** + +Run: `flutter test test/features/explore/domain/nl_prompt_test.dart test/features/explore/data/channel_nl_engine_test.dart` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +dart format lib/features/explore test/features/explore packages/submersion_nl +git add pubspec.yaml pubspec.lock packages/submersion_nl/pubspec.yaml packages/submersion_nl/lib/submersion_nl.dart lib/features/explore/domain/nl_engine.dart lib/features/explore/data/channel_nl_engine.dart test/features/explore/domain/nl_prompt_test.dart test/features/explore/data/channel_nl_engine_test.dart +git commit -m "feat(explore): on-device model engine contract, prompt and channel package" +``` + +--- + +### Task 9: Apple adapter (Swift, shared iOS and macOS) + +**Files:** +- Create: `packages/submersion_nl/darwin/Classes/SubmersionNlPlugin.swift` +- Create: `packages/submersion_nl/darwin/submersion_nl.podspec` + +**Interfaces:** +- Consumes: the channel contract of Task 8 (`availability {locale}`, `prepare {instructions, vocabulary}`, `compile {sentence, locale}`). +- Produces: JSON text whose top-level object is constrained by a `DynamicGenerationSchema` built from `vocabulary`. + +This task has no automated test. Verification is a manual smoke on macOS 26 (Apple silicon, Apple Intelligence enabled) recorded in the final task. Build against Xcode 26 or later; FoundationModels is weak-linked and every use is behind `#available(iOS 26.0, macOS 26.0, *)`, so the app still builds and runs on the current deployment targets (iOS 15, macOS 12) and reports `deviceNotEligible` there. + +- [ ] **Step 1: Write the podspec** + +```ruby +Pod::Spec.new do |s| + s.name = 'submersion_nl' + s.version = '0.1.0' + s.summary = 'On-device natural-language query compilation for Submersion.' + s.description = 'Apple Foundation Models with guided generation, behind a method channel.' + s.homepage = 'https://submersion.app' + s.license = { :type => 'MIT' } + s.author = { 'Submersion' => 'dev@submersion.app' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.ios.dependency 'Flutter' + s.osx.dependency 'FlutterMacOS' + s.ios.deployment_target = '15.0' + s.osx.deployment_target = '12.0' + s.weak_frameworks = 'FoundationModels' + s.swift_version = '5.9' +end +``` + +- [ ] **Step 2: Write the plugin** + +```swift +import Foundation +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif +#if canImport(FoundationModels) +import FoundationModels +#endif + +public class SubmersionNlPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + public static func register(with registrar: FlutterPluginRegistrar) { + #if os(iOS) + let messenger = registrar.messenger() + #else + let messenger = registrar.messenger + #endif + let instance = SubmersionNlPlugin() + let channel = FlutterMethodChannel(name: "submersion_nl", binaryMessenger: messenger) + registrar.addMethodCallDelegate(instance, channel: channel) + let events = FlutterEventChannel(name: "submersion_nl/download", binaryMessenger: messenger) + events.setStreamHandler(instance) + } + + // Apple downloads the model itself; the download stream is empty here. + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + events(FlutterEndOfEventStream) + return nil + } + public func onCancel(withArguments arguments: Any?) -> FlutterError? { nil } + + private var instructions: String = "" + private var vocabulary: [String: Any] = [:] + #if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, *) + private var session: LanguageModelSession? + #endif + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [:] + switch call.method { + case "availability": + result(availability(localeTag: args["locale"] as? String ?? "en")) + case "prepare": + instructions = args["instructions"] as? String ?? "" + vocabulary = args["vocabulary"] as? [String: Any] ?? [:] + prepare() + result(nil) + case "compile": + guard let sentence = args["sentence"] as? String else { + result(FlutterError(code: "decoding_failure", message: "missing sentence", details: nil)) + return + } + compile(sentence: sentence, localeTag: args["locale"] as? String ?? "en", result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func availability(localeTag: String) -> String { + #if canImport(FoundationModels) + if #available(iOS 26.0, macOS 26.0, *) { + let model = SystemLanguageModel.default + switch model.availability { + case .available: + return model.supportsLocale(Locale(identifier: localeTag)) ? "available" : "unsupportedLocale" + case .unavailable(let reason): + switch reason { + case .deviceNotEligible: return "deviceNotEligible" + case .appleIntelligenceNotEnabled: return "notEnabled" + case .modelNotReady: return "modelNotReady" + @unknown default: return "modelNotReady" + } + @unknown default: + return "modelNotReady" + } + } + #endif + return "deviceNotEligible" + } + + private func prepare() { + #if canImport(FoundationModels) + if #available(iOS 26.0, macOS 26.0, *) { + let s = LanguageModelSession(instructions: instructions) + s.prewarm() + session = s + } + #endif + } + + private func compile(sentence: String, localeTag: String, result: @escaping FlutterResult) { + #if canImport(FoundationModels) + if #available(iOS 26.0, macOS 26.0, *) { + Task { + do { + if session == nil { prepare() } + guard let session = session else { + result(FlutterError(code: "model_not_ready", message: nil, details: nil)) + return + } + let schema = try GenerationSchema(root: Self.querySchema(vocabulary), dependencies: []) + let response = try await session.respond(to: sentence, schema: schema) + result(response.content.jsonString) + } catch { + result(Self.mapError(error)) + } + } + return + } + #endif + result(FlutterError(code: "model_not_ready", message: "FoundationModels unavailable", details: nil)) + } + + #if canImport(FoundationModels) + /// Builds the schema v1 shape from the vocabulary Dart shipped, so the + /// enum lists are owned in one place and the model cannot emit a field or + /// op the compiler does not know. + @available(iOS 26.0, macOS 26.0, *) + private static func querySchema(_ vocab: [String: Any]) -> DynamicGenerationSchema { + func strings(_ key: String) -> [String] { vocab[key] as? [String] ?? [] } + let clause = DynamicGenerationSchema( + name: "Clause", + properties: [ + .init(name: "field", schema: DynamicGenerationSchema(name: "Field", anyOf: strings("fields"))), + .init(name: "op", schema: DynamicGenerationSchema(name: "Op", anyOf: strings("ops"))), + .init(name: "value", schema: DynamicGenerationSchema(type: String.self)), + .init(name: "unit", schema: DynamicGenerationSchema(name: "Unit", anyOf: strings("units") + ["none"])), + .init(name: "text", schema: DynamicGenerationSchema(type: String.self)), + ]) + let mention = DynamicGenerationSchema( + name: "Mention", + properties: [ + .init(name: "kind", schema: DynamicGenerationSchema(name: "Kind", anyOf: strings("mentionKinds"))), + .init(name: "text", schema: DynamicGenerationSchema(type: String.self)), + ]) + let time = DynamicGenerationSchema( + name: "Time", + properties: [.init(name: "text", schema: DynamicGenerationSchema(type: String.self))]) + return DynamicGenerationSchema( + name: "ParsedQuery", + properties: [ + .init(name: "schemaVersion", schema: DynamicGenerationSchema(type: Int.self)), + .init(name: "subject", schema: DynamicGenerationSchema(name: "Subject", anyOf: strings("subjects"))), + .init(name: "clauses", schema: DynamicGenerationSchema(arrayOf: clause)), + .init(name: "mentions", schema: DynamicGenerationSchema(arrayOf: mention)), + .init(name: "time", schema: time, isOptional: true), + .init(name: "unplaced", schema: DynamicGenerationSchema(arrayOf: DynamicGenerationSchema(type: String.self))), + ]) + } + + @available(iOS 26.0, macOS 26.0, *) + private static func mapError(_ error: Error) -> FlutterError { + let text = String(describing: error) + let code: String + if text.contains("exceededContextWindowSize") { code = "context_exceeded" } + else if text.contains("unsupportedLanguageOrLocale") { code = "unsupported_locale" } + else if text.contains("guardrailViolation") { code = "guardrail" } + else if text.contains("refusal") { code = "refusal" } + else if text.contains("decodingFailure") || text.contains("unsupportedGuide") { code = "decoding_failure" } + else if text.contains("assetsUnavailable") { code = "model_not_ready" } + else if text.contains("rateLimited") { code = "quota_exceeded" } + else { code = "unknown" } + return FlutterError(code: code, message: error.localizedDescription, details: nil) + } + #endif +} +``` + +Two deliberate compromises, both handled on the Dart side: + +1. `value` is declared as a string in the constrained schema because a `DynamicGenerationSchema` property has one type and the value is a number, a string or a list depending on the clause. The Dart side already coerces quoted numbers, lists and booleans and strips `unit: "none"` (`_coerceValue` in Task 1), so the schema can stay one-typed. +2. Error mapping is by description text because the typed error enums differ between the 26.x `LanguageModelSession.GenerationError` and the 27.x `LanguageModelError`; matching the case names covers both. Refine to typed catches when the minimum Xcode is settled. + +If `session.respond(to:schema:)` or `GeneratedContent.jsonString` do not exist under the installed SDK, use `session.respond(to: sentence, schema: schema, includeSchemaInPrompt: true)` and `String(describing: response.content)` respectively, and record the substitution in the spec's deviations section (Task 17). + +- [ ] **Step 3: Build the macOS app** + +Run: `flutter build macos --debug` from the worktree root. +Expected: the build succeeds with Xcode 26 or later. On an older Xcode the `canImport` guard compiles the plugin down to `deviceNotEligible`, which is acceptable for CI but must be noted in the PR. + +- [ ] **Step 4: Commit** + +```bash +git add packages/submersion_nl/darwin +git commit -m "feat(explore): Apple Foundation Models adapter with guided generation" +``` + +--- + +### Task 10: Android adapter (Kotlin) + +**Files:** +- Create: `packages/submersion_nl/android/build.gradle` +- Create: `packages/submersion_nl/android/src/main/AndroidManifest.xml` +- Create: `packages/submersion_nl/android/src/main/kotlin/app/submersion/nl/SubmersionNlPlugin.kt` + +**Interfaces:** the same channel contract as Task 9. + +Decision recorded here: the Android adapter ships prompt-only JSON validated by Dart. The ML Kit schema compiler (`genai-schema-compiler`, alpha, KSP) is not adopted in phase 1; a follow-up issue tracks constrained decoding once it leaves alpha. The Prompt API is validated for English and Korean only, so `availability` returns `unsupportedLocale` for other languages. + +- [ ] **Step 1: Write the Gradle files** + +```groovy +group = "app.submersion.nl" +version = "0.1.0" + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.1.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22") + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "app.submersion.nl" + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + defaultConfig { + minSdk = 26 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:1.8.22" + implementation "com.google.mlkit:genai-prompt:1.0.0-beta4" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" +} +``` + +`AndroidManifest.xml`: `` + +- [ ] **Step 2: Write the plugin** + +```kotlin +package app.submersion.nl + +import com.google.mlkit.genai.common.DownloadCallback +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.GenerativeModel +import com.google.mlkit.genai.prompt.generateContentRequest +import com.google.mlkit.genai.prompt.TextPart +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val CHANNEL = "submersion_nl" +private const val DOWNLOAD_CHANNEL = "submersion_nl/download" +private val SUPPORTED_LANGUAGES = setOf("en", "ko") + +/** + * Gemini Nano through the ML Kit GenAI Prompt API. Prompt-only JSON; the + * Dart side validates the payload against schema v1. The Prompt API is + * validated for English and Korean only, so other locales are reported as + * unsupported and the feature stays hidden. + */ +class SubmersionNlPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler { + + private lateinit var channel: MethodChannel + private lateinit var downloadChannel: EventChannel + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var model: GenerativeModel? = null + private var instructions: String = "" + private var downloadSink: EventChannel.EventSink? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(binding.binaryMessenger, CHANNEL) + channel.setMethodCallHandler(this) + downloadChannel = EventChannel(binding.binaryMessenger, DOWNLOAD_CHANNEL) + downloadChannel.setStreamHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + downloadChannel.setStreamHandler(null) + model?.close() + scope.cancel() + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + downloadSink = events + val m = model ?: Generation.getClient().also { model = it } + scope.launch { + try { + m.download(object : DownloadCallback { + override fun onDownloadStarted(bytesToDownload: Long) { events.success(0.0) } + override fun onDownloadProgress(totalBytesDownloaded: Long) {} + override fun onDownloadCompleted() { events.success(1.0); events.endOfStream() } + override fun onDownloadFailed(e: GenAiException) { + events.error("model_not_ready", e.message, null) + } + }) + } catch (e: Exception) { + events.error("model_not_ready", e.message, null) + } + } + } + + override fun onCancel(arguments: Any?) { downloadSink = null } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "availability" -> availability(call.argument("locale") ?: "en", result) + "prepare" -> { + instructions = call.argument("instructions") ?: "" + if (model == null) model = Generation.getClient() + result.success(null) + } + "compile" -> { + val sentence = call.argument("sentence") + if (sentence == null) { + result.error("decoding_failure", "missing sentence", null) + return + } + compile(sentence, result) + } + else -> result.notImplemented() + } + } + + private fun availability(localeTag: String, result: MethodChannel.Result) { + val language = localeTag.substringBefore('-').substringBefore('_').lowercase() + if (language !in SUPPORTED_LANGUAGES) { + result.success("unsupportedLocale") + return + } + val m = model ?: Generation.getClient().also { model = it } + scope.launch { + try { + val status = withContext(Dispatchers.IO) { m.checkStatus() } + result.success( + when (status) { + FeatureStatus.AVAILABLE -> "available" + FeatureStatus.DOWNLOADABLE -> "downloadable" + FeatureStatus.DOWNLOADING -> "downloading" + else -> "deviceNotEligible" + } + ) + } catch (e: Exception) { + result.success("deviceNotEligible") + } + } + } + + private fun compile(sentence: String, result: MethodChannel.Result) { + val m = model ?: Generation.getClient().also { model = it } + scope.launch { + try { + val prompt = instructions + "\n\nSentence: " + sentence + "\n" + val response = withContext(Dispatchers.IO) { + m.generateContent(generateContentRequest(TextPart(prompt)) { maxOutputTokens = 512 }) + } + val text = response.candidates.firstOrNull()?.text ?: "" + result.success(stripFences(text)) + } catch (e: GenAiException) { + result.error(mapCode(e), e.message, null) + } catch (e: Exception) { + result.error("unknown", e.message, null) + } + } + } + + private fun stripFences(text: String): String { + val trimmed = text.trim() + if (!trimmed.startsWith("```")) return trimmed + return trimmed.removePrefix("```json").removePrefix("```").removeSuffix("```").trim() + } + + private fun mapCode(e: GenAiException): String { + val name = e.errorCode.toString() + return when { + name.contains("REQUEST_TOO_LARGE") -> "context_exceeded" + name.contains("QUOTA") || name.contains("BUSY") -> "quota_exceeded" + name.contains("NOT_AVAILABLE") || name.contains("NOT_READY") || name.contains("DOWNLOAD") -> "model_not_ready" + name.contains("SAFETY") || name.contains("BLOCKED") -> "guardrail" + else -> "unknown" + } + } +} +``` + +The exact `GenAiException.errorCode` constants and the `generateContentRequest` builder names come from the `genai-prompt` beta4 artifact; compile the plugin and correct names against the compiler's error output rather than guessing further. Record any renames in the deviations section. + +- [ ] **Step 3: Build the Android app** + +Run: `flutter build apk --debug` +Expected: success. If `genai-prompt:1.0.0-beta4` cannot be resolved, check the ML Kit release notes for the current beta and bump the version. + +- [ ] **Step 4: Commit** + +```bash +git add packages/submersion_nl/android +git commit -m "feat(explore): Android Gemini Nano adapter via the ML Kit Prompt API" +``` + +--- +### Task 11: Gate providers, name index builder, Explore repository and query providers + +**Files:** +- Create: `lib/features/explore/presentation/providers/explore_gate_providers.dart` +- Create: `lib/features/explore/data/name_index_builder.dart` +- Create: `lib/features/explore/data/explore_repository.dart` +- Create: `lib/features/explore/presentation/providers/explore_providers.dart` +- Test: `test/features/explore/presentation/providers/explore_gate_providers_test.dart` +- Test: `test/features/explore/data/name_index_builder_test.dart` +- Test: `test/features/explore/data/explore_repository_test.dart` +- Test: `test/features/explore/presentation/providers/explore_providers_test.dart` + +**Interfaces:** +- Consumes: `NlEngine`, `ChannelNlEngine`, `QueryCompiler`, `NameIndex`; `localeProvider` and `settingsProvider` (`lib/features/settings/presentation/providers/settings_providers.dart`); `validatedCurrentDiverIdProvider` (`lib/features/divers/presentation/providers/diver_providers.dart`); repository providers `siteRepositoryProvider`, `speciesRepositoryProvider`, `equipmentRepositoryProvider`, `buddyRepositoryProvider`, `tagRepositoryProvider`, `diveCenterRepositoryProvider`, `tripRepositoryProvider`, `diveComputerRepositoryProvider` (`lib/features/dive_log/presentation/providers/dive_computer_providers.dart`), `diveRepositoryProvider`, `statisticsRepositoryProvider`; `builtInSpeciesName` (`lib/features/marine_life/presentation/species_name_lookup.dart`); `l10nForLocaleTag` (`lib/l10n/l10n_extension.dart`); `EquipmentAttributeCatalog` definitions for choice labels (`lib/features/equipment/domain/constants/equipment_attribute_catalog.dart`, the `EquipmentAttributeDef` entries with `kind == AttributeKind.choice`); `DiveStatsScope.and` (`lib/core/database/dive_stats_scope.dart`); `buildFilteredDiveIdSubquery`. +- Produces: + - `nlEngineProvider = Provider((ref) => ChannelNlEngine())` + - `explorePlatformSupportedProvider = Provider` from `defaultTargetPlatform` (android, iOS, macOS) + - `exploreAvailabilityProvider = FutureProvider` keyed on `localeProvider` + - `exploreEnabledProvider = Provider`: platform true AND availability resolved to `available` + - `class NameIndexBuilder { Future build({required String? diverId, required AppLocalizations l10n}) }` taking the repositories in its constructor + - `class ExploreRepository { Future> diveCountBySite(DiveFilterState filter, {String? diverId, int limit = 10}) }` with the stats scope applied + - `exploreFilterProvider = StateProvider` + - `nameIndexProvider = FutureProvider` + - `unitPrefsProvider = Provider` from `settingsProvider` + - `class ExploreState { String sentence; ParsedQuery? parsed; CompiledQuery? compiled; bool running; NlError? error; }` + - `class ExploreQueryNotifier extends StateNotifier` with `Future run(String sentence)`, `Future rerun(String sentence, ParsedQuery parsed)` (skips the model), `void removeChip(QueryChip chip)`, `void resolveWith(int mentionIndex, NameEntry entry)` (replaces the mention's text with the entry's label so the resolver hits exactly, then recompiles), `void clear()`. Each recompile writes `compiled.filter` into `exploreFilterProvider`. + - `exploreQueryProvider = StateNotifierProvider` + - `exploreResultsProvider = FutureProvider>` (first 100 summaries for the Explore filter) + - `exploreCountProvider = FutureProvider` + - `exploreChartDataProvider = FutureProvider.family` where `class ExploreChartData { List points; List<({String label, int count})> bars; }` + +- [ ] **Step 1: Write the failing tests** + +```dart +// test/features/explore/presentation/providers/explore_gate_providers_test.dart +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/explore/domain/nl_engine.dart'; +import 'package:submersion/features/explore/presentation/providers/explore_gate_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +class _FakeEngine implements NlEngine { + _FakeEngine(this.result); + final NlAvailability result; + String? askedLocale; + @override + Future availability(String localeTag) async { + askedLocale = localeTag; + return result; + } + @override + Future prepare() async {} + @override + Stream download() => const Stream.empty(); + @override + Future compile(String sentence, {required String localeTag}) async => '{}'; +} + +void main() { + ProviderContainer make(NlAvailability a, {bool platform = true, String locale = 'en'}) => ProviderContainer( + overrides: [ + nlEngineProvider.overrideWithValue(_FakeEngine(a)), + explorePlatformSupportedProvider.overrideWithValue(platform), + localeProvider.overrideWithValue(locale), + ], + ); + + test('enabled only when the platform is supported and the model is available', () async { + final c = make(NlAvailability.available); + await c.read(exploreAvailabilityProvider.future); + expect(c.read(exploreEnabledProvider), isTrue); + }); + + test('disabled on an unsupported platform even if the probe says available', () async { + final c = make(NlAvailability.available, platform: false); + expect(c.read(exploreEnabledProvider), isFalse); + }); + + test('disabled while the probe loads and when it says unsupportedLocale', () async { + final c = make(NlAvailability.unsupportedLocale, locale: 'hu'); + expect(c.read(exploreEnabledProvider), isFalse); + await c.read(exploreAvailabilityProvider.future); + expect(c.read(exploreEnabledProvider), isFalse); + }); + + test('the probe asks for the active locale, mapping system to the platform locale', () async { + final c = make(NlAvailability.available, locale: 'de'); + await c.read(exploreAvailabilityProvider.future); + expect((c.read(nlEngineProvider) as _FakeEngine).askedLocale, 'de'); + }); + + test('platform gate follows defaultTargetPlatform', () { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + expect(ProviderContainer().read(explorePlatformSupportedProvider), isFalse); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + expect(ProviderContainer().read(explorePlatformSupportedProvider), isTrue); + debugDefaultTargetPlatformOverride = null; + }); +} +``` + +```dart +// test/features/explore/data/explore_repository_test.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/explore/data/explore_repository.dart'; + +import '../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + final now = DateTime(2026, 6, 1).millisecondsSinceEpoch; + + setUp(() async { + db = await setUpTestDatabase(); + }); + tearDown(tearDownTestDatabase); + + Future site(String id) => db.into(db.diveSites).insert( + DiveSitesCompanion(id: Value(id), name: Value('Site $id'), createdAt: Value(now), updatedAt: Value(now)), + ); + Future dive(String id, String? siteId, {bool excluded = false, double? depth}) => db.into(db.dives).insert( + DivesCompanion( + id: Value(id), + diveDateTime: Value(now), + createdAt: Value(now), + updatedAt: Value(now), + siteId: Value(siteId), + excludedFromStats: Value(excluded), + maxDepth: Value(depth), + ), + ); + + test('counts dives per site under the filter and the stats scope', () async { + await site('a'); + await site('b'); + await dive('1', 'a', depth: 30); + await dive('2', 'a', depth: 10); + await dive('3', 'b', depth: 30); + await dive('4', 'a', depth: 30, excluded: true); + await dive('5', null, depth: 30); + final rows = await ExploreRepository().diveCountBySite(const DiveFilterState(minDepth: 20)); + expect(rows.map((r) => (r.siteId, r.name, r.count)), [('a', 'Site a', 1), ('b', 'Site b', 1)]); + }); +} +``` + +```dart +// test/features/explore/data/name_index_builder_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; +import 'package:submersion/features/explore/data/name_index_builder.dart'; +import 'package:submersion/features/explore/domain/name_index.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; +import 'package:submersion/l10n/arb/app_localizations_en.dart'; + +void main() { + test('builds place, site, species and gear entries from entity lists', () { + final index = NameIndexBuilder.fromEntities( + sites: [ + DiveSite(id: 's1', name: 'Salt Pier', country: 'Bonaire', region: 'Caribbean Netherlands'), + DiveSite(id: 's2', name: '1000 Steps', country: 'Bonaire', island: 'Bonaire'), + ], + species: const [ + Species(id: 'sp_green_turtle', commonName: 'Green Turtle', scientificName: 'Chelonia mydas', category: SpeciesCategory.reptile, isBuiltIn: true), + ], + equipment: [ + EquipmentItem(id: 'g1', name: 'MTX-R', type: EquipmentType.firstStage, brand: 'Apeks', model: 'MTX-R'), + ], + buddies: const [Buddy(id: 'b1', name: 'Sarah Jones')], + legacyBuddyNames: const ['Old Pal'], + tags: const [], + centers: const [], + trips: const [], + computers: const [], + l10n: AppLocalizationsEn(), + ); + + NameEntry one(MentionKind k, String label) => index.forKind(k).firstWhere((e) => e.label == label); + + expect(one(MentionKind.place, 'Bonaire').ids, unorderedEquals(['s1', 's2'])); + expect(one(MentionKind.place, 'Bonaire').rank, 0); + expect(one(MentionKind.place, 'Caribbean Netherlands').rank, 1); + expect(one(MentionKind.site, 'Salt Pier').ids, ['s1']); + expect(index.forKind(MentionKind.species).map((e) => e.label), containsAll(['Green Turtle', 'Chelonia mydas'])); + expect(one(MentionKind.gear, 'MTX-R').target, NameTarget.equipmentId); + expect(one(MentionKind.gear, 'Apeks MTX-R').rank, 1); + expect(index.forKind(MentionKind.gear).where((e) => e.target == NameTarget.attrChoice).map((e) => e.attrChoice), contains('trilaminate')); + expect(one(MentionKind.buddy, 'Old Pal').target, NameTarget.legacyBuddyName); + }); +} +``` + +Check the `DiveSite`, `Species`, `EquipmentItem` and `Buddy` constructors for their required parameters and add the minimum (the compile error names them). The `place` entry for the island `Bonaire` duplicates the country entry; the builder merges same-label place entries into one entry keeping the lowest rank and the union of ids. + +```dart +// test/features/explore/presentation/providers/explore_providers_test.dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/features/explore/domain/name_index.dart'; +import 'package:submersion/features/explore/domain/nl_engine.dart'; +import 'package:submersion/features/explore/domain/query_model.dart'; +import 'package:submersion/features/explore/presentation/providers/explore_gate_providers.dart'; +import 'package:submersion/features/explore/presentation/providers/explore_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +class _ScriptedEngine implements NlEngine { + _ScriptedEngine(this.json); + final String json; + int compileCalls = 0; + @override + Future availability(String localeTag) async => NlAvailability.available; + @override + Future prepare() async {} + @override + Stream download() => const Stream.empty(); + @override + Future compile(String sentence, {required String localeTag}) async { + compileCalls++; + return json; + } +} + +void main() { + const turtles = '{"schemaVersion":1,"subject":"dives","clauses":[{"field":"depth","op":"gt","value":20,"unit":"m","text":"below 20m"}],"mentions":[{"kind":"place","text":"Bonaire"}],"time":null,"unplaced":["maybe"]}'; + + ProviderContainer make(NlEngine engine) => ProviderContainer( + overrides: [ + nlEngineProvider.overrideWithValue(engine), + localeProvider.overrideWithValue('en'), + unitPrefsProvider.overrideWithValue( + const (depth: DepthUnit.meters, temperature: TemperatureUnit.celsius, pressure: PressureUnit.bar), + ), + nameIndexProvider.overrideWith( + (ref) async => const NameIndex([ + NameEntry(kind: MentionKind.place, label: 'Bonaire', ids: ['s1', 's2'], target: NameTarget.sitePlace), + ]), + ), + recentQueryRecorderProvider.overrideWithValue((sentence, locale, parsed) async {}), + ], + ); + + test('run compiles the sentence and publishes the filter', () async { + final engine = _ScriptedEngine(turtles); + final c = make(engine); + await c.read(exploreQueryProvider.notifier).run('Turtles below 20m in Bonaire'); + final s = c.read(exploreQueryProvider); + expect(s.running, isFalse); + expect(s.error, isNull); + expect(s.compiled!.filter.minDepth, 20); + expect(s.compiled!.filter.siteIds, ['s1', 's2']); + expect(s.compiled!.unplaced.single.text, 'maybe'); + expect(c.read(exploreFilterProvider).siteIds, ['s1', 's2']); + expect(engine.compileCalls, 1); + }); + + test('removeChip recompiles without the model', () async { + final engine = _ScriptedEngine(turtles); + final c = make(engine); + final n = c.read(exploreQueryProvider.notifier); + await n.run('x'); + n.removeChip(c.read(exploreQueryProvider).compiled!.chips.first); + expect(c.read(exploreFilterProvider).minDepth, isNull); + expect(c.read(exploreFilterProvider).siteIds, ['s1', 's2']); + expect(engine.compileCalls, 1); + }); + + test('invalid JSON is a schema mismatch error and leaves the filter empty', () async { + final c = make(_ScriptedEngine('{"schemaVersion":7}')); + await c.read(exploreQueryProvider.notifier).run('x'); + expect(c.read(exploreQueryProvider).error, NlError.schemaMismatch); + expect(c.read(exploreFilterProvider).hasActiveFilters, isFalse); + }); + + test('an engine exception surfaces as its error', () async { + final engine = _ThrowingEngine(); + final c = make(engine); + await c.read(exploreQueryProvider.notifier).run('x'); + expect(c.read(exploreQueryProvider).error, NlError.contextExceeded); + }); + + test('rerun with a stored parse skips the model', () async { + final engine = _ScriptedEngine(turtles); + final c = make(engine); + await c.read(exploreQueryProvider.notifier).rerun('x', ParsedQuery.fromJson({'schemaVersion': 1, 'subject': 'dives'})); + expect(engine.compileCalls, 0); + expect(c.read(exploreQueryProvider).compiled, isNotNull); + }); +} + +class _ThrowingEngine implements NlEngine { + @override + Future availability(String localeTag) async => NlAvailability.available; + @override + Future prepare() async {} + @override + Stream download() => const Stream.empty(); + @override + Future compile(String sentence, {required String localeTag}) async => + throw const NlException(NlError.contextExceeded); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/explore` +Expected: FAIL on the four new files (missing imports). + +- [ ] **Step 3: Write the gate providers** + +```dart +// lib/features/explore/presentation/providers/explore_gate_providers.dart +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/explore/data/channel_nl_engine.dart'; +import 'package:submersion/features/explore/domain/nl_engine.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +final nlEngineProvider = Provider((ref) => ChannelNlEngine()); + +/// Synchronous platform gate, separate from the async probe so an entry +/// point is never enabled transiently while the probe loads (the iCloud tile +/// pattern). Uses defaultTargetPlatform so tests can override the platform. +final explorePlatformSupportedProvider = Provider((ref) { + return switch (defaultTargetPlatform) { + TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.macOS => true, + _ => false, + }; +}); + +/// The model's answer for the active app locale. 'system' resolves to the +/// device locale tag. +final exploreAvailabilityProvider = FutureProvider((ref) async { + if (!ref.watch(explorePlatformSupportedProvider)) { + return NlAvailability.unsupportedPlatform; + } + final locale = ref.watch(localeProvider); + final tag = locale == 'system' + ? PlatformDispatcher.instance.locale.toLanguageTag() + : locale; + return ref.watch(nlEngineProvider).availability(tag); +}); + +final exploreEnabledProvider = Provider((ref) { + if (!ref.watch(explorePlatformSupportedProvider)) return false; + return ref.watch(exploreAvailabilityProvider).value == NlAvailability.available; +}); +``` + +`PlatformDispatcher` comes from `dart:ui`; import it. + +- [ ] **Step 4: Write the name index builder** + +```dart +// lib/features/explore/data/name_index_builder.dart +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; +import 'package:submersion/features/equipment/domain/constants/equipment_attribute_catalog.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; +import 'package:submersion/features/explore/domain/name_index.dart'; +import 'package:submersion/features/explore/domain/query_model.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/species_name_lookup.dart'; +import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; +import 'package:submersion/features/tags/domain/entities/tag.dart'; +import 'package:submersion/features/trips/data/repositories/trip_repository.dart'; +import 'package:submersion/features/trips/domain/entities/trip.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Builds the labels the resolver matches against, one query per kind. Ids +/// and labels only, so a 5,000-dive library builds it in well under a second. +class NameIndexBuilder { + NameIndexBuilder({ + required this.sites, + required this.species, + required this.equipment, + required this.buddies, + required this.dives, + required this.tags, + required this.centers, + required this.trips, + required this.computers, + }); + + final SiteRepository sites; + final SpeciesRepository species; + final EquipmentRepository equipment; + final BuddyRepository buddies; + final DiveRepository dives; + final TagRepository tags; + final DiveCenterRepository centers; + final TripRepository trips; + final DiveComputerRepository computers; + + Future build({required String? diverId, required AppLocalizations l10n}) async { + final results = await Future.wait([ + sites.getAllSites(diverId: diverId), + species.getAllSpecies(), + equipment.getAllEquipment(diverId: diverId), + buddies.getAllBuddies(diverId: diverId), + dives.getDistinctLegacyBuddyNames(diverId: diverId), + tags.getAllTags(diverId: diverId), + centers.getAllDiveCenters(diverId: diverId), + trips.getAllTrips(diverId: diverId), + computers.getAllComputers(diverId: diverId), + ]); + return fromEntities( + sites: results[0] as List, + species: results[1] as List, + equipment: results[2] as List, + buddies: results[3] as List, + legacyBuddyNames: results[4] as List, + tags: results[5] as List, + centers: results[6] as List, + trips: results[7] as List, + computers: results[8] as List, + l10n: l10n, + ); + } + + /// Pure assembly, so tests need no database. + static NameIndex fromEntities({ + required List sites, + required List species, + required List equipment, + required List buddies, + required List legacyBuddyNames, + required List tags, + required List centers, + required List trips, + required List computers, + required AppLocalizations l10n, + }) { + final entries = []; + + // Places: country 0, region 1, island 2, city 3; same label merges. + final places = ids})>{}; + void place(String? label, int rank, String siteId) { + if (label == null || label.trim().isEmpty) return; + final key = label.trim(); + final existing = places[key]; + if (existing == null) { + places[key] = (rank: rank, ids: {siteId}); + } else { + existing.ids.add(siteId); + if (rank < existing.rank) places[key] = (rank: rank, ids: existing.ids); + } + } + for (final s in sites) { + place(s.country, 0, s.id); + place(s.region, 1, s.id); + place(s.island, 2, s.id); + place(s.city, 3, s.id); + entries.add(NameEntry(kind: MentionKind.site, label: s.name, ids: [s.id], target: NameTarget.siteId)); + } + for (final p in places.entries) { + entries.add(NameEntry(kind: MentionKind.place, label: p.key, ids: p.value.ids.toList(), target: NameTarget.sitePlace, rank: p.value.rank)); + } + + for (final sp in species) { + final localized = sp.isBuiltIn ? builtInSpeciesName(l10n, sp.id) : null; + if (localized != null) { + entries.add(NameEntry(kind: MentionKind.species, label: localized, ids: [sp.id], target: NameTarget.speciesId, rank: 0)); + } + entries.add(NameEntry(kind: MentionKind.species, label: sp.commonName, ids: [sp.id], target: NameTarget.speciesId, rank: 1)); + final sci = sp.scientificName; + if (sci != null && sci.isNotEmpty) { + entries.add(NameEntry(kind: MentionKind.species, label: sci, ids: [sp.id], target: NameTarget.speciesId, rank: 2)); + } + } + + for (final e in equipment) { + entries.add(NameEntry(kind: MentionKind.gear, label: e.name, ids: [e.id], target: NameTarget.equipmentId, rank: 0)); + final brandModel = [e.brand, e.model].whereType().where((s) => s.isNotEmpty).join(' '); + if (brandModel.isNotEmpty && brandModel != e.name) { + entries.add(NameEntry(kind: MentionKind.gear, label: brandModel, ids: [e.id], target: NameTarget.equipmentId, rank: 1)); + } + } + // Attribute choices: every choice of every curated choice attribute, by + // its localized label, so "trilaminate" lowers to a condition. + for (final type in EquipmentType.values) { + for (final def in EquipmentAttributeCatalog.definitionsFor(type)) { + if (def.kind != AttributeKind.choice) continue; + for (final choice in def.choiceKeys ?? const []) { + final label = attributeChoiceLabel(l10n, def.key, choice) ?? choice; + entries.add(NameEntry(kind: MentionKind.gear, label: label, ids: const [], target: NameTarget.attrChoice, rank: 2, attrKey: def.key, attrChoice: choice)); + } + } + } + + for (final b in buddies) { + entries.add(NameEntry(kind: MentionKind.buddy, label: b.name, ids: [b.id], target: NameTarget.buddyId, rank: 0)); + } + for (final name in legacyBuddyNames) { + entries.add(NameEntry(kind: MentionKind.buddy, label: name, ids: const [], target: NameTarget.legacyBuddyName, rank: 1)); + } + for (final t in tags) { + entries.add(NameEntry(kind: MentionKind.tag, label: t.name, ids: [t.id], target: NameTarget.tagId)); + } + for (final c in centers) { + entries.add(NameEntry(kind: MentionKind.center, label: c.name, ids: [c.id], target: NameTarget.centerId)); + } + for (final t in trips) { + entries.add(NameEntry(kind: MentionKind.trip, label: t.name, ids: [t.id], target: NameTarget.tripId)); + } + for (final c in computers) { + entries.add(NameEntry(kind: MentionKind.computer, label: c.name, ids: [c.id], target: NameTarget.computerId)); + } + // Deduplicate identical (kind, label, identity) rows. + final seen = {}; + return NameIndex([for (final e in entries) if (seen.add('${e.kind.name}|${e.label}|${e.identity}')) e]); + } +} +``` + +Three helpers this references must exist; add whichever are missing: + +- `EquipmentAttributeCatalog.definitionsFor(EquipmentType)`: read the catalog file; it already exposes the per-type map of `EquipmentAttributeDef` (the literal near line 232). If the accessor has a different name, use it; do not add a second map. +- `attributeChoiceLabel(AppLocalizations, String key, String choice)`: the catalog's doc says choices are `attrChoice__