Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
865a260
docs(explore): design spec for on-device natural-language search
ericgriffin Sep 19, 2026
9929f11
docs(explore): phase 1 implementation plan (core plus dives)
ericgriffin Sep 20, 2026
412ceba
feat(explore): parsed query model with schema validation
ericgriffin Sep 20, 2026
cff4479
feat(explore): dive field catalog and unit grounding
ericgriffin Sep 20, 2026
b3a0264
feat(explore): deterministic time grammar
ericgriffin Sep 20, 2026
78d58cc
feat(explore): name index and fuzzy entity resolver
ericgriffin Sep 20, 2026
0a067bd
feat(dive-log): water temp, visibility, water type, species and site-…
ericgriffin Sep 20, 2026
d0a3a02
feat(dive-log): evaluate the explore axes in Statistics and the pagin…
ericgriffin Sep 20, 2026
d8a7423
feat(explore): compile a parsed query into the dive filter, chips and…
ericgriffin Sep 20, 2026
4934e6e
feat(explore): on-device model engine contract, prompt and channel pa…
ericgriffin Sep 20, 2026
b67edeb
feat(explore): Apple Foundation Models adapter with guided generation
ericgriffin Sep 20, 2026
dee4f8c
feat(explore): Android Gemini Nano adapter via the ML Kit Prompt API
ericgriffin Sep 20, 2026
60e5453
chore(explore): register the submersion_nl plugin on macOS
ericgriffin Sep 20, 2026
f0e1aab
feat(explore): availability gate, name index, query notifier and resu…
ericgriffin Sep 20, 2026
b0e61e1
fix(explore): use the Flow-based download and integer status of genai…
ericgriffin Sep 20, 2026
259eb65
feat(explore): recent queries in the local cache database
ericgriffin Sep 20, 2026
16f5128
feat(explore): localized strings for the Explore page and new filter …
ericgriffin Sep 20, 2026
4a8997a
chore(explore): lock the submersion_nl pod on macOS
ericgriffin Sep 20, 2026
b580275
feat(dive-log): filter sheet sections and chips for every filter axis
ericgriffin Sep 20, 2026
3e3fdd7
feat(explore): the Explore page with chips, charts, results and handoffs
ericgriffin Sep 20, 2026
c2519aa
feat(explore): route, gated entry point and shortcut
ericgriffin Sep 20, 2026
63a5539
style(explore): satisfy analyzer infos
ericgriffin Sep 20, 2026
5653a73
docs(explore): record phase 1 implementation deviations
ericgriffin Sep 20, 2026
c021842
fix(explore): address review findings on the chip contract, the gate …
ericgriffin Sep 20, 2026
e616ba7
Merge remote-tracking branch 'origin/main' into ericgriffin/on-device…
ericgriffin Sep 20, 2026
173401b
fix(l10n): interpolate the count in the French and Portuguese Explore…
ericgriffin Sep 20, 2026
cab3c7c
test(trips): build trip story hero dates as calendar days
ericgriffin Sep 20, 2026
ae097b5
test(trips): pin both DST directions for itinerary generation
ericgriffin Sep 20, 2026
5dc521e
fix(trips): count the duration label and countdown in calendar days
ericgriffin Sep 20, 2026
57bf2d2
Merge remote-tracking branch 'origin/main' into ericgriffin/on-device…
ericgriffin Sep 20, 2026
4764447
Merge remote-tracking branch 'origin/main' into ericgriffin/on-device…
ericgriffin Sep 20, 2026
870d4cb
docs(explore): phase 2 implementation plan (profile-derived predicates)
ericgriffin Sep 20, 2026
5de7439
Merge remote-tracking branch 'origin/main' into ericgriffin/on-device…
ericgriffin Sep 21, 2026
8d1f583
Merge remote-tracking branch 'origin/main' into ericgriffin/on-device…
ericgriffin Sep 26, 2026
aefd523
fix(explore): follow the statistics feature's rename to insights
ericgriffin Sep 26, 2026
b9be5c6
fix(explore): translate the Explore keyboard shortcut label
ericgriffin Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,840 changes: 5,840 additions & 0 deletions docs/superpowers/plans/2026-09-19-explore-phase1-core-dives.md

