feat(assistant): search the web too, and choose what goes along
The assistant's field now has a switch at its start: a question goes to
Claude or into a web search, and the field remembers which. A web search
opens the chosen engine's results: on Android the device's own web
search by default (ACTION_WEB_SEARCH, answered by its default browser or
search app; Brave Search where nothing answers), elsewhere Brave Search
by default; Settings › Assistant › Search engine offers Ecosia,
DuckDuckGo, Startpage, Qwant, Google, Bing, or one's own address with
%s where the question goes.
Above the field, chips offer what the question is about: a card's
front, back, each hint and its explanation, the studyset's name and
description. None is chosen, so only what the person picks goes along,
each part cut at 600 characters; Claude gets the question and a compact
line per chosen part, a web search the question and the chosen text,
never the studyset's name. A chosen chip alone is enough to send. The
old sentences around every question (where it was asked, the connector)
are gone: they cost tokens for nothing unless the studyset is chosen.
On a studyset's screen the button now stands above the bottom bar, as in
the Library: unfolded inside the bar, its chips lay outside the bar's
bounds, where taps don't land.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 14:41:36 +02:00
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
|
|
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
|
|
|
|
|
|
import '../../support/fake_card_gateway.dart';
|
|
|
|
|
import '../../support/fake_studyset_gateway.dart';
|
|
|
|
|
import '../../support/memory_device_settings_store.dart';
|
|
|
|
|
import '../../support/pump_app.dart';
|
|
|
|
|
import '../../support/test_dependencies.dart';
|
|
|
|
|
|
|
|
|
|
/// Rich content with one paragraph of [text].
|
|
|
|
|
RichContent paragraph(String text) => RichContent({
|
|
|
|
|
'document': {
|
|
|
|
|
'type': 'page',
|
|
|
|
|
'children': [
|
|
|
|
|
{
|
|
|
|
|
'type': 'paragraph',
|
|
|
|
|
'data': {
|
|
|
|
|
'delta': [
|
|
|
|
|
{'insert': text},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
late List<Uri> claude;
|
|
|
|
|
late List<Uri> searched;
|
|
|
|
|
late List<String> onDevice;
|
|
|
|
|
late bool opens;
|
|
|
|
|
late bool deviceAnswers;
|
|
|
|
|
late FakeStudysetGateway studysets;
|
|
|
|
|
late FakeCardGateway cards;
|
|
|
|
|
late MemoryDeviceSettingsStore store;
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
claude = [];
|
|
|
|
|
searched = [];
|
|
|
|
|
onDevice = [];
|
|
|
|
|
opens = true;
|
|
|
|
|
deviceAnswers = true;
|
|
|
|
|
cards = FakeCardGateway();
|
|
|
|
|
studysets = FakeStudysetGateway()
|
|
|
|
|
..receiveRemoteChange(
|
|
|
|
|
'japanese',
|
|
|
|
|
encodeStudysetPayload(
|
|
|
|
|
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Shows the app at [location] on a phone, asking turned [on] or off,
|
|
|
|
|
/// with questions going to [target].
|
|
|
|
|
Future<void> showApp(
|
|
|
|
|
WidgetTester tester, {
|
|
|
|
|
bool on = true,
|
|
|
|
|
String location = RoutePaths.home,
|
|
|
|
|
AssistantTarget target = AssistantTarget.claude,
|
|
|
|
|
String? engine,
|
|
|
|
|
String? customUrl,
|
|
|
|
|
}) async {
|
|
|
|
|
tester.view.physicalSize = const Size(400, 900);
|
|
|
|
|
tester.view.devicePixelRatio = 1;
|
|
|
|
|
addTearDown(tester.view.reset);
|
|
|
|
|
final initial = DeviceSettings(
|
|
|
|
|
assistant: on,
|
|
|
|
|
assistantTarget: target,
|
|
|
|
|
searchEngine: engine,
|
|
|
|
|
customSearchUrl: customUrl,
|
|
|
|
|
);
|
|
|
|
|
store = MemoryDeviceSettingsStore(initial);
|
|
|
|
|
await pumpApp(
|
|
|
|
|
tester,
|
|
|
|
|
testDependencies(
|
|
|
|
|
studysetGateway: studysets,
|
|
|
|
|
cardGateway: cards,
|
|
|
|
|
claude: ClaudeHandOff(
|
|
|
|
|
open: (url) async {
|
|
|
|
|
claude.add(url);
|
|
|
|
|
return opens;
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
webSearch: WebSearch(
|
|
|
|
|
open: (url) async {
|
|
|
|
|
searched.add(url);
|
|
|
|
|
return opens;
|
|
|
|
|
},
|
|
|
|
|
deviceSearch: (query) async {
|
|
|
|
|
onDevice.add(query);
|
|
|
|
|
return deviceAnswers;
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
deviceSettings: initial,
|
|
|
|
|
deviceSettingsStore: store,
|
|
|
|
|
),
|
|
|
|
|
location: location,
|
|
|
|
|
);
|
|
|
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What was handed to Claude.
|
|
|
|
|
String asked() => claude.single.queryParameters['q']!;
|
|
|
|
|
|
|
|
|
|
/// Types [question] into the field and sends it.
|
|
|
|
|
Future<void> send(WidgetTester tester, String question, String send) async {
|
|
|
|
|
await tester.enterText(find.byType(TextField).last, question);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.tap(find.byTooltip(send));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Unfolds the floating button.
|
|
|
|
|
Future<void> unfold(WidgetTester tester) async {
|
|
|
|
|
await tester.tap(find.byTooltip('Ask or search'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
group('Handing a question to Claude', () {
|
|
|
|
|
test('opens a new conversation at claude.ai with it written in', () {
|
|
|
|
|
final url = ClaudeHandOff.urlFor('Was heißt ひ? & mehr');
|
|
|
|
|
|
|
|
|
|
expect(url.host, 'claude.ai');
|
|
|
|
|
expect(url.path, '/new');
|
|
|
|
|
expect(url.queryParameters['q'], 'Was heißt ひ? & mehr');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('cuts a long question short where claude.ai would', () {
|
|
|
|
|
final prompt = ClaudeHandOff.clip('a' * 20000);
|
|
|
|
|
|
|
|
|
|
expect(prompt.length, ClaudeHandOff.maxPromptLength);
|
|
|
|
|
expect(prompt, endsWith('…'));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('Searching the web', () {
|
|
|
|
|
test("puts the question into the engine's address", () {
|
|
|
|
|
expect(
|
|
|
|
|
WebSearch.urlFor('は vs が', SearchEngine.brave).toString(),
|
|
|
|
|
'https://search.brave.com/search?q=%E3%81%AF+vs+%E3%81%8C',
|
|
|
|
|
);
|
|
|
|
|
expect(WebSearch.urlFor('x', SearchEngine.ecosia).host, 'www.ecosia.org');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a custom address takes the question where %s is; one that won't "
|
|
|
|
|
'do falls back to Brave Search', () {
|
|
|
|
|
expect(
|
|
|
|
|
WebSearch.urlFor(
|
|
|
|
|
'kanji',
|
|
|
|
|
SearchEngine.custom,
|
|
|
|
|
customUrl: 'https://search.example.org/?s=%s&lang=de',
|
|
|
|
|
).toString(),
|
|
|
|
|
'https://search.example.org/?s=kanji&lang=de',
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
WebSearch.urlFor(
|
|
|
|
|
'kanji',
|
|
|
|
|
SearchEngine.custom,
|
|
|
|
|
customUrl: 'ftp://nowhere',
|
|
|
|
|
).host,
|
|
|
|
|
'search.brave.com',
|
|
|
|
|
);
|
|
|
|
|
expect(SearchEngine.validTemplate('https://a.example/?q=%s'), isTrue);
|
|
|
|
|
expect(SearchEngine.validTemplate('https://a.example/'), isFalse);
|
|
|
|
|
expect(SearchEngine.validTemplate('javascript:%s'), isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("the device's own search exists only on Android", () {
|
|
|
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
|
|
|
expect(SearchEngine.platformDefault, SearchEngine.device);
|
|
|
|
|
expect(SearchEngine.byName('device'), SearchEngine.device);
|
|
|
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
|
|
|
|
expect(SearchEngine.platformDefault, SearchEngine.brave);
|
|
|
|
|
expect(SearchEngine.byName('device'), SearchEngine.brave);
|
|
|
|
|
debugDefaultTargetPlatformOverride = null;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('What goes along', () {
|
|
|
|
|
final card = CardContent(
|
|
|
|
|
type: CardType.vocabulary,
|
|
|
|
|
front: paragraph('火'),
|
|
|
|
|
back: RichContent.empty(),
|
|
|
|
|
reading: 'ひ',
|
|
|
|
|
meanings: const ['fire', 'flame'],
|
|
|
|
|
hints: [paragraph('火曜日は月曜日の次です。')],
|
|
|
|
|
explanation: paragraph('A kanji of the five elements.'),
|
|
|
|
|
);
|
|
|
|
|
final parts = AssistantTopicCard(
|
|
|
|
|
studysetTitle: 'Japanese',
|
|
|
|
|
card: card,
|
|
|
|
|
).parts;
|
|
|
|
|
|
|
|
|
|
test('a card offers its front, its back, each hint, its explanation and '
|
|
|
|
|
"the studyset's name", () {
|
|
|
|
|
expect(
|
|
|
|
|
[for (final part in parts) part.kind],
|
|
|
|
|
[
|
|
|
|
|
AssistantPartKind.front,
|
|
|
|
|
AssistantPartKind.back,
|
|
|
|
|
AssistantPartKind.hint,
|
|
|
|
|
AssistantPartKind.explanation,
|
|
|
|
|
AssistantPartKind.studyset,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
expect(parts[1].text, 'ひ\nfire; flame');
|
|
|
|
|
expect(parts[2].number, 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a long part is cut short', () {
|
|
|
|
|
const part = AssistantPart(kind: AssistantPartKind.hint, text: '');
|
|
|
|
|
final long = AssistantPart(
|
|
|
|
|
kind: AssistantPartKind.hint,
|
|
|
|
|
text: 'x' * 2000,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(long.sent.length, AssistantPart.maxLength);
|
|
|
|
|
expect(part.sent, '');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a web search takes the question and the text of each part chosen, '
|
|
|
|
|
"never the studyset's name", () {
|
|
|
|
|
expect(
|
|
|
|
|
searchQuery('Why?', const []),
|
|
|
|
|
'Why?',
|
|
|
|
|
);
|
|
|
|
|
expect(searchQuery('Why?', [parts[2], parts[4]]), 'Why? 火曜日は月曜日の次です。');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('Off unless turned on', () {
|
|
|
|
|
testWidgets('no field on Home', (tester) async {
|
|
|
|
|
await showApp(tester, on: false);
|
|
|
|
|
|
|
|
|
|
expect(find.byType(TextField), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('no button in the Library, Create studyset as before', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await showApp(tester, on: false, location: RoutePaths.library);
|
|
|
|
|
|
|
|
|
|
expect(find.byTooltip('Ask or search'), findsNothing);
|
|
|
|
|
expect(find.byTooltip('Create studyset'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('Settings turns it on, kept for the next start, and chooses '
|
|
|
|
|
'the search engine', (tester) async {
|
|
|
|
|
await showApp(tester, on: false, location: RoutePaths.settings);
|
|
|
|
|
final settings = find
|
|
|
|
|
.descendant(
|
|
|
|
|
of: find.byType(SettingsScreen),
|
|
|
|
|
matching: find.byType(Scrollable),
|
|
|
|
|
)
|
|
|
|
|
.first;
|
|
|
|
|
await tester.scrollUntilVisible(
|
|
|
|
|
find.text('Ask Claude or search the web'),
|
|
|
|
|
200,
|
|
|
|
|
scrollable: settings,
|
|
|
|
|
);
|
|
|
|
|
await tester.ensureVisible(find.text('Ask Claude or search the web'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.text('Ask Claude or search the web'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect((await store.read()).assistant, isTrue);
|
|
|
|
|
|
|
|
|
|
await tester.scrollUntilVisible(
|
|
|
|
|
find.text('Search engine'),
|
|
|
|
|
200,
|
|
|
|
|
scrollable: settings,
|
|
|
|
|
);
|
|
|
|
|
await tester.ensureVisible(find.text('Search engine'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.text('Search engine'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.text('Ecosia'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect((await store.read()).searchEngine, 'ecosia');
|
|
|
|
|
expect(find.text('Ecosia'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("Home's field hands the question alone to Claude, and empties", (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
|
|
|
|
|
await send(tester, 'What should I repeat today?', 'Ask in Claude');
|
|
|
|
|
|
|
|
|
|
expect(asked(), 'What should I repeat today?');
|
|
|
|
|
expect(
|
|
|
|
|
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
|
|
|
|
isEmpty,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the switch at its start sends to a web search instead, and '
|
|
|
|
|
'stays so', (tester) async {
|
|
|
|
|
// A desktop: a web search opens Brave Search's results.
|
|
|
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byTooltip('Search the web instead'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Search the web…'), findsOneWidget);
|
|
|
|
|
await send(tester, 'は vs が', 'Search');
|
|
|
|
|
|
|
|
|
|
expect(claude, isEmpty);
|
|
|
|
|
expect(searched.single.host, 'search.brave.com');
|
|
|
|
|
expect(searched.single.queryParameters['q'], 'は vs が');
|
|
|
|
|
expect((await store.read()).assistantTarget, AssistantTarget.web);
|
|
|
|
|
debugDefaultTargetPlatformOverride = null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("on Android, the device's own search takes it; Brave Search "
|
|
|
|
|
'where nothing on the device answers', (tester) async {
|
|
|
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
|
|
|
await showApp(tester, target: AssistantTarget.web);
|
|
|
|
|
|
|
|
|
|
await send(tester, 'particle は', 'Search');
|
|
|
|
|
expect(onDevice, ['particle は']);
|
|
|
|
|
expect(searched, isEmpty);
|
|
|
|
|
|
|
|
|
|
deviceAnswers = false;
|
|
|
|
|
await send(tester, 'particle が', 'Search');
|
|
|
|
|
expect(searched.single.host, 'search.brave.com');
|
|
|
|
|
debugDefaultTargetPlatformOverride = null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a custom engine searches at its own address', (tester) async {
|
|
|
|
|
await showApp(
|
|
|
|
|
tester,
|
|
|
|
|
target: AssistantTarget.web,
|
|
|
|
|
engine: 'custom',
|
|
|
|
|
customUrl: 'https://search.example.org/?s=%s',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await send(tester, 'kanji', 'Search');
|
|
|
|
|
|
|
|
|
|
expect(searched.single.toString(), 'https://search.example.org/?s=kanji');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('nothing to send while the field is blank and nothing chosen', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
|
|
|
|
|
await tester.enterText(find.byType(TextField), ' ');
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.tap(find.byTooltip('Ask in Claude'));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(claude, isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("says so when Claude can't be opened", (tester) async {
|
|
|
|
|
opens = false;
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
|
|
|
|
|
await send(tester, 'Hello?', 'Ask in Claude');
|
|
|
|
|
|
|
|
|
|
expect(find.text("Claude couldn't be opened."), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("the Library's button sits above Create studyset, unfolds into "
|
|
|
|
|
'the field, and folds back once sent', (tester) async {
|
|
|
|
|
await showApp(tester, location: RoutePaths.library);
|
|
|
|
|
final button = tester.getCenter(find.byTooltip('Ask or search'));
|
|
|
|
|
final create = tester.getCenter(find.byTooltip('Create studyset'));
|
|
|
|
|
expect(button.dy, lessThan(create.dy));
|
|
|
|
|
expect(button.dx, closeTo(create.dx, 8));
|
|
|
|
|
|
|
|
|
|
await unfold(tester);
|
|
|
|
|
expect(find.byTooltip('Create studyset'), findsNothing);
|
|
|
|
|
await send(tester, 'Which studyset needs me most?', 'Ask in Claude');
|
|
|
|
|
|
|
|
|
|
expect(asked(), 'Which studyset needs me most?');
|
|
|
|
|
expect(find.byTooltip('Ask or search'), findsOneWidget);
|
|
|
|
|
expect(find.byTooltip('Create studyset'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("a studyset's name goes along only when chosen, and never "
|
|
|
|
|
'into a web search', (tester) async {
|
|
|
|
|
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
|
|
|
|
final center = tester.getCenter(find.byTooltip('Ask or search'));
|
|
|
|
|
expect(center.dx, greaterThan(300));
|
|
|
|
|
expect(
|
|
|
|
|
center.dy,
|
feat(studyset): the screens behind a studyset are its path
Deeper studysets, on iOS and Android alike (decided 2026-10-11, after
Material 3's and Apple's guidelines, Android's principles of navigation and
Nielsen Norman Group's on breadcrumbs):
- The screens behind a studyset are its path: below "Japanese › Grammar ›
Tae Kim's Grammar Guide" lie Grammar's screen, then Japanese's, then the
library. A topic at any depth opens with the levels between pushed
beneath it; the path's steps go up by popping to that level's page (they
replaced the location before, so back could skip levels); a studyset
opened from elsewhere gets its levels beneath it. Back, Android's
predictive back, iOS's swipe from the edge and the path all lead one
level up.
- The path's steps are targets 48 dp high; a long press on back lists the
levels above, the nearest first and the library last, as iOS's back
buttons do.
- Below expanded windows the topics open in a sheet from "3 topics ▾" at
the end of the path's line, where Vaia puts "All topics"; no drawer,
whose swipe from the edge is iOS's way back and which Material 3
Expressive deprecates. Topics with topics of their own unfold them, at
any depth, in the sheet and the side column alike.
- The navigation bar steps aside inside a studyset (its screen, notes,
paths and dictionary): Material 3 puts it on the top-level screens and
toolbars below, never both. Apple's guidelines keep the tab bar, lest
people forget where they are; the path, starting at Library, says where.
A strip as high as the system's bar keeps those screens clear of it.
- The bar at the bottom keeps Add material and Study.
A trial for review, on its own branch, on top of the floating navigation
bar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-11 13:03:27 +02:00
|
|
|
lessThan(tester.getCenter(find.byTooltip('Add material')).dy),
|
feat(assistant): search the web too, and choose what goes along
The assistant's field now has a switch at its start: a question goes to
Claude or into a web search, and the field remembers which. A web search
opens the chosen engine's results: on Android the device's own web
search by default (ACTION_WEB_SEARCH, answered by its default browser or
search app; Brave Search where nothing answers), elsewhere Brave Search
by default; Settings › Assistant › Search engine offers Ecosia,
DuckDuckGo, Startpage, Qwant, Google, Bing, or one's own address with
%s where the question goes.
Above the field, chips offer what the question is about: a card's
front, back, each hint and its explanation, the studyset's name and
description. None is chosen, so only what the person picks goes along,
each part cut at 600 characters; Claude gets the question and a compact
line per chosen part, a web search the question and the chosen text,
never the studyset's name. A chosen chip alone is enough to send. The
old sentences around every question (where it was asked, the connector)
are gone: they cost tokens for nothing unless the studyset is chosen.
On a studyset's screen the button now stands above the bottom bar, as in
the Library: unfolded inside the bar, its chips lay outside the bar's
bounds, where taps don't land.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 14:41:36 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await unfold(tester);
|
|
|
|
|
await tester.tap(find.text('Studyset: Japanese'));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await send(tester, 'Quiz me on it', 'Ask in Claude');
|
|
|
|
|
expect(asked(), startsWith('Quiz me on it\n\n'));
|
|
|
|
|
expect(asked(), contains('“Japanese”'));
|
|
|
|
|
expect(asked(), contains('learn.example.org'));
|
|
|
|
|
|
|
|
|
|
await unfold(tester);
|
|
|
|
|
await tester.tap(find.byTooltip('Search the web instead'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Studyset: Japanese'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("in a session, the card's parts go along only as chosen, and "
|
|
|
|
|
'a chosen hint alone is enough to search', (tester) async {
|
|
|
|
|
// A desktop: a web search opens Brave Search's results.
|
|
|
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
|
|
|
|
final repository = CardRepository(cards);
|
|
|
|
|
addTearDown(repository.dispose);
|
|
|
|
|
await repository.create(
|
|
|
|
|
'japanese',
|
|
|
|
|
CardContent(
|
|
|
|
|
front: paragraph('ひ'),
|
|
|
|
|
back: paragraph('hi'),
|
|
|
|
|
hints: [paragraph('ひらがな is the cursive script.')],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await showApp(tester, location: RoutePaths.study('japanese'));
|
|
|
|
|
|
|
|
|
|
await unfold(tester);
|
|
|
|
|
await send(tester, 'How do I remember this?', 'Ask in Claude');
|
|
|
|
|
expect(asked(), 'How do I remember this?');
|
|
|
|
|
|
|
|
|
|
await unfold(tester);
|
|
|
|
|
await tester.tap(find.text('Front: ひ'));
|
|
|
|
|
await tester.tap(find.text('Back: hi'));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await send(tester, 'And this?', 'Ask in Claude');
|
|
|
|
|
expect(claude.last.queryParameters['q'], 'And this?\n\nFront: ひ\nBack: hi');
|
|
|
|
|
|
|
|
|
|
await unfold(tester);
|
|
|
|
|
await tester.tap(find.byTooltip('Search the web instead'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
// The chips scroll sideways.
|
|
|
|
|
await tester.ensureVisible(find.textContaining('Hint 1: ひらがな'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.textContaining('Hint 1: ひらがな'));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.tap(find.byTooltip('Search'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(
|
|
|
|
|
searched.single.queryParameters['q'],
|
|
|
|
|
'ひらがな is the cursive script.',
|
|
|
|
|
);
|
|
|
|
|
debugDefaultTargetPlatformOverride = null;
|
|
|
|
|
});
|
|
|
|
|
}
|