Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/blog-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ Ignore types you don't know; new ones may be added.
- `revision` is the document revision that was sent. When you publish, publish the content of the
submit you reviewed; the contributor may keep editing in the meantime.
- `fields` holds the fields you turned on; fields you didn't turn on are `null` (`tags` is then
`[]`). `coverImage` is the id of one of the `assets`.
`[]`). `coverImage` is the id of one of the `assets`. The contributor uploads the cover in the
Submit dialog, so it is usually **not** in the `markdown`: show it as the post's cover without
removing anything from the text.
- `markdown` is CommonMark with GitHub tables and task lists. Open suggestions in the document are
sent as the original text; comments are never included. There is no front matter: the title
and the other fields are in `fields` (the Markdown usually starts with the title as a heading,
Expand Down
204 changes: 204 additions & 0 deletions packages/wonky_app/lib/src/shell/form_dialog.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:shad/shad.dart';
Expand Down Expand Up @@ -184,6 +186,46 @@ class SelectFieldSpec extends DialogFieldSpec {
final String initial;
}

/// What [ImageFieldSpec.pick] returns: the new value (an id) and its
/// thumbnail.
typedef ImageFieldPick = ({String value, ImageProvider? preview});

/// An image the user uploads in the dialog (e.g. a story's cover image,
/// specs/18 §4.2): a thumbnail with *Upload* (*Replace* once set) and
/// *Remove*. The value is an id, `''` for none. [pick] picks and uploads an
/// image and returns its id (null when cancelled; a throw shows
/// [errorLabel]); [preview] resolves the thumbnail of [initial]. Keys:
/// `<dialogKey>.<id>` (the field), `.preview`, `.upload`, `.remove`.
class ImageFieldSpec extends DialogFieldSpec {
const ImageFieldSpec({
required super.id,
required super.label,
required this.pick,
required this.uploadLabel,
required this.replaceLabel,
required this.removeLabel,
required this.emptyLabel,
required this.uploadingLabel,
required this.errorLabel,
this.initial = '',
this.preview,
this.description,
});

final String initial;
final Future<ImageFieldPick?> Function() pick;
final FutureOr<ImageProvider?> Function(String value)? preview;
final String uploadLabel;
final String replaceLabel;
final String removeLabel;

/// Shown in the thumbnail box without an image.
final String emptyLabel;
final String uploadingLabel;
final String errorLabel;
final String? description;
}

/// A dialog button.
class DialogButtonSpec {
const DialogButtonSpec({
Expand Down Expand Up @@ -300,10 +342,54 @@ class _WonkyFormDialogState extends State<WonkyFormDialog>
};
case SelectFieldSpec():
_values[f.id] = f.initial;
case ImageFieldSpec():
_values[f.id] = f.initial;
_resolvePreview(f, f.initial);
}
}
}

/// Thumbnails of [ImageFieldSpec]s; ids of those uploading.
final Map<String, ImageProvider?> _previews = {};
final Set<String> _uploading = {};

void _resolvePreview(ImageFieldSpec f, String value) {
_previews[f.id] = null;
if (value.isEmpty || f.preview == null) return;
final p = f.preview!(value);
if (p is Future<ImageProvider?>) {
unawaited(
p.then((provider) {
if (mounted && _values[f.id] == value) {
setState(() => _previews[f.id] = provider);
}
}, onError: (Object _) {}),
);
} else {
_previews[f.id] = p;
}
}

Future<void> _pickImage(ImageFieldSpec f) async {
setState(() {
_uploading.add(f.id);
_errors[f.id] = null;
});
try {
final picked = await f.pick();
if (!mounted || picked == null) return;
setState(() {
_values[f.id] = picked.value;
_previews[f.id] = picked.preview;
});
if (picked.preview == null) _resolvePreview(f, picked.value);
} catch (e) {
if (mounted) setState(() => _errors[f.id] = f.errorLabel);
} finally {
if (mounted) setState(() => _uploading.remove(f.id));
}
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
Expand Down Expand Up @@ -352,6 +438,8 @@ class _WonkyFormDialogState extends State<WonkyFormDialog>
}