Large diffs are not rendered by default.

3,067 changes: 3,067 additions & 0 deletions docs/superpowers/plans/2026-09-20-explore-phase2-derived-predicates.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions lib/core/accessibility/app_shortcuts.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

import 'package:submersion/core/accessibility/shortcut_registry.dart';
import 'package:submersion/core/accessibility/shortcuts_help_dialog.dart';
import 'package:submersion/features/explore/presentation/providers/explore_gate_providers.dart';
import 'package:submersion/features/divers/presentation/widgets/diver_switcher_sheet.dart';

/// Creates a platform-appropriate shortcut activator.
Expand Down Expand Up @@ -97,6 +99,12 @@ class AppShortcuts {
activator: platformShortcut(LogicalKeyboardKey.keyF),
isGlobal: true,
),
ShortcutEntry(
label: 'Explore with a sentence',
category: 'Search',
activator: platformShortcut(LogicalKeyboardKey.keyE),
isGlobal: true,
),

// General
const ShortcutEntry(
Expand Down Expand Up @@ -174,6 +182,13 @@ class AppShortcuts {
platformShortcut(LogicalKeyboardKey.keyF): () {
context.push('/dives/search');
},
platformShortcut(LogicalKeyboardKey.keyE): () {
// The page is useless without a model, so the shortcut honours the
// same gate as the app bar action.
if (ProviderScope.containerOf(context).read(exploreEnabledProvider)) {
context.push('/dives/explore');
}
},

// Settings
platformShortcut(LogicalKeyboardKey.comma): () {
Expand Down
2 changes: 2 additions & 0 deletions lib/core/accessibility/shortcut_display.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ final Map<String, String Function(AppLocalizations)> _shortcutEntryLabels = {
'Go to Settings': (l10n) => l10n.accessibility_shortcut_goToSettings,
'Go back': (l10n) => l10n.accessibility_shortcut_goBack,
'Search dives': (l10n) => l10n.accessibility_shortcut_searchDives,
'Explore with a sentence': (l10n) =>
l10n.accessibility_shortcut_exploreWithSentence,
'Close / Cancel': (l10n) => l10n.accessibility_shortcut_closeCancel,
'Open settings': (l10n) => l10n.accessibility_shortcut_openSettings,
'Switch diver': (l10n) => l10n.accessibility_shortcut_switchDiver,
Expand Down
38 changes: 37 additions & 1 deletion lib/core/database/local_cache_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,24 @@ class DecoClassificationCache extends Table {
Set<Column> get primaryKey => {diveId};
}

/// The diver's last Explore sentences with the model's parse, so the field
/// can offer them and a re-run skips the model. Local-only by construction:
/// no HLC, never synced, never backed up; rows with an older schema version
/// are dropped on read.
class RecentQueries extends Table {
/// Normalized sentence plus locale, so a retyped sentence bumps its row.
TextColumn get key => text()();
TextColumn get sentence => text()();
TextColumn get locale => text()();
TextColumn get parsedJson => text()();
IntColumn get schemaVersion => integer()();
TextColumn get subject => text()();
IntColumn get lastUsedAt => integer()();

@override
Set<Column> get primaryKey => {key};
}

@DriftDatabase(
tables: [
LocalAssetCache,
Expand All @@ -284,13 +302,14 @@ class DecoClassificationCache extends Table {
WatchedRoots,
WatchedFolderIndex,
DecoClassificationCache,
RecentQueries,
],
)
class LocalCacheDatabase extends _$LocalCacheDatabase {
LocalCacheDatabase(super.e);

@override
int get schemaVersion => 17;
int get schemaVersion => 18;

@override
MigrationStrategy get migration => MigrationStrategy(
Expand Down Expand Up @@ -454,6 +473,10 @@ class LocalCacheDatabase extends _$LocalCacheDatabase {
);
}
}
// v18: Explore recent queries. Table-only rung, no backfill.
if (from < 18) {
await m.createTable(recentQueries);
}
},
beforeOpen: (details) async {
// Ladder-collision self-heal: a parallel branch that also claimed v7
Expand Down Expand Up @@ -535,6 +558,19 @@ class LocalCacheDatabase extends _$LocalCacheDatabase {
PRIMARY KEY (dive_id)
)
''');
// v18 mirror, same collision self-heal as above.
await customStatement('''
CREATE TABLE IF NOT EXISTS recent_queries (
key TEXT NOT NULL,
sentence TEXT NOT NULL,
locale TEXT NOT NULL,
parsed_json TEXT NOT NULL,
schema_version INTEGER NOT NULL,
subject TEXT NOT NULL,
last_used_at INTEGER NOT NULL,
PRIMARY KEY (key)
)
''');
await customStatement('''
CREATE TABLE IF NOT EXISTS swiss_bathy_tile_cache (
tile_key TEXT NOT NULL,
Expand Down
6 changes: 6 additions & 0 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import 'package:submersion/features/dive_3d/presentation/pages/compare_dives_3d_
import 'package:submersion/features/dive_log/presentation/pages/bulk_dive_edit_page.dart';
import 'package:submersion/features/dive_log/presentation/pages/dive_edit_page.dart';
import 'package:submersion/features/dive_log/presentation/pages/dive_search_page.dart';
import 'package:submersion/features/explore/presentation/pages/explore_page.dart';
import 'package:submersion/features/dive_log/presentation/pages/profile_editor_page.dart';
import 'package:submersion/features/dive_log/presentation/providers/profile_editor_provider.dart';
import 'package:submersion/features/maps/presentation/pages/dive_activity_map_page.dart';
Expand Down Expand Up @@ -442,6 +443,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
filterDiveId: state.uri.queryParameters['dive'],
),
),
GoRoute(
path: 'explore',
name: 'explore',
builder: (context, state) => const ExplorePage(),
),
GoRoute(
path: ':diveId',
name: 'diveDetail',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,18 @@ class DiveRepository {
)
.debounce(changeTickDebounce);

/// 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<void> watchSightingsFilterChanges() => _db
.tableUpdates(
TableUpdateQuery.allOf([
TableUpdateQuery.onTable(_db.dives),
TableUpdateQuery.onTable(_db.sightings),
]),
)
.debounce(changeTickDebounce);

/// [watchDiveListChanges] plus the tables the buddy filters read
/// ([DiveFilterState.readsBuddyLinks]), for a list filtered by buddy.
/// See [_buddyLinkTables].
Expand Down Expand Up @@ -2551,6 +2563,13 @@ class DiveRepository {
clauses.add('d.site_id = ?');
args.add(Variable(filter.siteId!));
}
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));
}
}
if (filter.tripId != null) {
clauses.add('d.trip_id = ?');
args.add(Variable(filter.tripId!));
Expand All @@ -2567,6 +2586,50 @@ class DiveRepository {
clauses.add('d.max_depth <= ?');
args.add(Variable(filter.maxDepth!));
}
// 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));
}
}
if (filter.favoritesOnly == true) {
clauses.add('d.is_favorite = 1');
}
Expand Down Expand Up @@ -2720,6 +2783,23 @@ class DiveRepository {
}
}

/// Distinct legacy free-text buddy names (`dives.buddy`), for name
/// matching. Names only, never a count.
// stats-scope-exempt: a name list, not an aggregate; an excluded dive's
// buddy is still a name the diver may type.
Future<List<String>> getDistinctLegacyBuddyNames({String? diverId}) async {
final diverFilter = diverId != null ? 'AND diver_id = ?' : '';
final rows = await _db
.customSelect(
'SELECT DISTINCT buddy FROM dives '
"WHERE buddy IS NOT NULL AND buddy <> '' $diverFilter "
'ORDER BY buddy',
variables: [if (diverId != null) Variable(diverId)],
)
.get();
return rows.map((r) => r.read<String>('buddy')).toList();
}

// ============================================================================
// Query Operations
// ============================================================================
Expand Down
Loading
Loading