feat(learn): + opens a sheet as Claude's, Camera, Photos and Files, and what goes along in rounded rows

Following the developer's review of 3 Nov 2026 (ADR 0021, D67):

- + below the assistant's field, and below a conversation's follow-up,
  opens "Add to your question" as Claude's "Add to session": three big
  tiles side by side, Camera (where the device takes photos) and Photos
  for an assistant reading images, and Files; below them, in the field, a
  row leading to what goes along, showing how far the assistant looks and
  how many parts are chosen.
- Camera and Photos through image_picker (flutter.dev's own): the camera's
  own app and the system's photo picker, no permission asked on Android, a
  file dialog on the desktop, which has no Camera; photos made at most
  2048 pixels a side, read on the device like any file attached.
- What goes along is chosen in rounded rows, as Claude's Permission, in
  place of radio buttons and chips: how far it looks with a tick, private
  studysets with a switch, the parts each with a tick, a card's image with
  its thumbnail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 21:56:18 +01:00
commit 7be31dcbfc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 639 additions and 94 deletions

View file

@ -33,6 +33,7 @@ import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_conversation_gateway.dart';
import '../../support/fake_grammar_gateway.dart';
import '../../support/fake_photo_picker.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/memory_asset_file_cache.dart';
@ -117,6 +118,7 @@ void main() {
late FakeSettingsGateway settings;
late FakeLlmClient llm;
late FakeFilePicker picker;
late FakePhotoPicker photos;
late MemoryDeviceSettingsStore store;
late List<Uri> searched;
late FakeAssetGateway assetGateway;
@ -143,6 +145,7 @@ void main() {
settings = FakeSettingsGateway();
llm = FakeLlmClient();
picker = FakeFilePicker();
photos = FakePhotoPicker();
searched = [];
assetGateway = FakeAssetGateway();
assetFiles = MemoryAssetFileCache();
@ -204,6 +207,7 @@ void main() {
grammarGateway: grammar,
grammarLanguages: grammarLanguages,
files: picker,
photos: photos,
),
location: location,
);
@ -270,6 +274,22 @@ void main() {
await closeContext(tester);
}
/// Opens + (with [tooltip]) and chooses [tile] in its sheet.
Future<void> add(
WidgetTester tester,
String tile, {
String tooltip = 'Attach a photo or file',
Finder? within,
}) async {
final plus = find.byTooltip(tooltip);
await tester.tap(
within == null ? plus : find.descendant(of: within, matching: plus),
);
await tester.pumpAndSettle();
await tester.tap(find.text(tile));
await tester.pumpAndSettle();
}
/// Opens the menu of who answers, below the field.
Future<void> openPicker(WidgetTester tester) async {
await tester.tap(find.byTooltip('Who answers'));
@ -1207,6 +1227,17 @@ void main() {
await tester.tap(find.byTooltip('Attach a photo or file'));
await tester.pumpAndSettle();
// As Claude's sheet has them (the developer's review of 3 Nov 2026).
final tiles = [
for (final tile in ['Camera', 'Photos', 'Files'])
tester.getRect(find.text(tile)),
];
expect(tiles[0].center.dy, tiles[1].center.dy);
expect(tiles[1].center.dy, tiles[2].center.dy);
expect(tiles[0].left, lessThan(tiles[1].left));
await tester.tap(find.text('Files'));
await tester.pumpAndSettle();
expect(find.text('Camera'), findsNothing, reason: 'the sheet closed');
expect(picker.asked.single, containsAll(['png', 'pdf', 'md']));
expect(find.text('notes.md'), findsOneWidget);
@ -1225,8 +1256,7 @@ void main() {
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
picker.nextMany = [(name: 'extra.txt', bytes: bytesOf('Not this.'))];
await tester.tap(find.byTooltip('Attach a photo or file'));
await tester.pumpAndSettle();
await add(tester, 'Files');
await tester.tap(
find.descendant(
of: find.widgetWithText(InputChip, 'extra.txt'),
@ -1259,6 +1289,44 @@ void main() {
);
});
testWidgets("+'s Camera takes a photo and Photos picks some, each "
'shown above the question; a desktop has no Camera; what goes '
"along is chosen from +'s sheet too", (tester) async {
assistants.offer = AssistantsOffer(assistants: [_seeing]);
await showApp(tester);
photos
..taken = (name: 'taken.jpg', bytes: bytesOf('a board'))
..picked = [
(name: 'one.jpg', bytes: bytesOf('one')),
(name: 'two.png', bytes: bytesOf('two')),
];
await add(tester, 'Camera');
await add(tester, 'Photos');
expect(photos.asked, ['camera', 'photos']);
for (final name in ['taken.jpg', 'one.jpg', 'two.png']) {
expect(find.widgetWithText(InputChip, name), findsOneWidget);
}
await tester.tap(find.byTooltip('Attach a photo or file'));
await tester.pumpAndSettle();
await tester.tap(find.text('What goes along').last);
await tester.pumpAndSettle();
expect(find.text('How far it looks'), findsOneWidget);
});
testWidgets('a desktop offers no Camera', (tester) async {
assistants.offer = AssistantsOffer(assistants: [_seeing]);
photos.hasCamera = false;
await showApp(tester);
await tester.tap(find.byTooltip('Attach a photo or file'));
await tester.pumpAndSettle();
expect(find.text('Camera'), findsNothing);
expect(find.text('Photos'), findsOneWidget);
expect(find.text('Files'), findsOneWidget);
});
testWidgets('an assistant reading no images is offered files alone, '
'and says why a picture picked anyway stays behind', (tester) async {
await showApp(tester);
@ -1266,6 +1334,10 @@ void main() {
await tester.tap(find.byTooltip('Attach a file'));
await tester.pumpAndSettle();
expect(find.text('Camera'), findsNothing);
expect(find.text('Photos'), findsNothing);
await tester.tap(find.text('Files'));
await tester.pumpAndSettle();
expect(picker.asked.single, isNot(contains('png')));
expect(
@ -1284,13 +1356,12 @@ void main() {
await ask(tester, 'Why て-form?');
picker.next = (name: 'lines.txt', bytes: bytesOf('One.\nTwo.'));
await tester.tap(
find.descendant(
of: find.byType(BottomSheet),
matching: find.byTooltip('Attach a file'),
),
await add(
tester,
'Files',
tooltip: 'Attach a file',
within: find.byType(BottomSheet),
);
await tester.pumpAndSettle();
expect(find.widgetWithText(InputChip, 'lines.txt'), findsOneWidget);
expect(
find.descendant(

View file

@ -0,0 +1,29 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/photo_picker.dart';
/// A [PhotoPicker] that takes [taken] and picks [picked], noting each ask.
class FakePhotoPicker() implements PhotoPicker {
@override
bool hasCamera = true;
/// What the camera takes next; null as if the person took none.
PickedFile? taken;
/// What the photos picked next are.
List<PickedFile> picked = const [];
/// How often the camera and the photos were asked, in order.
final asked = <String>[];
@override
Future<PickedFile?> take() async {
asked.add('camera');
return taken;
}
@override
Future<List<PickedFile>> pick() async {
asked.add('photos');
return picked;
}
}

View file

@ -24,6 +24,7 @@ import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.d
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/photo_picker.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
@ -95,6 +96,7 @@ import 'fake_note_gateway.dart';
import 'fake_path_answer_gateway.dart';
import 'fake_pdf_engine.dart';
import 'fake_pdf_source_gateway.dart';
import 'fake_photo_picker.dart';
import 'fake_pomodoro_platform.dart';
import 'fake_publish_gateway.dart';
import 'fake_review_gateway.dart';
@ -172,6 +174,7 @@ Dependencies testDependencies({
OpenPerson? openPerson,
WebSearch? webSearch,
SpeechInput? speech,
PhotoPicker? photos,
JellyfinGateway? jellyfinGateway,
JellyfinStore? jellyfinStore,
JellyfinSuggestion? jellyfinSuggestion,
@ -264,6 +267,7 @@ Dependencies testDependencies({
deviceSearch: (query) async => false,
),
speech: speech ?? FakeSpeechInput(),
photos: photos ?? FakePhotoPicker(),
jellyfin: JellyfinRepository(
(device) => jellyfinGateway ?? FakeJellyfinGateway(),
jellyfinStore ?? MemoryJellyfinStore(),