frappe_form2 0.1.1
frappe_form2: ^0.1.1 copied to clipboard
A library to render Frappe DocForm and generate a response
// ignore_for_file: constant_pattern_never_matches_value_type
import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:example/attachment_utils.dart';
import 'package:example/doc_form_samples.dart';
import 'package:frappe_form2/frappe_form2.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:json_field_editor/json_field_editor.dart';
void main() {
runApp(const MyApp());
}
var transactions = [
{
"label": "Groups",
"items": ["BOM", "Product Bundle", "Item Alternative"],
},
{
"label": "Pricing",
"items": ["Item Price", "Pricing Rule"],
},
{
"label": "Sell",
"items": ["Quotation", "Sales Order", "Delivery Note", "Sales Invoice"],
},
{
"label": "Buy",
"items": [
"Material Request",
"Supplier Quotation",
"Request for Quotation",
"Purchase Order",
"Purchase Receipt",
"Purchase Invoice",
],
},
{
"label": "Manufacture",
"items": ["Production Plan", "Work Order", "Item Manufacturer"],
},
{
"label": "Traceability",
"items": ["Serial No", "Batch"],
},
{
"label": "Stock Movement",
"items": ["Stock Entry", "Stock Reconciliation"],
},
];
StreamController<
({ThemeData? theme, InputDecorationTheme? inputDecorationTheme})
>
themeStream =
StreamController<
({ThemeData? theme, InputDecorationTheme? inputDecorationTheme})
>.broadcast();
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<
({ThemeData? theme, InputDecorationTheme? inputDecorationTheme})
>(
stream: themeStream.stream,
initialData: null,
builder: (context, snapshot) {
final theme =
snapshot.data?.theme ??
(View.of(context).platformDispatcher.platformBrightness ==
Brightness.light
? ThemeData.light()
: ThemeData.dark());
return MaterialApp(
title: 'Frappe Doc Form Demo',
scrollBehavior: const CustomScrollBehavior(),
theme: theme.copyWith(
inputDecorationTheme: snapshot.data?.inputDecorationTheme,
),
home: const MyHomePage(),
);
},
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final List<({String name, Locale? value})> locales = [
(name: 'System default', value: null),
(name: 'English', value: Locale('en', 'US')),
(name: 'Spanish', value: Locale('es', 'ES')),
(name: 'French', value: Locale('fr', 'FR')),
];
final List<({String name, String value})> forms = [
(
name: 'Supported Fields Test',
value: jsonEncode(DocFormSamples.fieldTest),
),
// (name: 'Table Test', value: DocFormSampRles.tableTest),
];
final List<({String name, InputDecorationTheme? value})>
inputDecorationThemes = [
(name: 'Default', value: null),
(
name: 'Outline',
value: const InputDecorationTheme(
contentPadding: EdgeInsets.only(left: 16, top: 12, bottom: 12),
border: OutlineInputBorder(),
),
),
(
name: 'Outline Stretched Rounded',
value: const InputDecorationTheme(
contentPadding: EdgeInsets.only(left: 16, top: 12, bottom: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(28)),
),
),
),
(
name: 'Outline Stretched Rounded Filled',
value: const InputDecorationTheme(
contentPadding: EdgeInsets.only(left: 16, top: 12, bottom: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(28)),
),
filled: true,
),
),
(
name: 'Outline Stretched Rounded Filled No Borders',
value: const InputDecorationTheme(
contentPadding: EdgeInsets.only(left: 16, top: 12, bottom: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(28)),
borderSide: BorderSide(style: BorderStyle.none, width: 0),
),
isDense: true,
alignLabelWithHint: true,
filled: true,
),
),
];
final List<({String name, ThemeData? value})> themes = [
(name: 'System Default', value: null),
(name: 'Light', value: ThemeData.light()),
(name: 'Dark', value: ThemeData.dark()),
];
static bool isValidJson(String? jsonString) {
if (jsonString == null) {
return false;
}
try {
json.decode(jsonString);
return true;
} on FormatException catch (_) {
return false;
}
}
Locale? selectedLocale;
String selectedForm = jsonEncode(DocFormSamples.fieldTest);
InputDecorationTheme? selectedInputDecorationTheme;
final extraLocalizations = [DocFormFrLocalization()];
ThemeData theme = ThemeData();
ThemeData? selectedTheme;
@override
Widget build(BuildContext context) {
theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Frappe Doc Form Demo'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DropdownButtonFormField<String>(
decoration: const InputDecoration(
label: Text('Select a Form sample'),
),
initialValue: selectedForm,
items: forms
.map(
(e) => DropdownMenuItem<String>(
value: e.value,
child: Text(e.name),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
selectedForm = value;
}
},
),
const SizedBox(height: 8.0),
TextButton.icon(
onPressed: () {
showDialog(
context: context,
builder: (context) {
String? nameError;
String? jsonError;
final nameController = JsonTextFieldController();
final jsonController = JsonTextFieldController();
return Dialog(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: StatefulBuilder(
builder: (context, dialogSetState) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Add a Form from JSON',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'Form Name',
errorText: nameError,
contentPadding: EdgeInsets.only(
left: 16,
top: 12,
bottom: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(28),
),
),
filled: true,
),
),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height *
0.6,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
child: JsonField(
controller: jsonController,
isFormatting: true,
showErrorMessage: true,
maxLines: null,
decoration: InputDecoration(
labelText: 'Form JSON',
errorText: jsonError,
contentPadding: EdgeInsets.only(
left: 16,
top: 12,
bottom: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(28),
),
),
filled: true,
),
),
),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => jsonController
.formatJson(sortJson: true),
child: const Text('Format JSON'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: () {
nameError = jsonError = null;
if (nameController.text.isEmpty) {
nameError = 'Name is required';
}
if (nameError == null &&
forms.any(
(item) =>
item.name ==
nameController.text,
)) {
nameError =
'A Form with this name already exists';
}
if (jsonController.text.isEmpty) {
jsonError = 'JSON is required';
}
if (jsonError == null &&
!isValidJson(
jsonController.text,
)) {
jsonError = 'Invalid JSON';
}
if (jsonError == null &&
forms.any(
(item) =>
item.value ==
jsonController.text,
)) {
jsonError =
'A Form with this JSON already exists';
}
if (nameError != null ||
jsonError != null) {
dialogSetState(() {});
return;
}
forms.add((
name: nameController.text,
value: jsonController.text,
));
selectedForm = jsonController.text;
setState(() {});
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primary,
foregroundColor:
theme.colorScheme.onPrimary,
),
child: const Text('Add'),
),
),
],
),
],
),
),
),
);
},
);
},
icon: const Icon(Icons.add),
label: const Text('Add a Form from JSON'),
),
const SizedBox(height: 16.0),
DropdownButtonFormField<Locale?>(
decoration: const InputDecoration(
label: Text('Select the Form locale'),
),
initialValue: selectedLocale,
items: locales
.map(
(e) => DropdownMenuItem<Locale>(
value: e.value,
child: Text(e.name),
),
)
.toList(),
onChanged: (value) {
selectedLocale = value;
},
),
const SizedBox(height: 16.0),
DropdownButtonFormField<InputDecorationTheme?>(
decoration: const InputDecoration(
label: Text('Select input decoration theme'),
),
initialValue: selectedInputDecorationTheme,
items: inputDecorationThemes
.map(
(e) => DropdownMenuItem<InputDecorationTheme>(
value: e.value,
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.78,
child: Text(e.name),
),
),
)
.toList(),
onChanged: (value) {
themeStream.add((
theme: selectedTheme,
inputDecorationTheme: selectedInputDecorationTheme = value,
));
},
),
const SizedBox(height: 16.0),
DropdownButtonFormField<ThemeData?>(
decoration: const InputDecoration(
label: Text('Select app theme'),
),
initialValue: selectedTheme,
items: themes
.map(
(e) => DropdownMenuItem<ThemeData>(
value: e.value,
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.78,
child: Text(e.name),
),
),
)
.toList(),
onChanged: (value) {
themeStream.add((
theme: selectedTheme = value,
inputDecorationTheme: selectedInputDecorationTheme,
));
},
),
],
),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: FloatingActionButton.extended(
extendedPadding: const EdgeInsets.symmetric(horizontal: 32.0),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DocFormPage(
form: form,
locale: selectedLocale,
localizations: extraLocalizations,
),
),
);
},
label: const Text('Open Form'),
),
);
}
DocForm get form => DocForm.fromJsonString(selectedForm);
}
class DocFormPage extends StatefulWidget {
final DocForm form;
final Locale? locale;
final List<DocFormBaseLocalization>? localizations;
const DocFormPage({
super.key,
required this.form,
this.locale,
this.localizations,
});
@override
State createState() => DocFormPageState();
}
class DocFormPageState extends State<DocFormPage> {
bool loading = true;
ThemeData theme = ThemeData();
@override
void initState() {
super.initState();
Future.delayed(
const Duration(seconds: 1),
() => setState(() => loading = false),
);
}
@override
Widget build(BuildContext context) {
theme = Theme.of(context);
//
final Map<String, List<Map<String, String>>> mockDocTypeData = {
"Fruit": [
{"value": "Apple", "description": "Red Crunchy"},
{"value": "Banana", "description": "Yellow Sweet"},
{"value": "Cherry", "description": "Small Red"},
],
"Animal": [
{"value": "Lion", "description": "King of Jungle"},
{"value": "Tiger", "description": "Striped Hunter"},
{"value": "Elephant", "description": "Trunk Giant"},
],
"Job": [
{"value": "Doctor", "description": "Medical Professional"},
{"value": "Engineer", "description": "Technical Builder"},
],
};
// The fetchSuggestions implementation
Future<List<Map<String, String>>> fetchLinkSuggestions({
required String doctype, // e.g., "Fruit"
required String query, // e.g., "ap"
}) async {
debugPrint("============================: '$query' and options: '$doctype'");
// 1. Simulate Network Latency
await Future.delayed(Duration(milliseconds: 200));
// 2. Find the "DocType" list
final List<Map<String, String>>? sourceList = mockDocTypeData[doctype];
if (sourceList == null) return [];
final lowerPattern = query.toLowerCase();
// 3. Filter by 'value' (the primary link field)
return sourceList.where((e) {
final value = e['value']?.toString().toLowerCase() ?? '';
final description = e['description']?.toString().toLowerCase() ?? '';
return value.contains(lowerPattern) ||
description.contains(lowerPattern);
}).toList();
} //
Map<String, String> formValues = {};
return DocFormView(
key: ValueKey(loading),
form: widget.form,
baseUrl: "https://your-frappe-instance.com/",
// ───────── STEP 1: CAPTURE CHANGES ─────────
onDocTypeChanged: (fieldname, value) {
setState(() {
formValues[fieldname ?? ""] = value ?? "";
debugPrint(" Field '$fieldname' changed to '$value'");
});
},
// ───────── STEP 2: RESOLVE DYNAMIC DOCTYPE ─────────
fetchSuggestions: (pattern, field) async {
try {
debugPrint('Searching in: ${field.options} for pattern: $pattern');
// Get the string from the field's 'options' metadata
final String? name = field.fieldName;
final String resolvedDocType =
// "Animal"??"";
(formValues.containsKey(name))
? (formValues[name] ?? "none")
: (field.options ?? "none");
// Call your API
final List<Map<String, dynamic>> results = await fetchLinkSuggestions(
doctype: resolvedDocType,
query: pattern,
);
return results;
} catch (e) {
return [];
}
},
// ------------------------------------
// ------------------------------------
controller: DocFormController(
onBuildFieldView: (field, children, onAttachmentLoaded) async {
switch (field.type) {
case FieldType.heatmap:
return HeatMapView(field: field, activityData: []);
case FieldType.connections:
return ConnectionsView(
transactions: transactions
.map((e) => Transaction.fromMap(e))
.toList(),
onTap: (String link) {},
);
default:
return null;
}
},
),
isLoading: loading,
onSubmit: onSubmit,
onCancel: onCancel,
onResponse: onResponse,
getDoctypesForDynamicLink: (String fieldName) async {
var list = widget.form.getDynamicLinkOptions(fieldName);
if (list.isEmpty) {
list = ["Job", "Fruit", "Animal"];
}
return list;
},
);
}
Future<Attachment?> onAttachmentLoaded() async {
return AttachmentUtils.pickAttachment(context);
}
Future<bool> onSubmit() async =>
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Warning'),
content: Text('Are you sure you want to submit?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Yes'),
),
],
),
) ??
false;
Future<bool> onCancel() async =>
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Warning'),
content: Text('Are you sure you want to cancel?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Yes'),
),
],
),
) ??
false;
void onResponse(Map<String, dynamic> formResponse) async {
String json = jsonEncode(formResponse);
var prettyString = const JsonEncoder.withIndent(' ').convert(formResponse);
debugPrint('''
========================================================================
$prettyString
========================================================================
''');
showDialog(
context: context,
builder: (context) => Dialog(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Form Response',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: JsonField(
controller: JsonTextFieldController()..text = json,
isFormatting: true,
showErrorMessage: true,
doInitFormatting: true,
readOnly: true,
showCursor: true,
enableInteractiveSelection: true,
maxLines: null,
decoration: InputDecoration(
contentPadding: EdgeInsets.only(
left: 16,
top: 12,
bottom: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(28)),
),
filled: true,
),
),
),
),
),
],
),
),
),
);
}
}
/// French localizations
class DocFormFrLocalization extends DocFormBaseLocalization {
DocFormFrLocalization() : super(Locale('fr', 'FR'));
@override
String get btnSubmit => 'Soumettre';
@override
String get btnUpload => 'Télécharger';
@override
String get btnChange => 'Changement';
@override
String get btnRemove => 'Retirer';
@override
String get textOtherOption => 'Autre option';
@override
String get textDate => 'Date';
@override
String get textTime => 'Temps';
@override
String get textLatitude => 'Latitude';
@override
String get textLongitude => 'Longitude';
@override
String get textPhone => 'Téléphone';
@override
String get textSearchPhoneCountryCode => 'Nom du pays ou code de composition';
@override
String get exceptionNoEmptyField => 'Ce champ est obligatoire.';
@override
String get exceptionValueMustBeAPositiveIntegerNumber =>
'La valeur doit être un nombre entier positif.';
@override
String get exceptionValueMustBeAPositiveNumber =>
'La valeur doit être un nombre positif.';
@override
String get exceptionValueMustBeANumber => 'La valeur doit être un numéro.';
@override
String get exceptionInvalidUrl => 'Invalid url.';
@override
String get exceptionInvalidPhoneNumber => 'Numéro de téléphone non valide.';
@override
String exceptionValueOutOfRange(dynamic minValue, dynamic maxValue) =>
'La valeur doit être comprise entre $minValue et $maxValue.';
@override
String exceptionTextLength(dynamic minLength, dynamic maxLength) =>
'Le texte doit contenir au moins des caractères $minLength et au maximum $maxLength.';
@override
String exceptionTextMaxLength(dynamic maxLength) =>
'Le texte doit contenir au maximum des caractères $maxLength.';
}
class CustomScrollBehavior extends MaterialScrollBehavior {
static const _webScrollPhysics = BouncingScrollPhysics(
parent: RangeMaintainingScrollPhysics(),
);
const CustomScrollBehavior() : super();
// Override behavior methods and getters like dragDevices
@override
Set<PointerDeviceKind> get dragDevices => PointerDeviceKind.values.toSet();
@override
ScrollPhysics getScrollPhysics(BuildContext context) =>
kIsWeb ? _webScrollPhysics : super.getScrollPhysics(context);
}