Natural click-driven presenter tour
Browse filesRework Present mode so every screen change is preceded by a visible cursor
landing and tap on the real widget, and follow the guided narrative
(home β card β concepts β catalog β nav bar β graph/chat β add).
- PresenterController: pointβtapβact gesture choreography; navigation routed
through a visible tap on the nav control; rewritten kTour script; speech
watchdog so a missing web 'end' event can no longer hang the tour.
- PresenterSpotlight: hold the target through the 150ms geometry-poll jitter
(fixes the snapping glide); always render the cursor; add a tap ripple.
- AgentBus: onLibraryTab / onOpenCatalogItem hooks.
- Screens register real tap targets (nav tabs, top icons, plus, first card,
reader blocks/insight/references/action-bar); Concepts & Catalog now flip
the real library tab instead of a full-screen swap.
- .gitignore: scope the bare 'presenter' rule to /presenter/ so the Flutter
feature dir is tracked (it never was) while the root prototype stays ignored.
- .gitignore +3 -1
- app/lib/data/services/api_client.dart +21 -0
- app/lib/ui/core/home_shell.dart +76 -18
- app/lib/ui/core/safe_notifier.dart +31 -0
- app/lib/ui/features/concepts/view_models/concepts_view_model.dart +2 -1
- app/lib/ui/features/feed/views/knowledge_feed_screen.dart +5 -0
- app/lib/ui/features/graph/views/graph_screen.dart +6 -1
- app/lib/ui/features/library/view_models/library_chat_view_model.dart +2 -1
- app/lib/ui/features/library/view_models/library_view_model.dart +2 -1
- app/lib/ui/features/library/views/library_screen.dart +87 -13
- app/lib/ui/features/presenter/agent_bus.dart +289 -0
- app/lib/ui/features/presenter/presenter_controller.dart +1102 -0
- app/lib/ui/features/presenter/presenter_overlay.dart +335 -0
- app/lib/ui/features/presenter/presenter_spotlight.dart +334 -0
- app/lib/ui/features/reader/view_models/chat_view_model.dart +2 -1
- app/lib/ui/features/reader/view_models/rabbit_hole_view_model.dart +2 -1
- app/lib/ui/features/reader/view_models/reader_view_model.dart +2 -1
- app/lib/ui/features/reader/views/reader_screen.dart +86 -13
- app/lib/ui/features/search/views/search_screen.dart +13 -1
- backend/app/api/presenter.py +45 -5
|
@@ -45,4 +45,6 @@ graph/
|
|
| 45 |
context.md
|
| 46 |
i-want-you-to-spicy-dusk.md
|
| 47 |
CACHY_OVERVIEW.md
|
| 48 |
-
|
|
|
|
|
|
|
|
|
| 45 |
context.md
|
| 46 |
i-want-you-to-spicy-dusk.md
|
| 47 |
CACHY_OVERVIEW.md
|
| 48 |
+
# Root-level Python prototype agent only β anchored so it does NOT swallow the
|
| 49 |
+
# Flutter feature dir app/lib/ui/features/presenter/.
|
| 50 |
+
/presenter/
|
|
@@ -144,6 +144,27 @@ class ApiClient {
|
|
| 144 |
return steps.whereType<Map<String, dynamic>>().toList();
|
| 145 |
}
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
// ------------------------------------------------------------------------- //
|
| 148 |
// Cards
|
| 149 |
// ------------------------------------------------------------------------- //
|
|
|
|
| 144 |
return steps.whereType<Map<String, dynamic>>().toList();
|
| 145 |
}
|
| 146 |
|
| 147 |
+
/// Relay a Present-mode event to the backend so it lands in the same
|
| 148 |
+
/// colour-coded pipeline log `start.py` shows β `debugPrint` alone only
|
| 149 |
+
/// reaches the terminal via the browser's DWDS log forwarding, which is
|
| 150 |
+
/// flaky, so this is the reliable path. Fire-and-forget: a failed relay
|
| 151 |
+
/// must never interrupt a live presentation, so errors are swallowed here.
|
| 152 |
+
void presenterLog(String message, {String level = 'info'}) {
|
| 153 |
+
unawaited(_postPresenterLog(message, level));
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
Future<void> _postPresenterLog(String message, String level) async {
|
| 157 |
+
try {
|
| 158 |
+
await _client.post(
|
| 159 |
+
_uri('/presenter/log'),
|
| 160 |
+
headers: {'content-type': 'application/json', ..._ownerHeader},
|
| 161 |
+
body: jsonEncode({'level': level, 'message': message}),
|
| 162 |
+
);
|
| 163 |
+
} catch (_) {
|
| 164 |
+
// Relay is best-effort; debugPrint is the fallback trail.
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
// ------------------------------------------------------------------------- //
|
| 169 |
// Cards
|
| 170 |
// ------------------------------------------------------------------------- //
|
|
@@ -13,10 +13,8 @@ import 'package:provider/provider.dart';
|
|
| 13 |
|
| 14 |
import '../features/actions/views/actions_screen.dart';
|
| 15 |
import '../features/capture/views/capture_sheet.dart';
|
| 16 |
-
import '../features/catalog/views/catalog_screen.dart';
|
| 17 |
import '../features/collections/views/collections_screen.dart';
|
| 18 |
import '../features/concepts/views/concept_detail_screen.dart';
|
| 19 |
-
import '../features/concepts/views/concepts_screen.dart';
|
| 20 |
import '../features/feed/views/connections_screen.dart';
|
| 21 |
import '../features/feed/views/knowledge_feed_screen.dart';
|
| 22 |
import '../features/graph/views/graph_screen.dart';
|
|
@@ -89,9 +87,21 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 89 |
PresenterController? _presenter;
|
| 90 |
Widget? _presenterScreen; // GraphScreen/SearchScreen shown during the tour
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
void _startPresenting() {
|
| 93 |
final repo = context.read<CardRepository>();
|
| 94 |
final bus = context.read<AgentBus>()
|
|
|
|
|
|
|
| 95 |
..onNavigate = _goToView
|
| 96 |
..onOpenCard = _openCardInPresenter
|
| 97 |
..onCreateCard = _createCardInPresenter
|
|
@@ -99,6 +109,9 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 99 |
..onOpenRabbitHole = _openRabbitHoleInPresenter
|
| 100 |
..onOpenCardChat = _openCardChatInPresenter
|
| 101 |
..onOpenLibraryChat = _openLibraryChatInPresenter;
|
|
|
|
|
|
|
|
|
|
| 102 |
final controller = PresenterController(repository: repo, bus: bus);
|
| 103 |
setState(() => _presenter = controller);
|
| 104 |
controller.addListener(_onPresenterChanged);
|
|
@@ -156,8 +169,12 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 156 |
setState(() => _presenterScreen = ReaderScreen(cardId: result.cardId));
|
| 157 |
}
|
| 158 |
return result.cardId;
|
| 159 |
-
} catch (
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
}
|
| 163 |
|
|
@@ -165,6 +182,12 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 165 |
if (_presenter?.phase == PresenterPhase.done) {
|
| 166 |
final c = _presenter;
|
| 167 |
c?.removeListener(_onPresenterChanged);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
setState(() {
|
| 169 |
_presenter = null;
|
| 170 |
_presenterScreen = null;
|
|
@@ -195,15 +218,23 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 195 |
case 'search':
|
| 196 |
_presenterScreen = const SearchScreen();
|
| 197 |
case 'catalog':
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
case 'concepts':
|
| 200 |
-
_presenterScreen =
|
|
|
|
|
|
|
| 201 |
case 'connections':
|
| 202 |
_presenterScreen = const ConnectionsScreen();
|
| 203 |
case 'library':
|
| 204 |
default:
|
| 205 |
_presenterScreen = null;
|
| 206 |
_index = 0;
|
|
|
|
| 207 |
}
|
| 208 |
});
|
| 209 |
}
|
|
@@ -274,13 +305,23 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 274 |
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
| 275 |
floatingActionButton: selecting
|
| 276 |
? null
|
| 277 |
-
:
|
|
|
|
|
|
|
|
|
|
| 278 |
bottomNavigationBar: selecting
|
| 279 |
? _SelectionNavSlot(
|
| 280 |
vm: vm,
|
| 281 |
onConfirmDelete: () => _confirmBulkDelete(context, vm),
|
| 282 |
)
|
| 283 |
-
:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
);
|
| 285 |
}
|
| 286 |
|
|
@@ -329,10 +370,13 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 329 |
Positioned.fill(
|
| 330 |
child: Row(
|
| 331 |
children: [
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
| 336 |
),
|
| 337 |
Expanded(
|
| 338 |
child: Stack(
|
|
@@ -357,7 +401,10 @@ class _HomeShellState extends State<HomeShell> {
|
|
| 357 |
List<Widget> _presenterLayers(BuildContext context) {
|
| 358 |
if (_presenter != null) {
|
| 359 |
return [
|
| 360 |
-
PresenterSpotlight(
|
|
|
|
|
|
|
|
|
|
| 361 |
PresenterOverlay(controller: _presenter!),
|
| 362 |
];
|
| 363 |
}
|
|
@@ -517,10 +564,18 @@ class _GlassRail extends StatelessWidget {
|
|
| 517 |
// ββ Mobile floating pill nav βββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 518 |
|
| 519 |
class _GlassNav extends StatelessWidget {
|
| 520 |
-
const _GlassNav({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
final int index;
|
| 522 |
final ValueChanged<int> onSelect;
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
@override
|
| 525 |
Widget build(BuildContext context) {
|
| 526 |
final b = Theme.of(context).brightness;
|
|
@@ -562,10 +617,13 @@ class _GlassNav extends StatelessWidget {
|
|
| 562 |
children: [
|
| 563 |
for (var i = 0; i < _navItems.length; i++)
|
| 564 |
Expanded(
|
| 565 |
-
child:
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
|
|
|
|
|
|
|
|
|
| 569 |
),
|
| 570 |
),
|
| 571 |
],
|
|
|
|
| 13 |
|
| 14 |
import '../features/actions/views/actions_screen.dart';
|
| 15 |
import '../features/capture/views/capture_sheet.dart';
|
|
|
|
| 16 |
import '../features/collections/views/collections_screen.dart';
|
| 17 |
import '../features/concepts/views/concept_detail_screen.dart';
|
|
|
|
| 18 |
import '../features/feed/views/connections_screen.dart';
|
| 19 |
import '../features/feed/views/knowledge_feed_screen.dart';
|
| 20 |
import '../features/graph/views/graph_screen.dart';
|
|
|
|
| 87 |
PresenterController? _presenter;
|
| 88 |
Widget? _presenterScreen; // GraphScreen/SearchScreen shown during the tour
|
| 89 |
|
| 90 |
+
// Spotlight anchor for the nav (bottom pill / side rail β only one mounted).
|
| 91 |
+
final GlobalKey _navKey = GlobalKey();
|
| 92 |
+
|
| 93 |
+
// Per-tab anchors so the presenter's cursor lands on the exact tab it taps,
|
| 94 |
+
// and the plus button so "add something" reads as a real click.
|
| 95 |
+
static const _navSpotlightIds = ['nav.home', 'nav.folders', 'nav.todo', 'nav.feed', 'nav.you'];
|
| 96 |
+
final List<GlobalKey> _navItemKeys =
|
| 97 |
+
List.generate(_navSpotlightIds.length, (_) => GlobalKey());
|
| 98 |
+
final GlobalKey _plusKey = GlobalKey();
|
| 99 |
+
|
| 100 |
void _startPresenting() {
|
| 101 |
final repo = context.read<CardRepository>();
|
| 102 |
final bus = context.read<AgentBus>()
|
| 103 |
+
..registerSpotlight('nav', _navKey)
|
| 104 |
+
..registerSpotlight('home.plus', _plusKey)
|
| 105 |
..onNavigate = _goToView
|
| 106 |
..onOpenCard = _openCardInPresenter
|
| 107 |
..onCreateCard = _createCardInPresenter
|
|
|
|
| 109 |
..onOpenRabbitHole = _openRabbitHoleInPresenter
|
| 110 |
..onOpenCardChat = _openCardChatInPresenter
|
| 111 |
..onOpenLibraryChat = _openLibraryChatInPresenter;
|
| 112 |
+
for (var i = 0; i < _navSpotlightIds.length; i++) {
|
| 113 |
+
bus.registerSpotlight(_navSpotlightIds[i], _navItemKeys[i]);
|
| 114 |
+
}
|
| 115 |
final controller = PresenterController(repository: repo, bus: bus);
|
| 116 |
setState(() => _presenter = controller);
|
| 117 |
controller.addListener(_onPresenterChanged);
|
|
|
|
| 169 |
setState(() => _presenterScreen = ReaderScreen(cardId: result.cardId));
|
| 170 |
}
|
| 171 |
return result.cardId;
|
| 172 |
+
} catch (e, st) {
|
| 173 |
+
// Live demo: don't crash the tour if the pipeline is down, but the
|
| 174 |
+
// failure shouldn't vanish silently either.
|
| 175 |
+
debugPrint('[Presenter] create_card($url) failed: $e');
|
| 176 |
+
debugPrintStack(stackTrace: st, label: '[Presenter] create_card');
|
| 177 |
+
return null;
|
| 178 |
}
|
| 179 |
}
|
| 180 |
|
|
|
|
| 182 |
if (_presenter?.phase == PresenterPhase.done) {
|
| 183 |
final c = _presenter;
|
| 184 |
c?.removeListener(_onPresenterChanged);
|
| 185 |
+
final bus = context.read<AgentBus>()
|
| 186 |
+
..unregisterSpotlight('nav', _navKey)
|
| 187 |
+
..unregisterSpotlight('home.plus', _plusKey);
|
| 188 |
+
for (var i = 0; i < _navSpotlightIds.length; i++) {
|
| 189 |
+
bus.unregisterSpotlight(_navSpotlightIds[i], _navItemKeys[i]);
|
| 190 |
+
}
|
| 191 |
setState(() {
|
| 192 |
_presenter = null;
|
| 193 |
_presenterScreen = null;
|
|
|
|
| 218 |
case 'search':
|
| 219 |
_presenterScreen = const SearchScreen();
|
| 220 |
case 'catalog':
|
| 221 |
+
// Concepts and Catalog are the library's own top tabs β flip the real
|
| 222 |
+
// tab (via the hook the library registers) instead of overlaying a
|
| 223 |
+
// separate screen, so it reads as a tap on the segment.
|
| 224 |
+
_presenterScreen = null;
|
| 225 |
+
_index = 0;
|
| 226 |
+
context.read<AgentBus>().onLibraryTab?.call(2);
|
| 227 |
case 'concepts':
|
| 228 |
+
_presenterScreen = null;
|
| 229 |
+
_index = 0;
|
| 230 |
+
context.read<AgentBus>().onLibraryTab?.call(1);
|
| 231 |
case 'connections':
|
| 232 |
_presenterScreen = const ConnectionsScreen();
|
| 233 |
case 'library':
|
| 234 |
default:
|
| 235 |
_presenterScreen = null;
|
| 236 |
_index = 0;
|
| 237 |
+
context.read<AgentBus>().onLibraryTab?.call(0);
|
| 238 |
}
|
| 239 |
});
|
| 240 |
}
|
|
|
|
| 305 |
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
| 306 |
floatingActionButton: selecting
|
| 307 |
? null
|
| 308 |
+
: KeyedSubtree(
|
| 309 |
+
key: _plusKey,
|
| 310 |
+
child: _CaptureButton(onTap: () => showCaptureSheet(context)),
|
| 311 |
+
),
|
| 312 |
bottomNavigationBar: selecting
|
| 313 |
? _SelectionNavSlot(
|
| 314 |
vm: vm,
|
| 315 |
onConfirmDelete: () => _confirmBulkDelete(context, vm),
|
| 316 |
)
|
| 317 |
+
: KeyedSubtree(
|
| 318 |
+
key: _navKey,
|
| 319 |
+
child: _GlassNav(
|
| 320 |
+
index: _index,
|
| 321 |
+
onSelect: _select,
|
| 322 |
+
itemKeys: _navItemKeys,
|
| 323 |
+
),
|
| 324 |
+
),
|
| 325 |
);
|
| 326 |
}
|
| 327 |
|
|
|
|
| 370 |
Positioned.fill(
|
| 371 |
child: Row(
|
| 372 |
children: [
|
| 373 |
+
KeyedSubtree(
|
| 374 |
+
key: _navKey,
|
| 375 |
+
child: _GlassRail(
|
| 376 |
+
index: _index,
|
| 377 |
+
onSelect: _select,
|
| 378 |
+
onCapture: () => showCaptureSheet(context),
|
| 379 |
+
),
|
| 380 |
),
|
| 381 |
Expanded(
|
| 382 |
child: Stack(
|
|
|
|
| 401 |
List<Widget> _presenterLayers(BuildContext context) {
|
| 402 |
if (_presenter != null) {
|
| 403 |
return [
|
| 404 |
+
PresenterSpotlight(
|
| 405 |
+
controller: _presenter!,
|
| 406 |
+
bus: context.read<AgentBus>(),
|
| 407 |
+
),
|
| 408 |
PresenterOverlay(controller: _presenter!),
|
| 409 |
];
|
| 410 |
}
|
|
|
|
| 564 |
// ββ Mobile floating pill nav βββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 565 |
|
| 566 |
class _GlassNav extends StatelessWidget {
|
| 567 |
+
const _GlassNav({
|
| 568 |
+
required this.index,
|
| 569 |
+
required this.onSelect,
|
| 570 |
+
this.itemKeys = const [],
|
| 571 |
+
});
|
| 572 |
final int index;
|
| 573 |
final ValueChanged<int> onSelect;
|
| 574 |
|
| 575 |
+
/// Per-tab spotlight anchors so the presenter's cursor lands on the exact
|
| 576 |
+
/// tab it taps. Empty when not presenting.
|
| 577 |
+
final List<GlobalKey> itemKeys;
|
| 578 |
+
|
| 579 |
@override
|
| 580 |
Widget build(BuildContext context) {
|
| 581 |
final b = Theme.of(context).brightness;
|
|
|
|
| 617 |
children: [
|
| 618 |
for (var i = 0; i < _navItems.length; i++)
|
| 619 |
Expanded(
|
| 620 |
+
child: KeyedSubtree(
|
| 621 |
+
key: i < itemKeys.length ? itemKeys[i] : null,
|
| 622 |
+
child: _NavBtn(
|
| 623 |
+
def: _navItems[i],
|
| 624 |
+
selected: index == i,
|
| 625 |
+
onTap: () => onSelect(i),
|
| 626 |
+
),
|
| 627 |
),
|
| 628 |
),
|
| 629 |
],
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// A [ChangeNotifier] that silently ignores `notifyListeners()` once disposed.
|
| 2 |
+
///
|
| 3 |
+
/// View models kick off async work (a network turn, a bootstrap) and then
|
| 4 |
+
/// `notifyListeners()` when it resolves. If the screen is torn down first β
|
| 5 |
+
/// the user pops it, or Present mode navigates on before the request
|
| 6 |
+
/// finishes β that late notify hits a disposed notifier and throws
|
| 7 |
+
/// "used after being disposed". Guarding every call site is error-prone;
|
| 8 |
+
/// guarding the notifier once is not.
|
| 9 |
+
library;
|
| 10 |
+
|
| 11 |
+
import 'package:flutter/foundation.dart';
|
| 12 |
+
|
| 13 |
+
mixin SafeNotifier on ChangeNotifier {
|
| 14 |
+
bool _disposed = false;
|
| 15 |
+
|
| 16 |
+
/// Whether [dispose] has run β useful for bailing out of in-flight async
|
| 17 |
+
/// work before it touches other disposed state.
|
| 18 |
+
bool get disposed => _disposed;
|
| 19 |
+
|
| 20 |
+
@override
|
| 21 |
+
void dispose() {
|
| 22 |
+
_disposed = true;
|
| 23 |
+
super.dispose();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
@override
|
| 27 |
+
void notifyListeners() {
|
| 28 |
+
if (_disposed) return;
|
| 29 |
+
super.notifyListeners();
|
| 30 |
+
}
|
| 31 |
+
}
|
|
@@ -6,10 +6,11 @@ import 'package:flutter/foundation.dart';
|
|
| 6 |
|
| 7 |
import '../../../../data/repositories/card_repository.dart';
|
| 8 |
import '../../../../domain/models/concept.dart';
|
|
|
|
| 9 |
|
| 10 |
enum ConceptsStatus { idle, loading, ready, error, empty }
|
| 11 |
|
| 12 |
-
class ConceptsViewModel extends ChangeNotifier {
|
| 13 |
ConceptsViewModel({required CardRepository repository})
|
| 14 |
: _repository = repository {
|
| 15 |
_repository.addListener(_onRepoChange);
|
|
|
|
| 6 |
|
| 7 |
import '../../../../data/repositories/card_repository.dart';
|
| 8 |
import '../../../../domain/models/concept.dart';
|
| 9 |
+
import '../../../core/safe_notifier.dart';
|
| 10 |
|
| 11 |
enum ConceptsStatus { idle, loading, ready, error, empty }
|
| 12 |
|
| 13 |
+
class ConceptsViewModel extends ChangeNotifier with SafeNotifier {
|
| 14 |
ConceptsViewModel({required CardRepository repository})
|
| 15 |
: _repository = repository {
|
| 16 |
_repository.addListener(_onRepoChange);
|
|
@@ -59,8 +59,10 @@ class _FeedViewState extends State<_FeedView> {
|
|
| 59 |
int _page = 0;
|
| 60 |
|
| 61 |
// Agent driving: expose feed paging to the presenter agent while mounted.
|
|
|
|
| 62 |
AgentBus? _bus;
|
| 63 |
FeedAgentHooks? _hooks;
|
|
|
|
| 64 |
|
| 65 |
int get _total {
|
| 66 |
if (!mounted) return 0;
|
|
@@ -82,12 +84,14 @@ class _FeedViewState extends State<_FeedView> {
|
|
| 82 |
);
|
| 83 |
_hooks = hooks;
|
| 84 |
_bus!.attachFeed(hooks);
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
@override
|
| 88 |
void dispose() {
|
| 89 |
final h = _hooks;
|
| 90 |
if (h != null) _bus?.detachFeed(h);
|
|
|
|
| 91 |
_controller.dispose();
|
| 92 |
_focusNode.dispose();
|
| 93 |
super.dispose();
|
|
@@ -191,6 +195,7 @@ class _FeedViewState extends State<_FeedView> {
|
|
| 191 |
return ScrollConfiguration(
|
| 192 |
behavior: const _FeedScrollBehavior(),
|
| 193 |
child: PageView.builder(
|
|
|
|
| 194 |
controller: _controller,
|
| 195 |
scrollDirection: Axis.vertical,
|
| 196 |
itemCount: vm.items.length,
|
|
|
|
| 59 |
int _page = 0;
|
| 60 |
|
| 61 |
// Agent driving: expose feed paging to the presenter agent while mounted.
|
| 62 |
+
// The page key gives its spotlight the pager's real bounds.
|
| 63 |
AgentBus? _bus;
|
| 64 |
FeedAgentHooks? _hooks;
|
| 65 |
+
final _pageKey = GlobalKey();
|
| 66 |
|
| 67 |
int get _total {
|
| 68 |
if (!mounted) return 0;
|
|
|
|
| 84 |
);
|
| 85 |
_hooks = hooks;
|
| 86 |
_bus!.attachFeed(hooks);
|
| 87 |
+
_bus!.registerSpotlight('feed.page', _pageKey);
|
| 88 |
}
|
| 89 |
|
| 90 |
@override
|
| 91 |
void dispose() {
|
| 92 |
final h = _hooks;
|
| 93 |
if (h != null) _bus?.detachFeed(h);
|
| 94 |
+
_bus?.unregisterSpotlight('feed.page', _pageKey);
|
| 95 |
_controller.dispose();
|
| 96 |
_focusNode.dispose();
|
| 97 |
super.dispose();
|
|
|
|
| 195 |
return ScrollConfiguration(
|
| 196 |
behavior: const _FeedScrollBehavior(),
|
| 197 |
child: PageView.builder(
|
| 198 |
+
key: _pageKey,
|
| 199 |
controller: _controller,
|
| 200 |
scrollDirection: Axis.vertical,
|
| 201 |
itemCount: vm.items.length,
|
|
@@ -166,9 +166,11 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 166 |
bool _showConcepts = true;
|
| 167 |
|
| 168 |
// Agent driving: the presenter agent operates the graph through these hooks
|
| 169 |
-
// while this screen is mounted (see AgentBus).
|
|
|
|
| 170 |
AgentBus? _bus;
|
| 171 |
GraphAgentHooks? _hooks;
|
|
|
|
| 172 |
|
| 173 |
@override
|
| 174 |
void initState() {
|
|
@@ -193,12 +195,14 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 193 |
);
|
| 194 |
_hooks = hooks;
|
| 195 |
_bus!.attachGraph(hooks);
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
@override
|
| 199 |
void dispose() {
|
| 200 |
final h = _hooks;
|
| 201 |
if (h != null) _bus?.detachGraph(h);
|
|
|
|
| 202 |
_ticker.dispose();
|
| 203 |
super.dispose();
|
| 204 |
}
|
|
@@ -968,6 +972,7 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 968 |
final size =
|
| 969 |
Size(constraints.maxWidth, constraints.maxHeight);
|
| 970 |
return ClipRect(
|
|
|
|
| 971 |
child: GestureDetector(
|
| 972 |
onScaleStart: (d) => _onScaleStart(d, size),
|
| 973 |
onScaleUpdate: (d) => _onScaleUpdate(d, size),
|
|
|
|
| 166 |
bool _showConcepts = true;
|
| 167 |
|
| 168 |
// Agent driving: the presenter agent operates the graph through these hooks
|
| 169 |
+
// while this screen is mounted (see AgentBus). The canvas key gives its
|
| 170 |
+
// spotlight the real canvas bounds.
|
| 171 |
AgentBus? _bus;
|
| 172 |
GraphAgentHooks? _hooks;
|
| 173 |
+
final _canvasKey = GlobalKey();
|
| 174 |
|
| 175 |
@override
|
| 176 |
void initState() {
|
|
|
|
| 195 |
);
|
| 196 |
_hooks = hooks;
|
| 197 |
_bus!.attachGraph(hooks);
|
| 198 |
+
_bus!.registerSpotlight('graph.canvas', _canvasKey);
|
| 199 |
}
|
| 200 |
|
| 201 |
@override
|
| 202 |
void dispose() {
|
| 203 |
final h = _hooks;
|
| 204 |
if (h != null) _bus?.detachGraph(h);
|
| 205 |
+
_bus?.unregisterSpotlight('graph.canvas', _canvasKey);
|
| 206 |
_ticker.dispose();
|
| 207 |
super.dispose();
|
| 208 |
}
|
|
|
|
| 972 |
final size =
|
| 973 |
Size(constraints.maxWidth, constraints.maxHeight);
|
| 974 |
return ClipRect(
|
| 975 |
+
key: _canvasKey,
|
| 976 |
child: GestureDetector(
|
| 977 |
onScaleStart: (d) => _onScaleStart(d, size),
|
| 978 |
onScaleUpdate: (d) => _onScaleUpdate(d, size),
|
|
@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
|
|
| 7 |
|
| 8 |
import '../../../../data/repositories/card_repository.dart';
|
| 9 |
import '../../../../data/services/api_client.dart';
|
|
|
|
| 10 |
|
| 11 |
class LibraryChatMessage {
|
| 12 |
const LibraryChatMessage({required this.role, required this.content});
|
|
@@ -17,7 +18,7 @@ class LibraryChatMessage {
|
|
| 17 |
Map<String, String> toWire() => {'role': role, 'content': content};
|
| 18 |
}
|
| 19 |
|
| 20 |
-
class LibraryChatViewModel extends ChangeNotifier {
|
| 21 |
LibraryChatViewModel({required CardRepository repository})
|
| 22 |
: _repository = repository;
|
| 23 |
|
|
|
|
| 7 |
|
| 8 |
import '../../../../data/repositories/card_repository.dart';
|
| 9 |
import '../../../../data/services/api_client.dart';
|
| 10 |
+
import '../../../core/safe_notifier.dart';
|
| 11 |
|
| 12 |
class LibraryChatMessage {
|
| 13 |
const LibraryChatMessage({required this.role, required this.content});
|
|
|
|
| 18 |
Map<String, String> toWire() => {'role': role, 'content': content};
|
| 19 |
}
|
| 20 |
|
| 21 |
+
class LibraryChatViewModel extends ChangeNotifier with SafeNotifier {
|
| 22 |
LibraryChatViewModel({required CardRepository repository})
|
| 23 |
: _repository = repository;
|
| 24 |
|
|
@@ -9,10 +9,11 @@ import 'package:flutter/foundation.dart';
|
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
| 10 |
import '../../../../domain/models/card.dart';
|
| 11 |
import '../../../../domain/models/enums.dart';
|
|
|
|
| 12 |
|
| 13 |
enum LibraryStatus { idle, loading, ready, error, empty }
|
| 14 |
|
| 15 |
-
class LibraryViewModel extends ChangeNotifier {
|
| 16 |
LibraryViewModel({required CardRepository repository})
|
| 17 |
: _repository = repository {
|
| 18 |
_repository.addListener(_onRepoChange);
|
|
|
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
| 10 |
import '../../../../domain/models/card.dart';
|
| 11 |
import '../../../../domain/models/enums.dart';
|
| 12 |
+
import '../../../core/safe_notifier.dart';
|
| 13 |
|
| 14 |
enum LibraryStatus { idle, loading, ready, error, empty }
|
| 15 |
|
| 16 |
+
class LibraryViewModel extends ChangeNotifier with SafeNotifier {
|
| 17 |
LibraryViewModel({required CardRepository repository})
|
| 18 |
: _repository = repository {
|
| 19 |
_repository.addListener(_onRepoChange);
|
|
@@ -33,6 +33,7 @@ import '../../catalog/views/catalog_screen.dart';
|
|
| 33 |
import '../../concepts/views/concepts_screen.dart';
|
| 34 |
import '../../graph/views/graph_screen.dart';
|
| 35 |
import '../../library/views/library_chat_screen.dart';
|
|
|
|
| 36 |
import '../../reader/views/reader_screen.dart';
|
| 37 |
import '../../search/views/search_screen.dart';
|
| 38 |
import '../view_models/library_view_model.dart';
|
|
@@ -50,18 +51,62 @@ class LibraryScreen extends StatelessWidget {
|
|
| 50 |
}
|
| 51 |
}
|
| 52 |
|
| 53 |
-
class _LibraryView extends
|
| 54 |
const _LibraryView();
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
@override
|
| 57 |
Widget build(BuildContext context) {
|
| 58 |
final vm = context.watch<LibraryViewModel>();
|
| 59 |
final scheme = Theme.of(context).colorScheme;
|
| 60 |
final isDesktop = MediaQuery.sizeOf(context).width >= Insets.desktop;
|
| 61 |
|
| 62 |
-
return
|
| 63 |
-
length: 3,
|
| 64 |
-
child: Scaffold(
|
| 65 |
backgroundColor: Colors.transparent,
|
| 66 |
appBar: AppBar(
|
| 67 |
titleSpacing: Insets.page,
|
|
@@ -76,6 +121,7 @@ class _LibraryView extends StatelessWidget {
|
|
| 76 |
),
|
| 77 |
),
|
| 78 |
IconButton(
|
|
|
|
| 79 |
tooltip: 'Graph',
|
| 80 |
icon: const PhosphorIcon(PhosphorIconsRegular.graph),
|
| 81 |
onPressed: () => Navigator.of(context).push(
|
|
@@ -83,6 +129,7 @@ class _LibraryView extends StatelessWidget {
|
|
| 83 |
),
|
| 84 |
),
|
| 85 |
IconButton(
|
|
|
|
| 86 |
tooltip: 'Chat',
|
| 87 |
icon: const PhosphorIcon(PhosphorIconsRegular.chats),
|
| 88 |
onPressed: () => Navigator.of(context).push(
|
|
@@ -90,6 +137,7 @@ class _LibraryView extends StatelessWidget {
|
|
| 90 |
),
|
| 91 |
),
|
| 92 |
IconButton(
|
|
|
|
| 93 |
tooltip: 'Search',
|
| 94 |
icon: const PhosphorIcon(PhosphorIconsRegular.magnifyingGlass),
|
| 95 |
onPressed: () => Navigator.of(context).push(
|
|
@@ -113,6 +161,7 @@ class _LibraryView extends StatelessWidget {
|
|
| 113 |
borderRadius: BorderRadius.circular(12),
|
| 114 |
),
|
| 115 |
child: TabBar(
|
|
|
|
| 116 |
dividerColor: Colors.transparent,
|
| 117 |
indicator: BoxDecoration(
|
| 118 |
color: scheme.surface,
|
|
@@ -127,10 +176,10 @@ class _LibraryView extends StatelessWidget {
|
|
| 127 |
unselectedLabelColor: scheme.onSurfaceVariant,
|
| 128 |
labelStyle: Brand.label(size: 12, weight: FontWeight.w700),
|
| 129 |
unselectedLabelStyle: Brand.label(size: 12, weight: FontWeight.w500),
|
| 130 |
-
tabs:
|
| 131 |
-
Tab(text: 'CARDS'),
|
| 132 |
-
Tab(text: 'CONCEPTS'),
|
| 133 |
-
Tab(text: 'CATALOG'),
|
| 134 |
],
|
| 135 |
),
|
| 136 |
),
|
|
@@ -139,15 +188,15 @@ class _LibraryView extends StatelessWidget {
|
|
| 139 |
),
|
| 140 |
),
|
| 141 |
),
|
| 142 |
-
body:
|
| 143 |
-
|
|
|
|
| 144 |
_CardsTab(),
|
| 145 |
ConceptsScreen(),
|
| 146 |
CatalogScreen(),
|
| 147 |
],
|
| 148 |
),
|
| 149 |
-
)
|
| 150 |
-
);
|
| 151 |
}
|
| 152 |
}
|
| 153 |
|
|
@@ -167,12 +216,27 @@ class _CardsTabState extends State<_CardsTab> {
|
|
| 167 |
/// The grid index that most recently received keyboard focus.
|
| 168 |
int _lastFocusedIndex = 0;
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
@override
|
| 171 |
void initState() {
|
| 172 |
super.initState();
|
| 173 |
_fraction = context.read<AppController>().splitPaneFraction;
|
| 174 |
}
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
/// Grows or shrinks [_focusNodes] to exactly [count] nodes.
|
| 177 |
/// New nodes are equipped with a listener that updates [_lastFocusedIndex].
|
| 178 |
void _ensureFocusNodes(int count) {
|
|
@@ -192,6 +256,10 @@ class _CardsTabState extends State<_CardsTab> {
|
|
| 192 |
|
| 193 |
@override
|
| 194 |
void dispose() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
for (final node in _focusNodes) {
|
| 196 |
node.dispose();
|
| 197 |
}
|
|
@@ -426,6 +494,7 @@ class _CardsTabState extends State<_CardsTab> {
|
|
| 426 |
child: FocusTraversalGroup(
|
| 427 |
policy: OrderedTraversalPolicy(),
|
| 428 |
child: GridView.builder(
|
|
|
|
| 429 |
padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 96),
|
| 430 |
physics: const AlwaysScrollableScrollPhysics(),
|
| 431 |
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
@@ -444,7 +513,7 @@ class _CardsTabState extends State<_CardsTab> {
|
|
| 444 |
(Platform.isMacOS ||
|
| 445 |
Platform.isWindows ||
|
| 446 |
Platform.isLinux);
|
| 447 |
-
|
| 448 |
focusNode: _focusNodes[i],
|
| 449 |
card: card,
|
| 450 |
api: api,
|
|
@@ -493,6 +562,11 @@ class _CardsTabState extends State<_CardsTab> {
|
|
| 493 |
},
|
| 494 |
onDelete: () => vm.delete(card.cardId),
|
| 495 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
},
|
| 497 |
),
|
| 498 |
),
|
|
|
|
| 33 |
import '../../concepts/views/concepts_screen.dart';
|
| 34 |
import '../../graph/views/graph_screen.dart';
|
| 35 |
import '../../library/views/library_chat_screen.dart';
|
| 36 |
+
import '../../presenter/agent_bus.dart';
|
| 37 |
import '../../reader/views/reader_screen.dart';
|
| 38 |
import '../../search/views/search_screen.dart';
|
| 39 |
import '../view_models/library_view_model.dart';
|
|
|
|
| 51 |
}
|
| 52 |
}
|
| 53 |
|
| 54 |
+
class _LibraryView extends StatefulWidget {
|
| 55 |
const _LibraryView();
|
| 56 |
|
| 57 |
+
@override
|
| 58 |
+
State<_LibraryView> createState() => _LibraryViewState();
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
class _LibraryViewState extends State<_LibraryView>
|
| 62 |
+
with SingleTickerProviderStateMixin {
|
| 63 |
+
late final TabController _tabs = TabController(length: 3, vsync: this);
|
| 64 |
+
|
| 65 |
+
// Presenter spotlight anchors: the top segments and top-bar icons the agent
|
| 66 |
+
// taps live here. Registered while mounted; the presenter's cursor resolves
|
| 67 |
+
// them to real geometry.
|
| 68 |
+
final _conceptsTabKey = GlobalKey();
|
| 69 |
+
final _catalogTabKey = GlobalKey();
|
| 70 |
+
final _graphKey = GlobalKey();
|
| 71 |
+
final _chatKey = GlobalKey();
|
| 72 |
+
final _searchKey = GlobalKey();
|
| 73 |
+
AgentBus? _bus;
|
| 74 |
+
|
| 75 |
+
@override
|
| 76 |
+
void didChangeDependencies() {
|
| 77 |
+
super.didChangeDependencies();
|
| 78 |
+
if (_bus != null) return;
|
| 79 |
+
_bus = context.read<AgentBus>()
|
| 80 |
+
..onLibraryTab = (i) {
|
| 81 |
+
if (i >= 0 && i < _tabs.length) _tabs.animateTo(i);
|
| 82 |
+
}
|
| 83 |
+
..registerSpotlight('library.tab.concepts', _conceptsTabKey)
|
| 84 |
+
..registerSpotlight('library.tab.catalog', _catalogTabKey)
|
| 85 |
+
..registerSpotlight('top.graph', _graphKey)
|
| 86 |
+
..registerSpotlight('top.chat', _chatKey)
|
| 87 |
+
..registerSpotlight('top.search', _searchKey);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
@override
|
| 91 |
+
void dispose() {
|
| 92 |
+
_bus
|
| 93 |
+
?..onLibraryTab = null
|
| 94 |
+
..unregisterSpotlight('library.tab.concepts', _conceptsTabKey)
|
| 95 |
+
..unregisterSpotlight('library.tab.catalog', _catalogTabKey)
|
| 96 |
+
..unregisterSpotlight('top.graph', _graphKey)
|
| 97 |
+
..unregisterSpotlight('top.chat', _chatKey)
|
| 98 |
+
..unregisterSpotlight('top.search', _searchKey);
|
| 99 |
+
_tabs.dispose();
|
| 100 |
+
super.dispose();
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
@override
|
| 104 |
Widget build(BuildContext context) {
|
| 105 |
final vm = context.watch<LibraryViewModel>();
|
| 106 |
final scheme = Theme.of(context).colorScheme;
|
| 107 |
final isDesktop = MediaQuery.sizeOf(context).width >= Insets.desktop;
|
| 108 |
|
| 109 |
+
return Scaffold(
|
|
|
|
|
|
|
| 110 |
backgroundColor: Colors.transparent,
|
| 111 |
appBar: AppBar(
|
| 112 |
titleSpacing: Insets.page,
|
|
|
|
| 121 |
),
|
| 122 |
),
|
| 123 |
IconButton(
|
| 124 |
+
key: _graphKey,
|
| 125 |
tooltip: 'Graph',
|
| 126 |
icon: const PhosphorIcon(PhosphorIconsRegular.graph),
|
| 127 |
onPressed: () => Navigator.of(context).push(
|
|
|
|
| 129 |
),
|
| 130 |
),
|
| 131 |
IconButton(
|
| 132 |
+
key: _chatKey,
|
| 133 |
tooltip: 'Chat',
|
| 134 |
icon: const PhosphorIcon(PhosphorIconsRegular.chats),
|
| 135 |
onPressed: () => Navigator.of(context).push(
|
|
|
|
| 137 |
),
|
| 138 |
),
|
| 139 |
IconButton(
|
| 140 |
+
key: _searchKey,
|
| 141 |
tooltip: 'Search',
|
| 142 |
icon: const PhosphorIcon(PhosphorIconsRegular.magnifyingGlass),
|
| 143 |
onPressed: () => Navigator.of(context).push(
|
|
|
|
| 161 |
borderRadius: BorderRadius.circular(12),
|
| 162 |
),
|
| 163 |
child: TabBar(
|
| 164 |
+
controller: _tabs,
|
| 165 |
dividerColor: Colors.transparent,
|
| 166 |
indicator: BoxDecoration(
|
| 167 |
color: scheme.surface,
|
|
|
|
| 176 |
unselectedLabelColor: scheme.onSurfaceVariant,
|
| 177 |
labelStyle: Brand.label(size: 12, weight: FontWeight.w700),
|
| 178 |
unselectedLabelStyle: Brand.label(size: 12, weight: FontWeight.w500),
|
| 179 |
+
tabs: [
|
| 180 |
+
const Tab(text: 'CARDS'),
|
| 181 |
+
Tab(key: _conceptsTabKey, text: 'CONCEPTS'),
|
| 182 |
+
Tab(key: _catalogTabKey, text: 'CATALOG'),
|
| 183 |
],
|
| 184 |
),
|
| 185 |
),
|
|
|
|
| 188 |
),
|
| 189 |
),
|
| 190 |
),
|
| 191 |
+
body: TabBarView(
|
| 192 |
+
controller: _tabs,
|
| 193 |
+
children: const [
|
| 194 |
_CardsTab(),
|
| 195 |
ConceptsScreen(),
|
| 196 |
CatalogScreen(),
|
| 197 |
],
|
| 198 |
),
|
| 199 |
+
);
|
|
|
|
| 200 |
}
|
| 201 |
}
|
| 202 |
|
|
|
|
| 216 |
/// The grid index that most recently received keyboard focus.
|
| 217 |
int _lastFocusedIndex = 0;
|
| 218 |
|
| 219 |
+
// Presenter agent: expose the grid's scroll so the agent can browse the
|
| 220 |
+
// library while narrating.
|
| 221 |
+
final _gridScroll = ScrollController();
|
| 222 |
+
final _firstCardKey = GlobalKey();
|
| 223 |
+
AgentBus? _bus;
|
| 224 |
+
|
| 225 |
@override
|
| 226 |
void initState() {
|
| 227 |
super.initState();
|
| 228 |
_fraction = context.read<AppController>().splitPaneFraction;
|
| 229 |
}
|
| 230 |
|
| 231 |
+
@override
|
| 232 |
+
void didChangeDependencies() {
|
| 233 |
+
super.didChangeDependencies();
|
| 234 |
+
if (_bus != null) return;
|
| 235 |
+
_bus = context.read<AgentBus>()
|
| 236 |
+
..registerScrollable('library', _gridScroll)
|
| 237 |
+
..registerSpotlight('card.first', _firstCardKey);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
/// Grows or shrinks [_focusNodes] to exactly [count] nodes.
|
| 241 |
/// New nodes are equipped with a listener that updates [_lastFocusedIndex].
|
| 242 |
void _ensureFocusNodes(int count) {
|
|
|
|
| 256 |
|
| 257 |
@override
|
| 258 |
void dispose() {
|
| 259 |
+
_bus
|
| 260 |
+
?..unregisterScrollable('library', _gridScroll)
|
| 261 |
+
..unregisterSpotlight('card.first', _firstCardKey);
|
| 262 |
+
_gridScroll.dispose();
|
| 263 |
for (final node in _focusNodes) {
|
| 264 |
node.dispose();
|
| 265 |
}
|
|
|
|
| 494 |
child: FocusTraversalGroup(
|
| 495 |
policy: OrderedTraversalPolicy(),
|
| 496 |
child: GridView.builder(
|
| 497 |
+
controller: _gridScroll,
|
| 498 |
padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 96),
|
| 499 |
physics: const AlwaysScrollableScrollPhysics(),
|
| 500 |
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
|
|
| 513 |
(Platform.isMacOS ||
|
| 514 |
Platform.isWindows ||
|
| 515 |
Platform.isLinux);
|
| 516 |
+
final tile = CardTile(
|
| 517 |
focusNode: _focusNodes[i],
|
| 518 |
card: card,
|
| 519 |
api: api,
|
|
|
|
| 562 |
},
|
| 563 |
onDelete: () => vm.delete(card.cardId),
|
| 564 |
);
|
| 565 |
+
// Anchor the first tile so the presenter's cursor lands on a
|
| 566 |
+
// real card before opening it.
|
| 567 |
+
return i == 0
|
| 568 |
+
? KeyedSubtree(key: _firstCardKey, child: tile)
|
| 569 |
+
: tile;
|
| 570 |
},
|
| 571 |
),
|
| 572 |
),
|
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// The bridge that lets the presenting agent actually *operate* the app.
|
| 2 |
+
///
|
| 3 |
+
/// Cachy has no central controller registry β each feature keeps its live UI
|
| 4 |
+
/// state private inside its own `State`. Rather than rewire every screen into a
|
| 5 |
+
/// global controller, the agent talks to screens through this bus:
|
| 6 |
+
///
|
| 7 |
+
/// * the host shell registers [onNavigate] / [onOpenCard] / [onCreateCard] so
|
| 8 |
+
/// the agent can move between views and open cards, and
|
| 9 |
+
/// * interactive screens (graph, feed, search) register a small set of
|
| 10 |
+
/// imperative *hooks* while they're mounted, which the agent invokes to
|
| 11 |
+
/// drive them live (focus a graph node, advance the feed, run a search).
|
| 12 |
+
///
|
| 13 |
+
/// A screen attaches its hooks in `initState`/`didChangeDependencies` and
|
| 14 |
+
/// detaches them in `dispose`, so the agent only ever drives what's on screen.
|
| 15 |
+
library;
|
| 16 |
+
|
| 17 |
+
import 'package:flutter/widgets.dart';
|
| 18 |
+
|
| 19 |
+
import '../../../domain/models/graph.dart';
|
| 20 |
+
|
| 21 |
+
/// A single thing the agent can do: a verb ([type]) plus its arguments.
|
| 22 |
+
///
|
| 23 |
+
/// The vocabulary is shared with the backend `/presenter/ask` endpoint, which
|
| 24 |
+
/// returns these to answer audience questions by *performing* them.
|
| 25 |
+
@immutable
|
| 26 |
+
class AgentAction {
|
| 27 |
+
const AgentAction(this.type, [this.args = const {}]);
|
| 28 |
+
|
| 29 |
+
/// One of: navigate, create_card, search, open_card, graph_focus,
|
| 30 |
+
/// graph_open, graph_wander, graph_reset, feed_next, feed_prev, wait.
|
| 31 |
+
final String type;
|
| 32 |
+
final Map<String, dynamic> args;
|
| 33 |
+
|
| 34 |
+
String? get view => args['view'] as String?;
|
| 35 |
+
String? get query => args['query'] as String?;
|
| 36 |
+
String? get url => args['url'] as String?;
|
| 37 |
+
|
| 38 |
+
factory AgentAction.fromJson(Map<String, dynamic> json) => AgentAction(
|
| 39 |
+
(json['do'] as String?)?.trim() ?? 'none',
|
| 40 |
+
Map<String, dynamic>.from(json)..remove('do'),
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/// One beat of narration: optionally [say] something, optionally do [action].
|
| 45 |
+
/// Beats run in order; within a beat speech starts first and the action fires a
|
| 46 |
+
/// moment later *while* the agent is still talking β the way a human presenter
|
| 47 |
+
/// narrates over their own clicks.
|
| 48 |
+
@immutable
|
| 49 |
+
class AgentBeat {
|
| 50 |
+
const AgentBeat({this.say, this.action, this.view, this.focus});
|
| 51 |
+
|
| 52 |
+
final String? say;
|
| 53 |
+
final AgentAction? action;
|
| 54 |
+
|
| 55 |
+
/// Convenience for tour beats that just switch view before speaking.
|
| 56 |
+
final String? view;
|
| 57 |
+
|
| 58 |
+
/// Optional spotlight target id (see [AgentBus.spotlightRect]); when absent
|
| 59 |
+
/// the target is derived from the action type.
|
| 60 |
+
final String? focus;
|
| 61 |
+
|
| 62 |
+
factory AgentBeat.fromJson(Map<String, dynamic> json) {
|
| 63 |
+
final rawAction = json['action'];
|
| 64 |
+
return AgentBeat(
|
| 65 |
+
say: (json['say'] as String?)?.trim(),
|
| 66 |
+
action: rawAction is Map<String, dynamic>
|
| 67 |
+
? AgentAction.fromJson(rawAction)
|
| 68 |
+
: null,
|
| 69 |
+
);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/// Imperative controls a mounted [graph screen] exposes to the agent.
|
| 74 |
+
class GraphAgentHooks {
|
| 75 |
+
GraphAgentHooks({
|
| 76 |
+
required this.nodes,
|
| 77 |
+
required this.isReady,
|
| 78 |
+
required this.focus,
|
| 79 |
+
required this.open,
|
| 80 |
+
required this.wander,
|
| 81 |
+
required this.reset,
|
| 82 |
+
required this.filterCluster,
|
| 83 |
+
required this.toggleConcepts,
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
/// Snapshot of the currently loaded graph nodes (empty until loaded).
|
| 87 |
+
final List<GraphNode> Function() nodes;
|
| 88 |
+
|
| 89 |
+
/// True once graph data is loaded and the screen can be driven.
|
| 90 |
+
final bool Function() isReady;
|
| 91 |
+
|
| 92 |
+
/// Center + select a node and dive into its neighborhood (ego view).
|
| 93 |
+
final Future<void> Function(String nodeId) focus;
|
| 94 |
+
|
| 95 |
+
/// Open a node's underlying card/concept.
|
| 96 |
+
final Future<void> Function(String nodeId) open;
|
| 97 |
+
|
| 98 |
+
/// Reheat the physics + drift the view so the layout visibly comes alive.
|
| 99 |
+
final Future<void> Function() wander;
|
| 100 |
+
|
| 101 |
+
/// Exit ego/local mode and recenter.
|
| 102 |
+
final void Function() reset;
|
| 103 |
+
|
| 104 |
+
/// Apply a cluster filter so only one community stays visible.
|
| 105 |
+
final Future<void> Function() filterCluster;
|
| 106 |
+
|
| 107 |
+
/// Toggle the concept hub nodes on/off.
|
| 108 |
+
final Future<void> Function() toggleConcepts;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/// Imperative controls a mounted feed exposes to the agent.
|
| 112 |
+
class FeedAgentHooks {
|
| 113 |
+
FeedAgentHooks({
|
| 114 |
+
required this.next,
|
| 115 |
+
required this.prev,
|
| 116 |
+
required this.count,
|
| 117 |
+
required this.shuffle,
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
final void Function() next;
|
| 121 |
+
final void Function() prev;
|
| 122 |
+
final int Function() count;
|
| 123 |
+
|
| 124 |
+
/// Reshuffle the feed into a fresh set of moments.
|
| 125 |
+
final void Function() shuffle;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/// Imperative controls a mounted search screen exposes to the agent.
|
| 129 |
+
class SearchAgentHooks {
|
| 130 |
+
SearchAgentHooks({required this.run, required this.filter});
|
| 131 |
+
|
| 132 |
+
/// Type a query into the search box and run it.
|
| 133 |
+
final void Function(String query) run;
|
| 134 |
+
|
| 135 |
+
/// Apply the first available content-type filter to the current results.
|
| 136 |
+
final void Function() filter;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/// Imperative controls a mounted reader exposes to the agent, so it can show
|
| 140 |
+
/// that cards are interactive (checkable steps and checklists) live.
|
| 141 |
+
class ReaderAgentHooks {
|
| 142 |
+
ReaderAgentHooks({
|
| 143 |
+
required this.isReady,
|
| 144 |
+
required this.toggleCheck,
|
| 145 |
+
required this.toggleStep,
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
/// True once the card is loaded.
|
| 149 |
+
final bool Function() isReady;
|
| 150 |
+
|
| 151 |
+
/// Toggle the first checklist item; returns true if there was one.
|
| 152 |
+
final Future<bool> Function() toggleCheck;
|
| 153 |
+
|
| 154 |
+
/// Mark the first step complete; returns true if there was one.
|
| 155 |
+
final Future<bool> Function() toggleStep;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/// Shared channel between the [PresenterController] agent and the app.
|
| 159 |
+
///
|
| 160 |
+
/// Provided app-wide (see `main.dart`). The shell sets the navigation callbacks;
|
| 161 |
+
/// feature screens attach/detach their hooks as they mount/unmount.
|
| 162 |
+
class AgentBus extends ChangeNotifier {
|
| 163 |
+
// ββ Shell-provided navigation βββββββββββββββββββββββββββββββββββββββββββ //
|
| 164 |
+
|
| 165 |
+
/// Switch the visible view. Recognized: library, feed, graph, search,
|
| 166 |
+
/// collections, actions, profile, catalog, concepts, connections.
|
| 167 |
+
void Function(String view)? onNavigate;
|
| 168 |
+
|
| 169 |
+
/// Open a card in the reader (kept under the agent glyph, not pushed).
|
| 170 |
+
Future<void> Function(String cardId)? onOpenCard;
|
| 171 |
+
|
| 172 |
+
/// Submit a URL to the live pipeline and open the resulting card.
|
| 173 |
+
Future<String?> Function(String url)? onCreateCard;
|
| 174 |
+
|
| 175 |
+
/// Open a concept's detail (auto-defines if it has no definition yet).
|
| 176 |
+
Future<void> Function(String conceptId, String name)? onOpenConcept;
|
| 177 |
+
|
| 178 |
+
/// Open the rabbit-hole explorer for a card, seeded with a topic.
|
| 179 |
+
Future<void> Function(String cardId, String seed)? onOpenRabbitHole;
|
| 180 |
+
|
| 181 |
+
/// Open the grounded per-card chat, seeded with a question it auto-asks.
|
| 182 |
+
Future<void> Function(String cardId, String title, String seed)?
|
| 183 |
+
onOpenCardChat;
|
| 184 |
+
|
| 185 |
+
/// Open the whole-library chat, seeded with a question it auto-asks.
|
| 186 |
+
Future<void> Function(String seed)? onOpenLibraryChat;
|
| 187 |
+
|
| 188 |
+
/// Switch the library's top segment (0 = Cards, 1 = Concepts, 2 = Catalog).
|
| 189 |
+
/// Concepts and Catalog are tabs inside the library, not separate screens β
|
| 190 |
+
/// the agent flips the real tab so the switch reads as a tap on the tab, not
|
| 191 |
+
/// a full-screen swap.
|
| 192 |
+
void Function(int index)? onLibraryTab;
|
| 193 |
+
|
| 194 |
+
/// Open the first catalog item's detail and generate its info (the catalog
|
| 195 |
+
/// "Fetch info" demo). No-op if the catalog is empty.
|
| 196 |
+
Future<void> Function()? onOpenCatalogItem;
|
| 197 |
+
|
| 198 |
+
// ββ Screen-provided hooks (present only while that screen is mounted) ββββ //
|
| 199 |
+
|
| 200 |
+
GraphAgentHooks? graph;
|
| 201 |
+
FeedAgentHooks? feed;
|
| 202 |
+
SearchAgentHooks? search;
|
| 203 |
+
ReaderAgentHooks? reader;
|
| 204 |
+
|
| 205 |
+
void attachGraph(GraphAgentHooks hooks) => graph = hooks;
|
| 206 |
+
void detachGraph(GraphAgentHooks hooks) {
|
| 207 |
+
if (identical(graph, hooks)) graph = null;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
void attachFeed(FeedAgentHooks hooks) => feed = hooks;
|
| 211 |
+
void detachFeed(FeedAgentHooks hooks) {
|
| 212 |
+
if (identical(feed, hooks)) feed = null;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
void attachSearch(SearchAgentHooks hooks) => search = hooks;
|
| 216 |
+
void detachSearch(SearchAgentHooks hooks) {
|
| 217 |
+
if (identical(search, hooks)) search = null;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
void attachReader(ReaderAgentHooks hooks) => reader = hooks;
|
| 221 |
+
void detachReader(ReaderAgentHooks hooks) {
|
| 222 |
+
if (identical(reader, hooks)) reader = null;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// ββ Spotlight target registry βββββββββββββββββββββββββββββββββββββββββββ //
|
| 226 |
+
//
|
| 227 |
+
// Screens tag the widget the agent acts on (search field, graph canvas, nav
|
| 228 |
+
// barβ¦) with a GlobalKey and register it here while mounted. The spotlight
|
| 229 |
+
// layer resolves live geometry from the key, so the guided-focus hole hugs
|
| 230 |
+
// the real widget instead of a guessed screen region.
|
| 231 |
+
|
| 232 |
+
final Map<String, GlobalKey> _spotlightKeys = {};
|
| 233 |
+
|
| 234 |
+
void registerSpotlight(String id, GlobalKey key) => _spotlightKeys[id] = key;
|
| 235 |
+
|
| 236 |
+
void unregisterSpotlight(String id, GlobalKey key) {
|
| 237 |
+
if (identical(_spotlightKeys[id], key)) _spotlightKeys.remove(id);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/// Current global-coordinate bounds of a registered target, or null while it
|
| 241 |
+
/// isn't mounted / laid out.
|
| 242 |
+
///
|
| 243 |
+
/// A GlobalKey can still resolve a `currentContext` for an element that has
|
| 244 |
+
/// been deactivated (mid screen-swap during Present mode) β calling
|
| 245 |
+
/// `findRenderObject()` on it throws "Cannot get renderObject of inactive
|
| 246 |
+
/// element". Guard on `context.mounted` and catch defensively so a
|
| 247 |
+
/// transitioning target just reads as "not visible yet" instead of crashing
|
| 248 |
+
/// the frame.
|
| 249 |
+
Rect? spotlightRect(String id) {
|
| 250 |
+
final context = _spotlightKeys[id]?.currentContext;
|
| 251 |
+
if (context == null || !context.mounted) return null;
|
| 252 |
+
try {
|
| 253 |
+
final box = context.findRenderObject();
|
| 254 |
+
if (box is! RenderBox || !box.attached || !box.hasSize) return null;
|
| 255 |
+
return box.localToGlobal(Offset.zero) & box.size;
|
| 256 |
+
} catch (_) {
|
| 257 |
+
return null;
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// ββ Scrollable registry βββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 262 |
+
//
|
| 263 |
+
// Screens with a meaningful scroll surface (library grid, reader body)
|
| 264 |
+
// register their ScrollController here while mounted, so the agent can
|
| 265 |
+
// scroll the app the way a person would while presenting.
|
| 266 |
+
|
| 267 |
+
final Map<String, ScrollController> _scrollables = {};
|
| 268 |
+
|
| 269 |
+
void registerScrollable(String id, ScrollController controller) =>
|
| 270 |
+
_scrollables[id] = controller;
|
| 271 |
+
|
| 272 |
+
void unregisterScrollable(String id, ScrollController controller) {
|
| 273 |
+
if (identical(_scrollables[id], controller)) _scrollables.remove(id);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
/// Smoothly scroll a registered surface by [dy] pixels (negative = up),
|
| 277 |
+
/// clamped to its extent. No-op while the surface isn't mounted.
|
| 278 |
+
Future<void> scrollBy(String id, double dy) async {
|
| 279 |
+
final c = _scrollables[id];
|
| 280 |
+
if (c == null || !c.hasClients) return;
|
| 281 |
+
final target =
|
| 282 |
+
(c.offset + dy).clamp(0.0, c.position.maxScrollExtent);
|
| 283 |
+
await c.animateTo(
|
| 284 |
+
target,
|
| 285 |
+
duration: const Duration(milliseconds: 900),
|
| 286 |
+
curve: Curves.easeInOutCubic,
|
| 287 |
+
);
|
| 288 |
+
}
|
| 289 |
+
}
|
|
@@ -0,0 +1,1102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// Drives the in-app "Present" mode: a self-navigating, spoken tour that
|
| 2 |
+
/// actually *operates* Cachy live β creating a card, running a search, scrolling
|
| 3 |
+
/// the feed, driving the knowledge graph β while narrating. The audience (or the
|
| 4 |
+
/// presenter) can tap the agent glyph any time to ask a question or hand it a
|
| 5 |
+
/// task; the agent stops the tour, performs it for real, asks if anything else
|
| 6 |
+
/// is needed, and then resumes the tour where it left off.
|
| 7 |
+
///
|
| 8 |
+
/// Speech uses the browser's Web Speech API via `flutter_tts` (free, any OS, no
|
| 9 |
+
/// install). Question answering runs server-side (`/presenter/ask`), which
|
| 10 |
+
/// returns a sequence of [AgentBeat]s (say + do) so answers are *performed*, not
|
| 11 |
+
/// just spoken. Everything the agent can do goes through the [AgentBus].
|
| 12 |
+
library;
|
| 13 |
+
|
| 14 |
+
import 'dart:async';
|
| 15 |
+
|
| 16 |
+
import 'package:flutter/foundation.dart';
|
| 17 |
+
import 'package:flutter_tts/flutter_tts.dart';
|
| 18 |
+
|
| 19 |
+
import '../../../data/repositories/card_repository.dart';
|
| 20 |
+
import '../../../domain/models/card.dart' as model;
|
| 21 |
+
import '../../../domain/models/concept.dart';
|
| 22 |
+
import '../../../domain/models/graph.dart';
|
| 23 |
+
import 'agent_bus.dart';
|
| 24 |
+
|
| 25 |
+
enum PresenterPhase { idle, speaking, thinking, acting, done }
|
| 26 |
+
|
| 27 |
+
/// The scripted tour. Each beat may switch [view], [say] a line, and/or run an
|
| 28 |
+
/// [action]; speech starts first and the action fires while the agent is still
|
| 29 |
+
/// talking, the way a human narrates over their own clicks. Lines are written
|
| 30 |
+
/// to be *spoken* β contractions, transitions, reactions β and avoid acronyms
|
| 31 |
+
/// so both the voice and the on-screen caption read naturally.
|
| 32 |
+
const List<AgentBeat> kTour = [
|
| 33 |
+
// ββ 1. Home ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
AgentBeat(
|
| 35 |
+
view: 'library',
|
| 36 |
+
say: "Hey! I'm Cachy's presenter, and instead of a slide deck I'll just "
|
| 37 |
+
"drive the app for real. This is live β tap me any time, ask anything, "
|
| 38 |
+
"and I'll show you.",
|
| 39 |
+
),
|
| 40 |
+
AgentBeat(
|
| 41 |
+
focus: 'content',
|
| 42 |
+
say: "So this is home. Every reel, short, or article you share into Cachy "
|
| 43 |
+
"comes back as a knowledge card. Let me scroll down through the shelf "
|
| 44 |
+
"so you can see the collection... and back up to the top.",
|
| 45 |
+
action: AgentAction('scroll', {'target': 'library', 'dy': 720, 'sweep': true}),
|
| 46 |
+
),
|
| 47 |
+
|
| 48 |
+
// ββ 2. Open the first card β the reader ββββββββββββββββββββββββββββββββββ
|
| 49 |
+
AgentBeat(
|
| 50 |
+
focus: 'card.first',
|
| 51 |
+
say: "Let me open this first card so you can see what's actually inside "
|
| 52 |
+
"one.",
|
| 53 |
+
action: AgentAction('open_card', {'query': 'auto'}),
|
| 54 |
+
),
|
| 55 |
+
AgentBeat(
|
| 56 |
+
focus: 'reader.blocks',
|
| 57 |
+
say: "This is the good part. From any source online, Cachy pulls out proper "
|
| 58 |
+
"structured notes β a one-liner, a summary, then clean blocks: steps, "
|
| 59 |
+
"key facts, callouts. Let me scroll through them.",
|
| 60 |
+
action: AgentAction('scroll', {'target': 'reader', 'dy': 640, 'sweep': true}),
|
| 61 |
+
),
|
| 62 |
+
AgentBeat(
|
| 63 |
+
focus: 'reader.insight',
|
| 64 |
+
say: "Further down there's a 'Going deeper' section β that's the AI adding "
|
| 65 |
+
"the analysis the source only hinted at.",
|
| 66 |
+
action: AgentAction('point'),
|
| 67 |
+
),
|
| 68 |
+
AgentBeat(
|
| 69 |
+
focus: 'reader.references',
|
| 70 |
+
say: "Then references β books, films, products the card mentions. Here's "
|
| 71 |
+
"the trick: press and hold any reference and it's saved straight to "
|
| 72 |
+
"your catalog.",
|
| 73 |
+
action: AgentAction('point'),
|
| 74 |
+
),
|
| 75 |
+
AgentBeat(
|
| 76 |
+
focus: 'reader.ask',
|
| 77 |
+
say: "Down on the bottom strip: Ask lets you chat with just this card, and "
|
| 78 |
+
"next to it is whatever action fits the content β share it, turn it "
|
| 79 |
+
"into a shopping list, drop it on a map.",
|
| 80 |
+
action: AgentAction('point'),
|
| 81 |
+
),
|
| 82 |
+
AgentBeat(
|
| 83 |
+
focus: 'reader.more',
|
| 84 |
+
say: "And the three dots hide the rest β copy, share, shopping list, and "
|
| 85 |
+
"more.",
|
| 86 |
+
action: AgentAction('point'),
|
| 87 |
+
),
|
| 88 |
+
AgentBeat(
|
| 89 |
+
focus: 'reader.insight',
|
| 90 |
+
say: "One more from in here β 'Dive deeper' opens a rabbit hole, pulling a "
|
| 91 |
+
"thread from the card out into wider knowledge. Watch.",
|
| 92 |
+
action: AgentAction('rabbit_hole', {'query': 'auto'}),
|
| 93 |
+
),
|
| 94 |
+
|
| 95 |
+
// ββ 3. Concepts (top segment) ββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
AgentBeat(
|
| 97 |
+
view: 'concepts',
|
| 98 |
+
focus: 'content',
|
| 99 |
+
say: "Back up top there are three segments. This one's Concepts β shared "
|
| 100 |
+
"ideas Cachy mines across your cards and stitches together. Let me "
|
| 101 |
+
"open one and define it on the spot.",
|
| 102 |
+
action: AgentAction('define_concept', {'query': 'auto'}),
|
| 103 |
+
),
|
| 104 |
+
|
| 105 |
+
// ββ 4. Catalog (top segment) βββββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
+
AgentBeat(
|
| 107 |
+
view: 'catalog',
|
| 108 |
+
focus: 'catalog.item',
|
| 109 |
+
say: "Next segment: the catalog. Remember those references you can hold to "
|
| 110 |
+
"save? They land here. Tap one and the AI fills in everything it can "
|
| 111 |
+
"find about it.",
|
| 112 |
+
action: AgentAction('catalog_item'),
|
| 113 |
+
),
|
| 114 |
+
|
| 115 |
+
// ββ 5. Nav bar, one by one βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 116 |
+
AgentBeat(
|
| 117 |
+
view: 'collections',
|
| 118 |
+
focus: 'folders.create',
|
| 119 |
+
say: "Now the bottom bar. Folders β your cards get auto-sorted in here, and "
|
| 120 |
+
"you can make your own. Let me create one.",
|
| 121 |
+
action: AgentAction('create_collection', {'name': 'Demo Folder'}),
|
| 122 |
+
),
|
| 123 |
+
AgentBeat(
|
| 124 |
+
focus: 'content',
|
| 125 |
+
say: "...and drop a card into it. Done.",
|
| 126 |
+
action: AgentAction('move_card'),
|
| 127 |
+
),
|
| 128 |
+
AgentBeat(
|
| 129 |
+
view: 'actions',
|
| 130 |
+
focus: 'todo.item',
|
| 131 |
+
say: "To-do. When you mark something as 'track in actions' inside a card, "
|
| 132 |
+
"its tasks show up here. Let me follow one in.",
|
| 133 |
+
action: AgentAction('follow_actions'),
|
| 134 |
+
),
|
| 135 |
+
AgentBeat(
|
| 136 |
+
focus: 'todo.item',
|
| 137 |
+
say: "Tap an item and it expands to every task from that one note β and "
|
| 138 |
+
"Cachy keeps nudging you until they're done.",
|
| 139 |
+
action: AgentAction('toggle_action_item'),
|
| 140 |
+
),
|
| 141 |
+
AgentBeat(
|
| 142 |
+
view: 'feed',
|
| 143 |
+
focus: 'feed.page',
|
| 144 |
+
say: "Feed. Think of it like your Instagram feed, but it's your own saved "
|
| 145 |
+
"knowledge β one-liners you can flick through. Let me scroll a few.",
|
| 146 |
+
action: AgentAction('feed_next'),
|
| 147 |
+
),
|
| 148 |
+
AgentBeat(
|
| 149 |
+
focus: 'feed.page',
|
| 150 |
+
say: "Keep going, or shuffle for a fresh set.",
|
| 151 |
+
action: AgentAction('feed_shuffle'),
|
| 152 |
+
),
|
| 153 |
+
AgentBeat(
|
| 154 |
+
view: 'profile',
|
| 155 |
+
focus: 'content',
|
| 156 |
+
say: "Last tab is You β settings and your library stats. Nothing fancy, "
|
| 157 |
+
"it's just home base.",
|
| 158 |
+
action: AgentAction('point'),
|
| 159 |
+
),
|
| 160 |
+
|
| 161 |
+
// ββ 6. Top icons: Graph, Chat ββββββββββββββββββββββββββββββββββββββββββββ
|
| 162 |
+
AgentBeat(
|
| 163 |
+
view: 'library',
|
| 164 |
+
say: "Back home for a second β up top there are two more.",
|
| 165 |
+
),
|
| 166 |
+
AgentBeat(
|
| 167 |
+
focus: 'top.graph',
|
| 168 |
+
say: "The graph. Every card and concept is a node, linked by meaning. Watch "
|
| 169 |
+
"the connections β and let me pull into one node's neighborhood.",
|
| 170 |
+
action: AgentAction('graph_wander'),
|
| 171 |
+
),
|
| 172 |
+
AgentBeat(
|
| 173 |
+
focus: 'graph.canvas',
|
| 174 |
+
action: AgentAction('graph_focus', {'query': 'auto'}),
|
| 175 |
+
),
|
| 176 |
+
AgentBeat(
|
| 177 |
+
view: 'library',
|
| 178 |
+
say: "And the chat.",
|
| 179 |
+
),
|
| 180 |
+
AgentBeat(
|
| 181 |
+
focus: 'top.chat',
|
| 182 |
+
say: "This one talks to your whole library at once β ask anything across "
|
| 183 |
+
"everything you've saved and it answers from your own notes.",
|
| 184 |
+
action: AgentAction('library_chat', {
|
| 185 |
+
'query': 'What are the main themes across everything saved here?',
|
| 186 |
+
}),
|
| 187 |
+
),
|
| 188 |
+
|
| 189 |
+
// ββ 7. Adding something to Cachy βββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
AgentBeat(
|
| 191 |
+
view: 'library',
|
| 192 |
+
say: "Last thing β let's add something new, live.",
|
| 193 |
+
),
|
| 194 |
+
AgentBeat(
|
| 195 |
+
focus: 'home.plus',
|
| 196 |
+
say: "I'll hit the plus button, paste in a link, and Cachy runs the whole "
|
| 197 |
+
"pipeline β fetching the page, pulling the text, and shaping it into a "
|
| 198 |
+
"card while we watch.",
|
| 199 |
+
action: AgentAction('create_card', {
|
| 200 |
+
'url': 'https://en.wikipedia.org/wiki/Spaced_repetition',
|
| 201 |
+
}),
|
| 202 |
+
),
|
| 203 |
+
AgentBeat(
|
| 204 |
+
focus: 'reader.blocks',
|
| 205 |
+
say: "And there it is β a brand-new card, built from nothing. That's Cachy. "
|
| 206 |
+
"I'm not going anywhere though β tap me, ask me anything, or hand me a "
|
| 207 |
+
"task.",
|
| 208 |
+
action: AgentAction('scroll', {'target': 'reader', 'dy': 560, 'sweep': true}),
|
| 209 |
+
),
|
| 210 |
+
];
|
| 211 |
+
|
| 212 |
+
class PresenterController extends ChangeNotifier {
|
| 213 |
+
PresenterController({required CardRepository repository, required AgentBus bus})
|
| 214 |
+
: _repo = repository,
|
| 215 |
+
_bus = bus {
|
| 216 |
+
_initTts();
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
final CardRepository _repo;
|
| 220 |
+
final AgentBus _bus;
|
| 221 |
+
final FlutterTts _tts = FlutterTts();
|
| 222 |
+
|
| 223 |
+
PresenterPhase phase = PresenterPhase.idle;
|
| 224 |
+
|
| 225 |
+
/// The line currently being spoken / last spoken β shown as an on-screen
|
| 226 |
+
/// caption beside the glyph.
|
| 227 |
+
String caption = '';
|
| 228 |
+
|
| 229 |
+
/// Spotlight target the agent is currently presenting: a widget id
|
| 230 |
+
/// registered on the [AgentBus] (e.g. 'search.field', 'graph.canvas'),
|
| 231 |
+
/// a pseudo-region ('content', 'nav') the spotlight resolves to a coarse
|
| 232 |
+
/// rect, or null for no spotlight. Set for the *whole* beat β narration and
|
| 233 |
+
/// action β so the light behaves like a tour guide's pointer, gliding from
|
| 234 |
+
/// target to target instead of flashing per action.
|
| 235 |
+
String? focusId;
|
| 236 |
+
|
| 237 |
+
/// True for the brief moment the agent "taps" the focused target β the
|
| 238 |
+
/// overlay renders a tap ripple on the cursor. Every screen change / effect
|
| 239 |
+
/// is preceded by one of these so the audience sees the click that caused it.
|
| 240 |
+
bool tapping = false;
|
| 241 |
+
|
| 242 |
+
bool _active = false;
|
| 243 |
+
int _tourIndex = 0;
|
| 244 |
+
final List<String> _questionQueue = [];
|
| 245 |
+
|
| 246 |
+
// Cached library snapshot for resolving fuzzy targets ("open the coffee card").
|
| 247 |
+
List<model.Card>? _cards;
|
| 248 |
+
List<ConceptEntry>? _conceptCache;
|
| 249 |
+
|
| 250 |
+
// The card the agent is currently demonstrating (opened/created), so reader,
|
| 251 |
+
// chat, and rabbit-hole actions act on a coherent subject.
|
| 252 |
+
String? _currentCardId;
|
| 253 |
+
|
| 254 |
+
// The collection the agent most recently created, so it can move a card into
|
| 255 |
+
// it on the next beat.
|
| 256 |
+
String? _lastCollectionId;
|
| 257 |
+
|
| 258 |
+
// A generation token distinguishes a natural utterance end from one we cut
|
| 259 |
+
// short (to handle a barge-in). Each utterance bumps it; the awaiter checks
|
| 260 |
+
// whether its token still matches when the completer resolves.
|
| 261 |
+
int _gen = 0;
|
| 262 |
+
Completer<void>? _speakCompleter;
|
| 263 |
+
|
| 264 |
+
bool get isActive => _active;
|
| 265 |
+
|
| 266 |
+
bool _voicePicked = false;
|
| 267 |
+
|
| 268 |
+
Future<void> _initTts() async {
|
| 269 |
+
_tts.setCompletionHandler(_finishUtterance);
|
| 270 |
+
_tts.setCancelHandler(_finishUtterance);
|
| 271 |
+
_tts.setErrorHandler((_) => _finishUtterance());
|
| 272 |
+
try {
|
| 273 |
+
// Rate scale differs by engine: the browser's Web Speech API treats 1.0
|
| 274 |
+
// as normal speed (0.5 is half-speed β that's what felt sluggish), while
|
| 275 |
+
// the native mobile engines treat ~0.5 as normal.
|
| 276 |
+
await _tts.setSpeechRate(kIsWeb ? 1.0 : 0.52);
|
| 277 |
+
await _tts.setPitch(1.0);
|
| 278 |
+
await _tts.setVolume(1.0);
|
| 279 |
+
await _selectNaturalVoice();
|
| 280 |
+
} catch (e, st) {
|
| 281 |
+
// Voice params are best-effort; speech still works with defaults.
|
| 282 |
+
_logError('TTS init', e, st);
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/// Pick the most natural-sounding English voice the platform offers, instead
|
| 287 |
+
/// of the robotic default. Web voices load asynchronously, so this is retried
|
| 288 |
+
/// before the first utterance (see [_speak]).
|
| 289 |
+
Future<void> _selectNaturalVoice() async {
|
| 290 |
+
try {
|
| 291 |
+
final raw = await _tts.getVoices;
|
| 292 |
+
if (raw is! List) return;
|
| 293 |
+
final voices = raw
|
| 294 |
+
.whereType<Map>()
|
| 295 |
+
.map((v) => v.map((k, val) => MapEntry('$k', '$val')))
|
| 296 |
+
.toList();
|
| 297 |
+
if (voices.isEmpty) return; // not loaded yet β try again next utterance
|
| 298 |
+
final english = voices
|
| 299 |
+
.where((v) => (v['locale'] ?? '').toLowerCase().startsWith('en'))
|
| 300 |
+
.toList();
|
| 301 |
+
final pool = english.isNotEmpty ? english : voices;
|
| 302 |
+
|
| 303 |
+
int score(Map<String, String> v) {
|
| 304 |
+
final name = (v['name'] ?? '').toLowerCase();
|
| 305 |
+
if (name.contains('natural')) return 6; // Edge/Azure "β¦ Natural"
|
| 306 |
+
if (name.contains('google')) return 5; // Chrome's higher-quality set
|
| 307 |
+
if (name.contains('samantha') || name.contains('ava')) return 4; // Apple
|
| 308 |
+
if (name.contains('aria') || name.contains('jenny')) return 4; // MS
|
| 309 |
+
if (name.contains('female')) return 2;
|
| 310 |
+
return 1;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
pool.sort((a, b) => score(b).compareTo(score(a)));
|
| 314 |
+
final best = pool.first;
|
| 315 |
+
await _tts.setVoice({
|
| 316 |
+
'name': best['name'] ?? '',
|
| 317 |
+
'locale': best['locale'] ?? 'en-US',
|
| 318 |
+
});
|
| 319 |
+
_voicePicked = true;
|
| 320 |
+
} catch (e, st) {
|
| 321 |
+
// Default voice is fine if selection isn't supported.
|
| 322 |
+
_logError('voice selection', e, st);
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
void _setPhase(PresenterPhase p, {String? caption}) {
|
| 327 |
+
phase = p;
|
| 328 |
+
if (caption != null) this.caption = caption;
|
| 329 |
+
notifyListeners();
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/// Log a presenter error into the backend's pipeline console (`start.py`'s
|
| 333 |
+
/// terminal) β errors during Present mode are largely swallowed so one bad
|
| 334 |
+
/// action/network call doesn't kill a live demo, but that means they'd
|
| 335 |
+
/// otherwise vanish silently. Relayed to `/presenter/log` because
|
| 336 |
+
/// `debugPrint` alone depends on the browser's DWDS forwarding actually
|
| 337 |
+
/// reaching the terminal, which isn't reliable; also mirrored to
|
| 338 |
+
/// `debugPrint` for anyone watching the browser console instead.
|
| 339 |
+
void _logError(String where, Object error, [StackTrace? st]) {
|
| 340 |
+
final line = '$where failed: $error';
|
| 341 |
+
debugPrint('[Presenter] $line');
|
| 342 |
+
if (st != null) debugPrintStack(stackTrace: st, label: '[Presenter] $where');
|
| 343 |
+
_repo.api.presenterLog(line, level: 'error');
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
/// Trace an action the agent is about to take (navigate, run a search,
|
| 347 |
+
/// scroll, open a cardβ¦) into the same pipeline console β so the log reads
|
| 348 |
+
/// as a live transcript of the demo, not just a silence broken by errors.
|
| 349 |
+
void _logInfo(String message) {
|
| 350 |
+
debugPrint('[Presenter] $message');
|
| 351 |
+
_repo.api.presenterLog(message);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/// A component the agent expected to act on never showed up in time (screen
|
| 355 |
+
/// didn't mount, data never loaded) β the action just gets skipped rather
|
| 356 |
+
/// than throwing, so this is the only trace that it happened.
|
| 357 |
+
void _logWarn(String message) {
|
| 358 |
+
debugPrint('[Presenter] $message');
|
| 359 |
+
_repo.api.presenterLog(message, level: 'warning');
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
/// Run [fn], logging and swallowing any error under [label] instead of
|
| 363 |
+
/// letting it escape β used to tag individual screen-hook calls (graph,
|
| 364 |
+
/// feed, search, reader, the shell's onOpen* callbacks) so a failure names
|
| 365 |
+
/// exactly which hook broke, not just the outer action type.
|
| 366 |
+
Future<void> _try(String label, FutureOr<void> Function() fn) async {
|
| 367 |
+
try {
|
| 368 |
+
await fn();
|
| 369 |
+
} catch (e, st) {
|
| 370 |
+
_logError(label, e, st);
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
void _finishUtterance() {
|
| 375 |
+
final c = _speakCompleter;
|
| 376 |
+
if (c != null && !c.isCompleted) c.complete();
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/// Speak [text]; resolve true if it finished naturally, false if superseded by
|
| 380 |
+
/// a barge-in.
|
| 381 |
+
Future<bool> _speak(String text) async {
|
| 382 |
+
if (text.trim().isEmpty) return true;
|
| 383 |
+
if (!_voicePicked) await _selectNaturalVoice(); // web voices load late
|
| 384 |
+
final myGen = ++_gen;
|
| 385 |
+
_setPhase(PresenterPhase.speaking, caption: text);
|
| 386 |
+
final completer = Completer<void>();
|
| 387 |
+
_speakCompleter = completer;
|
| 388 |
+
try {
|
| 389 |
+
await _tts.speak(text);
|
| 390 |
+
} catch (e, st) {
|
| 391 |
+
// Web without a user gesture, or an unavailable engine, can throw here β
|
| 392 |
+
// don't let it break the beat; treat the utterance as finished.
|
| 393 |
+
_logError('tts.speak', e, st);
|
| 394 |
+
_finishUtterance();
|
| 395 |
+
}
|
| 396 |
+
// Watchdog: on web the browser sometimes never fires the "end" event, which
|
| 397 |
+
// would hang the whole tour on this completer forever. Cap the wait at a
|
| 398 |
+
// generous estimate of how long the line takes to read so the beat always
|
| 399 |
+
// advances.
|
| 400 |
+
final words = text.trim().split(RegExp(r'\s+')).length;
|
| 401 |
+
final capMs = (words * 420 + 2500).clamp(4000, 30000);
|
| 402 |
+
await Future.any<void>([
|
| 403 |
+
completer.future,
|
| 404 |
+
Future<void>.delayed(Duration(milliseconds: capMs)),
|
| 405 |
+
]);
|
| 406 |
+
return _gen == myGen && _active;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
Future<void> _stopSpeaking() async {
|
| 410 |
+
_gen++; // supersede whatever is (or was) speaking
|
| 411 |
+
await _tts.stop();
|
| 412 |
+
_finishUtterance();
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
// ββ Public control ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 416 |
+
|
| 417 |
+
/// Begin the tour from the top.
|
| 418 |
+
Future<void> start() async {
|
| 419 |
+
if (_active) return;
|
| 420 |
+
_active = true;
|
| 421 |
+
_tourIndex = 0;
|
| 422 |
+
// A heartbeat the moment Present mode loads β confirms the log relay is
|
| 423 |
+
// wired before any error would need it.
|
| 424 |
+
_repo.api.presenterLog('Present mode started');
|
| 425 |
+
unawaited(_warmCards());
|
| 426 |
+
await _runTour();
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
/// The audience/presenter submitted a question or task. Interrupts the tour,
|
| 430 |
+
/// gets handled, then the tour resumes.
|
| 431 |
+
Future<void> ask(String question) async {
|
| 432 |
+
final q = question.trim();
|
| 433 |
+
if (q.isEmpty || !_active) return;
|
| 434 |
+
_questionQueue.add(q);
|
| 435 |
+
if (phase == PresenterPhase.speaking) {
|
| 436 |
+
await _stopSpeaking(); // tour/answer loop detects the barge-in and drains
|
| 437 |
+
} else if (phase == PresenterPhase.idle) {
|
| 438 |
+
await _handleQuestions();
|
| 439 |
+
if (_active) _setPhase(PresenterPhase.idle, caption: _idlePrompt);
|
| 440 |
+
}
|
| 441 |
+
// If acting/thinking, the current loop drains the queue when it next checks.
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
/// End the presentation and silence any speech.
|
| 445 |
+
Future<void> stop() async {
|
| 446 |
+
_active = false;
|
| 447 |
+
_questionQueue.clear();
|
| 448 |
+
await _stopSpeaking();
|
| 449 |
+
_setFocus(null);
|
| 450 |
+
_setPhase(PresenterPhase.done);
|
| 451 |
+
_repo.api.presenterLog('Present mode ended');
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
// ββ Tour loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 455 |
+
|
| 456 |
+
static const String _idlePrompt = 'Ask me anything, or tap to wrap up.';
|
| 457 |
+
|
| 458 |
+
Future<void> _runTour() async {
|
| 459 |
+
while (_active && _tourIndex < kTour.length) {
|
| 460 |
+
final completed = await _runBeat(kTour[_tourIndex]);
|
| 461 |
+
if (!_active) return;
|
| 462 |
+
if (!completed || _questionQueue.isNotEmpty) {
|
| 463 |
+
await _handleQuestions();
|
| 464 |
+
} else {
|
| 465 |
+
// A short breath between beats so the tour doesn't feel read off a
|
| 466 |
+
// teleprompter.
|
| 467 |
+
await _settle(ms: 450);
|
| 468 |
+
}
|
| 469 |
+
_tourIndex++;
|
| 470 |
+
}
|
| 471 |
+
if (!_active) return;
|
| 472 |
+
_setFocus(null);
|
| 473 |
+
_setPhase(PresenterPhase.idle, caption: _idlePrompt);
|
| 474 |
+
// Idle floor: any later question is handled by ask().
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
/// Answer queued questions/tasks by performing them, then offer more before
|
| 478 |
+
/// returning to the tour's natural flow.
|
| 479 |
+
Future<void> _handleQuestions() async {
|
| 480 |
+
while (_active && _questionQueue.isNotEmpty) {
|
| 481 |
+
final q = _questionQueue.removeAt(0);
|
| 482 |
+
_setFocus(null);
|
| 483 |
+
_setPhase(PresenterPhase.thinking, caption: 'Thinkingβ¦');
|
| 484 |
+
List<AgentBeat> beats;
|
| 485 |
+
try {
|
| 486 |
+
final raw = await _repo.api.presenterAsk(q);
|
| 487 |
+
beats = raw.map(AgentBeat.fromJson).toList();
|
| 488 |
+
} catch (e, st) {
|
| 489 |
+
_logError('ask("$q")', e, st);
|
| 490 |
+
beats = const [
|
| 491 |
+
AgentBeat(say: "I couldn't reach the backend to answer that."),
|
| 492 |
+
];
|
| 493 |
+
}
|
| 494 |
+
if (!_active) return;
|
| 495 |
+
for (final beat in beats) {
|
| 496 |
+
final completed = await _runBeat(beat);
|
| 497 |
+
if (!_active) return;
|
| 498 |
+
if (!completed) break; // a fresh barge-in β loop drains it next
|
| 499 |
+
}
|
| 500 |
+
if (!_active) return;
|
| 501 |
+
if (_questionQueue.isEmpty) {
|
| 502 |
+
// Offer the floor; if they stay quiet we resume the tour.
|
| 503 |
+
await _runBeat(const AgentBeat(
|
| 504 |
+
say: "Anything else? Otherwise I'll carry on.",
|
| 505 |
+
));
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
/// Run one beat: switch view, light the spotlight target, then speak *while*
|
| 511 |
+
/// performing the action β narration leads, the "hands" follow half a second
|
| 512 |
+
/// later, like a human demoing. Returns false if speech was interrupted by a
|
| 513 |
+
/// barge-in (so the caller can drain questions).
|
| 514 |
+
Future<bool> _runBeat(AgentBeat beat) async {
|
| 515 |
+
if (!_active) return false;
|
| 516 |
+
// Navigation is itself a visible tap on the nav control that reaches it,
|
| 517 |
+
// so a screen change never appears out of nowhere.
|
| 518 |
+
if (beat.view != null) {
|
| 519 |
+
await _gesture(_navTargetFor(beat.view!), () async {
|
| 520 |
+
_logInfo('navigate β ${beat.view}');
|
| 521 |
+
_bus.onNavigate?.call(beat.view!);
|
| 522 |
+
await _settle();
|
| 523 |
+
});
|
| 524 |
+
if (!_active) return false;
|
| 525 |
+
}
|
| 526 |
+
// Light the target the narration is about, so the cursor glides onto it
|
| 527 |
+
// while the agent talks.
|
| 528 |
+
_setFocus(beat.focus ??
|
| 529 |
+
(beat.action != null ? _targetFor(beat.action!.type) : null));
|
| 530 |
+
Future<bool>? speech;
|
| 531 |
+
if (beat.say != null && beat.say!.isNotEmpty) {
|
| 532 |
+
speech = _speak(beat.say!);
|
| 533 |
+
}
|
| 534 |
+
if (beat.action != null) {
|
| 535 |
+
if (speech != null) {
|
| 536 |
+
// Let the narration lead before the hand moves.
|
| 537 |
+
await Future<void>.delayed(const Duration(milliseconds: 550));
|
| 538 |
+
} else {
|
| 539 |
+
_setPhase(PresenterPhase.acting);
|
| 540 |
+
}
|
| 541 |
+
if (_active) {
|
| 542 |
+
// Tap the lit target, then fire the effect β the click precedes the
|
| 543 |
+
// change the audience sees.
|
| 544 |
+
await _tap();
|
| 545 |
+
try {
|
| 546 |
+
await _execute(beat.action!);
|
| 547 |
+
} catch (e, st) {
|
| 548 |
+
// A live action failing shouldn't kill the presentation.
|
| 549 |
+
_logError('action "${beat.action!.type}"', e, st);
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
final spoken = speech == null || await speech;
|
| 554 |
+
if (!spoken) return false;
|
| 555 |
+
return _active && _questionQueue.isEmpty;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
void _setFocus(String? id) {
|
| 559 |
+
if (focusId == id) return;
|
| 560 |
+
focusId = id;
|
| 561 |
+
tapping = false; // moving to a new target cancels any in-flight tap
|
| 562 |
+
notifyListeners();
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
void _setTapping(bool v) {
|
| 566 |
+
if (tapping == v) return;
|
| 567 |
+
tapping = v;
|
| 568 |
+
notifyListeners();
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
/// A visible "click" on the currently-focused target: pulse the tap ripple,
|
| 572 |
+
/// hold briefly, then release. Callers fire the real effect right after so
|
| 573 |
+
/// the audience sees the tap that caused the change. No-op if nothing's lit.
|
| 574 |
+
Future<void> _tap() async {
|
| 575 |
+
if (focusId == null || !_active) return;
|
| 576 |
+
_setTapping(true);
|
| 577 |
+
await _settle(ms: 220);
|
| 578 |
+
_setTapping(false);
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
/// Glide the cursor to [targetId], tap it, then run [effect] β the full
|
| 582 |
+
/// point-tap-act gesture used for navigation, so a screen change reads as a
|
| 583 |
+
/// tap on the nav control that triggered it rather than a magic swap.
|
| 584 |
+
Future<void> _gesture(String? targetId, FutureOr<void> Function() effect) async {
|
| 585 |
+
_setFocus(targetId);
|
| 586 |
+
await _settle(ms: 620); // let the cursor glide onto the target
|
| 587 |
+
if (!_active) return;
|
| 588 |
+
await _tap();
|
| 589 |
+
if (!_active) return;
|
| 590 |
+
await effect();
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
/// The nav control the agent taps to reach [view]: a bottom-nav tab, one of
|
| 594 |
+
/// the library's top segments (Concepts/Catalog), or a top-bar icon
|
| 595 |
+
/// (Graph/Chat/Search). Falls back to the coarse 'nav' region.
|
| 596 |
+
String _navTargetFor(String view) => switch (view) {
|
| 597 |
+
'library' => 'nav.home',
|
| 598 |
+
'collections' => 'nav.folders',
|
| 599 |
+
'actions' => 'nav.todo',
|
| 600 |
+
'feed' => 'nav.feed',
|
| 601 |
+
'profile' => 'nav.you',
|
| 602 |
+
'concepts' => 'library.tab.concepts',
|
| 603 |
+
'catalog' => 'library.tab.catalog',
|
| 604 |
+
'graph' => 'top.graph',
|
| 605 |
+
'search' => 'top.search',
|
| 606 |
+
'connections' || 'chat' => 'top.chat',
|
| 607 |
+
_ => 'nav',
|
| 608 |
+
};
|
| 609 |
+
|
| 610 |
+
/// Map an action to the spotlight target it visibly affects, so the light
|
| 611 |
+
/// guides the audience to the right widget (with coarse fallbacks).
|
| 612 |
+
String? _targetFor(String type) => switch (type) {
|
| 613 |
+
'search' => 'search.field',
|
| 614 |
+
'search_filter' => 'search.filters',
|
| 615 |
+
'navigate' => 'nav',
|
| 616 |
+
'graph_focus' ||
|
| 617 |
+
'graph_open' ||
|
| 618 |
+
'graph_wander' ||
|
| 619 |
+
'graph_reset' ||
|
| 620 |
+
'graph_cluster' ||
|
| 621 |
+
'graph_concepts' =>
|
| 622 |
+
'graph.canvas',
|
| 623 |
+
'feed_next' || 'feed_prev' || 'feed_shuffle' => 'feed.page',
|
| 624 |
+
'open_card' => 'card.first',
|
| 625 |
+
'create_card' => 'home.plus',
|
| 626 |
+
'reader_toggle' => 'reader.blocks',
|
| 627 |
+
'rabbit_hole' => 'reader.insight',
|
| 628 |
+
'card_chat' => 'reader.ask',
|
| 629 |
+
'catalog_item' => 'catalog.item',
|
| 630 |
+
'create_collection' => 'folders.create',
|
| 631 |
+
'follow_actions' || 'toggle_action_item' => 'todo.item',
|
| 632 |
+
'library_chat' => 'top.chat',
|
| 633 |
+
// 'point' and 'scroll' rely on the beat's explicit focus.
|
| 634 |
+
'point' || 'scroll' || 'wait' => null,
|
| 635 |
+
_ => 'content',
|
| 636 |
+
};
|
| 637 |
+
|
| 638 |
+
// ββ Action executor βββββββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 639 |
+
|
| 640 |
+
Future<void> _execute(AgentAction a) async {
|
| 641 |
+
_logInfo('action β ${a.type}${a.args.isEmpty ? '' : ' ${a.args}'}');
|
| 642 |
+
switch (a.type) {
|
| 643 |
+
case 'navigate':
|
| 644 |
+
if (a.view != null) {
|
| 645 |
+
_bus.onNavigate?.call(a.view!);
|
| 646 |
+
await _settle();
|
| 647 |
+
}
|
| 648 |
+
case 'wait':
|
| 649 |
+
final ms = (a.args['ms'] as num?)?.toInt() ?? 800;
|
| 650 |
+
await Future<void>.delayed(Duration(milliseconds: ms));
|
| 651 |
+
case 'scroll':
|
| 652 |
+
await _scrollSurface(a);
|
| 653 |
+
case 'create_card':
|
| 654 |
+
final url = a.url;
|
| 655 |
+
if (url != null && url.isNotEmpty) {
|
| 656 |
+
final id = await _bus.onCreateCard?.call(url);
|
| 657 |
+
if (id != null && id.isNotEmpty) _currentCardId = id;
|
| 658 |
+
await _settle(ms: 600);
|
| 659 |
+
}
|
| 660 |
+
case 'search':
|
| 661 |
+
await _runSearch(a.query);
|
| 662 |
+
case 'search_filter':
|
| 663 |
+
await _searchFilter();
|
| 664 |
+
case 'open_card':
|
| 665 |
+
await _openCard(a.query);
|
| 666 |
+
case 'reader_toggle':
|
| 667 |
+
await _readerToggle();
|
| 668 |
+
case 'graph_focus':
|
| 669 |
+
await _graphFocus(a.query, open: false);
|
| 670 |
+
case 'graph_open':
|
| 671 |
+
await _graphFocus(a.query, open: true);
|
| 672 |
+
case 'graph_wander':
|
| 673 |
+
await _graphWander();
|
| 674 |
+
case 'graph_reset':
|
| 675 |
+
await _ensureGraph();
|
| 676 |
+
await _try('graph.reset', () => _bus.graph?.reset());
|
| 677 |
+
await _settle(ms: 700);
|
| 678 |
+
case 'graph_cluster':
|
| 679 |
+
await _ensureGraph();
|
| 680 |
+
await _try('graph.filterCluster', () => _bus.graph?.filterCluster());
|
| 681 |
+
case 'graph_concepts':
|
| 682 |
+
await _ensureGraph();
|
| 683 |
+
await _try('graph.toggleConcepts', () => _bus.graph?.toggleConcepts());
|
| 684 |
+
case 'feed_next':
|
| 685 |
+
await _feedStep(forward: true);
|
| 686 |
+
case 'feed_prev':
|
| 687 |
+
await _feedStep(forward: false);
|
| 688 |
+
case 'feed_shuffle':
|
| 689 |
+
await _feedShuffle();
|
| 690 |
+
case 'open_concept':
|
| 691 |
+
case 'define_concept':
|
| 692 |
+
await _demoConcept(a.query);
|
| 693 |
+
case 'create_collection':
|
| 694 |
+
await _createCollection(a.args['name'] as String?);
|
| 695 |
+
case 'move_card':
|
| 696 |
+
await _moveCard();
|
| 697 |
+
case 'follow_actions':
|
| 698 |
+
await _followActions();
|
| 699 |
+
case 'toggle_action_item':
|
| 700 |
+
await _toggleActionItem();
|
| 701 |
+
case 'card_chat':
|
| 702 |
+
await _cardChat(a.query);
|
| 703 |
+
case 'library_chat':
|
| 704 |
+
await _libraryChat(a.query);
|
| 705 |
+
case 'rabbit_hole':
|
| 706 |
+
await _rabbitHole(a.query);
|
| 707 |
+
case 'open_connections':
|
| 708 |
+
_bus.onNavigate?.call('connections');
|
| 709 |
+
await _settle(ms: 700);
|
| 710 |
+
case 'catalog_item':
|
| 711 |
+
await _try('onOpenCatalogItem', () => _bus.onOpenCatalogItem?.call());
|
| 712 |
+
await _settle(ms: 900);
|
| 713 |
+
case 'point':
|
| 714 |
+
// Explain-only beat: the tap pulse already fired in _runBeat; nothing
|
| 715 |
+
// to execute. The lit target is what the audience is looking at.
|
| 716 |
+
break;
|
| 717 |
+
default:
|
| 718 |
+
break;
|
| 719 |
+
}
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
/// Scroll a registered surface ('library' grid, 'reader' body) by `dy`
|
| 723 |
+
/// pixels; with `sweep: true` it glides down, pauses, and glides back β the
|
| 724 |
+
/// browsing gesture a human makes while talking over a screen.
|
| 725 |
+
Future<void> _scrollSurface(AgentAction a) async {
|
| 726 |
+
final target = (a.args['target'] as String?) ?? 'library';
|
| 727 |
+
final dy = (a.args['dy'] as num?)?.toDouble() ?? 600;
|
| 728 |
+
await _try('scroll($target)', () => _bus.scrollBy(target, dy));
|
| 729 |
+
if (a.args['sweep'] == true) {
|
| 730 |
+
await _settle(ms: 600);
|
| 731 |
+
await _try('scroll($target, back)', () => _bus.scrollBy(target, -dy));
|
| 732 |
+
}
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
Future<void> _runSearch(String? query) async {
|
| 736 |
+
_bus.onNavigate?.call('search');
|
| 737 |
+
final ready =
|
| 738 |
+
await _waitFor(() => _bus.search != null, what: 'search screen', timeoutMs: 4000);
|
| 739 |
+
if (!ready) return;
|
| 740 |
+
final q = _resolveSearchQuery(query);
|
| 741 |
+
await _try('search.run("$q")', () => _bus.search?.run(q));
|
| 742 |
+
await _settle(ms: 900);
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
Future<void> _searchFilter() async {
|
| 746 |
+
_bus.onNavigate?.call('search');
|
| 747 |
+
final ready =
|
| 748 |
+
await _waitFor(() => _bus.search != null, what: 'search screen', timeoutMs: 4000);
|
| 749 |
+
if (!ready) return;
|
| 750 |
+
await _try('search.filter', () => _bus.search?.filter());
|
| 751 |
+
await _settle(ms: 700);
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
Future<void> _openCard(String? query) async {
|
| 755 |
+
final cards = await _warmCards();
|
| 756 |
+
final match = _bestCard(query, cards);
|
| 757 |
+
if (match != null) {
|
| 758 |
+
_currentCardId = match.cardId;
|
| 759 |
+
await _try('onOpenCard(${match.cardId})',
|
| 760 |
+
() => _bus.onOpenCard?.call(match.cardId));
|
| 761 |
+
await _settle(ms: 500);
|
| 762 |
+
}
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
Future<void> _readerToggle() async {
|
| 766 |
+
final card = await _cardForDemo();
|
| 767 |
+
if (card == null) return;
|
| 768 |
+
_currentCardId = card.cardId;
|
| 769 |
+
await _try(
|
| 770 |
+
'onOpenCard(${card.cardId})', () => _bus.onOpenCard?.call(card.cardId));
|
| 771 |
+
final ready =
|
| 772 |
+
await _waitFor(() => _bus.reader?.isReady() == true, what: 'reader');
|
| 773 |
+
final r = _bus.reader;
|
| 774 |
+
// No nested _speak here: actions may now run concurrently with narration,
|
| 775 |
+
// and a second utterance would orphan the first one's completer.
|
| 776 |
+
if (!ready || r == null) return;
|
| 777 |
+
var did = false;
|
| 778 |
+
await _try('reader.toggleCheck', () async => did = await r.toggleCheck());
|
| 779 |
+
if (!did) await _try('reader.toggleStep', () => r.toggleStep());
|
| 780 |
+
await _settle(ms: 600);
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
Future<void> _graphFocus(String? query, {required bool open}) async {
|
| 784 |
+
await _ensureGraph();
|
| 785 |
+
final g = _bus.graph;
|
| 786 |
+
if (g == null) return;
|
| 787 |
+
final nodes = g.nodes();
|
| 788 |
+
if (nodes.isEmpty) return;
|
| 789 |
+
final target = query == null || query.isEmpty || query == 'auto'
|
| 790 |
+
? _mostConnected(nodes)
|
| 791 |
+
: _bestNode(query, nodes);
|
| 792 |
+
if (target == null) return;
|
| 793 |
+
if (open) {
|
| 794 |
+
if (target.isCard) _currentCardId = target.id;
|
| 795 |
+
await _try('graph.open(${target.id})', () => g.open(target.id));
|
| 796 |
+
} else {
|
| 797 |
+
await _try('graph.focus(${target.id})', () => g.focus(target.id));
|
| 798 |
+
}
|
| 799 |
+
await _settle(ms: 400);
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
Future<void> _graphWander() async {
|
| 803 |
+
await _ensureGraph();
|
| 804 |
+
await _try('graph.wander', () => _bus.graph?.wander());
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
Future<void> _feedStep({required bool forward}) async {
|
| 808 |
+
_bus.onNavigate?.call('feed');
|
| 809 |
+
final ready =
|
| 810 |
+
await _waitFor(() => _bus.feed != null, what: 'feed screen', timeoutMs: 4000);
|
| 811 |
+
final f = _bus.feed;
|
| 812 |
+
if (!ready || f == null) return;
|
| 813 |
+
await _try(forward ? 'feed.next' : 'feed.prev',
|
| 814 |
+
() => forward ? f.next() : f.prev());
|
| 815 |
+
await _settle(ms: 900);
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
Future<void> _feedShuffle() async {
|
| 819 |
+
_bus.onNavigate?.call('feed');
|
| 820 |
+
final ready =
|
| 821 |
+
await _waitFor(() => _bus.feed != null, what: 'feed screen', timeoutMs: 4000);
|
| 822 |
+
if (!ready) return;
|
| 823 |
+
await _try('feed.shuffle', () => _bus.feed?.shuffle());
|
| 824 |
+
await _settle(ms: 1200);
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
Future<void> _ensureGraph() async {
|
| 828 |
+
_bus.onNavigate?.call('graph');
|
| 829 |
+
await _waitFor(() => _bus.graph?.isReady() == true,
|
| 830 |
+
what: 'graph screen', timeoutMs: 9000);
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
Future<void> _demoConcept(String? query) async {
|
| 834 |
+
final concepts = await _warmConcepts();
|
| 835 |
+
if (concepts.isEmpty) return; // nothing mined yet β skip quietly
|
| 836 |
+
final match = (query == null || query.isEmpty || query == 'auto')
|
| 837 |
+
? concepts.first
|
| 838 |
+
: (_bestBy(query, concepts, (c) => c.name) ?? concepts.first);
|
| 839 |
+
try {
|
| 840 |
+
await _repo.defineConcept(match.id);
|
| 841 |
+
} catch (e, st) {
|
| 842 |
+
// best-effort; the detail screen still opens and can define on demand.
|
| 843 |
+
_logError('defineConcept(${match.id})', e, st);
|
| 844 |
+
}
|
| 845 |
+
await _try('onOpenConcept(${match.id})',
|
| 846 |
+
() => _bus.onOpenConcept?.call(match.id, match.name));
|
| 847 |
+
await _settle(ms: 700);
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
Future<void> _createCollection(String? name) async {
|
| 851 |
+
final n = (name == null || name.isEmpty) ? 'Demo Folder' : name;
|
| 852 |
+
try {
|
| 853 |
+
final col = await _repo.createCollection(n);
|
| 854 |
+
_lastCollectionId = col.id;
|
| 855 |
+
} catch (e, st) {
|
| 856 |
+
_logError('createCollection("$n")', e, st);
|
| 857 |
+
}
|
| 858 |
+
_bus.onNavigate?.call('collections');
|
| 859 |
+
await _settle(ms: 900);
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
Future<void> _moveCard() async {
|
| 863 |
+
final card = await _cardForDemo();
|
| 864 |
+
if (card == null) return;
|
| 865 |
+
var colId = _lastCollectionId;
|
| 866 |
+
if (colId == null) {
|
| 867 |
+
try {
|
| 868 |
+
final col = await _repo.createCollection('Demo Folder');
|
| 869 |
+
colId = col.id;
|
| 870 |
+
_lastCollectionId = colId;
|
| 871 |
+
} catch (e, st) {
|
| 872 |
+
_logError('createCollection (for move)', e, st);
|
| 873 |
+
return;
|
| 874 |
+
}
|
| 875 |
+
}
|
| 876 |
+
try {
|
| 877 |
+
await _repo.moveCardToCollection(card.cardId, colId);
|
| 878 |
+
} catch (e, st) {
|
| 879 |
+
_logError('moveCardToCollection(${card.cardId})', e, st);
|
| 880 |
+
}
|
| 881 |
+
_bus.onNavigate?.call('collections');
|
| 882 |
+
await _settle(ms: 900);
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
Future<void> _followActions() async {
|
| 886 |
+
final card = await _cardWithActions();
|
| 887 |
+
if (card == null || card.actionItems.items.isEmpty) {
|
| 888 |
+
// Nothing to follow β still show the hub, but don't nest a _speak while
|
| 889 |
+
// the beat's narration may be running.
|
| 890 |
+
_bus.onNavigate?.call('actions');
|
| 891 |
+
await _settle(ms: 700);
|
| 892 |
+
return;
|
| 893 |
+
}
|
| 894 |
+
_currentCardId = card.cardId;
|
| 895 |
+
try {
|
| 896 |
+
await _repo.patchActionItems(
|
| 897 |
+
card.cardId,
|
| 898 |
+
model.ActionItems(followed: true, items: card.actionItems.items).toJson(),
|
| 899 |
+
);
|
| 900 |
+
} catch (e, st) {
|
| 901 |
+
_logError('patchActionItems (follow, ${card.cardId})', e, st);
|
| 902 |
+
}
|
| 903 |
+
_bus.onNavigate?.call('actions');
|
| 904 |
+
await _settle(ms: 900);
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
Future<void> _toggleActionItem() async {
|
| 908 |
+
final card = await _cardWithActions();
|
| 909 |
+
if (card == null || card.actionItems.items.isEmpty) return;
|
| 910 |
+
final first = card.actionItems.items.first;
|
| 911 |
+
final updated = model.ActionItems(
|
| 912 |
+
followed: true,
|
| 913 |
+
items: [
|
| 914 |
+
for (final it in card.actionItems.items)
|
| 915 |
+
it.id == first.id
|
| 916 |
+
? model.ActionItem(id: it.id, text: it.text, done: !it.done)
|
| 917 |
+
: it,
|
| 918 |
+
],
|
| 919 |
+
);
|
| 920 |
+
try {
|
| 921 |
+
await _repo.patchActionItems(card.cardId, updated.toJson());
|
| 922 |
+
} catch (e, st) {
|
| 923 |
+
_logError('patchActionItems (toggle, ${card.cardId})', e, st);
|
| 924 |
+
}
|
| 925 |
+
_bus.onNavigate?.call('actions');
|
| 926 |
+
await _settle(ms: 800);
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
Future<void> _cardChat(String? query) async {
|
| 930 |
+
final card = await _cardForDemo();
|
| 931 |
+
if (card == null) return;
|
| 932 |
+
final seed = (query == null || query.isEmpty)
|
| 933 |
+
? 'What is the single most useful takeaway here?'
|
| 934 |
+
: query;
|
| 935 |
+
final title =
|
| 936 |
+
card.base.oneLiner.isNotEmpty ? card.base.oneLiner : 'This card';
|
| 937 |
+
await _try('onOpenCardChat(${card.cardId})',
|
| 938 |
+
() => _bus.onOpenCardChat?.call(card.cardId, title, seed));
|
| 939 |
+
await _settle(ms: 1000);
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
Future<void> _libraryChat(String? query) async {
|
| 943 |
+
final seed = (query == null || query.isEmpty)
|
| 944 |
+
? 'What are the main themes across everything saved here?'
|
| 945 |
+
: query;
|
| 946 |
+
await _try('onOpenLibraryChat', () => _bus.onOpenLibraryChat?.call(seed));
|
| 947 |
+
await _settle(ms: 1000);
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
Future<void> _rabbitHole(String? query) async {
|
| 951 |
+
final card = await _cardForDemo();
|
| 952 |
+
if (card == null) return;
|
| 953 |
+
final seed = (query == null || query.isEmpty || query == 'auto')
|
| 954 |
+
? (card.base.oneLiner.isNotEmpty ? card.base.oneLiner : 'Tell me more')
|
| 955 |
+
: query;
|
| 956 |
+
await _try('onOpenRabbitHole(${card.cardId})',
|
| 957 |
+
() => _bus.onOpenRabbitHole?.call(card.cardId, seed));
|
| 958 |
+
await _settle(ms: 1000);
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
// ββ Target resolution βββββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 962 |
+
|
| 963 |
+
Future<List<model.Card>> _warmCards() async {
|
| 964 |
+
if (_cards != null) return _cards!;
|
| 965 |
+
try {
|
| 966 |
+
_cards = await _repo.list();
|
| 967 |
+
} catch (e, st) {
|
| 968 |
+
_logError('warmCards (repo.list)', e, st);
|
| 969 |
+
_cards = const [];
|
| 970 |
+
}
|
| 971 |
+
return _cards!;
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
Future<List<ConceptEntry>> _warmConcepts() async {
|
| 975 |
+
if (_conceptCache != null) return _conceptCache!;
|
| 976 |
+
try {
|
| 977 |
+
_conceptCache = await _repo.concepts();
|
| 978 |
+
} catch (e, st) {
|
| 979 |
+
_logError('warmConcepts (repo.concepts)', e, st);
|
| 980 |
+
_conceptCache = const [];
|
| 981 |
+
}
|
| 982 |
+
return _conceptCache!;
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
/// The card the agent should demonstrate: the one it's currently on, else the
|
| 986 |
+
/// first in the library.
|
| 987 |
+
Future<model.Card?> _cardForDemo() async {
|
| 988 |
+
final cards = await _warmCards();
|
| 989 |
+
if (cards.isEmpty) return null;
|
| 990 |
+
if (_currentCardId != null) {
|
| 991 |
+
for (final c in cards) {
|
| 992 |
+
if (c.cardId == _currentCardId) return c;
|
| 993 |
+
}
|
| 994 |
+
}
|
| 995 |
+
return cards.first;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
/// The first card that actually has to-do items (for the Actions demo), else
|
| 999 |
+
/// any card.
|
| 1000 |
+
Future<model.Card?> _cardWithActions() async {
|
| 1001 |
+
final cards = await _warmCards();
|
| 1002 |
+
for (final c in cards) {
|
| 1003 |
+
if (c.actionItems.items.isNotEmpty) return c;
|
| 1004 |
+
}
|
| 1005 |
+
return cards.isEmpty ? null : cards.first;
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
String _resolveSearchQuery(String? query) {
|
| 1009 |
+
if (query != null && query.isNotEmpty && query != 'auto') return query;
|
| 1010 |
+
final cards = _cards ?? const [];
|
| 1011 |
+
for (final c in cards) {
|
| 1012 |
+
if (c.base.tags.isNotEmpty) return c.base.tags.first;
|
| 1013 |
+
}
|
| 1014 |
+
if (cards.isNotEmpty) {
|
| 1015 |
+
final words = cards.first.base.oneLiner.split(RegExp(r'\s+'));
|
| 1016 |
+
final word = words.firstWhere((w) => w.length > 4, orElse: () => 'idea');
|
| 1017 |
+
return word.replaceAll(RegExp(r'[^A-Za-z]'), '');
|
| 1018 |
+
}
|
| 1019 |
+
return 'idea';
|
| 1020 |
+
}
|
| 1021 |
+
|
| 1022 |
+
model.Card? _bestCard(String? query, List<model.Card> cards) {
|
| 1023 |
+
if (cards.isEmpty) return null;
|
| 1024 |
+
if (query == null || query.isEmpty) return cards.first;
|
| 1025 |
+
String label(model.Card c) =>
|
| 1026 |
+
'${c.base.oneLiner} ${c.source.caption} ${c.base.tags.join(' ')}';
|
| 1027 |
+
return _bestBy(query, cards, label) ?? cards.first;
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
GraphNode? _bestNode(String query, List<GraphNode> nodes) =>
|
| 1031 |
+
_bestBy(query, nodes, (n) => '${n.label} ${n.tags.join(' ')}');
|
| 1032 |
+
|
| 1033 |
+
GraphNode? _mostConnected(List<GraphNode> nodes) {
|
| 1034 |
+
final sorted = [...nodes]..sort((a, b) => b.degree.compareTo(a.degree));
|
| 1035 |
+
return sorted.isEmpty ? null : sorted.first;
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
/// Simple token-overlap fuzzy match β good enough to point the agent at the
|
| 1039 |
+
/// right card/node from a spoken query.
|
| 1040 |
+
T? _bestBy<T>(String query, List<T> items, String Function(T) labelOf) {
|
| 1041 |
+
final qTokens = _tokens(query);
|
| 1042 |
+
if (qTokens.isEmpty) return null;
|
| 1043 |
+
T? best;
|
| 1044 |
+
var bestScore = 0.0;
|
| 1045 |
+
for (final item in items) {
|
| 1046 |
+
final label = labelOf(item).toLowerCase();
|
| 1047 |
+
final lTokens = _tokens(label);
|
| 1048 |
+
var score = 0.0;
|
| 1049 |
+
for (final t in qTokens) {
|
| 1050 |
+
if (lTokens.contains(t)) {
|
| 1051 |
+
score += 2;
|
| 1052 |
+
} else if (label.contains(t)) {
|
| 1053 |
+
score += 1;
|
| 1054 |
+
}
|
| 1055 |
+
}
|
| 1056 |
+
if (score > bestScore) {
|
| 1057 |
+
bestScore = score;
|
| 1058 |
+
best = item;
|
| 1059 |
+
}
|
| 1060 |
+
}
|
| 1061 |
+
return bestScore > 0 ? best : null;
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
Set<String> _tokens(String s) => s
|
| 1065 |
+
.toLowerCase()
|
| 1066 |
+
.split(RegExp(r'[^a-z0-9]+'))
|
| 1067 |
+
.where((t) => t.length > 2)
|
| 1068 |
+
.toSet();
|
| 1069 |
+
|
| 1070 |
+
// ββ Timing helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 1071 |
+
|
| 1072 |
+
/// Give a freshly-navigated screen a beat to mount/animate in.
|
| 1073 |
+
Future<void> _settle({int ms = 320}) =>
|
| 1074 |
+
Future<void>.delayed(Duration(milliseconds: ms));
|
| 1075 |
+
|
| 1076 |
+
/// Poll [ready] until true or [timeoutMs] elapses; returns whether it
|
| 1077 |
+
/// became ready. A screen/hook that never mounts doesn't throw β the
|
| 1078 |
+
/// dependent action just quietly no-ops β so this is the only trace that a
|
| 1079 |
+
/// component failed to show up in time, logged under [what].
|
| 1080 |
+
Future<bool> _waitFor(
|
| 1081 |
+
bool Function() ready, {
|
| 1082 |
+
required String what,
|
| 1083 |
+
int timeoutMs = 5000,
|
| 1084 |
+
}) async {
|
| 1085 |
+
final deadline = DateTime.now().add(Duration(milliseconds: timeoutMs));
|
| 1086 |
+
while (_active && !ready() && DateTime.now().isBefore(deadline)) {
|
| 1087 |
+
await Future<void>.delayed(const Duration(milliseconds: 120));
|
| 1088 |
+
}
|
| 1089 |
+
final ok = ready();
|
| 1090 |
+
if (!ok && _active) {
|
| 1091 |
+
_logWarn('$what never became ready after ${timeoutMs}ms β skipped');
|
| 1092 |
+
}
|
| 1093 |
+
return ok;
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
@override
|
| 1097 |
+
void dispose() {
|
| 1098 |
+
_active = false;
|
| 1099 |
+
_tts.stop();
|
| 1100 |
+
super.dispose();
|
| 1101 |
+
}
|
| 1102 |
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// The "Present" mode surface: a Siri-like glowing glyph that floats in a
|
| 2 |
+
/// corner while the agent drives the app. It pulses in the agent's current phase
|
| 3 |
+
/// colour, shows a live caption of what's being said/done, and β when tapped β
|
| 4 |
+
/// expands into a panel where you can hand the agent a question or a task.
|
| 5 |
+
library;
|
| 6 |
+
|
| 7 |
+
import 'dart:ui';
|
| 8 |
+
|
| 9 |
+
import 'package:flutter/material.dart';
|
| 10 |
+
|
| 11 |
+
import '../../core/theme.dart';
|
| 12 |
+
import 'presenter_controller.dart';
|
| 13 |
+
|
| 14 |
+
class PresenterOverlay extends StatefulWidget {
|
| 15 |
+
const PresenterOverlay({super.key, required this.controller});
|
| 16 |
+
final PresenterController controller;
|
| 17 |
+
|
| 18 |
+
@override
|
| 19 |
+
State<PresenterOverlay> createState() => _PresenterOverlayState();
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
class _PresenterOverlayState extends State<PresenterOverlay>
|
| 23 |
+
with SingleTickerProviderStateMixin {
|
| 24 |
+
late final AnimationController _pulse = AnimationController(
|
| 25 |
+
vsync: this,
|
| 26 |
+
duration: const Duration(milliseconds: 2200),
|
| 27 |
+
)..repeat(reverse: true);
|
| 28 |
+
|
| 29 |
+
final _field = TextEditingController();
|
| 30 |
+
final _focus = FocusNode();
|
| 31 |
+
bool _expanded = false;
|
| 32 |
+
|
| 33 |
+
@override
|
| 34 |
+
void dispose() {
|
| 35 |
+
_pulse.dispose();
|
| 36 |
+
_field.dispose();
|
| 37 |
+
_focus.dispose();
|
| 38 |
+
super.dispose();
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
void _toggle() {
|
| 42 |
+
setState(() => _expanded = !_expanded);
|
| 43 |
+
if (_expanded) {
|
| 44 |
+
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus());
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
void _submit() {
|
| 49 |
+
final q = _field.text.trim();
|
| 50 |
+
if (q.isEmpty) return;
|
| 51 |
+
_field.clear();
|
| 52 |
+
widget.controller.ask(q);
|
| 53 |
+
_focus.requestFocus();
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
@override
|
| 57 |
+
Widget build(BuildContext context) {
|
| 58 |
+
final width = MediaQuery.sizeOf(context).width;
|
| 59 |
+
final bottomInset = MediaQuery.of(context).padding.bottom;
|
| 60 |
+
// Clear the floating pill nav on mobile; the side rail leaves the corner free.
|
| 61 |
+
final bottom = bottomInset + (width >= Insets.desktop ? 24 : 92);
|
| 62 |
+
|
| 63 |
+
return Positioned(
|
| 64 |
+
right: 16,
|
| 65 |
+
bottom: bottom,
|
| 66 |
+
child: AnimatedBuilder(
|
| 67 |
+
animation: widget.controller,
|
| 68 |
+
builder: (context, _) {
|
| 69 |
+
return Column(
|
| 70 |
+
crossAxisAlignment: CrossAxisAlignment.end,
|
| 71 |
+
mainAxisSize: MainAxisSize.min,
|
| 72 |
+
children: [
|
| 73 |
+
if (_expanded)
|
| 74 |
+
_Panel(
|
| 75 |
+
controller: widget.controller,
|
| 76 |
+
field: _field,
|
| 77 |
+
focus: _focus,
|
| 78 |
+
onSubmit: _submit,
|
| 79 |
+
onEnd: () => widget.controller.stop(),
|
| 80 |
+
)
|
| 81 |
+
else if (widget.controller.caption.isNotEmpty)
|
| 82 |
+
_CaptionBubble(text: widget.controller.caption),
|
| 83 |
+
const SizedBox(height: 12),
|
| 84 |
+
_Glyph(
|
| 85 |
+
phase: widget.controller.phase,
|
| 86 |
+
pulse: _pulse,
|
| 87 |
+
onTap: _toggle,
|
| 88 |
+
expanded: _expanded,
|
| 89 |
+
),
|
| 90 |
+
],
|
| 91 |
+
);
|
| 92 |
+
},
|
| 93 |
+
),
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// ββ The glowing orb βββββββββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 99 |
+
|
| 100 |
+
class _Glyph extends StatelessWidget {
|
| 101 |
+
const _Glyph({
|
| 102 |
+
required this.phase,
|
| 103 |
+
required this.pulse,
|
| 104 |
+
required this.onTap,
|
| 105 |
+
required this.expanded,
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
final PresenterPhase phase;
|
| 109 |
+
final Animation<double> pulse;
|
| 110 |
+
final VoidCallback onTap;
|
| 111 |
+
final bool expanded;
|
| 112 |
+
|
| 113 |
+
@override
|
| 114 |
+
Widget build(BuildContext context) {
|
| 115 |
+
final color = _phaseColor(context, phase);
|
| 116 |
+
final busy = phase == PresenterPhase.speaking ||
|
| 117 |
+
phase == PresenterPhase.thinking ||
|
| 118 |
+
phase == PresenterPhase.acting;
|
| 119 |
+
|
| 120 |
+
return Semantics(
|
| 121 |
+
button: true,
|
| 122 |
+
label: 'Presenter agent β tap to ask or hand it a task',
|
| 123 |
+
child: MouseRegion(
|
| 124 |
+
cursor: SystemMouseCursors.click,
|
| 125 |
+
child: GestureDetector(
|
| 126 |
+
onTap: onTap,
|
| 127 |
+
child: AnimatedBuilder(
|
| 128 |
+
animation: pulse,
|
| 129 |
+
builder: (context, _) {
|
| 130 |
+
// A soft double-halo that breathes; wider when the agent is busy.
|
| 131 |
+
final t = pulse.value;
|
| 132 |
+
final haloBlur = (busy ? 30.0 : 20.0) + t * 14;
|
| 133 |
+
final haloSpread = (busy ? 8.0 : 4.0) + t * 6;
|
| 134 |
+
return Container(
|
| 135 |
+
width: 64,
|
| 136 |
+
height: 64,
|
| 137 |
+
decoration: BoxDecoration(
|
| 138 |
+
shape: BoxShape.circle,
|
| 139 |
+
gradient: RadialGradient(
|
| 140 |
+
colors: [
|
| 141 |
+
Color.lerp(color, Colors.white, 0.35)!,
|
| 142 |
+
color,
|
| 143 |
+
Color.lerp(color, Colors.black, 0.25)!,
|
| 144 |
+
],
|
| 145 |
+
stops: const [0.0, 0.55, 1.0],
|
| 146 |
+
center: const Alignment(-0.3, -0.4),
|
| 147 |
+
),
|
| 148 |
+
boxShadow: [
|
| 149 |
+
BoxShadow(
|
| 150 |
+
color: color.withValues(alpha: 0.55),
|
| 151 |
+
blurRadius: haloBlur,
|
| 152 |
+
spreadRadius: haloSpread,
|
| 153 |
+
),
|
| 154 |
+
BoxShadow(
|
| 155 |
+
color: color.withValues(alpha: 0.25),
|
| 156 |
+
blurRadius: haloBlur * 2,
|
| 157 |
+
spreadRadius: haloSpread * 1.6,
|
| 158 |
+
),
|
| 159 |
+
],
|
| 160 |
+
),
|
| 161 |
+
child: Center(
|
| 162 |
+
child: Icon(
|
| 163 |
+
expanded ? Icons.keyboard_arrow_down_rounded : _phaseIcon(phase),
|
| 164 |
+
color: Colors.white,
|
| 165 |
+
size: 28,
|
| 166 |
+
),
|
| 167 |
+
),
|
| 168 |
+
);
|
| 169 |
+
},
|
| 170 |
+
),
|
| 171 |
+
),
|
| 172 |
+
),
|
| 173 |
+
);
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// ββ Collapsed caption bubble ββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 178 |
+
|
| 179 |
+
class _CaptionBubble extends StatelessWidget {
|
| 180 |
+
const _CaptionBubble({required this.text});
|
| 181 |
+
final String text;
|
| 182 |
+
|
| 183 |
+
@override
|
| 184 |
+
Widget build(BuildContext context) {
|
| 185 |
+
final theme = Theme.of(context);
|
| 186 |
+
return ConstrainedBox(
|
| 187 |
+
constraints: const BoxConstraints(maxWidth: 320),
|
| 188 |
+
child: ClipRRect(
|
| 189 |
+
borderRadius: BorderRadius.circular(18),
|
| 190 |
+
child: BackdropFilter(
|
| 191 |
+
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
| 192 |
+
child: Container(
|
| 193 |
+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
| 194 |
+
decoration: BoxDecoration(
|
| 195 |
+
color: theme.colorScheme.surface.withValues(alpha: 0.82),
|
| 196 |
+
borderRadius: BorderRadius.circular(18),
|
| 197 |
+
border: Border.all(
|
| 198 |
+
color: theme.colorScheme.primary.withValues(alpha: 0.30),
|
| 199 |
+
),
|
| 200 |
+
),
|
| 201 |
+
child: Text(
|
| 202 |
+
text,
|
| 203 |
+
style: theme.textTheme.bodyMedium,
|
| 204 |
+
maxLines: 4,
|
| 205 |
+
overflow: TextOverflow.ellipsis,
|
| 206 |
+
),
|
| 207 |
+
),
|
| 208 |
+
),
|
| 209 |
+
),
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// ββ Expanded input panel ββββββββββββββββββββββββββββββββββββββββββββββββββ //
|
| 215 |
+
|
| 216 |
+
class _Panel extends StatelessWidget {
|
| 217 |
+
const _Panel({
|
| 218 |
+
required this.controller,
|
| 219 |
+
required this.field,
|
| 220 |
+
required this.focus,
|
| 221 |
+
required this.onSubmit,
|
| 222 |
+
required this.onEnd,
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
final PresenterController controller;
|
| 226 |
+
final TextEditingController field;
|
| 227 |
+
final FocusNode focus;
|
| 228 |
+
final VoidCallback onSubmit;
|
| 229 |
+
final VoidCallback onEnd;
|
| 230 |
+
|
| 231 |
+
@override
|
| 232 |
+
Widget build(BuildContext context) {
|
| 233 |
+
final theme = Theme.of(context);
|
| 234 |
+
return ConstrainedBox(
|
| 235 |
+
constraints: const BoxConstraints(maxWidth: 340),
|
| 236 |
+
child: ClipRRect(
|
| 237 |
+
borderRadius: BorderRadius.circular(20),
|
| 238 |
+
child: BackdropFilter(
|
| 239 |
+
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
| 240 |
+
child: Container(
|
| 241 |
+
padding: const EdgeInsets.all(14),
|
| 242 |
+
decoration: BoxDecoration(
|
| 243 |
+
color: theme.colorScheme.surface.withValues(alpha: 0.86),
|
| 244 |
+
borderRadius: BorderRadius.circular(20),
|
| 245 |
+
border: Border.all(
|
| 246 |
+
color: theme.colorScheme.primary.withValues(alpha: 0.35),
|
| 247 |
+
),
|
| 248 |
+
),
|
| 249 |
+
child: Column(
|
| 250 |
+
mainAxisSize: MainAxisSize.min,
|
| 251 |
+
crossAxisAlignment: CrossAxisAlignment.start,
|
| 252 |
+
children: [
|
| 253 |
+
Row(
|
| 254 |
+
children: [
|
| 255 |
+
Text(
|
| 256 |
+
_phaseLabel(controller.phase),
|
| 257 |
+
style: theme.textTheme.labelSmall?.copyWith(
|
| 258 |
+
letterSpacing: 1.2,
|
| 259 |
+
color: theme.colorScheme.primary,
|
| 260 |
+
),
|
| 261 |
+
),
|
| 262 |
+
const Spacer(),
|
| 263 |
+
TextButton.icon(
|
| 264 |
+
onPressed: onEnd,
|
| 265 |
+
icon: const Icon(Icons.stop_circle_outlined, size: 18),
|
| 266 |
+
label: const Text('End'),
|
| 267 |
+
),
|
| 268 |
+
],
|
| 269 |
+
),
|
| 270 |
+
const SizedBox(height: 2),
|
| 271 |
+
Text(
|
| 272 |
+
controller.caption.isEmpty ? 'β¦' : controller.caption,
|
| 273 |
+
style: theme.textTheme.bodyMedium,
|
| 274 |
+
maxLines: 4,
|
| 275 |
+
overflow: TextOverflow.ellipsis,
|
| 276 |
+
),
|
| 277 |
+
const SizedBox(height: 12),
|
| 278 |
+
TextField(
|
| 279 |
+
controller: field,
|
| 280 |
+
focusNode: focus,
|
| 281 |
+
onSubmitted: (_) => onSubmit(),
|
| 282 |
+
textInputAction: TextInputAction.send,
|
| 283 |
+
decoration: InputDecoration(
|
| 284 |
+
hintText: 'Ask, or hand me a taskβ¦',
|
| 285 |
+
isDense: true,
|
| 286 |
+
filled: true,
|
| 287 |
+
fillColor: theme.colorScheme.surfaceContainerHighest
|
| 288 |
+
.withValues(alpha: 0.5),
|
| 289 |
+
border: OutlineInputBorder(
|
| 290 |
+
borderRadius: BorderRadius.circular(14),
|
| 291 |
+
borderSide: BorderSide.none,
|
| 292 |
+
),
|
| 293 |
+
suffixIcon: IconButton(
|
| 294 |
+
icon: const Icon(Icons.send_rounded),
|
| 295 |
+
onPressed: onSubmit,
|
| 296 |
+
),
|
| 297 |
+
),
|
| 298 |
+
),
|
| 299 |
+
],
|
| 300 |
+
),
|
| 301 |
+
),
|
| 302 |
+
),
|
| 303 |
+
),
|
| 304 |
+
);
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// ββ Phase β colour / icon / label βββββββββββββββββββββββββββββββββββββββββ //
|
| 309 |
+
|
| 310 |
+
Color _phaseColor(BuildContext context, PresenterPhase phase) {
|
| 311 |
+
final scheme = Theme.of(context).colorScheme;
|
| 312 |
+
return switch (phase) {
|
| 313 |
+
PresenterPhase.speaking => const Color(0xFF3DDC97),
|
| 314 |
+
PresenterPhase.thinking => const Color(0xFFF5A623),
|
| 315 |
+
PresenterPhase.acting => const Color(0xFF4DA8FF),
|
| 316 |
+
PresenterPhase.idle => scheme.primary,
|
| 317 |
+
PresenterPhase.done => Colors.grey,
|
| 318 |
+
};
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
IconData _phaseIcon(PresenterPhase phase) => switch (phase) {
|
| 322 |
+
PresenterPhase.speaking => Icons.graphic_eq_rounded,
|
| 323 |
+
PresenterPhase.thinking => Icons.auto_awesome_rounded,
|
| 324 |
+
PresenterPhase.acting => Icons.touch_app_rounded,
|
| 325 |
+
PresenterPhase.idle => Icons.mic_none_rounded,
|
| 326 |
+
PresenterPhase.done => Icons.check_rounded,
|
| 327 |
+
};
|
| 328 |
+
|
| 329 |
+
String _phaseLabel(PresenterPhase p) => switch (p) {
|
| 330 |
+
PresenterPhase.speaking => 'PRESENTING',
|
| 331 |
+
PresenterPhase.thinking => 'THINKING',
|
| 332 |
+
PresenterPhase.acting => 'DOING IT LIVE',
|
| 333 |
+
PresenterPhase.idle => 'READY β ASK ME',
|
| 334 |
+
PresenterPhase.done => 'DONE',
|
| 335 |
+
};
|
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// The guided-focus layer for Present mode: while the agent presents a beat it
|
| 2 |
+
/// dims the surrounding interface, lights the widget it's talking about, and
|
| 3 |
+
/// glides a presentation cursor there β the coach-marks feel of an app's
|
| 4 |
+
/// first-run tour.
|
| 5 |
+
///
|
| 6 |
+
/// Targets are real widgets: screens register a GlobalKey per target id on the
|
| 7 |
+
/// [AgentBus] (see `agent_bus.dart`), and this layer resolves live geometry
|
| 8 |
+
/// from the key every ~150 ms while lit, so the hole hugs the actual widget
|
| 9 |
+
/// even if it mounts late or the layout shifts. Ids with no registered widget
|
| 10 |
+
/// fall back to a coarse screen region. The layer stays mounted for the whole
|
| 11 |
+
/// presentation so the hole and cursor *animate between* targets instead of
|
| 12 |
+
/// restarting, and it's wrapped in [IgnorePointer] so it never blocks input.
|
| 13 |
+
library;
|
| 14 |
+
|
| 15 |
+
import 'dart:async';
|
| 16 |
+
|
| 17 |
+
import 'package:flutter/material.dart';
|
| 18 |
+
|
| 19 |
+
import '../../core/theme.dart';
|
| 20 |
+
import 'agent_bus.dart';
|
| 21 |
+
import 'presenter_controller.dart';
|
| 22 |
+
|
| 23 |
+
class PresenterSpotlight extends StatefulWidget {
|
| 24 |
+
const PresenterSpotlight({
|
| 25 |
+
super.key,
|
| 26 |
+
required this.controller,
|
| 27 |
+
required this.bus,
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
final PresenterController controller;
|
| 31 |
+
final AgentBus bus;
|
| 32 |
+
|
| 33 |
+
@override
|
| 34 |
+
State<PresenterSpotlight> createState() => _PresenterSpotlightState();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
class _PresenterSpotlightState extends State<PresenterSpotlight> {
|
| 38 |
+
Timer? _poll;
|
| 39 |
+
|
| 40 |
+
@override
|
| 41 |
+
void initState() {
|
| 42 |
+
super.initState();
|
| 43 |
+
widget.controller.addListener(_onFocusChanged);
|
| 44 |
+
_onFocusChanged();
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@override
|
| 48 |
+
void dispose() {
|
| 49 |
+
widget.controller.removeListener(_onFocusChanged);
|
| 50 |
+
_poll?.cancel();
|
| 51 |
+
super.dispose();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/// While a target is lit, re-resolve its geometry a few times a second so a
|
| 55 |
+
/// screen that mounts (or scrolls) after navigation still gets hugged.
|
| 56 |
+
void _onFocusChanged() {
|
| 57 |
+
final lit = widget.controller.focusId != null;
|
| 58 |
+
if (lit && _poll == null) {
|
| 59 |
+
_poll = Timer.periodic(const Duration(milliseconds: 150), (_) {
|
| 60 |
+
if (mounted) setState(() {});
|
| 61 |
+
});
|
| 62 |
+
} else if (!lit) {
|
| 63 |
+
_poll?.cancel();
|
| 64 |
+
_poll = null;
|
| 65 |
+
}
|
| 66 |
+
if (mounted) setState(() {});
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@override
|
| 70 |
+
Widget build(BuildContext context) {
|
| 71 |
+
return Positioned.fill(
|
| 72 |
+
child: IgnorePointer(
|
| 73 |
+
child: LayoutBuilder(
|
| 74 |
+
builder: (context, constraints) {
|
| 75 |
+
final size = Size(constraints.maxWidth, constraints.maxHeight);
|
| 76 |
+
final id = widget.controller.focusId;
|
| 77 |
+
final rect = id == null ? null : _resolve(id, size);
|
| 78 |
+
// Dim the app only around genuinely small targets (a button, a nav
|
| 79 |
+
// item); dimming a region that already fills the screen just makes
|
| 80 |
+
// everything darker. The cursor, though, always shows so the
|
| 81 |
+
// audience can see where the agent is clicking.
|
| 82 |
+
final small = rect != null &&
|
| 83 |
+
rect.width * rect.height < size.width * size.height * 0.5;
|
| 84 |
+
return _Guidance(
|
| 85 |
+
target: rect,
|
| 86 |
+
dim: small,
|
| 87 |
+
tapping: widget.controller.tapping,
|
| 88 |
+
screen: size,
|
| 89 |
+
color: Theme.of(context).colorScheme.primary,
|
| 90 |
+
);
|
| 91 |
+
},
|
| 92 |
+
),
|
| 93 |
+
),
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/// Real widget bounds when the target is registered and mounted, else a
|
| 98 |
+
/// generous fallback region so guidance degrades instead of vanishing.
|
| 99 |
+
Rect _resolve(String id, Size size) {
|
| 100 |
+
final live = widget.bus.spotlightRect(id);
|
| 101 |
+
if (live != null && !live.isEmpty) {
|
| 102 |
+
// Keep the hole on screen even if the widget hangs off an edge.
|
| 103 |
+
final screen = Offset.zero & size;
|
| 104 |
+
final clamped = live.intersect(screen.inflate(-4));
|
| 105 |
+
if (!clamped.isEmpty) return clamped;
|
| 106 |
+
}
|
| 107 |
+
return _fallbackRect(id, size);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
Rect _fallbackRect(String id, Size size) {
|
| 111 |
+
final w = size.width;
|
| 112 |
+
final h = size.height;
|
| 113 |
+
final desktop = w >= Insets.desktop;
|
| 114 |
+
// Bottom nav / side rail.
|
| 115 |
+
if (id == 'nav' || id.startsWith('nav.')) {
|
| 116 |
+
return desktop
|
| 117 |
+
? Rect.fromLTWH(0, h * 0.18, 88, h * 0.64) // left rail
|
| 118 |
+
: Rect.fromLTWH(w * 0.08, h - 100, w * 0.84, 74); // bottom pill
|
| 119 |
+
}
|
| 120 |
+
// Add button β bottom-right on mobile, in the rail on desktop.
|
| 121 |
+
if (id == 'home.plus') {
|
| 122 |
+
return desktop
|
| 123 |
+
? Rect.fromLTWH(16, 24, 56, 56)
|
| 124 |
+
: Rect.fromLTWH(w - 88, h - 168, 64, 64);
|
| 125 |
+
}
|
| 126 |
+
// Top bar: search field, top-bar icons, library segment tabs.
|
| 127 |
+
if (id.startsWith('search.') ||
|
| 128 |
+
id.startsWith('top.') ||
|
| 129 |
+
id.startsWith('library.tab') ||
|
| 130 |
+
id == 'top') {
|
| 131 |
+
final left = w * (desktop ? 0.18 : 0.06);
|
| 132 |
+
return Rect.fromLTWH(left, 8, w - left - 16, 96);
|
| 133 |
+
}
|
| 134 |
+
// The main content stage ('content', 'feed.page', 'graph.canvas',
|
| 135 |
+
// 'card.first', 'reader.*', 'catalog.item', 'folders.*', 'todo.*', β¦).
|
| 136 |
+
final left = desktop ? 96.0 : w * 0.06;
|
| 137 |
+
return Rect.fromLTWH(
|
| 138 |
+
left,
|
| 139 |
+
h * 0.14,
|
| 140 |
+
w - left - (desktop ? 24 : w * 0.06),
|
| 141 |
+
h * 0.66,
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/// Always mounted so the implicit tweens retarget β the hole and cursor glide
|
| 147 |
+
/// from the previous target to the next instead of resetting. When [target] is
|
| 148 |
+
/// null the whole layer just fades out, holding its last geometry.
|
| 149 |
+
class _Guidance extends StatefulWidget {
|
| 150 |
+
const _Guidance({
|
| 151 |
+
required this.target,
|
| 152 |
+
required this.dim,
|
| 153 |
+
required this.tapping,
|
| 154 |
+
required this.screen,
|
| 155 |
+
required this.color,
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
final Rect? target;
|
| 159 |
+
|
| 160 |
+
/// Punch a dimming hole around the target (only worthwhile for small ones).
|
| 161 |
+
final bool dim;
|
| 162 |
+
|
| 163 |
+
/// The agent is tapping the target right now β render a tap ripple.
|
| 164 |
+
final bool tapping;
|
| 165 |
+
|
| 166 |
+
final Size screen;
|
| 167 |
+
final Color color;
|
| 168 |
+
|
| 169 |
+
@override
|
| 170 |
+
State<_Guidance> createState() => _GuidanceState();
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
class _GuidanceState extends State<_Guidance> {
|
| 174 |
+
late Rect _target = Rect.fromCenter(
|
| 175 |
+
center: widget.screen.center(Offset.zero),
|
| 176 |
+
width: 120,
|
| 177 |
+
height: 120,
|
| 178 |
+
);
|
| 179 |
+
|
| 180 |
+
@override
|
| 181 |
+
void initState() {
|
| 182 |
+
super.initState();
|
| 183 |
+
if (widget.target != null) _target = widget.target!;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
@override
|
| 187 |
+
void didUpdateWidget(_Guidance old) {
|
| 188 |
+
super.didUpdateWidget(old);
|
| 189 |
+
// Adopt a new target only when it actually moved β the ~150ms geometry
|
| 190 |
+
// poll re-resolves the same widget with sub-pixel jitter every tick, and
|
| 191 |
+
// feeding that into the tween would restart the glide forever (the old
|
| 192 |
+
// "broken spotlight"). A few pixels of slack absorbs the jitter.
|
| 193 |
+
final t = widget.target;
|
| 194 |
+
if (t != null && !_closeEnough(t, _target)) _target = t;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
bool _closeEnough(Rect a, Rect b) =>
|
| 198 |
+
(a.center - b.center).distance < 6 &&
|
| 199 |
+
(a.width - b.width).abs() < 6 &&
|
| 200 |
+
(a.height - b.height).abs() < 6;
|
| 201 |
+
|
| 202 |
+
@override
|
| 203 |
+
Widget build(BuildContext context) {
|
| 204 |
+
return AnimatedOpacity(
|
| 205 |
+
opacity: widget.target != null ? 1 : 0,
|
| 206 |
+
duration: const Duration(milliseconds: 350),
|
| 207 |
+
curve: Curves.easeOut,
|
| 208 |
+
child: TweenAnimationBuilder<Rect?>(
|
| 209 |
+
// Only `end` matters β TweenAnimationBuilder animates from the current
|
| 210 |
+
// in-flight value to a new end whenever it changes, so the hole and
|
| 211 |
+
// cursor glide smoothly target-to-target.
|
| 212 |
+
tween: RectTween(begin: _target, end: _target),
|
| 213 |
+
duration: const Duration(milliseconds: 550),
|
| 214 |
+
curve: Curves.easeInOutCubic,
|
| 215 |
+
builder: (context, animated, _) {
|
| 216 |
+
final r = animated ?? _target;
|
| 217 |
+
return Stack(
|
| 218 |
+
children: [
|
| 219 |
+
if (widget.dim)
|
| 220 |
+
Positioned.fill(
|
| 221 |
+
child:
|
| 222 |
+
CustomPaint(painter: _SpotlightPainter(r, widget.color)),
|
| 223 |
+
),
|
| 224 |
+
// Presentation cursor gliding to the target, like a tour guide's
|
| 225 |
+
// pointer, with a tap ripple when the agent clicks.
|
| 226 |
+
Positioned(
|
| 227 |
+
left: r.center.dx - 18,
|
| 228 |
+
top: r.center.dy - 18,
|
| 229 |
+
child: _Cursor(color: widget.color, tapping: widget.tapping),
|
| 230 |
+
),
|
| 231 |
+
],
|
| 232 |
+
);
|
| 233 |
+
},
|
| 234 |
+
),
|
| 235 |
+
);
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
class _SpotlightPainter extends CustomPainter {
|
| 240 |
+
_SpotlightPainter(this.rect, this.color);
|
| 241 |
+
final Rect rect;
|
| 242 |
+
final Color color;
|
| 243 |
+
|
| 244 |
+
@override
|
| 245 |
+
void paint(Canvas canvas, Size size) {
|
| 246 |
+
final full = Offset.zero & size;
|
| 247 |
+
final cut = RRect.fromRectAndRadius(
|
| 248 |
+
rect.inflate(8),
|
| 249 |
+
const Radius.circular(16),
|
| 250 |
+
);
|
| 251 |
+
// Scrim with a hole punched out around the lit widget.
|
| 252 |
+
final scrim = Path.combine(
|
| 253 |
+
PathOperation.difference,
|
| 254 |
+
Path()..addRect(full),
|
| 255 |
+
Path()..addRRect(cut),
|
| 256 |
+
);
|
| 257 |
+
// Light enough that a dark theme doesn't collapse into a black wash.
|
| 258 |
+
canvas.drawPath(scrim, Paint()..color = Colors.black.withValues(alpha: 0.35));
|
| 259 |
+
// Glowing ring around the lit widget.
|
| 260 |
+
canvas.drawRRect(
|
| 261 |
+
cut,
|
| 262 |
+
Paint()
|
| 263 |
+
..style = PaintingStyle.stroke
|
| 264 |
+
..strokeWidth = 2
|
| 265 |
+
..color = color.withValues(alpha: 0.85)
|
| 266 |
+
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
|
| 267 |
+
);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
@override
|
| 271 |
+
bool shouldRepaint(_SpotlightPainter old) =>
|
| 272 |
+
old.rect != rect || old.color != color;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/// The tour-guide pointer. A steady dot that glides to the target, plus an
|
| 276 |
+
/// expanding ring that fires while [tapping] so a "click" is visible.
|
| 277 |
+
class _Cursor extends StatelessWidget {
|
| 278 |
+
const _Cursor({required this.color, required this.tapping});
|
| 279 |
+
final Color color;
|
| 280 |
+
final bool tapping;
|
| 281 |
+
|
| 282 |
+
@override
|
| 283 |
+
Widget build(BuildContext context) {
|
| 284 |
+
return SizedBox(
|
| 285 |
+
width: 36,
|
| 286 |
+
height: 36,
|
| 287 |
+
child: Stack(
|
| 288 |
+
alignment: Alignment.center,
|
| 289 |
+
children: [
|
| 290 |
+
// Tap ripple: a ring that expands and fades out on each tap.
|
| 291 |
+
TweenAnimationBuilder<double>(
|
| 292 |
+
key: ValueKey(tapping),
|
| 293 |
+
tween: Tween(begin: tapping ? 0.0 : 1.0, end: 1.0),
|
| 294 |
+
duration: const Duration(milliseconds: 260),
|
| 295 |
+
curve: Curves.easeOut,
|
| 296 |
+
builder: (context, t, _) {
|
| 297 |
+
if (!tapping) return const SizedBox.shrink();
|
| 298 |
+
return Opacity(
|
| 299 |
+
opacity: (1 - t).clamp(0.0, 1.0),
|
| 300 |
+
child: Container(
|
| 301 |
+
width: 12 + 30 * t,
|
| 302 |
+
height: 12 + 30 * t,
|
| 303 |
+
decoration: BoxDecoration(
|
| 304 |
+
shape: BoxShape.circle,
|
| 305 |
+
border: Border.all(
|
| 306 |
+
color: Colors.white.withValues(alpha: 0.9), width: 2),
|
| 307 |
+
),
|
| 308 |
+
),
|
| 309 |
+
);
|
| 310 |
+
},
|
| 311 |
+
),
|
| 312 |
+
// The pointer dot.
|
| 313 |
+
Container(
|
| 314 |
+
width: 24,
|
| 315 |
+
height: 24,
|
| 316 |
+
decoration: BoxDecoration(
|
| 317 |
+
shape: BoxShape.circle,
|
| 318 |
+
color: color.withValues(alpha: 0.35),
|
| 319 |
+
border:
|
| 320 |
+
Border.all(color: Colors.white.withValues(alpha: 0.9), width: 2),
|
| 321 |
+
boxShadow: [
|
| 322 |
+
BoxShadow(
|
| 323 |
+
color: color.withValues(alpha: 0.7),
|
| 324 |
+
blurRadius: 18,
|
| 325 |
+
spreadRadius: 2,
|
| 326 |
+
),
|
| 327 |
+
],
|
| 328 |
+
),
|
| 329 |
+
),
|
| 330 |
+
],
|
| 331 |
+
),
|
| 332 |
+
);
|
| 333 |
+
}
|
| 334 |
+
}
|
|
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
|
|
| 6 |
|
| 7 |
import '../../../../data/repositories/card_repository.dart';
|
| 8 |
import '../../../../data/services/api_client.dart';
|
|
|
|
| 9 |
|
| 10 |
class ChatMessage {
|
| 11 |
const ChatMessage({required this.role, required this.content});
|
|
@@ -16,7 +17,7 @@ class ChatMessage {
|
|
| 16 |
Map<String, String> toWire() => {'role': role, 'content': content};
|
| 17 |
}
|
| 18 |
|
| 19 |
-
class ChatViewModel extends ChangeNotifier {
|
| 20 |
ChatViewModel({required CardRepository repository, required this.cardId})
|
| 21 |
: _repository = repository;
|
| 22 |
|
|
|
|
| 6 |
|
| 7 |
import '../../../../data/repositories/card_repository.dart';
|
| 8 |
import '../../../../data/services/api_client.dart';
|
| 9 |
+
import '../../../core/safe_notifier.dart';
|
| 10 |
|
| 11 |
class ChatMessage {
|
| 12 |
const ChatMessage({required this.role, required this.content});
|
|
|
|
| 17 |
Map<String, String> toWire() => {'role': role, 'content': content};
|
| 18 |
}
|
| 19 |
|
| 20 |
+
class ChatViewModel extends ChangeNotifier with SafeNotifier {
|
| 21 |
ChatViewModel({required CardRepository repository, required this.cardId})
|
| 22 |
: _repository = repository;
|
| 23 |
|
|
@@ -8,8 +8,9 @@ import 'package:flutter/foundation.dart';
|
|
| 8 |
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
| 10 |
import '../../../../data/services/api_client.dart';
|
|
|
|
| 11 |
|
| 12 |
-
class RabbitHoleViewModel extends ChangeNotifier {
|
| 13 |
RabbitHoleViewModel({required CardRepository repository, required this.cardId})
|
| 14 |
: _repository = repository;
|
| 15 |
|
|
|
|
| 8 |
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
| 10 |
import '../../../../data/services/api_client.dart';
|
| 11 |
+
import '../../../core/safe_notifier.dart';
|
| 12 |
|
| 13 |
+
class RabbitHoleViewModel extends ChangeNotifier with SafeNotifier {
|
| 14 |
RabbitHoleViewModel({required CardRepository repository, required this.cardId})
|
| 15 |
: _repository = repository;
|
| 16 |
|
|
@@ -12,8 +12,9 @@ import '../../../../domain/models/artifact.dart';
|
|
| 12 |
import '../../../../domain/models/card.dart';
|
| 13 |
import '../../../../domain/models/concept.dart';
|
| 14 |
import '../../../../domain/models/pipeline_event.dart';
|
|
|
|
| 15 |
|
| 16 |
-
class ReaderViewModel extends ChangeNotifier {
|
| 17 |
ReaderViewModel({required CardRepository repository, required this.cardId})
|
| 18 |
: _repository = repository;
|
| 19 |
|
|
|
|
| 12 |
import '../../../../domain/models/card.dart';
|
| 13 |
import '../../../../domain/models/concept.dart';
|
| 14 |
import '../../../../domain/models/pipeline_event.dart';
|
| 15 |
+
import '../../../core/safe_notifier.dart';
|
| 16 |
|
| 17 |
+
class ReaderViewModel extends ChangeNotifier with SafeNotifier {
|
| 18 |
ReaderViewModel({required CardRepository repository, required this.cardId})
|
| 19 |
: _repository = repository;
|
| 20 |
|
|
@@ -72,10 +72,40 @@ class _ReaderAgentBridge extends StatefulWidget {
|
|
| 72 |
State<_ReaderAgentBridge> createState() => _ReaderAgentBridgeState();
|
| 73 |
}
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
class _ReaderAgentBridgeState extends State<_ReaderAgentBridge> {
|
| 76 |
AgentBus? _bus;
|
| 77 |
ReaderAgentHooks? _hooks;
|
| 78 |
ReaderViewModel? _vm;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
@override
|
| 81 |
void didChangeDependencies() {
|
|
@@ -90,6 +120,15 @@ class _ReaderAgentBridgeState extends State<_ReaderAgentBridge> {
|
|
| 90 |
);
|
| 91 |
_hooks = hooks;
|
| 92 |
_bus!.attachReader(hooks);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
Future<bool> _toggleCheck() async {
|
|
@@ -130,11 +169,25 @@ class _ReaderAgentBridgeState extends State<_ReaderAgentBridge> {
|
|
| 130 |
void dispose() {
|
| 131 |
final h = _hooks;
|
| 132 |
if (h != null) _bus?.detachReader(h);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
super.dispose();
|
| 134 |
}
|
| 135 |
|
| 136 |
@override
|
| 137 |
-
Widget build(BuildContext context) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
class _ReaderView extends StatelessWidget {
|
|
@@ -168,6 +221,7 @@ class _ReaderView extends StatelessWidget {
|
|
| 168 |
final accent = ContentAccent.of(card.base.contentType);
|
| 169 |
final readMins = _estimateReadMinutes(card);
|
| 170 |
final highlightStore = context.read<HighlightStore>();
|
|
|
|
| 171 |
|
| 172 |
void onHighlight(String text) {
|
| 173 |
highlightStore.add(Highlight(
|
|
@@ -241,7 +295,9 @@ class _ReaderView extends StatelessWidget {
|
|
| 241 |
|
| 242 |
// Structured blocks β skeleton while building, fades in on arrival
|
| 243 |
if (card.blocks.isNotEmpty) ...[
|
| 244 |
-
_fadeIn(
|
|
|
|
|
|
|
| 245 |
blocks: card.blocks,
|
| 246 |
onToggleChecklist: vm.toggleChecklistItem,
|
| 247 |
onToggleStep: vm.toggleStep,
|
|
@@ -255,7 +311,7 @@ class _ReaderView extends StatelessWidget {
|
|
| 255 |
),
|
| 256 |
),
|
| 257 |
onHighlight: card.isReady ? onHighlight : null,
|
| 258 |
-
)),
|
| 259 |
if (card.isProcessing) ...[
|
| 260 |
const SizedBox(height: 12),
|
| 261 |
const _SkeletonBox(height: 70, radius: Insets.radius),
|
|
@@ -274,16 +330,23 @@ class _ReaderView extends StatelessWidget {
|
|
| 274 |
if (card.isReady &&
|
| 275 |
card.insight != null &&
|
| 276 |
card.insight!.hasContent)
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
| 283 |
),
|
| 284 |
|
| 285 |
// Referenced catalog items
|
| 286 |
-
if (card.isReady)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
// Concepts this card contributed to
|
| 289 |
if (card.isReady) _ConceptsStrip(entries: vm.concepts),
|
|
@@ -302,20 +365,30 @@ class _ReaderView extends StatelessWidget {
|
|
| 302 |
return Column(
|
| 303 |
children: [
|
| 304 |
_EmbeddedHeader(card: card, accent: accent, onClose: onClose),
|
| 305 |
-
Expanded(child: SingleChildScrollView(child: content)),
|
| 306 |
-
if (card.isReady)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
],
|
| 308 |
);
|
| 309 |
}
|
| 310 |
|
| 311 |
return Scaffold(
|
| 312 |
body: CustomScrollView(
|
|
|
|
| 313 |
slivers: [
|
| 314 |
_FaceAppBar(card: card, api: api, accent: accent),
|
| 315 |
SliverToBoxAdapter(child: content),
|
| 316 |
],
|
| 317 |
),
|
| 318 |
-
bottomSheet: card.isReady
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
);
|
| 320 |
}
|
| 321 |
|
|
|
|
| 72 |
State<_ReaderAgentBridge> createState() => _ReaderAgentBridgeState();
|
| 73 |
}
|
| 74 |
|
| 75 |
+
/// Spotlight anchors for the reader's sections, so the presenter's cursor can
|
| 76 |
+
/// land on the real blocks / "Going deeper" / references / action bar it's
|
| 77 |
+
/// talking about instead of a guessed region.
|
| 78 |
+
class ReaderSpotlightKeys {
|
| 79 |
+
final blocks = GlobalKey();
|
| 80 |
+
final insight = GlobalKey();
|
| 81 |
+
final references = GlobalKey();
|
| 82 |
+
final actionBar = GlobalKey();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/// Hands the section keys down to [_ReaderView] without threading them through
|
| 86 |
+
/// every constructor. Absent when not presenting.
|
| 87 |
+
class _ReaderSpotlight extends InheritedWidget {
|
| 88 |
+
const _ReaderSpotlight({required this.keys, required super.child});
|
| 89 |
+
final ReaderSpotlightKeys keys;
|
| 90 |
+
|
| 91 |
+
static ReaderSpotlightKeys? of(BuildContext context) => context
|
| 92 |
+
.dependOnInheritedWidgetOfExactType<_ReaderSpotlight>()
|
| 93 |
+
?.keys;
|
| 94 |
+
|
| 95 |
+
@override
|
| 96 |
+
bool updateShouldNotify(_ReaderSpotlight old) => false;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
class _ReaderAgentBridgeState extends State<_ReaderAgentBridge> {
|
| 100 |
AgentBus? _bus;
|
| 101 |
ReaderAgentHooks? _hooks;
|
| 102 |
ReaderViewModel? _vm;
|
| 103 |
+
final _bodyKey = GlobalKey();
|
| 104 |
+
final _spotlight = ReaderSpotlightKeys();
|
| 105 |
+
|
| 106 |
+
// Handed to the reader's scroll view below via PrimaryScrollController, so
|
| 107 |
+
// the agent can scroll through a card while narrating it.
|
| 108 |
+
final _scroll = ScrollController();
|
| 109 |
|
| 110 |
@override
|
| 111 |
void didChangeDependencies() {
|
|
|
|
| 120 |
);
|
| 121 |
_hooks = hooks;
|
| 122 |
_bus!.attachReader(hooks);
|
| 123 |
+
_bus!.registerSpotlight('reader.body', _bodyKey);
|
| 124 |
+
_bus!.registerSpotlight('reader.blocks', _spotlight.blocks);
|
| 125 |
+
_bus!.registerSpotlight('reader.insight', _spotlight.insight);
|
| 126 |
+
_bus!.registerSpotlight('reader.references', _spotlight.references);
|
| 127 |
+
// Ask and the three-dots menu both live in the bottom action bar; point at
|
| 128 |
+
// it for both β the narration says which control.
|
| 129 |
+
_bus!.registerSpotlight('reader.ask', _spotlight.actionBar);
|
| 130 |
+
_bus!.registerSpotlight('reader.more', _spotlight.actionBar);
|
| 131 |
+
_bus!.registerScrollable('reader', _scroll);
|
| 132 |
}
|
| 133 |
|
| 134 |
Future<bool> _toggleCheck() async {
|
|
|
|
| 169 |
void dispose() {
|
| 170 |
final h = _hooks;
|
| 171 |
if (h != null) _bus?.detachReader(h);
|
| 172 |
+
_bus?.unregisterSpotlight('reader.body', _bodyKey);
|
| 173 |
+
_bus?.unregisterSpotlight('reader.blocks', _spotlight.blocks);
|
| 174 |
+
_bus?.unregisterSpotlight('reader.insight', _spotlight.insight);
|
| 175 |
+
_bus?.unregisterSpotlight('reader.references', _spotlight.references);
|
| 176 |
+
_bus?.unregisterSpotlight('reader.ask', _spotlight.actionBar);
|
| 177 |
+
_bus?.unregisterSpotlight('reader.more', _spotlight.actionBar);
|
| 178 |
+
_bus?.unregisterScrollable('reader', _scroll);
|
| 179 |
+
_scroll.dispose();
|
| 180 |
super.dispose();
|
| 181 |
}
|
| 182 |
|
| 183 |
@override
|
| 184 |
+
Widget build(BuildContext context) => PrimaryScrollController(
|
| 185 |
+
controller: _scroll,
|
| 186 |
+
child: _ReaderSpotlight(
|
| 187 |
+
keys: _spotlight,
|
| 188 |
+
child: KeyedSubtree(key: _bodyKey, child: widget.child),
|
| 189 |
+
),
|
| 190 |
+
);
|
| 191 |
}
|
| 192 |
|
| 193 |
class _ReaderView extends StatelessWidget {
|
|
|
|
| 221 |
final accent = ContentAccent.of(card.base.contentType);
|
| 222 |
final readMins = _estimateReadMinutes(card);
|
| 223 |
final highlightStore = context.read<HighlightStore>();
|
| 224 |
+
final spotlight = _ReaderSpotlight.of(context);
|
| 225 |
|
| 226 |
void onHighlight(String text) {
|
| 227 |
highlightStore.add(Highlight(
|
|
|
|
| 295 |
|
| 296 |
// Structured blocks β skeleton while building, fades in on arrival
|
| 297 |
if (card.blocks.isNotEmpty) ...[
|
| 298 |
+
_fadeIn(KeyedSubtree(
|
| 299 |
+
key: spotlight?.blocks,
|
| 300 |
+
child: BlockList(
|
| 301 |
blocks: card.blocks,
|
| 302 |
onToggleChecklist: vm.toggleChecklistItem,
|
| 303 |
onToggleStep: vm.toggleStep,
|
|
|
|
| 311 |
),
|
| 312 |
),
|
| 313 |
onHighlight: card.isReady ? onHighlight : null,
|
| 314 |
+
))),
|
| 315 |
if (card.isProcessing) ...[
|
| 316 |
const SizedBox(height: 12),
|
| 317 |
const _SkeletonBox(height: 70, radius: Insets.radius),
|
|
|
|
| 330 |
if (card.isReady &&
|
| 331 |
card.insight != null &&
|
| 332 |
card.insight!.hasContent)
|
| 333 |
+
KeyedSubtree(
|
| 334 |
+
key: spotlight?.insight,
|
| 335 |
+
child: InsightSection(
|
| 336 |
+
insight: card.insight!,
|
| 337 |
+
accent: accent,
|
| 338 |
+
cardId: card.cardId,
|
| 339 |
+
cardTitle: card.base.oneLiner,
|
| 340 |
+
readMinutes: readMins,
|
| 341 |
+
),
|
| 342 |
),
|
| 343 |
|
| 344 |
// Referenced catalog items
|
| 345 |
+
if (card.isReady)
|
| 346 |
+
KeyedSubtree(
|
| 347 |
+
key: spotlight?.references,
|
| 348 |
+
child: _ReferencesStrip(entries: vm.artifacts),
|
| 349 |
+
),
|
| 350 |
|
| 351 |
// Concepts this card contributed to
|
| 352 |
if (card.isReady) _ConceptsStrip(entries: vm.concepts),
|
|
|
|
| 365 |
return Column(
|
| 366 |
children: [
|
| 367 |
_EmbeddedHeader(card: card, accent: accent, onClose: onClose),
|
| 368 |
+
Expanded(child: SingleChildScrollView(primary: true, child: content)),
|
| 369 |
+
if (card.isReady)
|
| 370 |
+
KeyedSubtree(
|
| 371 |
+
key: spotlight?.actionBar,
|
| 372 |
+
child: PrimaryActionBar(card: card),
|
| 373 |
+
),
|
| 374 |
],
|
| 375 |
);
|
| 376 |
}
|
| 377 |
|
| 378 |
return Scaffold(
|
| 379 |
body: CustomScrollView(
|
| 380 |
+
primary: true,
|
| 381 |
slivers: [
|
| 382 |
_FaceAppBar(card: card, api: api, accent: accent),
|
| 383 |
SliverToBoxAdapter(child: content),
|
| 384 |
],
|
| 385 |
),
|
| 386 |
+
bottomSheet: card.isReady
|
| 387 |
+
? KeyedSubtree(
|
| 388 |
+
key: spotlight?.actionBar,
|
| 389 |
+
child: PrimaryActionBar(card: card),
|
| 390 |
+
)
|
| 391 |
+
: null,
|
| 392 |
);
|
| 393 |
}
|
| 394 |
|
|
@@ -38,9 +38,12 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 38 |
String _query = '';
|
| 39 |
ContentType? _filter; // null = All
|
| 40 |
|
| 41 |
-
// Agent driving: let the presenter agent run searches while mounted
|
|
|
|
| 42 |
AgentBus? _bus;
|
| 43 |
SearchAgentHooks? _hooks;
|
|
|
|
|
|
|
| 44 |
|
| 45 |
@override
|
| 46 |
void didChangeDependencies() {
|
|
@@ -50,6 +53,9 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 50 |
final hooks = SearchAgentHooks(run: _runAgentQuery, filter: _applyAgentFilter);
|
| 51 |
_hooks = hooks;
|
| 52 |
_bus!.attachSearch(hooks);
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
void _runAgentQuery(String query) {
|
|
@@ -114,6 +120,9 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 114 |
void dispose() {
|
| 115 |
final h = _hooks;
|
| 116 |
if (h != null) _bus?.detachSearch(h);
|
|
|
|
|
|
|
|
|
|
| 117 |
_debounce?.cancel();
|
| 118 |
_controller.dispose();
|
| 119 |
super.dispose();
|
|
@@ -126,6 +135,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 126 |
appBar: AppBar(
|
| 127 |
titleSpacing: 0,
|
| 128 |
title: TextField(
|
|
|
|
| 129 |
controller: _controller,
|
| 130 |
autofocus: true,
|
| 131 |
onChanged: _onChanged,
|
|
@@ -182,6 +192,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 182 |
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 183 |
children: [
|
| 184 |
_FilterBar(
|
|
|
|
| 185 |
types: _presentTypes,
|
| 186 |
selected: _filter,
|
| 187 |
total: _results.length,
|
|
@@ -224,6 +235,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 224 |
/// filters for types present in the results (docs/06 search).
|
| 225 |
class _FilterBar extends StatelessWidget {
|
| 226 |
const _FilterBar({
|
|
|
|
| 227 |
required this.types,
|
| 228 |
required this.selected,
|
| 229 |
required this.total,
|
|
|
|
| 38 |
String _query = '';
|
| 39 |
ContentType? _filter; // null = All
|
| 40 |
|
| 41 |
+
// Agent driving: let the presenter agent run searches while mounted, and
|
| 42 |
+
// give its spotlight real geometry for the field and the filter bar.
|
| 43 |
AgentBus? _bus;
|
| 44 |
SearchAgentHooks? _hooks;
|
| 45 |
+
final _fieldKey = GlobalKey();
|
| 46 |
+
final _filtersKey = GlobalKey();
|
| 47 |
|
| 48 |
@override
|
| 49 |
void didChangeDependencies() {
|
|
|
|
| 53 |
final hooks = SearchAgentHooks(run: _runAgentQuery, filter: _applyAgentFilter);
|
| 54 |
_hooks = hooks;
|
| 55 |
_bus!.attachSearch(hooks);
|
| 56 |
+
_bus!
|
| 57 |
+
..registerSpotlight('search.field', _fieldKey)
|
| 58 |
+
..registerSpotlight('search.filters', _filtersKey);
|
| 59 |
}
|
| 60 |
|
| 61 |
void _runAgentQuery(String query) {
|
|
|
|
| 120 |
void dispose() {
|
| 121 |
final h = _hooks;
|
| 122 |
if (h != null) _bus?.detachSearch(h);
|
| 123 |
+
_bus
|
| 124 |
+
?..unregisterSpotlight('search.field', _fieldKey)
|
| 125 |
+
..unregisterSpotlight('search.filters', _filtersKey);
|
| 126 |
_debounce?.cancel();
|
| 127 |
_controller.dispose();
|
| 128 |
super.dispose();
|
|
|
|
| 135 |
appBar: AppBar(
|
| 136 |
titleSpacing: 0,
|
| 137 |
title: TextField(
|
| 138 |
+
key: _fieldKey,
|
| 139 |
controller: _controller,
|
| 140 |
autofocus: true,
|
| 141 |
onChanged: _onChanged,
|
|
|
|
| 192 |
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 193 |
children: [
|
| 194 |
_FilterBar(
|
| 195 |
+
key: _filtersKey,
|
| 196 |
types: _presentTypes,
|
| 197 |
selected: _filter,
|
| 198 |
total: _results.length,
|
|
|
|
| 235 |
/// filters for types present in the results (docs/06 search).
|
| 236 |
class _FilterBar extends StatelessWidget {
|
| 237 |
const _FilterBar({
|
| 238 |
+
super.key,
|
| 239 |
required this.types,
|
| 240 |
required this.selected,
|
| 241 |
required this.total,
|
|
@@ -33,8 +33,8 @@ _VIEWS = {
|
|
| 33 |
|
| 34 |
# Action verbs the Flutter agent knows how to execute (mirrors AgentAction).
|
| 35 |
_ACTIONS = {
|
| 36 |
-
"navigate", "wait", "
|
| 37 |
-
"reader_toggle",
|
| 38 |
"graph_focus", "graph_open", "graph_wander", "graph_reset", "graph_cluster",
|
| 39 |
"graph_concepts",
|
| 40 |
"feed_next", "feed_prev", "feed_shuffle",
|
|
@@ -78,13 +78,19 @@ asks a question or hands you a task, don't just describe things β DEMONSTRATE
|
|
| 78 |
them by driving the app, then narrate what's happening.
|
| 79 |
|
| 80 |
You reply with an ordered list of "beats". Each beat has:
|
| 81 |
-
- "say": one short
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
An action is an object with a "do" field and optional args:
|
| 86 |
- {{"do":"navigate","view":"<view>"}} β view is one of: library, feed, graph,
|
| 87 |
search, collections, actions, profile, catalog, concepts, connections.
|
|
|
|
|
|
|
| 88 |
- {{"do":"search","query":"<text>"}} β open search and run a query.
|
| 89 |
- {{"do":"search_filter"}} β narrow current results by content type.
|
| 90 |
- {{"do":"open_card","query":"<text>"}} β open the best-matching saved card.
|
|
@@ -125,6 +131,11 @@ class AskRequest(BaseModel):
|
|
| 125 |
question: str
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
class Step(BaseModel):
|
| 129 |
say: str | None = None
|
| 130 |
action: dict[str, Any] | None = None
|
|
@@ -161,6 +172,15 @@ def _clean_action(raw: Any) -> dict[str, Any] | None:
|
|
| 161 |
action["ms"] = int(raw.get("ms", 800))
|
| 162 |
except (TypeError, ValueError):
|
| 163 |
action["ms"] = 800
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
return action
|
| 165 |
|
| 166 |
|
|
@@ -198,6 +218,26 @@ def _parse(raw: str | None) -> AskResponse:
|
|
| 198 |
return AskResponse(steps=steps) if steps else fallback
|
| 199 |
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
@router.post("/ask", response_model=AskResponse)
|
| 202 |
async def ask(req: AskRequest) -> AskResponse:
|
| 203 |
"""Answer one audience question/task as an ordered list of speak+do beats."""
|
|
|
|
| 33 |
|
| 34 |
# Action verbs the Flutter agent knows how to execute (mirrors AgentAction).
|
| 35 |
_ACTIONS = {
|
| 36 |
+
"navigate", "wait", "scroll", "create_card", "search", "search_filter",
|
| 37 |
+
"open_card", "reader_toggle",
|
| 38 |
"graph_focus", "graph_open", "graph_wander", "graph_reset", "graph_cluster",
|
| 39 |
"graph_concepts",
|
| 40 |
"feed_next", "feed_prev", "feed_shuffle",
|
|
|
|
| 78 |
them by driving the app, then narrate what's happening.
|
| 79 |
|
| 80 |
You reply with an ordered list of "beats". Each beat has:
|
| 81 |
+
- "say": one short line to speak aloud, or null to perform the action silently.
|
| 82 |
+
Write it the way a friendly human presenter talks: casual, contractions,
|
| 83 |
+
short sentences, react to what's on screen ("okay, watch this", "there it
|
| 84 |
+
is"). No markdown. Avoid acronyms entirely β say "the link" not "URL",
|
| 85 |
+
"a quick summary" not "TL;DR" β never spell letters out.
|
| 86 |
+
- "action": one thing to do, or null to just talk. The app speaks the line
|
| 87 |
+
WHILE performing the action, like a person narrating over their own clicks.
|
| 88 |
|
| 89 |
An action is an object with a "do" field and optional args:
|
| 90 |
- {{"do":"navigate","view":"<view>"}} β view is one of: library, feed, graph,
|
| 91 |
search, collections, actions, profile, catalog, concepts, connections.
|
| 92 |
+
- {{"do":"scroll","target":"library|reader","dy":<pixels>}} β scroll the
|
| 93 |
+
library grid or the open card; negative dy scrolls up.
|
| 94 |
- {{"do":"search","query":"<text>"}} β open search and run a query.
|
| 95 |
- {{"do":"search_filter"}} β narrow current results by content type.
|
| 96 |
- {{"do":"open_card","query":"<text>"}} β open the best-matching saved card.
|
|
|
|
| 131 |
question: str
|
| 132 |
|
| 133 |
|
| 134 |
+
class LogRequest(BaseModel):
|
| 135 |
+
message: str
|
| 136 |
+
level: str = "info"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
class Step(BaseModel):
|
| 140 |
say: str | None = None
|
| 141 |
action: dict[str, Any] | None = None
|
|
|
|
| 172 |
action["ms"] = int(raw.get("ms", 800))
|
| 173 |
except (TypeError, ValueError):
|
| 174 |
action["ms"] = 800
|
| 175 |
+
if verb == "scroll":
|
| 176 |
+
target = str(raw.get("target", "library")).strip().lower()
|
| 177 |
+
if target not in {"library", "reader"}:
|
| 178 |
+
return None
|
| 179 |
+
action["target"] = target
|
| 180 |
+
try:
|
| 181 |
+
action["dy"] = int(raw.get("dy", 600))
|
| 182 |
+
except (TypeError, ValueError):
|
| 183 |
+
action["dy"] = 600
|
| 184 |
return action
|
| 185 |
|
| 186 |
|
|
|
|
| 218 |
return AskResponse(steps=steps) if steps else fallback
|
| 219 |
|
| 220 |
|
| 221 |
+
_LOG_LEVELS = {
|
| 222 |
+
"debug": logging.DEBUG,
|
| 223 |
+
"info": logging.INFO,
|
| 224 |
+
"warning": logging.WARNING,
|
| 225 |
+
"error": logging.ERROR,
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@router.post("/log")
|
| 230 |
+
async def relay_log(req: LogRequest) -> dict[str, bool]:
|
| 231 |
+
"""Relay a Present-mode event from the browser into this same pipeline
|
| 232 |
+
log, since `debugPrint` alone depends on the browser's DWDS log
|
| 233 |
+
forwarding making it back to the terminal β flaky enough that presenter
|
| 234 |
+
errors were going unseen. This endpoint is the reliable path: whatever
|
| 235 |
+
lands here is guaranteed to print in `start.py`'s console."""
|
| 236 |
+
level = _LOG_LEVELS.get(req.level.strip().lower(), logging.INFO)
|
| 237 |
+
log.log(level, "[frontend] %s", req.message.strip())
|
| 238 |
+
return {"ok": True}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
@router.post("/ask", response_model=AskResponse)
|
| 242 |
async def ask(req: AskRequest) -> AskResponse:
|
| 243 |
"""Answer one audience question/task as an ordered list of speak+do beats."""
|