205 lines
6.5 KiB
Dart
205 lines
6.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../core/theme/theme.dart';
|
|
import '../../data/models/custom_object.dart';
|
|
import '../../providers/custom_objects_provider.dart';
|
|
import '../../routes/app_router.dart';
|
|
import 'link_field_picker.dart';
|
|
|
|
/// The custom records that link to a built-in record, on its detail screen.
|
|
/// Hidden unless the org has custom objects; the server decides what each
|
|
/// person may read and change, and the app offers writes only as it says.
|
|
class RelatedObjectsSection extends ConsumerWidget {
|
|
const RelatedObjectsSection({
|
|
super.key,
|
|
required this.to,
|
|
required this.recordId,
|
|
});
|
|
|
|
final String to;
|
|
final String recordId;
|
|
|
|
void _refusal(BuildContext context, String? message) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message ?? 'Could not change this link'),
|
|
backgroundColor: AppColors.danger600,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _add(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
RelatedAnswer answer,
|
|
) async {
|
|
final field = await showModalBottomSheet<RelatedLinkField>(
|
|
context: context,
|
|
backgroundColor: AppColors.surface,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (sheet) => SafeArea(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Text('Add to', style: AppTypography.h3),
|
|
),
|
|
for (final f in answer.linkFields)
|
|
ListTile(
|
|
minTileHeight: 48,
|
|
title: Text('${f.objectLabel}: ${f.fieldLabel}'),
|
|
onTap: () => Navigator.pop(sheet, f),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (field == null || !context.mounted) return;
|
|
final picked = await showLinkSearchSheet(
|
|
context,
|
|
title: '${field.objectLabel}: ${field.fieldLabel}',
|
|
target: 'obj:${field.objectKey}',
|
|
);
|
|
if (picked == null || !context.mounted) return;
|
|
final response = await ref
|
|
.read(customObjectsApiProvider)
|
|
.addRelated(to, recordId, picked.id, field.fieldKey);
|
|
if (!context.mounted) return;
|
|
if (response.success) {
|
|
ref.invalidate(relatedProvider((to: to, id: recordId)));
|
|
} else {
|
|
_refusal(context, response.message);
|
|
}
|
|
}
|
|
|
|
Future<void> _remove(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
RelatedGroup group,
|
|
RelatedRecordRef record,
|
|
) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
title: Text('Remove from ${group.objectLabel}: ${group.fieldLabel}?'),
|
|
content: Text(
|
|
'${record.name.isEmpty ? 'This record' : record.name} will no '
|
|
'longer link here.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialog, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialog, true),
|
|
child: const Text('Remove'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !context.mounted) return;
|
|
final response = await ref
|
|
.read(customObjectsApiProvider)
|
|
.removeRelated(to, recordId, record.id, group.fieldKey);
|
|
if (!context.mounted) return;
|
|
if (response.success) {
|
|
ref.invalidate(relatedProvider((to: to, id: recordId)));
|
|
} else {
|
|
_refusal(context, response.message);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final answer = ref.watch(relatedProvider((to: to, id: recordId))).value;
|
|
if (answer == null || answer.showsNothing) return const SizedBox.shrink();
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
border: Border.all(color: AppColors.gray200),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Related', style: AppTypography.h3),
|
|
for (final group in answer.groups) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'${group.objectLabel}: ${group.fieldLabel}',
|
|
style: AppTypography.label.copyWith(
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
for (final record in group.records)
|
|
_row(context, ref, answer, group, record),
|
|
],
|
|
if (answer.linkFields.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: TextButton.icon(
|
|
style: TextButton.styleFrom(
|
|
minimumSize: const Size(44, 44),
|
|
alignment: Alignment.centerLeft,
|
|
),
|
|
onPressed: () => _add(context, ref, answer),
|
|
icon: const Icon(LucideIcons.plus, size: 16),
|
|
label: const Text('Add to'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _row(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
RelatedAnswer answer,
|
|
RelatedGroup group,
|
|
RelatedRecordRef record,
|
|
) {
|
|
final name = record.name.isEmpty ? 'Untitled' : record.name;
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: InkWell(
|
|
onTap: () => context.push(
|
|
AppRoutes.objectRecord(group.objectKey, record.id),
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(minHeight: 44),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
name,
|
|
style: AppTypography.body.copyWith(
|
|
color: AppColors.primary600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (answer.removable(group))
|
|
IconButton(
|
|
tooltip: 'Remove ${record.name}',
|
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
|
icon: const Icon(LucideIcons.x, size: 18),
|
|
onPressed: () => _remove(context, ref, group, record),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|