bottleneckcrm/mobile/lib/widgets/forms/custom_fields_form.dart

361 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/theme/theme.dart';
import '../../data/models/custom_field_definition.dart';
import '../../providers/lookup_provider.dart';
import '../../services/org_date.dart';
import '../common/common.dart';
/// Schema-driven custom-fields editor.
///
/// Loads the per-org `CustomFieldDefinition` rows for the given `targetModel`
/// (or renders the [definitions] it is handed) with one input per definition.
/// The current value map is held by the parent; we only call `onChanged` so
/// the parent decides when to persist.
class CustomFieldsForm extends ConsumerWidget {
final String? targetModel;
final List<CustomFieldDefinition>? definitions;
final Map<String, dynamic> values;
final ValueChanged<Map<String, dynamic>> onChanged;
const CustomFieldsForm({
super.key,
this.targetModel,
this.definitions,
required this.values,
required this.onChanged,
}) : assert(
(targetModel == null) != (definitions == null),
'Give exactly one of targetModel and definitions',
);
/// A cleared field stays in the map as null. The server keeps every key a
/// save leaves out, so removing it would leave the old value in place.
void _setValue(String key, dynamic value) {
final next = Map<String, dynamic>.from(values);
next[key] = (value is String && value.isEmpty) ? null : value;
onChanged(next);
}
Widget _fields(List<CustomFieldDefinition> defs) {
if (defs.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final def in defs) ...[
_CustomFieldInput(
definition: def,
value: values[def.key],
onChanged: (v) => _setValue(def.key, v),
),
const SizedBox(height: 16),
],
],
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final given = definitions;
if (given != null) return _fields(given);
final asyncDefs = ref.watch(customFieldDefinitionsProvider(targetModel!));
return asyncDefs.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (err, _) => Text(
'Failed to load custom fields',
style: AppTypography.caption.copyWith(color: AppColors.danger500),
),
data: _fields,
);
}
}
class _CustomFieldInput extends StatelessWidget {
final CustomFieldDefinition definition;
final dynamic value;
final ValueChanged<dynamic> onChanged;
const _CustomFieldInput({
required this.definition,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
switch (definition.fieldType) {
case CustomFieldType.text:
return _textInput(maxLines: 1);
case CustomFieldType.textarea:
return _textInput(maxLines: 4);
case CustomFieldType.number:
return _numberInput();
case CustomFieldType.dropdown:
return _dropdownInput(context);
case CustomFieldType.date:
return _dateInput(context);
case CustomFieldType.checkbox:
return _checkboxInput();
}
}
String _label() =>
definition.isRequired ? '${definition.label} *' : definition.label;
Widget _textInput({required int maxLines}) {
return FloatingLabelInput(
label: _label(),
controller: TextEditingController(text: value?.toString() ?? ''),
maxLines: maxLines,
onChanged: onChanged,
);
}
Widget _numberInput() {
return FloatingLabelInput(
label: _label(),
controller: TextEditingController(text: value?.toString() ?? ''),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.\-]'))],
onChanged: (v) {
if (v.isEmpty) {
onChanged(null);
return;
}
final n = num.tryParse(v);
onChanged(n ?? v);
},
);
}
Widget _dropdownInput(BuildContext context) {
final selectedLabel = definition.options
.where((o) => o.value == value?.toString())
.map((o) => o.label)
.firstOrNull;
// A stored value that is no longer an option shows as itself; only the
// user choosing something else changes it.
final stored = value?.toString() ?? '';
final shown = selectedLabel ?? (stored.isEmpty ? null : stored);
return _DropdownField(
label: _label(),
valueLabel: shown ?? 'Select…',
hasValue: shown != null,
onTap: () => _showDropdownSheet(context),
);
}
void _showDropdownSheet(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.gray300,
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text(definition.label, style: AppTypography.h3),
),
if (!definition.isRequired && value != null)
InkWell(
onTap: () {
onChanged(null);
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
Icon(LucideIcons.x, size: 18, color: AppColors.gray500),
const SizedBox(width: 12),
Text(
'Clear',
style: AppTypography.body.copyWith(
color: AppColors.gray600,
),
),
],
),
),
),
for (final opt in definition.options)
InkWell(
onTap: () {
onChanged(opt.value);
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
Expanded(
child: Text(
opt.label,
style: AppTypography.body.copyWith(
fontWeight: opt.value == value?.toString()
? FontWeight.w600
: FontWeight.normal,
color: opt.value == value?.toString()
? AppColors.primary600
: AppColors.textPrimary,
),
),
),
if (opt.value == value?.toString())
Icon(
LucideIcons.check,
size: 20,
color: AppColors.primary600,
),
],
),
),
),
const SizedBox(height: 16),
],
),
),
);
}
Widget _dateInput(BuildContext context) {
DateTime? parsed;
if (value is String && (value as String).isNotEmpty) {
parsed = DateTime.tryParse(value as String);
}
final display = parsed == null
? 'Select date'
: '${parsed.year.toString().padLeft(4, '0')}-'
'${parsed.month.toString().padLeft(2, '0')}-'
'${parsed.day.toString().padLeft(2, '0')}';
return _DropdownField(
label: _label(),
valueLabel: display,
hasValue: parsed != null,
icon: LucideIcons.calendar,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: parsed ?? orgToday(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
final iso =
'${picked.year.toString().padLeft(4, '0')}-'
'${picked.month.toString().padLeft(2, '0')}-'
'${picked.day.toString().padLeft(2, '0')}';
onChanged(iso);
}
},
);
}
Widget _checkboxInput() {
final checked = value == true || value == 'true' || value == 1;
return InkWell(
onTap: () => onChanged(!checked),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Checkbox(value: checked, onChanged: (v) => onChanged(v ?? false)),
Expanded(child: Text(_label(), style: AppTypography.body)),
],
),
),
);
}
}
class _DropdownField extends StatelessWidget {
final String label;
final String valueLabel;
final bool hasValue;
final IconData? icon;
final VoidCallback onTap;
const _DropdownField({
required this.label,
required this.valueLabel,
required this.hasValue,
required this.onTap,
this.icon,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.caption.copyWith(color: AppColors.textSecondary),
),
const SizedBox(height: 8),
GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: AppColors.gray50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: AppColors.textSecondary),
const SizedBox(width: 12),
],
Expanded(
child: Text(
valueLabel,
style: AppTypography.body.copyWith(
color: hasValue
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
),
Icon(
LucideIcons.chevronDown,
size: 20,
color: AppColors.textSecondary,
),
],
),
),
),
],
);
}
}