• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inventree / inventree-app / 15820811198

23 Jun 2025 09:42AM UTC coverage: 1.566%. Remained the same
15820811198

push

github

web-flow
Use FVM in GitHub Actions and migrate to Flutter 3.32.4 (#641)

* feat: implement Flutter version management using FVM across CI workflows

* Flutter 3.29.3 + minor Package upgrades

* Replace deprecated `withOpacity`

* Upgrade major package versions without breaking changes.

* Disable unnecessary_async rule

Re-enable later

* New language version and automated fixes

- unnecessary_breaks
- unnecessary_underscore

* Update BUILDING.md to use fvm commands

* Add gitignore files for Android and iOS build artifacts

* Migrate iOS dependencies to Swift Package Manager

This is being done automatically by Flutter

* Flutter 3.32.4

* New sdk version

* docs: add IDE setup instructions and troubleshooting guide for FVM integration

0 of 14 new or added lines in 3 files covered. (0.0%)

20 existing lines in 6 files now uncovered.

759 of 48469 relevant lines covered (1.57%)

0.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/lib/api_form.dart
1

2
import "dart:io";
3

4
import "package:intl/intl.dart";
5
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
6
import "package:dropdown_search/dropdown_search.dart";
7
import "package:datetime_picker_formfield/datetime_picker_formfield.dart";
8
import "package:flutter/material.dart";
9

10
import "package:inventree/api.dart";
11
import "package:inventree/app_colors.dart";
12
import "package:inventree/barcode/barcode.dart";
13
import "package:inventree/helpers.dart";
14
import "package:inventree/l10.dart";
15

16
import "package:inventree/inventree/company.dart";
17
import "package:inventree/inventree/part.dart";
18
import "package:inventree/inventree/project_code.dart";
19
import "package:inventree/inventree/purchase_order.dart";
20
import "package:inventree/inventree/sales_order.dart";
21
import "package:inventree/inventree/stock.dart";
22

23
import "package:inventree/inventree/sentry.dart";
24

25
import "package:inventree/widget/dialogs.dart";
26
import "package:inventree/widget/fields.dart";
27
import "package:inventree/widget/progress.dart";
28
import "package:inventree/widget/snacks.dart";
29

30

31
/*
32
 * Class that represents a single "form field",
33
 * defined by the InvenTree API
34
 */
35
class APIFormField {
36

37
  // Constructor
38
  APIFormField(this.name, this.data);
×
39

40
  // File to be uploaded for this filed
41
  File? attachedfile;
42

43
  // Name of this field
44
  final String name;
45

46
  // JSON data which defines the field
47
  final Map<String, dynamic> data;
48

49
  // JSON field definition provided by the server
50
  Map<String, dynamic> definition = {};
51

52
  dynamic initial_data;
53

54
  // Return the "lookup path" for this field, within the server data
55
  String get lookupPath {
×
56

57
    // Simple top-level case
58
    if (parent.isEmpty && !nested) {
×
59
      return name;
×
60
    }
61

62
    List<String> path = [];
×
63

64
    if (parent.isNotEmpty) {
×
65
      path.add(parent);
×
66
      path.add("child");
×
67
    }
68

69
    if (nested) {
×
70
      path.add("children");
×
71
      path.add(name);
×
72
    }
73

74
    return path.join(".");
×
75
  }
76

77
  /*
78
   * Extract a field parameter from the provided field definition.
79
   *
80
   * - First the user-provided data is checked
81
   * - Second, the server-provided definition is checked
82
   *
83
   * - Finally, return null
84
   */
85
  dynamic getParameter(String key) {
×
86
    if (data.containsKey(key)) {
×
87
      return data[key];
×
88
    } else if (definition.containsKey(key)) {
×
89
      return definition[key];
×
90
    } else {
91
      return null;
92
    }
93
  }
94

95
  // Get the "api_url" associated with a related field
96
  String get api_url => (getParameter("api_url") ?? "") as String;
×
97

98
  // Get the "model" associated with a related field
99
  String get model => (getParameter("model") ?? "") as String;
×
100

101
  // Is this field hidden?
102
  bool get hidden => (getParameter("hidden") ?? false) as bool;
×
103

104
  // Is this field nested? (Nested means part of an array)
105
  // Note: This parameter is only defined locally
106
  bool get nested => (data["nested"] ?? false) as bool;
×
107

108
  // What is the "parent" field of this field?
109
  // Note: This parameter is only defined locally
110
  String get parent => (data["parent"] ?? "") as String;
×
111

112
  bool get isSimple => !nested && parent.isEmpty;
×
113

114
  // Is this field read only?
115
  bool get readOnly => (getParameter("read_only") ?? false) as bool;
×
116

117
  bool get multiline => (getParameter("multiline") ?? false) as bool;
×
118

119
  // Get the "value" as a string (look for "default" if not available)
120
  dynamic get value => data["value"] ?? data["instance_value"] ?? defaultValue;
×
121

122
  // Render value to string (for form submission)
123
  String renderValueToString() {
×
124
    if (data["value"] == null) {
×
125
      return "";
126
    } else {
127
      return data["value"].toString();
×
128
    }
129
  }
130

131
  // Get the "default" as a string
132
  dynamic get defaultValue => getParameter("default");
×
133

134
  // Construct a set of "filters" for this field (e.g. related field)
135
  Map<String, String> get filters {
×
136

137
    Map<String, String> _filters = {};
×
138

139
    // Start with the field "definition" (provided by the server)
140
    if (definition.containsKey("filters")) {
×
141

142
      try {
143
        var fDef = definition["filters"] as Map<String, dynamic>;
×
144

145
        fDef.forEach((String key, dynamic value) {
×
146
          _filters[key] = value.toString();
×
147
        });
148

149
      } catch (error) {
150
        // pass
151
      }
152
    }
153

154
    // Next, look at any "instance_filters" provided by the server
155
    if (definition.containsKey("instance_filters")) {
×
156

157
      try {
158
        var fIns = definition["instance_filters"] as Map<String, dynamic>;
×
159

160
        fIns.forEach((String key, dynamic value) {
×
161
          _filters[key] = value.toString();
×
162
        });
163
      } catch (error) {
164
        // pass
165
      }
166

167
    }
168

169
    // Finally, augment or override with any filters provided by the calling function
170
    if (data.containsKey("filters")) {
×
171
      try {
172
        var fDat = data["filters"] as Map<String, dynamic>;
×
173

174
        fDat.forEach((String key, dynamic value) {
×
175
          _filters[key] = value.toString();
×
176
        });
177
      } catch (error) {
178
        // pass
179
      }
180
    }
181

182
    return _filters;
183

184
  }
185

186
  bool hasErrors() => errorMessages().isNotEmpty;
×
187

188
  // Extract error messages from the server response
189
  void extractErrorMessages(APIResponse response) {
×
190

191
    dynamic errors;
192

193
    if (isSimple) {
×
194
      // Simple fields are easily handled
195
      errors = response.data[name];
×
196
    } else {
197
      if (parent.isNotEmpty) {
×
198
        dynamic parentElement = response.data[parent];
×
199

200
        // Extract from list
201
        if (parentElement is List) {
×
202
          parentElement = parentElement[0];
×
203
        }
204

205
        if (parentElement is Map) {
×
206
          errors = parentElement[name];
×
207
        }
208
      }
209
    }
210

211
    data["errors"] = errors;
×
212
  }
213

214
  // Return the error message associated with this field
215
  List<String> errorMessages() {
×
216

217
    dynamic errors = data["errors"] ?? [];
×
218

219
    // Handle the case where a single error message is returned
220
    if (errors is String) {
×
221
      errors = [errors];
×
222
    }
223

224
    errors = errors as List<dynamic>;
225

226
    List<String> messages = [];
×
227

228
    for (dynamic error in errors) {
×
229
      messages.add(error.toString());
×
230
    }
231

232
    return messages;
233
  }
234

235
  // Is this field required?
236
  bool get required => (getParameter("required") ?? false) as bool;
×
237

238
  String get type => (getParameter("type") ?? "").toString();
×
239

240
  String get label => (getParameter("label") ?? "").toString();
×
241

242
  String get helpText => (getParameter("help_text") ?? "").toString();
×
243

244
  String get placeholderText => (getParameter("placeholder") ?? "").toString();
×
245

246
  List<dynamic> get choices => (getParameter("choices") ?? []) as List<dynamic>;
×
247

248
  Future<void> loadInitialData() async {
×
249

250
    // Only for "related fields"
251
    if (type != "related field") {
×
252
      return;
253
    }
254

255
    // Null value? No point!
256
    if (value == null) {
×
257
      return;
258
    }
259

260
    int? pk = int.tryParse(value.toString());
×
261

262
    if (pk == null) {
263
      return;
264
    }
265

266
    String url = api_url + "/" + pk.toString() + "/";
×
267

268
    final APIResponse response = await InvenTreeAPI().get(
×
269
      url,
270
      params: filters,
×
271
    );
272

273
    if (response.successful()) {
×
274
      initial_data = response.data;
×
275
    }
276
  }
277

278
  // Construct a widget for this input
279
  Widget constructField(BuildContext context) {
×
280

281
    switch (type) {
×
282
      case "string":
×
283
      case "url":
×
284
        return _constructString();
×
285
      case "boolean":
×
286
        return _constructBoolean();
×
287
      case "related field":
×
288
        return _constructRelatedField();
×
289
      case "float":
×
290
      case "decimal":
×
291
        return _constructFloatField();
×
292
      case "choice":
×
293
        return _constructChoiceField();
×
294
      case "file upload":
×
295
      case "image upload":
×
296
        return _constructFileField();
×
297
      case "date":
×
298
        return _constructDateField();
×
299
      case "barcode":
×
300
        return _constructBarcodeField(context);
×
301
      default:
302
        return ListTile(
×
303
          title: Text(
×
304
            "Unsupported field type: '${type}' for field '${name}'",
×
305
            style: TextStyle(
×
306
                color: COLOR_DANGER,
307
                fontStyle: FontStyle.italic),
308
          )
309
        );
310
    }
311
  }
312

313
  // Field for capturing a barcode
314
  Widget _constructBarcodeField(BuildContext context) {
×
315

316
    TextEditingController controller = TextEditingController();
×
317

318
    String barcode = (value ?? "").toString();
×
319

320
    if (barcode.isEmpty) {
×
321
      barcode = L10().barcodeNotAssigned;
×
322
    }
323

324
    controller.text = barcode;
×
325

326
    return InputDecorator(
×
327
      decoration: InputDecoration(
×
328
        labelText: required ? label + "*" : label,
×
329
        labelStyle: _labelStyle(),
×
330
        helperText: helpText,
×
331
        helperStyle: _helperStyle(),
×
332
        hintText: placeholderText,
×
333
      ),
334
      child: ListTile(
×
335
        title: TextField(
×
336
          readOnly: true,
337
          controller: controller,
338
        ),
339
        trailing: IconButton(
×
340
          icon: Icon(TablerIcons.qrcode),
×
341
          onPressed: () async {
×
342
            var handler = UniqueBarcodeHandler((String hash) {
×
343
              controller.text = hash;
×
344
              data["value"] = hash;
×
345

346
              barcodeSuccess(L10().barcodeAssigned);
×
347
            });
348

349
            scanBarcode(context, handler: handler);
×
350
          },
351
        ),
352
      )
353
    );
354

355
  }
356

357
  // Field for displaying and selecting dates
358
  Widget _constructDateField() {
×
359

360
    DateTime? currentDate = DateTime.tryParse((value ?? "")as String);
×
361

362
    return InputDecorator(
×
363
      decoration: InputDecoration(
×
364
        labelText: label,
×
365
        labelStyle: _labelStyle(),
×
366
        helperStyle: _helperStyle(),
×
367
        helperText: helpText,
×
368
      ),
369
      child: DateTimeField(
×
370
        format: DateFormat("yyyy-MM-dd"),
×
371
        initialValue: currentDate,
372
        onChanged: (DateTime? time) {
×
373
          // Save the time string
374
          if (time == null) {
375
            data["value"] = null;
×
376
          } else {
377
            data["value"] = time.toString().split(" ").first;
×
378
          }
379
        },
380
        onShowPicker: (context, value) async {
×
381
          final time = await showDatePicker(
×
382
            context: context,
383
            initialDate: currentDate ?? DateTime.now(),
×
384
            firstDate: DateTime(1900),
×
385
            lastDate: DateTime(2100),
×
386
          );
387

388
          return time;
389
        },
390
      )
391
    );
392

393
  }
394

395

396
  // Field for selecting and uploading files
397
  Widget _constructFileField() {
×
398

399
    TextEditingController controller = TextEditingController();
×
400

401
    controller.text = (attachedfile?.path ?? L10().attachmentSelect).split("/").last;
×
402

403
    return InputDecorator(
×
404
      decoration: InputDecoration(
×
405
        labelText: label,
×
406
        labelStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
×
407
      ),
408
      child: ListTile(
×
409
        title: TextField(
×
410
          readOnly: true,
411
          controller: controller,
412
        ),
413
        trailing: IconButton(
×
414
          icon: Icon(TablerIcons.circle_plus),
×
415
          onPressed: () async {
×
416
            FilePickerDialog.pickFile(
×
417
              message: L10().attachmentSelect,
×
418
              onPicked: (file) {
×
419
                // Display the filename
420
                controller.text = file.path.split("/").last;
×
421

422
                // Save the file
423
                attachedfile = file;
×
424
              }
425
            );
426
          },
427
        )
428
      )
429
    );
430
  }
431

432
  // Field for selecting from multiple choice options
433
  Widget _constructChoiceField() {
×
434

435
    dynamic initial;
436

437
    // Check if the current value is within the allowed values
438
    for (var opt in choices) {
×
439
      if (opt["value"] == value) {
×
440
        initial = opt;
441
        break;
442
      }
443
    }
444

445
    return DropdownSearch<dynamic>(
×
446
      popupProps: PopupProps.bottomSheet(
×
447
        showSelectedItems: false,
448
        searchFieldProps: TextFieldProps(
×
449
          autofocus: true
450
        )
451
      ),
452
      selectedItem: initial,
453
      items: choices,
×
454
      dropdownDecoratorProps: DropDownDecoratorProps(
×
455
          dropdownSearchDecoration: InputDecoration(
×
456
        labelText: label,
×
457
        hintText: helpText,
×
458
      )),
459
      onChanged: null,
460
      clearButtonProps: ClearButtonProps(isVisible: !required),
×
461
      itemAsString: (dynamic item) {
×
462
        return (item["display_name"] ?? "") as String;
×
463
      },
464
      onSaved: (item) {
×
465
        if (item == null) {
466
          data["value"] = null;
×
467
        } else {
468
          data["value"] = item["value"];
×
469
        }
470
      });
471
  }
472

473
  // Construct a floating point numerical input field
474
  Widget _constructFloatField() {
×
475

476
    // Initial value: try to cast to a valid number
477
    String initial = "";
478

479
    double? initialNumber = double.tryParse(value.toString());
×
480

481
    if (initialNumber != null) {
482
      initial = simpleNumberString(initialNumber);
×
483
    }
484

485
    return TextFormField(
×
486
      decoration: InputDecoration(
×
487
        labelText: required ? label + "*" : label,
×
488
        labelStyle: _labelStyle(),
×
489
        helperText: helpText,
×
490
        helperStyle: _helperStyle(),
×
491
        hintText: placeholderText,
×
492
      ),
493
      initialValue: initial,
494
      keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
×
495
      validator: (value) {
×
496
        value = value?.trim() ?? "";
×
497

498
        // Allow empty numbers, *if* this field is not required
499
        if (value.isEmpty && !required) {
×
500
          return null;
501
        }
502

503
        double? quantity = double.tryParse(value.toString());
×
504

505
        if (quantity == null) {
506
          return L10().numberInvalid;
×
507
        }
508

509
        return null;
510
      },
511
      onSaved: (val) {
×
512
        data["value"] = val;
×
513
      },
514
    );
515

516
  }
517

518
  // Construct an input for a related field
519
  Widget _constructRelatedField() {
×
520
    return DropdownSearch<dynamic>(
×
521
      popupProps: PopupProps.bottomSheet(
×
522
        showSelectedItems: true,
523
        isFilterOnline: true,
524
        showSearchBox: true,
525
        itemBuilder: (context, item, isSelected) {
×
526
          return _renderRelatedField(name, item, isSelected, true);
×
527
        },
528
        emptyBuilder: (context, item) {
×
529
          return _renderEmptyResult();
×
530
        },
531
        searchFieldProps: TextFieldProps(
×
532
          autofocus: true
533
        )
534
      ),
535
      selectedItem: initial_data,
×
536
      asyncItems: (String filter) async {
×
537
        Map<String, String> _filters = {
×
538
          ..._relatedFieldFilters(),
×
539
          ...filters,
×
540
        };
541

542
        _filters["search"] = filter;
×
543
        _filters["offset"] = "0";
×
544
        _filters["limit"] = "25";
×
545

546
        final APIResponse response = await InvenTreeAPI().get(api_url, params: _filters);
×
547

548
        if (response.isValid()) {
×
549
          return response.resultsList();
×
550
        } else {
551
          return [];
×
552
        }
553
      },
554
      clearButtonProps: ClearButtonProps(
×
555
        isVisible: !required
×
556
      ),
557
      dropdownDecoratorProps: DropDownDecoratorProps(
×
558
          dropdownSearchDecoration: InputDecoration(
×
559
        labelText: label,
×
560
        hintText: helpText,
×
561
      )),
562
      onChanged: null,
563
      itemAsString: (dynamic item) {
×
564
        Map<String, dynamic> data = item as Map<String, dynamic>;
565

566
        switch (model) {
×
567
          case InvenTreePart.MODEL_TYPE:
×
568
            return InvenTreePart.fromJson(data).fullname;
×
569
          case InvenTreeCompany.MODEL_TYPE:
×
570
            return InvenTreeCompany.fromJson(data).name;
×
571
          case InvenTreePurchaseOrder.MODEL_TYPE:
×
572
            return InvenTreePurchaseOrder.fromJson(data).reference;
×
573
          case InvenTreeSalesOrder.MODEL_TYPE:
×
574
            return InvenTreeSalesOrder.fromJson(data).reference;
×
575
          case InvenTreePartCategory.MODEL_TYPE:
×
576
            return InvenTreePartCategory.fromJson(data).pathstring;
×
577
          case InvenTreeStockLocation.MODEL_TYPE:
×
578
            return InvenTreeStockLocation.fromJson(data).pathstring;
×
579
          default:
580
            return "itemAsString not implemented for '${model}'";
×
581
        }
582
      },
583
      dropdownBuilder: (context, item) {
×
584
        return _renderRelatedField(name, item, true, false);
×
585
      },
586
      onSaved: (item) {
×
587
        if (item != null) {
588
          data["value"] = item["pk"];
×
589
        } else {
590
          data["value"] = null;
×
591
        }
592
      },
593
      compareFn: (dynamic item, dynamic selectedItem) {
×
594
        // Comparison is based on the PK value
595

596
        if (item == null || selectedItem == null) {
597
          return false;
598
        }
599

600
        bool result = false;
601

602
        try {
603
          result = item["pk"].toString() == selectedItem["pk"].toString();
×
604
        } catch (error) {
605
          // Catch any conversion errors
606
          result = false;
607
        }
608

609
        return result;
610
      });
611
  }
612

613
  // Construct a set of custom filters for the dropdown search
614
  Map<String, String> _relatedFieldFilters() {
×
615

616
    switch (model) {
×
617
      case InvenTreeSupplierPart.MODEL_TYPE:
×
618
        return InvenTreeSupplierPart().defaultListFilters();
×
619
      case InvenTreeStockItem.MODEL_TYPE:
×
620
        return InvenTreeStockItem().defaultListFilters();
×
621
      default:
622
        break;
623
    }
624

625
    return {};
×
626
  }
627

628
  // Render a "related field" based on the "model" type
629
  Widget _renderRelatedField(String fieldName, dynamic item, bool selected, bool extended) {
×
630

631
    // Convert to JSON
632
    Map<String, dynamic> data = {};
×
633

634
    try {
635
      if (item is Map<String, dynamic>) {
×
636
        data = Map<String, dynamic>.from(item);
×
637
      } else {
638
        data = {};
×
639
      }
640
    } catch (error, stackTrace) {
641
      data = {};
×
642

643
      sentryReportError(
×
644
        "_renderRelatedField", error, stackTrace,
645
        context: {
×
646
          "method": "_renderRelateField",
647
          "field_name": fieldName,
648
          "item": item.toString(),
×
649
          "selected": selected.toString(),
×
650
          "extended": extended.toString(),
×
651
        }
652
      );
653
    }
654

655
    switch (model) {
×
656
      case InvenTreePart.MODEL_TYPE:
×
657
        var part = InvenTreePart.fromJson(data);
×
658

659
        return ListTile(
×
660
          title: Text(
×
661
              part.fullname,
×
662
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
663
          ),
664
          subtitle: extended ? Text(
×
665
            part.description,
×
666
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
667
          ) : null,
668
          leading: extended ? InvenTreeAPI().getThumbnail(part.thumbnail) : null,
×
669
        );
670
      case InvenTreePartTestTemplate.MODEL_TYPE:
×
671
          var template = InvenTreePartTestTemplate.fromJson(data);
×
672

673
          return ListTile(
×
674
            title: Text(template.testName),
×
675
            subtitle: Text(template.description),
×
676
          );
677
      case InvenTreeSupplierPart.MODEL_TYPE:
×
678
        var part = InvenTreeSupplierPart.fromJson(data);
×
679

680
        return ListTile(
×
681
          title: Text(part.SKU),
×
682
          subtitle: Text(part.partName),
×
683
          leading: extended ? InvenTreeAPI().getThumbnail(part.partImage) : null,
×
684
          trailing: extended && part.supplierImage.isNotEmpty ? InvenTreeAPI().getThumbnail(part.supplierImage) : null,
×
685
        );
686
      case InvenTreePartCategory.MODEL_TYPE:
×
687

688
        var cat = InvenTreePartCategory.fromJson(data);
×
689

690
        return ListTile(
×
691
          title: Text(
×
692
              cat.pathstring,
×
693
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
694
          ),
695
          subtitle: extended ? Text(
×
696
            cat.description,
×
697
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
698
          ) : null,
699
        );
700
      case InvenTreeStockItem.MODEL_TYPE:
×
701
        var item = InvenTreeStockItem.fromJson(data);
×
702

703
        return ListTile(
×
704
          title: Text(
×
705
            item.partName,
×
706
          ),
707
          leading: InvenTreeAPI().getThumbnail(item.partThumbnail),
×
708
          trailing: Text(item.quantityString()),
×
709
        );
710
      case InvenTreeStockLocation.MODEL_TYPE:
×
711
        var loc = InvenTreeStockLocation.fromJson(data);
×
712

713
        return ListTile(
×
714
          title: Text(
×
715
              loc.pathstring,
×
716
              style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
×
717
          ),
718
          subtitle: extended ? Text(
×
719
            loc.description,
×
720
            style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
×
721
          ) : null,
722
        );
723
      case InvenTreeSalesOrderShipment.MODEL_TYPE:
×
724
        var shipment = InvenTreeSalesOrderShipment.fromJson(data);
×
725

726
        return ListTile(
×
727
          title: Text(shipment.reference),
×
728
          subtitle: Text(shipment.tracking_number),
×
729
          trailing: shipment.shipped ? Text(shipment.shipment_date!) : null,
×
730
        );
731
      case "owner":
×
732
        String name = (data["name"] ?? "") as String;
×
733
        bool isGroup = (data["label"] ?? "") == "group";
×
734
        return ListTile(
×
735
          title: Text(name),
×
736
          leading: Icon(isGroup ? TablerIcons.users : TablerIcons.user),
×
737
        );
738
      case "contact":
×
739
        String name = (data["name"] ?? "") as String;
×
740
        String role = (data["role"] ?? "") as String;
×
741
        return ListTile(
×
742
          title: Text(name),
×
743
          subtitle: Text(role),
×
744
        );
745
      case InvenTreeCompany.MODEL_TYPE:
×
746
        var company = InvenTreeCompany.fromJson(data);
×
747
        return ListTile(
×
748
            title: Text(company.name),
×
749
            subtitle: extended ? Text(company.description) : null,
×
750
            leading: InvenTreeAPI().getThumbnail(company.thumbnail)
×
751
        );
752
      case InvenTreeProjectCode.MODEL_TYPE:
×
753
        var project_code = InvenTreeProjectCode.fromJson(data);
×
754
        return ListTile(
×
755
            title: Text(project_code.code),
×
756
            subtitle: Text(project_code.description),
×
757
            leading: Icon(TablerIcons.list)
×
758
        );
759
      default:
760
        return ListTile(
×
761
          title: Text(
×
762
              "Unsupported model",
763
              style: TextStyle(
×
764
                  fontWeight: FontWeight.bold,
765
                  color: COLOR_DANGER
766
              )
767
          ),
768
          subtitle: Text("Model '${model}' rendering not supported"),
×
769
        );
770
    }
771
  }
772

773
  // Construct a widget to instruct the user that no results were found
774
  Widget _renderEmptyResult() {
×
775
    return ListTile(
×
776
      leading: Icon(TablerIcons.search),
×
777
      title: Text(L10().noResults),
×
778
      subtitle: Text(
×
779
        L10().queryNoResults,
×
780
        style: TextStyle(fontStyle: FontStyle.italic),
×
781
      ),
782
    );
783
  }
784

785

786
  // Construct a string input element
787
  Widget _constructString() {
×
788

789
    if (readOnly) {
×
790
      return ListTile(
×
791
        title: Text(label),
×
792
        subtitle: Text(helpText),
×
793
        trailing: Text(value.toString()),
×
794
      );
795
    }
796

797
    return TextFormField(
×
798
      decoration: InputDecoration(
×
799
        labelText: required ? label + "*" : label,
×
800
        labelStyle: _labelStyle(),
×
801
        helperText: helpText,
×
802
        helperStyle: _helperStyle(),
×
803
        hintText: placeholderText,
×
804
      ),
805
      readOnly: readOnly,
×
806
      maxLines: multiline ? null : 1,
×
807
      expands: false,
808
      initialValue: (value ?? "") as String,
×
809
      onSaved: (val) {
×
810
        data["value"] = val;
×
811
      },
812
      validator: (value) {
×
813
        if (required && (value == null || value.isEmpty)) {
×
814
          // return L10().valueCannotBeEmpty;
815
        }
816

817
        return null;
818
      },
819
    );
820
  }
821

822
  // Construct a boolean input element
823
  Widget _constructBoolean() {
×
824

825
    bool? initial_value;
826

827
    if (value is bool || value == null) {
×
828
      initial_value = value as bool?;
×
829
    } else {
830
      String vs = value.toString().toLowerCase();
×
831
      initial_value = ["1", "true", "yes"].contains(vs);
×
832
    }
833

834
    return CheckBoxField(
×
835
      label: label,
×
836
      labelStyle: _labelStyle(),
×
837
      helperText: helpText,
×
838
      helperStyle: _helperStyle(),
×
839
      initial: initial_value,
840
      tristate: (getParameter("tristate") ?? false) as bool,
×
841
      onSaved: (val) {
×
842
        data["value"] = val;
×
843
      },
844
    );
845
  }
846

847
  TextStyle _labelStyle() {
×
848
    return TextStyle(
×
849
      fontWeight: FontWeight.bold,
850
      fontSize: 18,
851
      fontFamily: "arial",
852
      color: hasErrors() ? COLOR_DANGER : null,
×
853
      fontStyle: FontStyle.normal,
854
    );
855
  }
856

857
  TextStyle _helperStyle() {
×
858
    return TextStyle(
×
859
      fontStyle: FontStyle.italic,
860
      color: hasErrors() ? COLOR_DANGER : null,
×
861
    );
862
  }
863

864
}
865

866

867
/*
868
 * Extract field options from a returned OPTIONS request
869
 */
870
Map<String, dynamic> extractFields(APIResponse response) {
×
871

872
  if (!response.isValid()) {
×
873
    return {};
×
874
  }
875

876
  var data = response.asMap();
×
877

878
  if (!data.containsKey("actions")) {
×
879
    return {};
×
880
  }
881

882
  var actions = response.data["actions"] as Map<String, dynamic>;
×
883

884
  dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
×
885

886
  return result as Map<String, dynamic>;
887
}
888

889
/*
890
 * Extract a field definition (map) from the provided JSON data.
891
 *
892
 * Notes:
893
 * - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"),
894
 * - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity"
895
 *
896
 * The map "tree" is traversed based on the provided lookup string, which can use dotted notation.
897
 * This allows complex paths to be used to lookup field information.
898
 */
899
Map<String, dynamic> extractFieldDefinition(Map<String, dynamic> data, String lookup) {
×
900

901
  List<String> path = lookup.split(".");
×
902

903
  // Shadow copy the data for path traversal
904
  Map<String, dynamic> _data = data;
905

906
  // Iterate through all but the last element of the path
907
  for (int ii = 0; ii < (path.length - 1); ii++) {
×
908

909
    String el = path[ii];
×
910

911
    if (!_data.containsKey(el)) {
×
912
      print("Could not find field definition for ${lookup}:");
×
913
      print("- Key ${el} missing at index ${ii}");
×
914
      return {};
×
915
    }
916

917
    try {
918
      _data = _data[el] as Map<String, dynamic>;
×
919
    } catch (error, stackTrace) {
920
      print("Could not find sub-field element '${el}' for ${lookup}:");
×
921
      print(error.toString());
×
922

923
      // Report the error
924
      sentryReportError(
×
925
        "apiForm.extractFieldDefinition : path traversal",
926
        error, stackTrace,
927
        context: {
×
928
          "path": path.toString(),
×
929
          "el": el,
930
        }
931
      );
932
      return {};
×
933
    }
934
  }
935

936
  String el = path.last;
×
937

938
  if (!_data.containsKey(el)) {
×
939
    return {};
×
940
  } else {
941

942
    try {
943
      Map<String, dynamic> definition = _data[el] as Map<String, dynamic>;
×
944

945
      return definition;
946
    } catch (error, stacktrace) {
947
      print("Could not find field definition for ${lookup}");
×
948
      print(error.toString());
×
949

950
      // Report the error
951
      sentryReportError(
×
952
        "apiForm.extractFieldDefinition : as map",
953
        error, stacktrace,
954
        context: {
×
955
          "el": el.toString(),
×
956
        }
957
      );
958

959
      return {};
×
960
    }
961

962
  }
963
}
964

965

966
/*
967
 * Launch an API-driven form,
968
 * which uses the OPTIONS metadata (at the provided URL)
969
 * to determine how the form elements should be rendered!
970
 *
971
 * @param title is the title text to display on the form
972
 * @param url is the API URl to make the OPTIONS request to
973
 * @param fields is a map of fields to display (with optional overrides)
974
 * @param modelData is the (optional) existing modelData
975
 * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH)
976
 */
977

978
Future<void> launchApiForm(
×
979
    BuildContext context, String title, String url, Map<String, dynamic> fields,
980
    {
981
      String fileField = "",
982
      Map<String, dynamic> modelData = const {},
983
      String method = "PATCH",
984
      Function(Map<String, dynamic>)? onSuccess,
985
      bool Function(Map<String, dynamic>)? validate,
986
      Function? onCancel,
987
      IconData icon = TablerIcons.device_floppy
988
    }) async {
989

990
  showLoadingOverlay();
×
991

992
  // List of fields defined by the server
993
  Map<String, dynamic> serverFields = {};
×
994

995
  if (url.isNotEmpty) {
×
996

997
    var options = await InvenTreeAPI().options(url);
×
998

999
    // Invalid response from server
1000
    if (!options.isValid()) {
×
1001
      hideLoadingOverlay();
×
1002
      return;
1003
    }
1004

1005
    serverFields = extractFields(options);
×
1006

1007
    if (serverFields.isEmpty) {
×
1008
      // User does not have permission to perform this action
1009
      showSnackIcon(
×
1010
        L10().response403,
×
1011
        icon: TablerIcons.user_x,
1012
      );
1013

1014
      hideLoadingOverlay();
×
1015
      return;
1016
    }
1017
  }
1018

1019
  // Construct a list of APIFormField objects
1020
  List<APIFormField> formFields = [];
×
1021

1022
  APIFormField field;
1023

1024
  for (String fieldName in fields.keys) {
×
1025

1026
    dynamic data = fields[fieldName];
×
1027

1028
    Map<String, dynamic> fieldData = {};
×
1029

1030
    if (data is Map) {
×
1031
      fieldData = Map<String, dynamic>.from(data);
×
1032
    }
1033

1034
    // Iterate through the provided fields we wish to display
1035

1036
    field = APIFormField(fieldName, fieldData);
×
1037

1038
    // Extract the definition of this field from the data received from the server
1039
    field.definition = extractFieldDefinition(serverFields, field.lookupPath);
×
1040

1041
    // Skip fields with empty definitions
1042
    if (url.isNotEmpty && field.definition.isEmpty) {
×
1043
      print("Warning: Empty field definition for field '${fieldName}'");
×
1044
    }
1045

1046
    // Add instance value to the field
1047
    dynamic model_value = modelData[fieldName];
×
1048

1049
    if (model_value != null) {
1050
      field.data["instance_value"] = model_value;
×
1051

1052
      if (field.data["value"] == null) {
×
1053
        field.data["value"] = model_value;
×
1054
      }
1055
    }
1056
    formFields.add(field);
×
1057
  }
1058

1059
  // Grab existing data for each form field
1060
  for (var field in formFields) {
×
1061
    await field.loadInitialData();
×
1062
  }
1063

1064
  hideLoadingOverlay();
×
1065

1066
  // Now, launch a new widget!
1067
  Navigator.push(
×
1068
    context,
1069
    MaterialPageRoute(builder: (context) => APIFormWidget(
×
1070
      title,
1071
      url,
1072
      formFields,
1073
      method,
1074
      onSuccess: onSuccess,
1075
      validate: validate,
1076
      fileField: fileField,
1077
      icon: icon,
1078
    ))
1079
  );
1080
}
1081

1082

1083
class APIFormWidget extends StatefulWidget {
1084

1085
  const APIFormWidget(
×
1086
      this.title,
1087
      this.url,
1088
      this.fields,
1089
      this.method,
1090
      {
1091
        Key? key,
1092
        this.onSuccess,
1093
        this.validate,
1094
        this.fileField = "",
1095
        this.icon = TablerIcons.device_floppy,
1096
      }
1097
      ) : super(key: key);
×
1098

1099
  //! Form title to display
1100
  final String title;
1101

1102
  //! API URL
1103
  final String url;
1104

1105
  //! API method
1106
  final String method;
1107

1108
  final String fileField;
1109

1110
  // Icon
1111
  final IconData icon;
1112

1113
  final List<APIFormField> fields;
1114

1115
  final Function(Map<String, dynamic>)? onSuccess;
1116

1117
  final bool Function(Map<String, dynamic>)? validate;
1118

1119
  @override
×
1120
  _APIFormWidgetState createState() => _APIFormWidgetState();
×
1121

1122
}
1123

1124

1125
class _APIFormWidgetState extends State<APIFormWidget> {
1126

1127
  _APIFormWidgetState() : super();
×
1128

1129
  final _formKey = GlobalKey<FormState>();
1130

1131
  List<String> nonFieldErrors = [];
1132

1133
  bool spacerRequired = false;
1134

1135
  List<Widget> _buildForm() {
×
1136

1137
    List<Widget> widgets = [];
×
1138

1139
    // Display non-field errors first
1140
    if (nonFieldErrors.isNotEmpty) {
×
1141
      for (String error in nonFieldErrors) {
×
1142
        widgets.add(
×
1143
          ListTile(
×
1144
            title: Text(
×
1145
              error,
1146
              style: TextStyle(
×
1147
                color: COLOR_DANGER,
1148
              ),
1149
            ),
1150
            leading: Icon(
×
1151
              TablerIcons.exclamation_circle,
1152
              color: COLOR_DANGER
1153
            ),
1154
          )
1155
        );
1156
      }
1157

1158
      widgets.add(Divider(height: 5));
×
1159

1160
    }
1161

1162
    for (var field in widget.fields) {
×
1163

1164
      if (field.hidden) {
×
1165
        continue;
1166
      }
1167

1168
      // Add divider before some widgets
1169
      if (spacerRequired) {
×
1170
        switch (field.type) {
×
1171
          case "related field":
×
1172
          case "choice":
×
1173
            widgets.add(Divider(height: 15));
×
1174
          default:
1175
            break;
1176
        }
1177
      }
1178

1179
      widgets.add(field.constructField(context));
×
1180

1181
      if (field.hasErrors()) {
×
1182
        for (String error in field.errorMessages()) {
×
1183
          widgets.add(
×
1184
            ListTile(
×
1185
              title: Text(
×
1186
                error,
1187
                style: TextStyle(
×
1188
                  color: COLOR_DANGER,
1189
                  fontStyle: FontStyle.italic,
1190
                  fontSize: 16,
1191
                ),
1192
              )
1193
            )
1194
          );
1195
        }
1196
      }
1197

1198
      // Add divider after some widgets
1199
      switch (field.type) {
×
1200
        case "related field":
×
1201
        case "choice":
×
1202
          widgets.add(Divider(height: 15));
×
1203
          spacerRequired = false;
×
1204
        default:
UNCOV
1205
          spacerRequired = true;
×
1206
      }
1207
    }
1208

1209
    return widgets;
1210
  }
1211

1212
  Future<APIResponse> _submit(Map<String, dynamic> data) async {
×
1213

1214
    // If a file upload is required, we have to handle the submission differently
1215
    if (widget.fileField.isNotEmpty) {
×
1216

1217
      // Pop the "file" field
1218
      data.remove(widget.fileField);
×
1219

1220
      for (var field in widget.fields) {
×
1221
        if (field.name == widget.fileField) {
×
1222

1223
          File? file = field.attachedfile;
×
1224

1225
          if (file != null) {
1226

1227
            // A valid file has been supplied
1228
            final response = await InvenTreeAPI().uploadFile(
×
1229
              widget.url,
×
1230
              file,
1231
              name: widget.fileField,
×
1232
              fields: data,
1233
            );
1234

1235
            return response;
1236
          }
1237
        }
1238
      }
1239
    }
1240

1241
    if (widget.method == "POST") {
×
1242

1243
      showLoadingOverlay();
×
1244
      final response =  await InvenTreeAPI().post(
×
1245
        widget.url,
×
1246
        body: data,
1247
        expectedStatusCode: null
1248
      );
1249
      hideLoadingOverlay();
×
1250

1251
      return response;
1252

1253
    } else {
1254
      showLoadingOverlay();
×
1255
      final response = await InvenTreeAPI().patch(
×
1256
        widget.url,
×
1257
        body: data,
1258
        expectedStatusCode: null
1259
      );
1260
      hideLoadingOverlay();
×
1261

1262
      return response;
1263
    }
1264
  }
1265

1266
  void extractNonFieldErrors(APIResponse response) {
×
1267

1268
    List<String> errors = [];
×
1269

1270
    Map<String, dynamic> data = response.asMap();
×
1271

1272
    // Potential keys representing non-field errors
1273
    List<String> keys = [
×
1274
      "__all__",
1275
      "non_field_errors",
1276
      "errors",
1277
    ];
1278

1279
    for (String key in keys) {
×
1280
      if (data.containsKey(key)) {
×
1281
        dynamic result = data[key];
×
1282

1283
        if (result is String) {
×
1284
          errors.add(result);
×
1285
        } else if (result is List) {
×
1286
          for (dynamic element in result) {
×
1287
            errors.add(element.toString());
×
1288
          }
1289
        }
1290
      }
1291
    }
1292

1293
    nonFieldErrors = errors;
×
1294
  }
1295

1296
  /* Check for errors relating to an *unhandled* field name
1297
  * These errors will not be displayed and potentially confuse the user
1298
  * So, we need to know if these are ever happening
1299
  */
1300
  void checkInvalidErrors(APIResponse response) {
×
1301
    var errors = response.asMap();
×
1302

1303
    for (String fieldName in errors.keys) {
×
1304

1305
      bool match = false;
1306

1307
      switch (fieldName) {
1308
        case "__all__":
×
1309
        case "non_field_errors":
×
1310
        case "errors":
×
1311
          // ignore these global fields
1312
          match = true;
1313
          continue;
1314
        default:
1315
          for (var field in widget.fields) {
×
1316

1317
            // Hidden fields can't display errors, so we won't match
1318
            if (field.hidden) {
×
1319
              continue;
1320
            }
1321

1322
            if (field.name == fieldName) {
×
1323
              // Direct Match found!
1324
              match = true;
1325
              break;
1326
            } else if (field.parent == fieldName) {
×
1327

1328
              var error = errors[fieldName];
×
1329

1330
              if (error is List) {
×
1331
                for (var el in error) {
×
1332
                  if (el is Map && el.containsKey(field.name)) {
×
1333
                    match = true;
1334
                    break;
1335
                  }
1336
                }
1337
              } else if (error is Map && error.containsKey(field.name)) {
×
1338
                match = true;
1339
                break;
1340
              }
1341
            }
1342
          }
1343

1344
      }
1345

1346
      if (!match) {
1347
        // Match for an unknown / unsupported field
1348
        sentryReportMessage(
×
1349
          "API form returned error for unsupported field",
1350
          context: {
×
1351
            "url": response.url,
×
1352
            "status_code": response.statusCode.toString(),
×
1353
            "field": fieldName,
1354
            "error_message": response.data.toString(),
×
1355
          }
1356
        );
1357
      }
1358
    }
1359
  }
1360

1361
  /*
1362
   * Submit the form data to the server, and handle the results
1363
   */
1364
  Future<void> _save(BuildContext context) async {
×
1365

1366
    // Package up the form data
1367
    Map<String, dynamic> data = {};
×
1368

1369
    // Iterate through and find "simple" top-level fields
1370

1371
    for (var field in widget.fields) {
×
1372

1373
      if (field.readOnly) {
×
1374
        continue;
1375
      }
1376

1377
      if (field.isSimple) {
×
1378
        // Simple top-level field data
1379
        data[field.name] = field.data["value"];
×
1380
      } else {
1381
        // Not so simple... (WHY DID I MAKE THE API SO COMPLEX?)
1382
        if (field.parent.isNotEmpty) {
×
1383

1384
          // TODO: This is a dirty hack, there *must* be a cleaner way?!
1385

1386
          dynamic parent = data[field.parent] ?? {};
×
1387

1388
          // In the case of a "nested" object, we need to extract the first item
1389
          if (parent is List) {
×
1390
            parent = parent.first;
×
1391
          }
1392

1393
          parent[field.name] = field.data["value"];
×
1394

1395
          // Nested fields must be handled as an array!
1396
          // For now, we only allow single length nested fields
1397
          if (field.nested) {
×
1398
            parent = [parent];
×
1399
          }
1400

1401
          data[field.parent] = parent;
×
1402
        }
1403
      }
1404
    }
1405
    
1406
    final bool isValid = widget.validate?.call(data) ?? true;
×
1407

1408
    if (!isValid) {
1409
      return;
1410
    }
1411

1412
    // Run custom onSuccess function
1413
    var successFunc = widget.onSuccess;
×
1414

1415
    // An "empty" URL means we don't want to submit the form anywhere
1416
    // Perhaps we just want to process the data?
1417
    if (widget.url.isEmpty) {
×
1418
      // Hide the form
1419
      Navigator.pop(context);
×
1420

1421
      if (successFunc != null) {
1422
        // Return the raw "submitted" data, rather than the server response
1423
        successFunc(data);
×
1424
      }
1425

1426
      return;
1427
    }
1428

1429
    final response = await _submit(data);
×
1430

1431
    if (!response.isValid()) {
×
1432
      showServerError(widget.url, L10().serverError, L10().responseInvalid);
×
1433
      return;
1434
    }
1435

1436
    switch (response.statusCode) {
×
1437
      case 200:
×
1438
      case 201:
×
1439
        // Form was successfully validated by the server
1440

1441
        // Hide this form
1442
        Navigator.pop(context);
×
1443

1444
        if (successFunc != null) {
1445

1446
          // Ensure the response is a valid JSON structure
1447
          Map<String, dynamic> json = {};
×
1448

1449
          var data = response.asMap();
×
1450

1451
          for (String key in data.keys) {
×
1452
            json[key.toString()] = data[key];
×
1453
          }
1454

1455
          successFunc(json);
×
1456
        }
1457
        return;
1458
      case 400:
×
1459
        // Form submission / validation error
1460
        showSnackIcon(
×
1461
          L10().formError,
×
1462
          success: false,
1463
        );
1464

1465
        // Update field errors
1466
        for (var field in widget.fields) {
×
1467
          field.extractErrorMessages(response);
×
1468
        }
1469

1470
        extractNonFieldErrors(response);
×
1471
        checkInvalidErrors(response);
×
UNCOV
1472
      case 401:
×
1473
        showSnackIcon(
×
1474
          "401: " + L10().response401,
×
1475
          success: false
1476
        );
UNCOV
1477
      case 403:
×
1478
        showSnackIcon(
×
1479
          "403: " + L10().response403,
×
1480
          success: false,
1481
        );
UNCOV
1482
      case 404:
×
1483
        showSnackIcon(
×
1484
          "404: " + L10().response404,
×
1485
          success: false,
1486
        );
UNCOV
1487
      case 405:
×
1488
        showSnackIcon(
×
1489
          "405: " + L10().response405,
×
1490
          success: false,
1491
        );
UNCOV
1492
      case 500:
×
1493
        showSnackIcon(
×
1494
          "500: " + L10().response500,
×
1495
          success: false,
1496
        );
1497
      default:
UNCOV
1498
        showSnackIcon(
×
1499
          "${response.statusCode}: " + L10().responseInvalid,
×
1500
          success: false,
1501
        );
1502
    }
1503

1504
    setState(() {
×
1505
      // Refresh the form
1506
    });
1507

1508
  }
1509

1510
  @override
×
1511
  Widget build(BuildContext context) {
1512

1513
    return Scaffold(
×
1514
      appBar: AppBar(
×
1515
        title: Text(widget.title),
×
1516
        backgroundColor: COLOR_APP_BAR,
×
1517
        actions: [
×
1518
          IconButton(
×
1519
            icon: Icon(widget.icon),
×
1520
            onPressed: () {
×
1521

1522
              if (_formKey.currentState!.validate()) {
×
1523
                _formKey.currentState!.save();
×
1524

1525
                _save(context);
×
1526
              }
1527
            },
1528
          )
1529
        ]
1530
      ),
1531
      body: Form(
×
1532
        key: _formKey,
×
1533
        child: SingleChildScrollView(
×
1534
          child: Column(
×
1535
            mainAxisAlignment: MainAxisAlignment.start,
1536
            mainAxisSize: MainAxisSize.min,
1537
            crossAxisAlignment: CrossAxisAlignment.start,
1538
            children: _buildForm(),
×
1539
          ),
1540
          padding: EdgeInsets.all(16),
×
1541
        )
1542
      )
1543
    );
1544

1545
  }
1546
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc