frappe_form 0.8.0
frappe_form: ^0.8.0 copied to clipboard
A library to render Frappe DocForm and generate a response
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_form/frappe_form.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());
}
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: DocFormSamples.fieldTest),
(name: 'Table Test', value: DocFormSamples.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 = 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);
return DocFormView(
key: ValueKey(loading),
form: widget.form,
onAttachmentLoaded: onAttachmentLoaded,
locale: widget.locale,
localizations: widget.localizations,
isLoading: loading,
onSubmit: onSubmit,
onCancel: onCancel,
onResponse: onResponse,
);
}
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);
}