void _press(DialogButtonSpec b) {
// Not before an image field's upload has finished.
if (b.id != widget.spec.cancelId && _uploading.isNotEmpty) return;
if (b.id != widget.spec.cancelId && b.validates && !_validate()) return;
Navigator.of(context).pop(DialogResult(b.id, _all));
}
Expand All @@ -377,7 +465,10 @@ class _WonkyFormDialogState extends State<WonkyFormDialog>
CheckboxFieldSpec() => 'checkbox',
ChecklistFieldSpec() => 'checklist',
SelectFieldSpec() => 'select',
ImageFieldSpec() => 'image',
},
if (f is ImageFieldSpec && _uploading.contains(f.id))
'uploading': true,
'value': _all[f.id],
if (f is ChecklistFieldSpec) ...{
'options': [for (final i in f.items) i.value],
Expand Down Expand Up @@ -416,6 +507,13 @@ class _WonkyFormDialogState extends State<WonkyFormDialog>
case SelectFieldSpec():
if (!f.options.containsKey(value)) return false;
setState(() => _values[id] = value);
case ImageFieldSpec():
// An id of an image that is already uploaded, or '' for none.
if (value is! String) return false;
setState(() {
_values[id] = value;
_resolvePreview(f, value);
});
case null:
return false;
}
Expand Down Expand Up @@ -498,6 +596,110 @@ class _WonkyFormDialogState extends State<WonkyFormDialog>
);
}

Widget _image(ImageFieldSpec f, ShadThemeData theme, String? key) {
final cs = theme.colorScheme;
final value = _values[f.id]! as String;
final preview = _previews[f.id];
final uploading = _uploading.contains(f.id);
ValueKey<String>? k(String part) =>
key == null ? null : ValueKey<String>('$key.${f.id}$part');
final Widget thumbnail;
if (uploading) {
thumbnail = Text(f.uploadingLabel, style: theme.textTheme.muted);
} else if (value.isEmpty) {
thumbnail = Column(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Icon(LucideIcons.image, size: 20, color: cs.mutedForeground),
Text(
f.emptyLabel,
textAlign: TextAlign.center,
style: theme.textTheme.muted,
),
],
);
} else if (preview == null) {
thumbnail = Icon(LucideIcons.image, size: 20, color: cs.mutedForeground);
} else {
thumbnail = Image(
key: k('.preview'),
image: preview,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, _, _) =>
Icon(LucideIcons.imageOff, size: 20, color: cs.mutedForeground),
);
}
return Column(
key: k(''),
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(f.label, style: theme.textTheme.small),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 12,
children: [
// 16:9, the usual shape of a blog's cover.
Container(
width: 128,
height: 72,
alignment: Alignment.center,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: cs.muted,
border: Border.all(color: cs.border),
borderRadius: theme.radius,
),
child: thumbnail,
),
Flexible(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
ShadButton.outline(
key: k('.upload'),
size: ShadButtonSize.sm,
enabled: !uploading,
leading: const Icon(LucideIcons.upload, size: 16),
onPressed: () => unawaited(_pickImage(f)),
child: Text(value.isEmpty ? f.uploadLabel : f.replaceLabel),
),
if (value.isNotEmpty && !uploading)
ShadButton.ghost(
key: k('.remove'),
size: ShadButtonSize.sm,
leading: const Icon(LucideIcons.x, size: 16),
onPressed: () => setState(() {
_values[f.id] = '';
_previews[f.id] = null;
}),
child: Text(f.removeLabel),
),
],
),
),
],
),
if (f.description != null) ...[
const SizedBox(height: 6),
Text(f.description!, style: theme.textTheme.muted),
],
if (_errors[f.id] != null) ...[
const SizedBox(height: 4),
Text(
_errors[f.id]!,
style: theme.textTheme.small.copyWith(color: cs.destructive),
),
],
],
);
}

