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
14 changed files with 639 additions and 94 deletions

View file

@ -2131,5 +2131,9 @@
"studysetPrivacyTopic": "Seine Privatsphäre ist die des Lernsets, in dem es liegt.",
"studysetPrivacyPublish": "Veröffentlichen …",
"studysetSheetVisibility": "Sichtbarkeit",
"studysetSheetPrivate": "Privat"
"studysetSheetPrivate": "Privat",
"assistantAddTitle": "Zu deiner Frage hinzufügen",
"assistantAddCamera": "Kamera",
"assistantAddPhotos": "Fotos",
"assistantAddFiles": "Dateien"
}

View file

@ -2549,5 +2549,13 @@
"studysetSheetVisibility": "Visibility",
"@studysetSheetVisibility": {"description": "Heading in a top-level studyset's Edit sheet above its privacy switch (ADR 0019)."},
"studysetSheetPrivate": "Private",
"@studysetSheetPrivate": {"description": "The switch making a top-level studyset private (end-to-end encrypted) in its Edit sheet."}
"@studysetSheetPrivate": {"description": "The switch making a top-level studyset private (end-to-end encrypted) in its Edit sheet."},
"assistantAddTitle": "Add to your question",
"@assistantAddTitle": {"description": "Title of the sheet + opens below the assistant's field, as Claude's \"Add to session\"."},
"assistantAddCamera": "Camera",
"@assistantAddCamera": {"description": "Tile in +'s sheet: takes a photo with the camera's own app."},
"assistantAddPhotos": "Photos",
"@assistantAddPhotos": {"description": "Tile in +'s sheet: picks photos from the device."},
"assistantAddFiles": "Files",
"@assistantAddFiles": {"description": "Tile in +'s sheet: picks files (PDFs, texts, images)."}
}

View file

@ -12577,6 +12577,30 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Private'**
String get studysetSheetPrivate;
/// Title of the sheet + opens below the assistant's field, as Claude's "Add to session".
///
/// In en, this message translates to:
/// **'Add to your question'**
String get assistantAddTitle;
/// Tile in +'s sheet: takes a photo with the camera's own app.
///
/// In en, this message translates to:
/// **'Camera'**
String get assistantAddCamera;
/// Tile in +'s sheet: picks photos from the device.
///
/// In en, this message translates to:
/// **'Photos'**
String get assistantAddPhotos;
/// Tile in +'s sheet: picks files (PDFs, texts, images).
///
/// In en, this message translates to:
/// **'Files'**
String get assistantAddFiles;
}
class _AppLocalizationsDelegate

View file

@ -8067,4 +8067,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetSheetPrivate => 'Privat';
@override
String get assistantAddTitle => 'Zu deiner Frage hinzufügen';
@override
String get assistantAddCamera => 'Kamera';
@override
String get assistantAddPhotos => 'Fotos';
@override
String get assistantAddFiles => 'Dateien';
}

View file

@ -7994,6 +7994,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetSheetPrivate => 'Private';
@override
String get assistantAddTitle => 'Add to your question';
@override
String get assistantAddCamera => 'Camera';
@override
String get assistantAddPhotos => 'Photos';
@override
String get assistantAddFiles => 'Files';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -27,6 +27,7 @@ import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.da
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/sync_conversation_gateway.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';
@ -197,6 +198,9 @@ class Dependencies({
/// Turns a question spoken into its words, on the device (D67).
required final SpeechInput speech,
/// Photos taken or picked to go with a question (D67).
required final PhotoPicker photos,
/// The person's own Jellyfin server, to watch and learn from: the
/// device's, kept across instances.
required final JellyfinRepository jellyfin,
@ -331,6 +335,7 @@ class Dependencies({
pomodoro: device.pomodoro,
webSearch: WebSearch(),
speech: DeviceSpeechInput(),
photos: ImagePickerPhotoPicker(),
jellyfin: device.jellyfin,
jellyfinSuggestion: JellyfinSuggestion(
() async => (await client.instance.info()).jellyfinUrl,

View file

@ -0,0 +1,53 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:image_picker/image_picker.dart' show ImagePicker, ImageSource, XFile;
/// Photos to go with a question (ADR 0021, D67; the developer's review of
/// 3 Nov 2026): one taken with the device's camera, or some picked from
/// its photos, read on the device and sent only with the question.
abstract interface class PhotoPicker() {
/// Whether this device takes photos: phones do, desktops don't.
bool get hasCamera;
/// A photo the person takes with the camera's own app; null if they
/// took none.
Future<PickedFile?> take();
/// The photos the person picks; none if they picked none.
Future<List<PickedFile>> pick();
}
/// [PhotoPicker] through `image_picker`: the camera's own app, and the
/// system's photo picker on Android (no permission asked) and iOS, a file
/// dialog on the desktop. Photos are made at most [maxSide] wide and high,
/// which providers read as well, and small enough to send.
class ImagePickerPhotoPicker({final double maxSide = 2048})
implements PhotoPicker {
final _picker = ImagePicker();
@override
bool get hasCamera => _picker.supportsImageSource(ImageSource.camera);
@override
Future<PickedFile?> take() async {
final photo = await _picker.pickImage(
source: ImageSource.camera,
maxWidth: maxSide,
maxHeight: maxSide,
imageQuality: 85,
);
return photo == null ? null : await _read(photo);
}
@override
Future<List<PickedFile>> pick() async => [
for (final photo in await _picker.pickMultiImage(
maxWidth: maxSide,
maxHeight: maxSide,
imageQuality: 85,
))
await _read(photo),
];
static Future<PickedFile> _read(XFile photo) async =>
(name: photo.name, bytes: await photo.readAsBytes());
}

View file

@ -337,6 +337,25 @@ class _AssistantFieldState() extends State<AssistantField> {
images: choice.images,
assistant: choice.name,
onAttached: (part) => setState(() => _attached.add(part)),
// How far it looks and what goes along, as Claude's sheet has
// Permission (the developer's review of 3 Nov 2026).
rows: [
(
icon: LucideIcons.scanSearch,
title: l10n.assistantContext,
value: [
scopeText(
l10n,
AssistantScope(
reach: _reach,
includePrivate: _includePrivate,
),
),
if (chosen > 0) l10n.assistantContextChosen(chosen),
].join(' · '),
onTap: () => unawaited(_openContext(answerer)),
),
],
),
gap,
Expanded(
@ -545,6 +564,11 @@ class _ContextSheetState() extends State<_ContextSheet> {
late var _includePrivate = widget.includePrivate;
late final _chosen = {...widget.chosen};
void _setPrivate(bool on) {
setState(() => _includePrivate = on);
widget.onIncludePrivate(on);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -555,88 +579,82 @@ class _ContextSheetState() extends State<_ContextSheet> {
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rows in rounded groups, as Claude's Permission, rather than
// radio buttons and chips (the developer's review of 3 Nov 2026).
if (widget.reaches.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
padding: const EdgeInsets.fromLTRB(4, 8, 4, 8),
child: Text(l10n.assistantContextReach, style: heading),
),
RadioGroup<AssistantReach>(
groupValue: _reach,
onChanged: (reach) {
if (reach != null) {
setState(() => _reach = reach);
widget.onReach(reach);
}
},
child: Column(
children: [
for (final each in widget.reaches)
RadioListTile<AssistantReach>(
value: each,
title: Text(reachLongText(l10n, each)),
RoundedGroup(
children: [
for (final each in widget.reaches)
RoundedRow(
icon: switch (each) {
AssistantReach.ticked => LucideIcons.listChecks,
AssistantReach.studyset => LucideIcons.bookOpen,
AssistantReach.tree => LucideIcons.folderTree,
AssistantReach.library => LucideIcons.libraryBig,
},
title: reachLongText(l10n, each),
trailing: _Check(on: each == _reach),
onTap: () {
setState(() => _reach = each);
widget.onReach(each);
},
),
if (widget.offersPrivate)
RoundedRow(
icon: LucideIcons.lock,
title: l10n.assistantScopePrivate,
trailing: Switch(
value: _includePrivate && _reach != AssistantReach.ticked,
onChanged: _reach == AssistantReach.ticked
? null
: _setPrivate,
),
],
),
onTap: _reach == AssistantReach.ticked
? null
: () => _setPrivate(!_includePrivate),
),
],
),
if (widget.offersPrivate)
CheckboxListTile(
value: _includePrivate && _reach != AssistantReach.ticked,
onChanged: _reach == AssistantReach.ticked
? null
: (on) {
setState(() => _includePrivate = on ?? false);
widget.onIncludePrivate(on ?? false);
},
title: Text(l10n.assistantScopePrivate),
),
],
if (widget.parts.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Text(l10n.assistantContextParts, style: heading),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LayoutBuilder(
builder: (context, constraints) => Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final part in widget.parts)
Tooltip(
// An image's text is its asset row's id.
message: part.kind == AssistantPartKind.image
? partText(l10n, part)
: part.sent,
waitDuration: const Duration(milliseconds: 400),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
),
child: FilterChip(
avatar: part.kind == AssistantPartKind.image
? _Thumbnail(assetId: part.text)
: null,
label: Text(
partText(l10n, part),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
selected: _chosen.contains(part),
onSelected: (_) {
setState(
() => _chosen.contains(part)
? _chosen.remove(part)
: _chosen.add(part),
);
widget.onToggle(part);
},
),
),
),
],
),
),
RoundedGroup(
children: [
for (final part in widget.parts)
Tooltip(
// An image's text is its asset row's id.
message: part.kind == AssistantPartKind.image
? partText(l10n, part)
: part.sent,
waitDuration: const Duration(milliseconds: 400),
child: RoundedRow(
icon: LucideIcons.textQuote,
leading: part.kind == AssistantPartKind.image
? SizedBox.square(
dimension: 40,
child: _Thumbnail(assetId: part.text),
)
: null,
title: partText(l10n, part),
trailing: _Check(on: _chosen.contains(part)),
onTap: () {
setState(
() => _chosen.contains(part)
? _chosen.remove(part)
: _chosen.add(part),
);
widget.onToggle(part);
},
),
),
],
),
],
const SizedBox(height: 16),
@ -835,7 +853,19 @@ class const _AnswererLabel({required final Answerer answerer})
}
}
/// A card's image on its chip, small.
/// A tick at a row's end while it is chosen ([on]); its room kept
/// otherwise, so that rows don't shift.
class const _Check({required final bool on}) extends StatelessWidget {
@override
Widget build(BuildContext context) => SizedBox.square(
dimension: 24,
child: on
? Icon(LucideIcons.check, color: Theme.of(context).colorScheme.primary)
: null,
);
}
/// A card's image on its row, small.
class const _Thumbnail({required final String assetId})
extends StatelessWidget {
@override

View file

@ -1,9 +1,11 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_attachments.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -25,25 +27,35 @@ ButtonStyle boxedButtonStyle(BuildContext context) => IconButton.styleFrom(
padding: EdgeInsets.zero,
);
/// The + below a question's field: picks files to go along, read on the
/// device ([AssistantAttachments]), images only where the assistant
/// [assistant] reads [images]; each read hands its part to [onAttached],
/// each refused says why.
/// What + offers besides the files: a row leading to another choice, as
/// Claude's sheet has Permission (the developer's review of 3 Nov 2026).
typedef AddRow = ({
IconData icon,
String title,
String? value,
VoidCallback onTap,
});
/// The + below a question's field: a sheet as Claude's "Add to session"
/// (the developer's review of 3 Nov 2026), three tiles above, Camera and
/// Photos where the assistant [assistant] reads [images] (Camera where the
/// device takes photos), and Files; then [rows], each leading to a choice
/// of its own. What is taken or picked is read on the device
/// ([AssistantAttachments]), each read handing its part to [onAttached],
/// each refused saying why.
class const AttachButton({
required final bool images,
required final String assistant,
required final ValueChanged<AssistantPart> onAttached,
final List<AddRow> rows = const [],
super.key,
}) extends StatelessWidget {
Future<void> _attach(BuildContext context) async {
/// Reads the [picked] files, as images only where the assistant reads
/// them.
Future<void> _read(BuildContext context, List<PickedFile> picked) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final dependencies = Dependencies.of(context);
final picked = await dependencies.files.pickMany(
label: l10n.assistantAttachFiles,
extensions: AssistantAttachments.extensionsFor(images: images),
);
final reader = AssistantAttachments(dependencies.pdfEngine);
final reader = AssistantAttachments(Dependencies.of(context).pdfEngine);
for (final file in picked) {
final read = await reader.read(file, images: images);
if (read.part case final part?) {
@ -69,6 +81,37 @@ class const AttachButton({
}
}
Future<void> _open(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final dependencies = Dependencies.of(context);
final photos = dependencies.photos;
final chosen = await showAdaptiveSheet<_Source>(
context,
builder: (sheet) => _AddSheet(
sources: [
if (images && photos.hasCamera) _Source.camera,
if (images) _Source.photos,
_Source.files,
],
rows: rows,
),
);
if (chosen == null || !context.mounted) {
return;
}
final picked = switch (chosen) {
_Source.camera => [?await photos.take()],
_Source.photos => await photos.pick(),
_Source.files => await dependencies.files.pickMany(
label: l10n.assistantAttachFiles,
extensions: AssistantAttachments.extensionsFor(images: images),
),
};
if (context.mounted) {
await _read(context, picked);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -76,7 +119,181 @@ class const AttachButton({
tooltip: images ? l10n.assistantAttach : l10n.assistantAttachFile,
style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.plus, size: 20),
onPressed: () => unawaited(_attach(context)),
onPressed: () => unawaited(_open(context)),
);
}
}
/// Where what + adds comes from.
enum _Source() {
camera,
photos,
files,
}
/// +'s sheet: the [sources] as tiles side by side, then the [rows] in one
/// rounded group. A tile closes it with its source; a row closes it and
/// leads on.
class const _AddSheet({
required final List<_Source> sources,
required final List<AddRow> rows,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.assistantAddTitle,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
Row(
children: [
for (final (index, source) in sources.indexed) ...[
if (index > 0) const SizedBox(width: 8),
Expanded(
child: _Tile(
icon: switch (source) {
_Source.camera => LucideIcons.camera,
_Source.photos => LucideIcons.image,
_Source.files => LucideIcons.fileUp,
},
label: switch (source) {
_Source.camera => l10n.assistantAddCamera,
_Source.photos => l10n.assistantAddPhotos,
_Source.files => l10n.assistantAddFiles,
},
onTap: () => Navigator.of(context).pop(source),
),
),
],
],
),
if (rows.isNotEmpty) ...[
const SizedBox(height: 12),
RoundedGroup(
children: [
for (final row in rows)
RoundedRow(
icon: row.icon,
title: row.title,
value: row.value,
onTap: () {
Navigator.of(context).pop();
row.onTap();
},
),
],
),
],
const SizedBox(height: 16),
],
),
);
}
}
/// A big tile of +'s sheet: its icon in a circle above its [label].
class const _Tile({
required final IconData icon,
required final String label,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
children: [
CircleAvatar(
radius: 24,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
foregroundColor: theme.colorScheme.onSurface,
child: Icon(icon, size: 22),
),
const SizedBox(height: 8),
Text(
label,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
/// Rows set apart as one rounded group, as Claude's sheets have them: a
/// tile a shade above the sheet, its first and last corners rounded, a
/// hairline of the sheet between rows.
class const RoundedGroup({required final List<Widget> children, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, child) in children.indexed) ...[
if (index > 0) const SizedBox(height: 2),
Material(
color: theme.colorScheme.surfaceContainerHigh,
child: child,
),
],
],
),
);
}
}
/// A row of a [RoundedGroup]: its icon in a circle, its [title], beneath it
/// its [value] where it has one, and at its end [trailing], a chevron where
/// none is given.
class const RoundedRow({
required final IconData icon,
required final String title,
/// Shown in the icon's place, as a picture's thumbnail.
final Widget? leading,
final String? value,
final Widget? trailing,
final VoidCallback? onTap,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
onTap: onTap,
leading:
leading ??
CircleAvatar(
radius: 20,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
foregroundColor: theme.colorScheme.onSurface,
child: Icon(icon, size: 20),
),
title: Text(title),
subtitle: value == null ? null : Text(value!),
trailing:
trailing ??
Icon(
LucideIcons.chevronRight,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
}

View file

@ -98,6 +98,10 @@ dependencies:
html: ^0.15.6
# Talks to Jellyfin servers; Serverpod's client brings it along already.
http: ^1.6.0
# Photos for an assistant's question, taken with the camera's own app or
# picked from the system's photo picker (the developer's review of 3 Nov
# 2026): flutter.dev's own, no permission asked on Android.
image_picker: ^1.2.4
intl: ^0.20.3
lucide_icons_flutter: ^3.1.20
material_ui: ^1.5.0

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(),

View file

@ -737,6 +737,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.4"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_secure_storage:
dependency: transitive
description:
@ -1000,6 +1008,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.10.1"
image_picker:
dependency: transitive
description:
name: image_picker
sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a
url: "https://pub.dev"
source: hosted
version: "1.2.4"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4"
url: "https://pub.dev"
source: hosted
version: "0.8.13+25"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b8f508306db2bf71141aba2de4ec13456338bf01e13f034bd2594f341a5f8eb2
url: "https://pub.dev"
source: hosted
version: "0.8.13+14"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042"
url: "https://pub.dev"
source: hosted
version: "2.11.2"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
integration_test:
dependency: transitive
description: flutter