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:
parent
a7904bac05
commit
7be31dcbfc
14 changed files with 639 additions and 94 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)."}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
72
pubspec.lock
72
pubspec.lock
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue