import 'attachment.dart'; import 'custom_field_definition.dart'; Map _map(dynamic v) => v is Map ? Map.from(v) : {}; List> _maps(dynamic v) => v is List ? [ for (final e in v) if (e is Map) _map(e), ] : const []; String _str(dynamic v, [String fallback = '']) => v?.toString() ?? fallback; DateTime? _date(dynamic v) => v is String ? DateTime.tryParse(v) : null; const objectsTurnedOffNotice = 'Creating and editing is turned off for this organization.'; const objectsLoadFailedNotice = 'Custom objects could not be loaded. Try again.'; /// Whether a stored custom field value has anything to show. bool hasFieldValue(dynamic v) => v != null && (v is! String || v.trim().isNotEmpty) && (v is! List || v.isNotEmpty); /// A stored custom field value as the user reads it. A dropdown shows its /// option label, or the stored value itself when the option is gone. String displayValue(ObjectField field, dynamic raw) { if (!hasFieldValue(raw)) return 'None'; if (raw is bool || field.fieldType == 'checkbox') { return raw == true ? 'Yes' : 'No'; } if (raw is List) return raw.map((e) => e.toString()).join(', '); final text = raw.toString(); if (field.fieldType == 'dropdown') { for (final o in field.options) { if (o.value == text) return o.label; } } return text; } /// The answer of `GET /api/objects/`. Absent routes (a 404) never reach here. class CustomObjectsAnswer { final bool hasCustomObjects; final List objects; final List linkTargets; const CustomObjectsAnswer({ this.hasCustomObjects = false, this.objects = const [], this.linkTargets = const [], }); bool get offered => hasCustomObjects || objects.isNotEmpty; factory CustomObjectsAnswer.fromJson(Map json) => CustomObjectsAnswer( hasCustomObjects: json['has_custom_objects'] == true, objects: _maps(json['objects']).map(ObjectSummary.fromJson).toList(), linkTargets: _maps( json['link_targets'], ).map(LinkTarget.fromJson).toList(), ); } class ObjectSummary { final String key; final String label; final String labelPlural; final String nameLabel; final String description; final String recordAccess; final bool isActive; const ObjectSummary({ required this.key, required this.label, required this.labelPlural, this.nameLabel = 'Name', this.description = '', this.recordAccess = 'everyone', this.isActive = true, }); factory ObjectSummary.fromJson(Map json) { final label = _str(json['label']); return ObjectSummary( key: _str(json['key']), label: label, labelPlural: _str(json['label_plural'], label), nameLabel: _str(json['name_label'], 'Name'), description: _str(json['description']), recordAccess: _str(json['record_access'], 'everyone'), isActive: json['is_active'] as bool? ?? true, ); } } class LinkTarget { final String key; final String label; final String labelPlural; const LinkTarget({ required this.key, required this.label, required this.labelPlural, }); factory LinkTarget.fromJson(Map json) { final label = _str(json['label']); return LinkTarget( key: _str(json['key']), label: label, labelPlural: _str(json['label_plural'], label), ); } } class ObjectField { final String key; final String label; final String fieldType; final List options; final bool isRequired; final int displayOrder; final bool isActive; final String? target; final bool? many; const ObjectField({ required this.key, required this.label, required this.fieldType, this.options = const [], this.isRequired = false, this.displayOrder = 0, this.isActive = true, this.target, this.many, }); bool get isLink => fieldType == 'link'; factory ObjectField.fromJson(Map json) { final options = []; for (final o in _maps(json['options'])) { final value = _str(o['value']); if (value.isEmpty) continue; options.add( CustomFieldOption(value: value, label: _str(o['label'], value)), ); } return ObjectField( key: _str(json['key']), label: _str(json['label']), fieldType: _str(json['field_type'], 'text'), options: options, isRequired: json['is_required'] as bool? ?? false, displayOrder: json['display_order'] as int? ?? 0, isActive: json['is_active'] as bool? ?? true, target: json['target'] as String?, many: json['many'] as bool?, ); } /// The id is empty: a field is addressed by its key, never by id. CustomFieldDefinition toDefinition(String objectKey) => CustomFieldDefinition( id: '', targetModel: 'obj:$objectKey', key: key, label: label, fieldType: CustomFieldType.fromString(fieldType), options: options, isRequired: isRequired, displayOrder: displayOrder, isActive: isActive, ); } class ObjectDefinition { final ObjectSummary summary; final List fields; const ObjectDefinition({required this.summary, this.fields = const []}); List _ordered(bool Function(ObjectField f) test) { final out = fields.where((f) => f.isActive && test(f)).toList(); out.sort((a, b) => a.displayOrder.compareTo(b.displayOrder)); return out; } List get customFields => _ordered((f) => !f.isLink); List get linkFields => _ordered((f) => f.isLink); factory ObjectDefinition.fromJson(Map json) => ObjectDefinition( summary: ObjectSummary.fromJson(json), fields: _maps(json['fields']).map(ObjectField.fromJson).toList(), ); } class PersonRef { final String id; final String name; final String email; const PersonRef({required this.id, this.name = '', this.email = ''}); factory PersonRef.fromJson(Map json) => PersonRef( id: _str(json['id']), name: _str(json['name']), email: _str(json['email']), ); } class LinkedRecord { final String id; final String object; final String name; const LinkedRecord({required this.id, required this.object, this.name = ''}); factory LinkedRecord.fromJson(Map json) => LinkedRecord( id: _str(json['id']), object: _str(json['object']), name: _str(json['name']), ); } class LinkValue { final bool many; final List records; final int hidden; const LinkValue({this.many = true, this.records = const [], this.hidden = 0}); /// A one-record field holding a link the caller cannot open is never sent. bool get readOnly => !many && hidden > 0; factory LinkValue.fromJson(Map json) => LinkValue( many: json['many'] as bool? ?? true, records: _maps(json['records']).map(LinkedRecord.fromJson).toList(), hidden: json['hidden'] as int? ?? 0, ); } class RecordAttachment { static const photoExtensions = {'jpg', 'jpeg', 'png', 'webp', 'gif'}; final String id; final String fileName; final bool isImage; final PersonRef? createdBy; final DateTime? createdAt; final bool canDelete; const RecordAttachment({ required this.id, required this.fileName, this.isImage = false, this.createdBy, this.createdAt, this.canDelete = false, }); bool get isPhoto { if (!isImage) return false; final dot = fileName.lastIndexOf('.'); if (dot < 0) return false; return photoExtensions.contains(fileName.substring(dot + 1).toLowerCase()); } /// A record's file is always fetched by id. The name stands in for /// `filePath` only so `hasFile` is true; it is never read as a path. Attachment toAttachment() => Attachment( id: id, fileName: fileName, filePath: fileName, createdAt: createdAt, createdBy: createdBy == null ? null : (createdBy!.email.isNotEmpty ? createdBy!.email : createdBy!.name), ); factory RecordAttachment.fromJson(Map json) { final cb = json['created_by']; return RecordAttachment( id: _str(json['id']), fileName: _str(json['file_name'], 'Attachment'), isImage: json['is_image'] == true, createdBy: cb is Map ? PersonRef.fromJson(_map(cb)) : null, createdAt: _date(json['created_at']), canDelete: json['can_delete'] == true, ); } } class ObjectRecord { final String id; final String name; final Map customFields; final List assignedTo; final DateTime? createdAt; final DateTime? updatedAt; final Map links; final PersonRef? createdBy; final bool canEdit; final bool canDelete; final List attachments; const ObjectRecord({ required this.id, required this.name, this.customFields = const {}, this.assignedTo = const [], this.createdAt, this.updatedAt, this.links = const {}, this.createdBy, this.canEdit = false, this.canDelete = false, this.attachments = const [], }); factory ObjectRecord.fromJson(Map json) { final cb = json['created_by']; return ObjectRecord( id: _str(json['id']), name: _str(json['name']), customFields: _map(json['custom_fields']), assignedTo: _maps(json['assigned_to']).map(PersonRef.fromJson).toList(), createdAt: _date(json['created_at']), updatedAt: _date(json['updated_at']), links: { for (final e in _map(json['links']).entries) e.key: LinkValue.fromJson(_map(e.value)), }, createdBy: cb is Map ? PersonRef.fromJson(_map(cb)) : null, canEdit: json['can_edit'] == true, canDelete: json['can_delete'] == true, attachments: _maps( json['attachments'], ).map(RecordAttachment.fromJson).toList(), ); } } class RelatedRecordRef { final String id; final String name; const RelatedRecordRef({required this.id, this.name = ''}); factory RelatedRecordRef.fromJson(Map json) => RelatedRecordRef(id: _str(json['id']), name: _str(json['name'])); } class RelatedGroup { final String objectKey; final String objectLabel; final String fieldKey; final String fieldLabel; final List records; const RelatedGroup({ required this.objectKey, required this.objectLabel, required this.fieldKey, required this.fieldLabel, this.records = const [], }); factory RelatedGroup.fromJson(Map json) { final object = _map(json['object']); final field = _map(json['field']); return RelatedGroup( objectKey: _str(object['key']), objectLabel: _str(object['label']), fieldKey: _str(field['key']), fieldLabel: _str(field['label']), records: _maps(json['records']).map(RelatedRecordRef.fromJson).toList(), ); } } class RelatedLinkField { final String objectKey; final String objectLabel; final String fieldKey; final String fieldLabel; const RelatedLinkField({ required this.objectKey, required this.objectLabel, required this.fieldKey, required this.fieldLabel, }); factory RelatedLinkField.fromJson(Map json) { final object = _map(json['object']); final field = _map(json['field']); return RelatedLinkField( objectKey: _str(object['key']), objectLabel: _str(object['label']), fieldKey: _str(field['key']), fieldLabel: _str(field['label']), ); } } class RelatedAnswer { final List groups; final bool canEdit; final List linkFields; const RelatedAnswer({ this.groups = const [], this.canEdit = false, this.linkFields = const [], }); /// A record can be taken out of a group only where the caller may edit and /// the field is one of the many-record fields the server listed. bool removable(RelatedGroup g) => canEdit && linkFields.any( (f) => f.objectKey == g.objectKey && f.fieldKey == g.fieldKey, ); bool get showsNothing => groups.isEmpty && linkFields.isEmpty; factory RelatedAnswer.fromJson(Map json) => RelatedAnswer( groups: _maps(json['groups']).map(RelatedGroup.fromJson).toList(), canEdit: json['can_edit'] == true, linkFields: _maps( json['link_fields'], ).map(RelatedLinkField.fromJson).toList(), ); } /// Latin letters that decompose to a base letter plus a combining mark, which /// the web's `normalize('NFKD')` strips. const _accentGroups = { 'a': 'àáâãäåāăą', 'c': 'çćĉċč', 'd': 'ďđ', 'e': 'èéêëēĕėęě', 'g': 'ĝğġģ', 'i': 'ìíîïĩīĭįı', 'l': 'ĺļľł', 'n': 'ñńņň', 'o': 'òóôõöøōŏő', 'r': 'ŕŗř', 's': 'śŝşš', 't': 'ţť', 'u': 'ùúûüũūŭůűų', 'y': 'ýÿ', 'z': 'źżž', }; final _accentMap = { for (final e in _accentGroups.entries) for (final r in e.value.runes) String.fromCharCode(r): e.key, }; /// The web's `suggestKey`: accents stripped, runs of anything but a-z0-9 /// become one underscore, the leading non-letters and trailing underscore go. String suggestObjectKey(String label, int max) { final buf = StringBuffer(); for (final r in label.toLowerCase().runes) { final ch = String.fromCharCode(r); buf.write(_accentMap[ch] ?? ch); } var s = buf .toString() .replaceAll(RegExp(r'[^a-z0-9]+'), '_') .replaceFirst(RegExp(r'^[^a-z]+'), ''); if (s.length > max) s = s.substring(0, max); return s.replaceFirst(RegExp(r'_+$'), ''); }