Host services
A form carries values; the host owns everything that touches the outside world: records to look up, files to store, a camera, credentials to check. A FormHostServices holds a hook for each, and the host gives it once, to the KitScope<FormKit> its forms and its designer sit under. A hook you leave out disables the control that needs it, and says so (a reference with no referenceSearch reads "Reference lookup unavailable"), so a form never half-works silently.
Hook (FormHostServices member) | Type | Used by |
|---|---|---|
referenceSearch | Future<List<FormChoice>> Function(String target, String query, int pageSize) | ReferenceFormField |
referenceLabel | Future<String?> Function(String target, String value) | ReferenceFormField |
referenceScanner | Future<String?> Function(BuildContext, ReferenceFormField) | ReferenceFormField with scan |
referenceScanResolver | Future<FormChoice?> Function(String target, String scanned) | ReferenceFormField with scan |
codeSearch | Future<List<FormChoice>> Function(String catalog, String? dimension, String query, int pageSize) | MoneyFormField, QuantityFormField |
attachmentPicker | Future<List<AttachmentValue>?> Function(BuildContext, AttachmentFormField, List<AttachmentValue> current) | AttachmentFormField |
attachmentImage | ImageProvider? Function(AttachmentValue) | AttachmentFormField thumbnails and image preview |
attachmentPreview | Future<void> Function(BuildContext, AttachmentValue) | AttachmentFormField, files that aren't images |
signatureService | FormSignatureService | SignatureFormField |
signatureDigest | String? Function(FormKitForm) | SignatureFormField, the digest a signature signs |
assets | FormAssetProvider | image content, and the designer's image picker |
vyuh_form_kit itself has no platform plugin. The camera scanner and the file, gallery and camera pickers live in vyuh_form_kit_capture, which implements referenceScanner, attachmentPicker and attachmentImage; see The platform package. The same hooks are parameters of FormFieldControl when you use a control on its own.
One scope for the host
The kit and the services are given once, near the top of the app or of the screen, in a KitScope<FormKit>. FormView and FormKitEditor below it take both from there; the views of the app's own types are parts of the kit itself (see Custom fields):
KitScope<FormKit>(
kit: appKit, // its extensions carry the views of the app's own types
services: FormHostServices(
referenceSearch: (target, query, pageSize) async => const <FormChoice>[],
signatureService: signatureService,
assets: assets,
),
child: FormView(form: form, onSubmit: save),
)FormHostServices.none is the default. copyWith adds to a ready-made set, such as the platform package's (platform.services.copyWith(referenceSearch: search)), and a scope is optional for a definition that uses no service: where a service is needed and absent, the control says so where it is. In debug builds a FormView under the scope names any custom field or content type of its kit that has no view part.
Barcode scanning
Set scan: true on a reference field and a scan button sits beside the lookup:
reference('item', 'Item', target: 'inventory.item', scan: true);
ReferenceFormField(key: 'item', label: 'Item', target: 'inventory.item', scan: true);Scanning is a setting of the reference, not another field type: the value is the same record id (or set of ids, with multiple), and the designer has it as Scan with camera in the reference's settings. A scan is two steps, each its own hook:
referenceScanneropens a camera view and returns the text of the code it read, ornullwhen the user cancels. The platform package provides one.referenceScanResolveris the host's: it turns that text into a record of the field'starget, or returnsnullwhen no record has that code. The text may be an id, a barcode, or the code printed on a label; only the host knows which.
FormHostServices(
referenceScanner: platform.barcodeScanner,
referenceScanResolver: (target, scanned) async {
final item = await inventory.findByBarcode(scanned); // your lookup
return item == null ? null : FormChoice(value: item.id, label: item.name);
},
)The record is set as if it had been picked and shows by its label; with multiple it is added to the others, once. A code no record matches, or a lookup that throws, shows a toast ("No item matches “X”." or "The lookup failed. Try again.") and changes nothing. A reference with scan but no referenceScanner shows its scan button disabled; one with a scanner but no resolver says scanned codes can't be looked up. A read-only reference shows no scan button; a disabled one shows it muted with the rest of the control.
Attachments
The form keeps references to files, never bytes. An attachment's value is an AttachmentValue:
| Member | Meaning |
|---|---|
reference | The host's key for the stored file. |
name, mediaType, bytes | As the file was named, typed and sized. |
sha256 | The lowercase hex SHA-256 of the file's bytes; null for a reference the host stored itself and whose bytes the form never saw. |
toJson() and AttachmentValue.fromJson round-trip it, and fromJson refuses a checksum that is not 64 lowercase hex digits.
attachmentPicker receives the field and the files it holds, asks the user, and returns the files to keep (the current ones with new ones added, or just the new one for a single attachment), or null on cancel. It decides where files come from: the file browser, the photo gallery, the camera. The control shows each file below:
- an image with a picture from
attachmentImageis a square thumbnail; tapping it opens a large, zoomable preview dialog with its type, size and checksum; - every other file is a chip; tapping it calls
attachmentPreview, the host's own viewer. The kit has no PDF dependency: a host that wants to show PDFs opens its viewer there. Without the hook a file chip does nothing when tapped; - while the control is editable each file has a remove button; a read-only control still previews, and a disabled one is muted as a whole.
Size, count and media-type limits are an AttachmentValidation on the field (see Validation); the platform picker reads them to narrow the file browser and to refuse a file at once, with the reason.
Checking a stored file against what was attached is the host's job, and the platform package has the helper:
final stored = await storage.read(file.reference);
switch (checkAttachment(file, stored)) {
case AttachmentIntegrity.verified:
accept();
case AttachmentIntegrity.mismatch:
reject('The file was changed.');
case AttachmentIntegrity.unchecked:
accept(); // no checksum to check against
}file.isIntact(stored) is the same check as a bool, true only when the file has a checksum and the bytes and size match it.
Signatures
A SignatureFormField is an electronic signature with credential checks, not a drawn one. The form never asks for or sees a password: the host's FormSignatureService runs the ceremony.
abstract class FormSignatureService {
const FormSignatureService();
Future<SignatureValue?> sign(BuildContext context, SignatureRequest request);
bool get canClear => false;
}
final class SignatureRequest {
final SignatureFormField field;
final String? formDigest;
FormSignatureMeaning get meaning; // what the signer attests
FormSignatureRemarks get remarksMode; // whether a reason is hidden, optional or required
String? get statement; // what the signer declares
}When the user presses Sign, the control calls sign with the request. The service asks who is signing, checks their password or PIN against its own authority, records the signature, and returns a SignatureValue that names that record, or null when the signer cancels or fails. The control sets the value; nothing else changes.
final class QaSignatures extends FormSignatureService {
const QaSignatures(this.auth);
final AuthService auth;
@override
Future<SignatureValue?> sign(BuildContext context, SignatureRequest request) async {
final result = await showSigningDialog( // your ceremony UI
context,
statement: request.statement,
askReason: request.remarksMode != FormSignatureRemarks.hidden,
reasonRequired: request.remarksMode == FormSignatureRemarks.required,
);
if (result == null) return null;
final assertion = await auth.verify(result.userId, result.credential); // checked by the host
if (assertion == null) return null;
return SignatureValue(
signerId: assertion.userId,
signerName: assertion.displayName,
meaning: request.meaning,
signedAt: assertion.issuedAt,
method: FormSignatureMethod.password,
receiptId: assertion.id,
reason: result.reason,
contentDigest: request.formDigest,
);
}
}
final services = FormHostServices(signatureService: QaSignatures(auth));signatureDigest is how the host binds a signature to what was signed. It receives the form and returns the SHA-256 (lowercase hex) of whatever the host signs (the canonical values, a server version); the control passes it to sign as request.formDigest, and the service puts it in the value's contentDigest. Compute it with package:crypto in the host; the kit never hashes.
What a signed field shows and does:
- Unsigned: a Sign button (the field's
signLabel) and the field'shint, or "Not signed". Thestatementis for the host's signing prompt (request.statement); the control itself doesn't show it. Without asignatureServicethe button is disabled and the field says signing isn't available. - Signed: a legible receipt of two lines, "Approved by A. Rao" and then when, how and why; hovering it shows the receipt id and the full reason. It is a result, so it looks the same under every access and offers no edit. It is cleared only when the service's
canClearis true and the control is editable; the default is that a signature is final. - Read-only shows the receipt, or "Not signed", and no button. Disabled mutes it as one unit.
Validation: a required signature field must be signed. SignatureValidation asks the same of an optional field, or when a rule applies it. A value signed as another meaning than the field's, or without a reason when the field's remarks is required, fails validation, so a service that returns the wrong thing is caught before the form is submitted.
A signature in the form is a claim, not proof
SignatureValue is data. Anything that can build one can build a forged one, so a form's signature proves nothing by itself. The host must verify on the server: look up receiptId in its own signature records, check the signer, meaning and time against them, and compare contentDigest with a digest of the content as stored. Never trust a SignatureValue from a client without this. The kit never sees or stores a credential, and nothing in the value can be used to sign again; keep it that way in your service, and never put a password, PIN or token in reason, receiptId or anywhere in the value. Make a signed record read-only with CdxAccess.readOnly (see Filling forms); read-only is how it looks, and the server's refusal to change it is how it is enforced.
The platform package
vyuh_form_kit_capture holds every platform plugin the kit would otherwise need: mobile_scanner for the camera scanner, file_picker and image_picker for attachments, and crypto for checksums. vyuh_form_kit and vyuh_form_types stay free of them, so a server or a headless host that uses the types doesn't build a camera plugin.
dependencies:
vyuh_form_kit_capture:
hosted: https://pub.vyuh.tech
version: ^0.20.0FormCaptureServices gives the hooks as a ready-made FormHostServices (platform.services: the scanner, the attachment picker and the picture of a picked image), to which the host adds its own:
final platform = FormCaptureServices();
KitScope<FormKit>(
kit: appKit,
services: platform.services.copyWith(
referenceScanResolver: resolveByCode, // yours
signatureService: qaSignatures, // yours
),
child: FormView(form: form),
);It also exposes what it is made of, for a host that wants one piece:
| API | What it does |
|---|---|
showBarcodeScanner(context, {title, instruction, invalidMessage, showTorch, accepts, settle, engine}) | A dialog with a camera view and a torch button; returns the text of the first code it reads that accepts, or null. A code is held for settle (300 ms) with a spinner before the dialog closes, and reads in that time are ignored, so one code closes it once. A code accepts refuses shows invalidMessage and scanning goes on. The camera is released before the dialog goes, on the web too (the mobile_scanner plugin leaves the browser's media stream running; ScannerMediaGuard stops it). |
pickAttachments(context, field, current, {required store, sources}) | A FormAttachmentPicker: files from the browser, and for an image field images from the gallery and a photo from the camera, each offered where the platform has it. A field with one source goes straight to it; others ask where from. |
AttachmentBytesStore | Holds the bytes of every file picked in memory under its reference (local:<n>), for thumbnails and for the host to read back with bytesOf(file) and upload when the form is submitted. clear() it afterwards. |
AttachmentLimits.of(field) | The limits the picker applies, read from the field's AttachmentValidation: largest file, media types, count. A refused file is left out and a toast says why; the validation still checks the value whoever put it there. |
sha256HexOf(bytes), checkAttachment(file, bytes), file.isIntact(bytes) | The checksum, and checking stored bytes against it. |
Every file pickAttachments accepts is read, checksummed and stored. A single attachment takes the one new file; a field of several gets the current files with the new ones added. A platform refusal (a camera permission denied) is a toast and changes nothing.
The camera and the gallery are offered on Android and iOS, and the camera on the web; on desktop, images come from the file browser. The plugins need their usual platform setup: camera and photo-library usage descriptions in the iOS Info.plist, the camera permission on Android, and an app that builds for the web with vyuh_cdx_ui also needs the isolate_manager override described in the vyuh_cdx_ui README.
Testing a form with these hooks
Every platform call goes through a small interface, so a widget test never opens a camera: give FormCaptureServices(attachmentSources: …, scannerEngine: …) your own AttachmentSources and BarcodeScannerEngine, or call the hooks directly.
final platform = FormCaptureServices(
attachmentSources: fakeSources, // implements AttachmentSources
scannerEngine: () => fakeEngine, // implements BarcodeScannerEngine
);