@override
Widget build(BuildContext context) {
final spec = widget.spec;
Expand Down Expand Up @@ -587,6 +789,8 @@ class _WonkyFormDialogState extends State<WonkyFormDialog>
],
),
);
case ImageFieldSpec():
fields.add(_image(f, theme, key));
}
}
// Below shad's `sm` breakpoint the actions stack vertically and each
Expand Down
88 changes: 88 additions & 0 deletions packages/wonky_app/test/shell/form_dialog_image_field_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shad/shad.dart';
import 'package:wonky_app/wonky_app.dart';

Finder _key(String k) => find.byKey(ValueKey<String>(k));

/// The image field of the form dialog (the submit dialog's cover image,
/// specs/18 §4.2).
void main() {
late Future<DialogResult?> result;

Future<McpDescribableDialog> open(
WidgetTester tester,
Future<ImageFieldPick?> Function() pick,
) async {
await tester.pumpWidget(
ShadApp(
home: Builder(
builder: (context) => Center(
child: ShadButton(
onPressed: () => result = showWonkyFormDialog(
context,
WonkyDialogSpec(
title: 'Submit',
dialogKey: 'dialog.test',
fields: [
ImageFieldSpec(
id: 'cover',
label: 'Cover image',
pick: pick,
uploadLabel: 'Upload image',
replaceLabel: 'Replace',
removeLabel: 'Remove',
emptyLabel: 'No cover',
uploadingLabel: 'Uploading…',
errorLabel: 'Upload failed',
),
],
buttons: const [
DialogButtonSpec(id: 'cancel', label: 'Cancel'),
DialogButtonSpec(id: 'ok', label: 'OK', primary: true),
],
),
),
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
return tester.state<State>(find.byType(WonkyFormDialog))
as McpDescribableDialog;
}

testWidgets('Given an image field then MCP sees it as an image and can set '
'and clear its value', (tester) async {
final dialog = await open(tester, () async => null);

final field = (dialog.describe()['fields']! as List).single as Map;
expect(field['type'], 'image');
expect(field['value'], '');
expect(dialog.setField('cover', 'file-1'), isTrue);
await tester.pump();
expect(find.text('Replace'), findsOneWidget);
expect(dialog.setField('cover', 3), isFalse);

dialog.press('ok');
await tester.pumpAndSettle();
expect((await result)!.text('cover'), 'file-1');
});

testWidgets('Given an upload that fails then the error shows and the value '
'stays empty', (tester) async {
final dialog = await open(tester, () async => throw StateError('offline'));

await tester.tap(_key('dialog.test.cover.upload'));
await tester.pumpAndSettle();

expect(find.text('Upload failed'), findsOneWidget);
expect(find.text('Upload image'), findsOneWidget);
dialog.press('ok');
await tester.pumpAndSettle();
expect((await result)!.text('cover'), '');
});
}
4 changes: 3 additions & 1 deletion packages/wonky_core/lib/mcp/write_tools.dart
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,8 @@ abstract final class WriteTools {
title: 'Set the block type',
description:
'Changes every line touched by the range to a paragraph, heading '
'(level 1–6), bullet/ordered/task list item (indent 0–8, checked), '
'(level 1–6), bullet/ordered/task list item (indent 0–8, checked; '
'a list item in a quote keeps the quote unless quoted is false), '
'quote or code block (language). Converting lines to code merges '
'them into one code block.$_addressing',
inputSchema: WriteSchemas.doc(
Expand All @@ -446,6 +447,7 @@ abstract final class WriteTools {
'level': const JsonSchema.integer(minimum: 1, maximum: 6),
'indent': const JsonSchema.integer(minimum: 0, maximum: 8),
'checked': const JsonSchema.boolean(),
'quoted': const JsonSchema.boolean(),
'language': const JsonSchema.string(maxLength: 40),
},
required: ['range', 'type'],
Expand Down
Loading
Loading