645 lines
20 KiB
Dart
645 lines
20 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../config/api_config.dart';
|
|
import '../data/api_envelope.dart';
|
|
import '../data/models/custom_field_definition.dart';
|
|
import '../data/models/custom_object.dart';
|
|
import '../data/models/lookup_models.dart';
|
|
import '../services/api_service.dart';
|
|
import 'auth_provider.dart';
|
|
|
|
/// What `GET /api/objects/` says about the org. `null` means the server has no
|
|
/// custom objects (a self-hosted install answers 404). Any other failure is an
|
|
/// error state, so a timeout never reads as "no custom objects".
|
|
class CustomObjectsNotifier extends AsyncNotifier<CustomObjectsAnswer?> {
|
|
@override
|
|
Future<CustomObjectsAnswer?> build() async {
|
|
ref.watch(selectedOrgProvider.select((o) => o?.id));
|
|
final response = await ApiService().get(ApiConfig.objects);
|
|
if (response.statusCode == 404) return null;
|
|
final data = response.data;
|
|
if (!response.success || data == null) {
|
|
throw Exception(response.message ?? 'Could not load custom objects');
|
|
}
|
|
return CustomObjectsAnswer.fromJson(data);
|
|
}
|
|
}
|
|
|
|
// No automatic retry: a failure shows as an error at once, and it is asked
|
|
// again only when the user opens More or taps Retry.
|
|
final customObjectsProvider =
|
|
AsyncNotifierProvider<CustomObjectsNotifier, CustomObjectsAnswer?>(
|
|
CustomObjectsNotifier.new,
|
|
retry: (_, _) => null,
|
|
);
|
|
|
|
/// The only way to read the answer for display. Riverpod keeps the previous
|
|
/// value through a reload and a failed load, which after an org switch or a
|
|
/// sign-out is another org's (or another user's) objects, so both read null.
|
|
CustomObjectsAnswer? shownCustomObjects(AsyncValue<CustomObjectsAnswer?> v) =>
|
|
v.hasError || v.isLoading ? null : v.value;
|
|
|
|
/// The last load failed and nothing is asking again yet.
|
|
bool customObjectsFailed(AsyncValue<CustomObjectsAnswer?> v) =>
|
|
v.hasError && !v.isLoading;
|
|
|
|
/// Asks again after a failure. Called when the user opens More, never from a
|
|
/// build, so a failing server is never asked in a loop.
|
|
void retryFailedCustomObjects(WidgetRef ref) {
|
|
if (customObjectsFailed(ref.read(customObjectsProvider))) {
|
|
ref.invalidate(customObjectsProvider);
|
|
}
|
|
}
|
|
|
|
final objectDefinitionProvider = FutureProvider.autoDispose
|
|
.family<ObjectDefinition?, String>((ref, key) async {
|
|
if (!ApiConfig.isObjectKey(key)) return null;
|
|
final response = await ApiService().get(ApiConfig.object(key));
|
|
if (response.statusCode == 404) return null;
|
|
final data = response.data;
|
|
if (!response.success || data == null) {
|
|
throw Exception(response.message ?? 'Failed to load this object');
|
|
}
|
|
return ObjectDefinition.fromJson(data);
|
|
}, retry: (_, _) => null);
|
|
|
|
final objectsSettingsProvider =
|
|
FutureProvider.autoDispose<CustomObjectsAnswer?>((ref) async {
|
|
final response = await ApiService().get(
|
|
ApiConfig.objects,
|
|
queryParams: {'include_archived': 'true'},
|
|
);
|
|
final data = response.data;
|
|
if (!response.success || data == null) return null;
|
|
return CustomObjectsAnswer.fromJson(data);
|
|
});
|
|
|
|
class ObjectSettingsData {
|
|
final ObjectSummary summary;
|
|
final List<ObjectField> fields;
|
|
|
|
const ObjectSettingsData({required this.summary, this.fields = const []});
|
|
}
|
|
|
|
/// The object and every field it has, turned-off ones included for admins.
|
|
final objectSettingsProvider = FutureProvider.autoDispose
|
|
.family<ObjectSettingsData, String>((ref, key) async {
|
|
final api = ApiService();
|
|
final results = await Future.wait([
|
|
api.get(ApiConfig.object(key)),
|
|
api.get(ApiConfig.objectFields(key)),
|
|
]);
|
|
final object = results[0];
|
|
final fields = results[1];
|
|
if (!object.success || object.data == null) {
|
|
throw Exception(object.message ?? 'Failed to load this object');
|
|
}
|
|
if (!fields.success || fields.data == null) {
|
|
throw Exception(fields.message ?? 'Failed to load its fields');
|
|
}
|
|
return ObjectSettingsData(
|
|
summary: ObjectSummary.fromJson(object.data!),
|
|
fields: listFromEnvelope(fields.data!, const [
|
|
'fields',
|
|
]).map(ObjectField.fromJson).toList(),
|
|
);
|
|
}, retry: (_, _) => null);
|
|
|
|
const int objectRecordsPageSize = 20;
|
|
|
|
class ObjectRecordsData {
|
|
final List<ObjectRecord> records;
|
|
final int count;
|
|
final String query;
|
|
|
|
const ObjectRecordsData({
|
|
this.records = const [],
|
|
this.count = 0,
|
|
this.query = '',
|
|
});
|
|
|
|
bool get hasMore => records.length < count;
|
|
}
|
|
|
|
class ObjectRecordsNotifier extends AsyncNotifier<ObjectRecordsData> {
|
|
ObjectRecordsNotifier(this.key);
|
|
|
|
final String key;
|
|
String _query = '';
|
|
// Bumped whenever the list is replaced, so a page fetched for an older list
|
|
// is dropped instead of appended.
|
|
int _generation = 0;
|
|
int? _pagingGeneration;
|
|
|
|
@override
|
|
Future<ObjectRecordsData> build() => _fetch(query: '', offset: 0);
|
|
|
|
Future<ObjectRecordsData> _fetch({
|
|
required String query,
|
|
required int offset,
|
|
}) async {
|
|
final response = await ApiService().get(
|
|
ApiConfig.objectRecords(key),
|
|
queryParams: {
|
|
'limit': '$objectRecordsPageSize',
|
|
if (offset > 0) 'offset': '$offset',
|
|
if (query.isNotEmpty) 'q': query,
|
|
},
|
|
);
|
|
final data = response.data;
|
|
if (!response.success || data == null) {
|
|
throw Exception(response.message ?? 'Failed to load records');
|
|
}
|
|
final records = listFromEnvelope(data, const [
|
|
'results',
|
|
]).map(ObjectRecord.fromJson).toList();
|
|
return ObjectRecordsData(
|
|
records: records,
|
|
count: data['count'] as int? ?? records.length,
|
|
query: query,
|
|
);
|
|
}
|
|
|
|
Future<void> setQuery(String query) async {
|
|
_query = query.trim();
|
|
final generation = ++_generation;
|
|
state = const AsyncLoading();
|
|
final next = await AsyncValue.guard(() => _fetch(query: _query, offset: 0));
|
|
if (!ref.mounted || generation != _generation) return;
|
|
state = next;
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
final generation = ++_generation;
|
|
final next = await AsyncValue.guard(() => _fetch(query: _query, offset: 0));
|
|
if (!ref.mounted || generation != _generation) return;
|
|
state = next;
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
final current = state.value;
|
|
if (current == null || !current.hasMore || state.isLoading) return;
|
|
if (_pagingGeneration == _generation) return;
|
|
final generation = _generation;
|
|
_pagingGeneration = generation;
|
|
try {
|
|
final next = await AsyncValue.guard(
|
|
() => _fetch(query: current.query, offset: current.records.length),
|
|
);
|
|
final page = next.value;
|
|
if (!ref.mounted || page == null || generation != _generation) return;
|
|
state = AsyncData(
|
|
ObjectRecordsData(
|
|
records: [...current.records, ...page.records],
|
|
count: page.count,
|
|
query: current.query,
|
|
),
|
|
);
|
|
} finally {
|
|
if (_pagingGeneration == generation) _pagingGeneration = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
final objectRecordsProvider = AsyncNotifierProvider.autoDispose
|
|
.family<ObjectRecordsNotifier, ObjectRecordsData, String>(
|
|
ObjectRecordsNotifier.new,
|
|
retry: (_, _) => null,
|
|
);
|
|
|
|
const int linkSearchLimit = 20;
|
|
|
|
class CustomObjectsApi {
|
|
CustomObjectsApi([ApiService? api]) : _api = api ?? ApiService();
|
|
|
|
final ApiService _api;
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> fetchRecord(
|
|
String key,
|
|
String id,
|
|
) => _api.get(ApiConfig.objectRecord(key, id));
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> createRecord(
|
|
String key,
|
|
Map<String, dynamic> body,
|
|
) => _api.post(ApiConfig.objectRecords(key), body);
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> updateRecord(
|
|
String key,
|
|
String id,
|
|
Map<String, dynamic> body,
|
|
) => _api.patch(ApiConfig.objectRecord(key, id), body);
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> deleteRecord(
|
|
String key,
|
|
String id,
|
|
) => _api.delete(ApiConfig.objectRecord(key, id));
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> deleteAttachment(
|
|
String key,
|
|
String id,
|
|
String attachmentId,
|
|
) => _api.delete(ApiConfig.objectRecordAttachment(key, id, attachmentId));
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> createObject(
|
|
Map<String, dynamic> body,
|
|
) => _api.post(ApiConfig.objects, body);
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> updateObject(
|
|
String key,
|
|
Map<String, dynamic> body,
|
|
) => _api.patch(ApiConfig.object(key), body);
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> deleteObject(String key) =>
|
|
_api.delete(ApiConfig.object(key));
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> createField(
|
|
String key,
|
|
Map<String, dynamic> body,
|
|
) => _api.post(ApiConfig.objectFields(key), body);
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> updateField(
|
|
String key,
|
|
String fieldKey,
|
|
Map<String, dynamic> body,
|
|
) => _api.patch(ApiConfig.objectField(key, fieldKey), body);
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> turnOffField(
|
|
String key,
|
|
String fieldKey,
|
|
) => _api.delete(ApiConfig.objectField(key, fieldKey));
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> addRelated(
|
|
String to,
|
|
String id,
|
|
String record,
|
|
String field,
|
|
) => _api.post(ApiConfig.objectsRelated, {
|
|
'to': to,
|
|
'id': id,
|
|
'record': record,
|
|
'field': field,
|
|
});
|
|
|
|
Future<ApiResponse<Map<String, dynamic>>> removeRelated(
|
|
String to,
|
|
String id,
|
|
String record,
|
|
String field,
|
|
) => _api.delete(
|
|
Uri.parse(ApiConfig.objectsRelated)
|
|
.replace(
|
|
queryParameters: {
|
|
'to': to,
|
|
'id': id,
|
|
'record': record,
|
|
'field': field,
|
|
},
|
|
)
|
|
.toString(),
|
|
);
|
|
|
|
/// Built-in kinds are searched through their community list endpoints and
|
|
/// read the way the matching lookup in `lookup_provider.dart` reads them.
|
|
/// A failed request throws, so it never reads as "no matches".
|
|
Future<List<LinkedRecord>> searchTargets(String target, String query) async {
|
|
final spec = _searchSpec(target);
|
|
if (spec == null) return const [];
|
|
final response = await _api.get(
|
|
spec.url,
|
|
queryParams: {spec.param: query, 'limit': '$linkSearchLimit'},
|
|
);
|
|
final data = response.data;
|
|
if (!response.success || data == null) {
|
|
throw Exception(response.message ?? 'Could not search');
|
|
}
|
|
return [
|
|
for (final row in listFromEnvelope(
|
|
data,
|
|
spec.paths,
|
|
).take(linkSearchLimit))
|
|
LinkedRecord(
|
|
id: row['id']?.toString() ?? '',
|
|
object: target,
|
|
name: spec.label(row),
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
class _SearchSpec {
|
|
const _SearchSpec(this.url, this.paths, this.label, {this.param = 'search'});
|
|
|
|
final String url;
|
|
final List<String> paths;
|
|
final String Function(Map<String, dynamic> row) label;
|
|
final String param;
|
|
}
|
|
|
|
String _name(Map<String, dynamic> row) =>
|
|
EntityLookup.fromJson(row, labelKeys: const ['name']).label;
|
|
|
|
_SearchSpec? _searchSpec(String target) {
|
|
switch (target) {
|
|
case 'lead':
|
|
return _SearchSpec(
|
|
ApiConfig.leads,
|
|
const ['open_leads.open_leads'],
|
|
(row) => EntityLookup.person(
|
|
row,
|
|
fallbackKeys: const ['email', 'company_name'],
|
|
).label,
|
|
);
|
|
case 'contact':
|
|
return _SearchSpec(ApiConfig.contacts, const [
|
|
'results',
|
|
'contacts',
|
|
], (row) => ContactLookup.fromJson(row).fullName);
|
|
case 'account':
|
|
return _SearchSpec(ApiConfig.accounts, const [
|
|
'active_accounts.open_accounts',
|
|
'accounts',
|
|
'results',
|
|
], _name);
|
|
case 'deal':
|
|
return _SearchSpec(ApiConfig.opportunities, const [
|
|
'opportunities',
|
|
], _name);
|
|
case 'ticket':
|
|
return _SearchSpec(ApiConfig.tickets, const ['cases'], _name);
|
|
case 'task':
|
|
return _SearchSpec(ApiConfig.tasks, const [
|
|
'tasks',
|
|
'results',
|
|
], (row) => EntityLookup.fromJson(row, labelKeys: const ['title']).label);
|
|
}
|
|
if (target.startsWith('obj:')) {
|
|
final key = target.substring(4);
|
|
try {
|
|
return _SearchSpec(
|
|
ApiConfig.objectRecords(key),
|
|
const ['results'],
|
|
_name,
|
|
param: 'q',
|
|
);
|
|
} on ArgumentError {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
final customObjectsApiProvider = Provider<CustomObjectsApi>(
|
|
(ref) => CustomObjectsApi(),
|
|
);
|
|
|
|
final relatedProvider = FutureProvider.autoDispose
|
|
.family<RelatedAnswer?, ({String to, String id})>((ref, arg) async {
|
|
final answer = shownCustomObjects(ref.watch(customObjectsProvider));
|
|
if (answer == null || answer.objects.isEmpty) return null;
|
|
final response = await ApiService().get(
|
|
ApiConfig.objectsRelated,
|
|
queryParams: {'to': arg.to, 'id': arg.id},
|
|
);
|
|
final data = response.data;
|
|
if (!response.success || data == null) return null;
|
|
return RelatedAnswer.fromJson(data);
|
|
});
|
|
|
|
final linkTargetSearchProvider = FutureProvider.autoDispose
|
|
.family<List<LinkedRecord>, ({String target, String query})>(
|
|
(ref, arg) => ref
|
|
.watch(customObjectsApiProvider)
|
|
.searchTargets(arg.target, arg.query),
|
|
retry: (_, _) => null,
|
|
);
|
|
|
|
final attachmentBytesProvider = FutureProvider.autoDispose
|
|
.family<Uint8List?, String>((ref, id) async {
|
|
final response = await ApiService().getBytes(
|
|
ApiConfig.attachmentDownload(id),
|
|
);
|
|
return response.success ? response.data : null;
|
|
});
|
|
|
|
dynamic _blankToNull(dynamic v) => v is String && v.trim().isEmpty ? null : v;
|
|
|
|
bool _sameSet(Iterable<String> a, Iterable<String> b) {
|
|
final x = a.toSet();
|
|
final y = b.toSet();
|
|
return x.length == y.length && x.containsAll(y);
|
|
}
|
|
|
|
/// What to send for a save. A new record sends what it has; an edit sends only
|
|
/// what changed, since the server merges, so an untouched field is never
|
|
/// rewritten (and a hidden link held by a one-record field is never sent).
|
|
Map<String, dynamic> recordWriteBody({
|
|
required ObjectDefinition definition,
|
|
required String name,
|
|
required Map<String, dynamic> customFields,
|
|
required Map<String, List<String>> links,
|
|
required List<String> assignees,
|
|
ObjectRecord? original,
|
|
}) {
|
|
final body = <String, dynamic>{'name': name};
|
|
|
|
final customKeys = {for (final f in definition.customFields) f.key};
|
|
final linkKeys = {for (final f in definition.linkFields) f.key};
|
|
|
|
final custom = <String, dynamic>{};
|
|
for (final e in customFields.entries) {
|
|
if (!customKeys.contains(e.key)) continue;
|
|
final next = _blankToNull(e.value);
|
|
if (original == null) {
|
|
if (next != null) custom[e.key] = next;
|
|
} else if (next != _blankToNull(original.customFields[e.key])) {
|
|
custom[e.key] = next;
|
|
}
|
|
}
|
|
if (custom.isNotEmpty) body['custom_fields'] = custom;
|
|
|
|
final changedLinks = <String, List<String>>{};
|
|
for (final e in links.entries) {
|
|
if (!linkKeys.contains(e.key)) continue;
|
|
if (original == null) {
|
|
if (e.value.isNotEmpty) changedLinks[e.key] = e.value;
|
|
continue;
|
|
}
|
|
final held = original.links[e.key];
|
|
if (held != null && held.readOnly) continue;
|
|
final before = held?.records.map((r) => r.id) ?? const <String>[];
|
|
if (!_sameSet(before, e.value)) changedLinks[e.key] = e.value;
|
|
}
|
|
if (changedLinks.isNotEmpty) body['links'] = changedLinks;
|
|
|
|
final sendAssignees = original == null
|
|
? assignees.isNotEmpty
|
|
: !_sameSet(original.assignedTo.map((p) => p.id), assignees);
|
|
if (sendAssignees) body['assigned_to'] = assignees;
|
|
return body;
|
|
}
|
|
|
|
String _messageText(dynamic v) {
|
|
if (v is List) return v.map((m) => m.toString()).join(' ');
|
|
return v?.toString() ?? '';
|
|
}
|
|
|
|
/// One `<label>: <message>` line per refused field.
|
|
String recordWriteError(
|
|
ApiResponse<dynamic> response,
|
|
ObjectDefinition definition,
|
|
) {
|
|
final data = response.data;
|
|
if (data is Map) {
|
|
final labels = {for (final f in definition.fields) f.key: f.label};
|
|
final lines = <String>[];
|
|
|
|
void add(String label, dynamic v) {
|
|
final text = _messageText(v);
|
|
if (text.isNotEmpty) lines.add('$label: $text');
|
|
}
|
|
|
|
if (data['name'] != null) add(definition.summary.nameLabel, data['name']);
|
|
if (data['assigned_to'] != null) add('Assigned to', data['assigned_to']);
|
|
for (final section in const ['custom_fields', 'links']) {
|
|
final nested = data[section];
|
|
if (nested is! Map) continue;
|
|
for (final e in nested.entries) {
|
|
final key = e.key.toString();
|
|
if (key == '_size') {
|
|
final text = _messageText(e.value);
|
|
if (text.isNotEmpty) lines.add(text);
|
|
} else {
|
|
add(labels[key] ?? key, e.value);
|
|
}
|
|
}
|
|
}
|
|
if (lines.isNotEmpty) return lines.join('\n');
|
|
final detail = _messageText(data['detail']);
|
|
if (detail.isNotEmpty) return detail;
|
|
}
|
|
return response.message ?? 'Could not save that record.';
|
|
}
|
|
|
|
/// The object settings write body, as the web's `readObjectForm` builds it.
|
|
/// An edit never carries the key; the name label is sent only when filled.
|
|
Map<String, dynamic> objectWriteBody({
|
|
required bool creating,
|
|
required String label,
|
|
required String labelPlural,
|
|
String key = '',
|
|
String nameLabel = '',
|
|
String description = '',
|
|
String recordAccess = 'own',
|
|
}) {
|
|
final body = <String, dynamic>{
|
|
'label': label.trim(),
|
|
'label_plural': labelPlural.trim(),
|
|
'description': description.trim(),
|
|
'record_access': recordAccess == 'everyone' ? 'everyone' : 'own',
|
|
};
|
|
if (creating) body['key'] = key.trim();
|
|
if (nameLabel.trim().isNotEmpty) body['name_label'] = nameLabel.trim();
|
|
return body;
|
|
}
|
|
|
|
/// Rows with a value keep it (records store it); a new row gets one from its
|
|
/// label, or `option_<n>` when the label has no Latin letters or digits.
|
|
List<Map<String, String>> _optionRows(List<CustomFieldOption> options) {
|
|
final used = {
|
|
for (final o in options)
|
|
if (o.value.isNotEmpty) o.value,
|
|
};
|
|
var next = 1;
|
|
final rows = <Map<String, String>>[];
|
|
for (final o in options) {
|
|
final label = o.label.trim();
|
|
if (label.isEmpty) continue;
|
|
var value = o.value;
|
|
if (value.isEmpty) {
|
|
value = slugifyOptionValue(label);
|
|
while (value.isEmpty || used.contains(value)) {
|
|
value = 'option_${next++}';
|
|
}
|
|
used.add(value);
|
|
}
|
|
rows.add({'value': value, 'label': label});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/// The field write body, as the web's `readFieldForm` builds it. `fieldType`
|
|
/// says which shape applies on an edit too, but an edit never sends `key`,
|
|
/// `field_type`, `target` or `many`; a link never sends `options` or
|
|
/// `is_required`; `is_required` is an explicit bool.
|
|
Map<String, dynamic> fieldWriteBody({
|
|
required bool creating,
|
|
required String label,
|
|
required String fieldType,
|
|
String key = '',
|
|
String target = '',
|
|
bool many = false,
|
|
bool isRequired = false,
|
|
int? displayOrder,
|
|
List<CustomFieldOption> options = const [],
|
|
}) {
|
|
final isLink = fieldType == 'link';
|
|
final body = <String, dynamic>{'label': label.trim()};
|
|
if (creating) {
|
|
body['key'] = key.trim();
|
|
body['field_type'] = fieldType;
|
|
if (isLink) {
|
|
body['target'] = target;
|
|
body['many'] = many;
|
|
}
|
|
}
|
|
if (!isLink) body['is_required'] = isRequired;
|
|
if (displayOrder != null) body['display_order'] = displayOrder;
|
|
if (fieldType == 'dropdown') body['options'] = _optionRows(options);
|
|
return body;
|
|
}
|
|
|
|
/// The names the object form shows for the keys it sends.
|
|
const objectFormLabels = {
|
|
'key': 'Key',
|
|
'label': 'Name (singular)',
|
|
'label_plural': 'Name (plural)',
|
|
'name_label': "What each record's name is called",
|
|
'description': 'Description',
|
|
'record_access': 'Who can see its records',
|
|
};
|
|
|
|
/// The names the field form shows for the keys it sends.
|
|
const fieldFormLabels = {
|
|
'key': 'Key',
|
|
'label': 'Label',
|
|
'field_type': 'Type',
|
|
'options': 'Options',
|
|
'target': 'Links to',
|
|
'many': 'How many',
|
|
'is_required': 'Required',
|
|
'display_order': 'Order',
|
|
};
|
|
|
|
/// What the server refused, as one readable line per field or its `detail`.
|
|
/// [labels] says what the form calls each key; unknown keys keep their name.
|
|
String settingsWriteError(
|
|
ApiResponse<dynamic> response,
|
|
String fallback, {
|
|
Map<String, String> labels = const {},
|
|
}) {
|
|
final data = response.data;
|
|
if (data is Map) {
|
|
final detail = _messageText(data['detail']);
|
|
if (detail.isNotEmpty) return detail;
|
|
final lines = [
|
|
for (final e in data.entries)
|
|
if (_messageText(e.value).isNotEmpty)
|
|
'${labels[e.key] ?? e.key}: ${_messageText(e.value)}',
|
|
];
|
|
if (lines.isNotEmpty) return lines.join('\n');
|
|
}
|
|
return response.message ?? fallback;
|
|
}
|