shared
This commit is contained in:
parent
bc02a06d56
commit
0805cf44e4
@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:barcode_widget/barcode_widget.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
|
||||||
|
class BarcodeField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final Future<String?> Function()? scanner; // returns scanned text
|
||||||
|
|
||||||
|
BarcodeField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = '',
|
||||||
|
this.isRequired = false,
|
||||||
|
this.scanner,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (v) {
|
||||||
|
if (isRequired && (v == null || v.trim().isEmpty)) return 'Required';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border:
|
||||||
|
Border.all(color: colorScheme.outlineVariant.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (controller.text.trim().isNotEmpty) ...[
|
||||||
|
BarcodeWidget(
|
||||||
|
barcode: Barcode.code128(),
|
||||||
|
data: controller.text.trim(),
|
||||||
|
width: 220,
|
||||||
|
height: 80,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText:
|
||||||
|
hint.isEmpty ? 'Enter or scan barcode' : hint,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10)),
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
validator: validator,
|
||||||
|
onChanged: (_) => onChanged?.call(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Scan Barcode',
|
||||||
|
icon: Icon(Icons.document_scanner_rounded,
|
||||||
|
color: colorScheme.primary),
|
||||||
|
onPressed: scanner == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final val = await scanner!.call();
|
||||||
|
if (val != null) {
|
||||||
|
controller.text = val;
|
||||||
|
onChanged?.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -124,13 +124,13 @@ class _CalculatedWidgetState extends State<_CalculatedWidget> {
|
|||||||
case 'Addition':
|
case 'Addition':
|
||||||
acc += nums[i];
|
acc += nums[i];
|
||||||
break;
|
break;
|
||||||
case 'sub':
|
case 'Subtraction':
|
||||||
acc -= nums[i];
|
acc -= nums[i];
|
||||||
break;
|
break;
|
||||||
case 'mul':
|
case 'Multiplication':
|
||||||
acc *= nums[i];
|
acc *= nums[i];
|
||||||
break;
|
break;
|
||||||
case 'div':
|
case 'Division':
|
||||||
if (nums[i] != 0) acc /= nums[i];
|
if (nums[i] != 0) acc /= nums[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
187
base_project/lib/BuilderField/shared/fields/currency_field.dart
Normal file
187
base_project/lib/BuilderField/shared/fields/currency_field.dart
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
|
||||||
|
class CurrencyField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final String currencySymbol;
|
||||||
|
final int decimalDigits;
|
||||||
|
final List<Map<String, String>>
|
||||||
|
currencyOptions; // [{code: 'INR', symbol: 'â¹', name: 'India (INR)'}]
|
||||||
|
|
||||||
|
CurrencyField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = '',
|
||||||
|
this.isRequired = false,
|
||||||
|
this.currencySymbol = 'â¹',
|
||||||
|
this.decimalDigits = 2,
|
||||||
|
List<Map<String, String>>? currencyOptions,
|
||||||
|
}) : currencyOptions = currencyOptions ??
|
||||||
|
const [
|
||||||
|
{'code': 'INR', 'symbol': 'â¹', 'name': 'India (INR)'},
|
||||||
|
{'code': 'USD', 'symbol': '\$', 'name': 'USA (USD)'},
|
||||||
|
{'code': 'EUR', 'symbol': '€', 'name': 'Euro (EUR)'},
|
||||||
|
{'code': 'GBP', 'symbol': '£', 'name': 'UK (GBP)'},
|
||||||
|
{'code': 'AUD', 'symbol': 'A\$', 'name': 'Australia (AUD)'},
|
||||||
|
{'code': 'CAD', 'symbol': 'C\$', 'name': 'Canada (CAD)'},
|
||||||
|
{'code': 'CHF', 'symbol': 'CHF', 'name': 'Switzerland (CHF)'},
|
||||||
|
{'code': 'CNY', 'symbol': 'Â¥', 'name': 'China (CNY)'},
|
||||||
|
{'code': 'HKD', 'symbol': 'HK\$', 'name': 'Hong Kong (HKD)'},
|
||||||
|
{'code': 'NZD', 'symbol': 'NZ\$', 'name': 'New Zealand (NZD)'},
|
||||||
|
{'code': 'SGD', 'symbol': 'S\$', 'name': 'Singapore (SGD)'},
|
||||||
|
{'code': 'ZAR', 'symbol': 'R', 'name': 'South Africa (ZAR)'},
|
||||||
|
{'code': 'SEK', 'symbol': 'kr', 'name': 'Sweden (SEK)'},
|
||||||
|
{'code': 'NOK', 'symbol': 'kr', 'name': 'Norway (NOK)'},
|
||||||
|
{'code': 'MXN', 'symbol': '\$', 'name': 'Mexico (MXN)'},
|
||||||
|
{'code': 'BRL', 'symbol': 'R\$', 'name': 'Brazil (BRL)'},
|
||||||
|
{'code': 'RUB', 'symbol': '₽', 'name': 'Russia (RUB)'},
|
||||||
|
{'code': 'KRW', 'symbol': '₩', 'name': 'South Korea (KRW)'},
|
||||||
|
{'code': 'TRY', 'symbol': '₺', 'name': 'Turkey (TRY)'},
|
||||||
|
{'code': 'JPY', 'symbol': '¥', 'name': 'Japan (JPY)'},
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
final v = value?.trim() ?? '';
|
||||||
|
if (isRequired && v.isEmpty) return 'Required';
|
||||||
|
if (v.isNotEmpty && double.tryParse(v.replaceAll(',', '')) == null) {
|
||||||
|
return 'Invalid amount';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return _CurrencyFieldWidget(
|
||||||
|
controller: controller,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
label: label,
|
||||||
|
hint: hint.isEmpty ? '0.${'0' * decimalDigits}' : hint,
|
||||||
|
isRequired: isRequired,
|
||||||
|
decimalDigits: decimalDigits,
|
||||||
|
defaultSymbol: currencySymbol,
|
||||||
|
options: currencyOptions,
|
||||||
|
onChanged: onChanged,
|
||||||
|
validator: validator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CurrencyFieldWidget extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final int decimalDigits;
|
||||||
|
final String defaultSymbol;
|
||||||
|
final List<Map<String, String>> options;
|
||||||
|
final VoidCallback? onChanged;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
|
||||||
|
const _CurrencyFieldWidget({
|
||||||
|
required this.controller,
|
||||||
|
required this.colorScheme,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
required this.isRequired,
|
||||||
|
required this.decimalDigits,
|
||||||
|
required this.defaultSymbol,
|
||||||
|
required this.options,
|
||||||
|
this.onChanged,
|
||||||
|
this.validator,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CurrencyFieldWidget> createState() => _CurrencyFieldWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CurrencyFieldWidgetState extends State<_CurrencyFieldWidget> {
|
||||||
|
late String _selectedSymbol;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedSymbol = widget.defaultSymbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = widget.colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.label,
|
||||||
|
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surface,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: cs.outlineVariant.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _selectedSymbol,
|
||||||
|
items: widget.options
|
||||||
|
.map((o) => DropdownMenuItem<String>(
|
||||||
|
value: o['symbol'],
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(o['symbol'] ?? ''),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
o['code'] ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val == null) return;
|
||||||
|
setState(() => _selectedSymbol = val);
|
||||||
|
widget.onChanged?.call();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: widget.controller,
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.hint,
|
||||||
|
prefixText: '$_selectedSymbol ',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12)),
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
validator: widget.validator,
|
||||||
|
onChanged: (_) => widget.onChanged?.call(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,319 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../ui/entity_form.dart';
|
||||||
|
|
||||||
|
import 'custom_text_field.dart';
|
||||||
|
import 'number_field.dart' as shared_number;
|
||||||
|
import 'date_field.dart' as shared_date;
|
||||||
|
import 'currency_field.dart';
|
||||||
|
import 'qr_code_field.dart';
|
||||||
|
import 'barcode_field.dart';
|
||||||
|
|
||||||
|
/// FieldGroupField
|
||||||
|
/// - Visual group that renders multiple sub-inputs inside a styled container
|
||||||
|
/// - Uses EntityForm's composite submit capability via assignByJsonPaths + paths
|
||||||
|
/// - The group controller stores a JSON string of path->value; on submit, EntityForm
|
||||||
|
/// assigns each value into the request body by its json path.
|
||||||
|
class FieldGroupField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final List<GroupSubField>? subFields;
|
||||||
|
// Schema-driven: field name -> type (e.g., 'text', 'number', 'date', 'currency', 'qrcode', 'barcode')
|
||||||
|
final String? groupPrefix; // e.g., 'primary'
|
||||||
|
final Map<String, String>? schema;
|
||||||
|
|
||||||
|
FieldGroupField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = '',
|
||||||
|
this.isRequired = false,
|
||||||
|
this.subFields,
|
||||||
|
this.groupPrefix,
|
||||||
|
this.schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (v) {
|
||||||
|
if (!isRequired) return null;
|
||||||
|
final fields = _effectiveSubFields;
|
||||||
|
if (fields.any((sf) => sf.isRequired)) {
|
||||||
|
final scope = _scope;
|
||||||
|
if (scope == null) return null;
|
||||||
|
for (final sf in fields) {
|
||||||
|
if (!sf.isRequired) continue;
|
||||||
|
final c = scope.controllers[sf.path];
|
||||||
|
if (c == null || c.text.trim().isEmpty) {
|
||||||
|
return 'Please complete required fields';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
static EntityFormScope? get _scope => _EntityFormScopeAccessor.scope;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? get customProperties => {
|
||||||
|
'excludeFromSubmit': true,
|
||||||
|
'assignByJsonPaths': true,
|
||||||
|
'paths': _effectiveSubFields.map((e) => e.path).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return _EntityFormScopeAccessor(
|
||||||
|
builder: (scope) {
|
||||||
|
final fields = _effectiveSubFields;
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border:
|
||||||
|
Border.all(color: colorScheme.outlineVariant.withOpacity(0.4)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withOpacity(0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hint.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
hint,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...fields.map((sf) => _buildSubField(
|
||||||
|
scope, sf, colorScheme, controller, onChanged)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubField(
|
||||||
|
EntityFormScope? scope,
|
||||||
|
GroupSubField sf,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
TextEditingController groupController,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
) {
|
||||||
|
final existing = scope?.controllers[sf.path];
|
||||||
|
final TextEditingController controller =
|
||||||
|
existing ?? TextEditingController();
|
||||||
|
// Ensure controller is registered in scope for inner fields
|
||||||
|
if (scope != null && existing == null) {
|
||||||
|
scope.controllers[sf.path] = controller;
|
||||||
|
}
|
||||||
|
// Prefill subfield from group's encoded initial JSON (built by EntityForm)
|
||||||
|
if (controller.text.isEmpty && groupController.text.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final decoded = const JsonDecoder().convert(groupController.text);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final dynamic v = decoded[sf.path];
|
||||||
|
if (v != null && v.toString().isNotEmpty) {
|
||||||
|
controller.text = v.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore decode errors; groupController may not be JSON yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.addListener(() {
|
||||||
|
// Encode all subfield values into the group controller for submit mapping
|
||||||
|
final Map<String, dynamic> map = {};
|
||||||
|
for (final s in _effectiveSubFields) {
|
||||||
|
final c = scope?.controllers[s.path];
|
||||||
|
if (c != null && c.text.isNotEmpty) {
|
||||||
|
map[s.path] = c.text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupController.text = _encode(map);
|
||||||
|
onChanged?.call();
|
||||||
|
scope?.notifyParent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build appropriate shared field by type, but render it inline
|
||||||
|
final BaseField field = _toSharedField(sf);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (sf.label.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: Text(
|
||||||
|
sf.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
field.buildField(
|
||||||
|
controller: controller,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _encode(Map<String, dynamic> map) => const JsonEncoder().convert(map);
|
||||||
|
|
||||||
|
List<GroupSubField> get _effectiveSubFields {
|
||||||
|
if (subFields != null && subFields!.isNotEmpty) return subFields!;
|
||||||
|
final List<GroupSubField> out = [];
|
||||||
|
final prefix = groupPrefix ?? '';
|
||||||
|
final schemaMap = schema ?? const {};
|
||||||
|
schemaMap.forEach((name, type) {
|
||||||
|
final path = prefix.isEmpty ? name : '$prefix.$name';
|
||||||
|
out.add(GroupSubField(
|
||||||
|
path: path,
|
||||||
|
label: _capitalize(name.replaceAll('_', ' ')),
|
||||||
|
hint: '',
|
||||||
|
keyboardType: _keyboardFromType(type),
|
||||||
|
isRequired: false,
|
||||||
|
type: type,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInputType _keyboardFromType(String type) {
|
||||||
|
switch (type.toLowerCase()) {
|
||||||
|
case 'number':
|
||||||
|
case 'currency':
|
||||||
|
return TextInputType.number;
|
||||||
|
default:
|
||||||
|
return TextInputType.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalize(String s) {
|
||||||
|
if (s.isEmpty) return s;
|
||||||
|
return s[0].toUpperCase() + s.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseField _toSharedField(GroupSubField sf) {
|
||||||
|
final t = (sf.type ?? 'text').toLowerCase();
|
||||||
|
switch (t) {
|
||||||
|
case 'number':
|
||||||
|
return shared_number.NumberField(
|
||||||
|
fieldKey: sf.path,
|
||||||
|
label: sf.label,
|
||||||
|
hint: sf.hint,
|
||||||
|
isRequired: sf.isRequired,
|
||||||
|
);
|
||||||
|
case 'date':
|
||||||
|
return shared_date.DateField(
|
||||||
|
fieldKey: sf.path,
|
||||||
|
label: sf.label,
|
||||||
|
hint: sf.hint,
|
||||||
|
isRequired: sf.isRequired,
|
||||||
|
);
|
||||||
|
case 'currency':
|
||||||
|
return CurrencyField(
|
||||||
|
fieldKey: sf.path,
|
||||||
|
label: sf.label,
|
||||||
|
hint: sf.hint,
|
||||||
|
isRequired: sf.isRequired,
|
||||||
|
);
|
||||||
|
case 'qrcode':
|
||||||
|
case 'qr':
|
||||||
|
return QRCodeField(
|
||||||
|
fieldKey: sf.path,
|
||||||
|
label: sf.label,
|
||||||
|
hint: sf.hint,
|
||||||
|
isRequired: sf.isRequired,
|
||||||
|
);
|
||||||
|
case 'barcode':
|
||||||
|
return BarcodeField(
|
||||||
|
fieldKey: sf.path,
|
||||||
|
label: sf.label,
|
||||||
|
hint: sf.hint,
|
||||||
|
isRequired: sf.isRequired,
|
||||||
|
);
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return CustomTextField(
|
||||||
|
fieldKey: sf.path,
|
||||||
|
label: sf.label,
|
||||||
|
hint: sf.hint,
|
||||||
|
isRequired: sf.isRequired,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupSubField {
|
||||||
|
final String path; // JSON path for EntityForm to assign during submit
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final TextInputType keyboardType;
|
||||||
|
final bool isRequired;
|
||||||
|
final String? type; // schema type identifier
|
||||||
|
|
||||||
|
GroupSubField({
|
||||||
|
required this.path,
|
||||||
|
required this.label,
|
||||||
|
this.hint = '',
|
||||||
|
this.keyboardType = TextInputType.text,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to access EntityFormScope in a builder without changing base components
|
||||||
|
class _EntityFormScopeAccessor extends StatefulWidget {
|
||||||
|
final Widget Function(EntityFormScope? scope) builder;
|
||||||
|
const _EntityFormScopeAccessor({required this.builder});
|
||||||
|
|
||||||
|
static EntityFormScope? get scope => _lastScope;
|
||||||
|
static EntityFormScope? _lastScope;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EntityFormScopeAccessor> createState() =>
|
||||||
|
_EntityFormScopeAccessorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntityFormScopeAccessorState extends State<_EntityFormScopeAccessor> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scope = EntityFormScope.of(context);
|
||||||
|
_EntityFormScopeAccessor._lastScope = scope;
|
||||||
|
return widget.builder(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
|
||||||
|
class QRCodeField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final Future<String?> Function()? scanner; // returns scanned text
|
||||||
|
|
||||||
|
QRCodeField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = '',
|
||||||
|
this.isRequired = false,
|
||||||
|
this.scanner,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (v) {
|
||||||
|
if (isRequired && (v == null || v.trim().isEmpty)) return 'Required';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border:
|
||||||
|
Border.all(color: colorScheme.outlineVariant.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (controller.text.trim().isNotEmpty) ...[
|
||||||
|
QrImageView(
|
||||||
|
data: controller.text.trim(),
|
||||||
|
version: QrVersions.auto,
|
||||||
|
size: 160,
|
||||||
|
gapless: false,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText:
|
||||||
|
hint.isEmpty ? 'Enter or scan QR data' : hint,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10)),
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
validator: validator,
|
||||||
|
onChanged: (_) => onChanged?.call(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Scan QR',
|
||||||
|
icon: Icon(Icons.qr_code_scanner_rounded,
|
||||||
|
color: colorScheme.primary),
|
||||||
|
onPressed: scanner == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final val = await scanner!.call();
|
||||||
|
if (val != null) {
|
||||||
|
controller.text = val;
|
||||||
|
onChanged?.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -70,18 +70,26 @@ class _ValueListPickerWidget extends StatefulWidget {
|
|||||||
|
|
||||||
class _ValueListPickerWidgetState extends State<_ValueListPickerWidget> {
|
class _ValueListPickerWidgetState extends State<_ValueListPickerWidget> {
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
String? _loadError;
|
||||||
|
|
||||||
Future<void> _open() async {
|
Future<void> _open() async {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
List<Map<String, dynamic>> options = const [];
|
|
||||||
try {
|
// Kick off the load but render the sheet immediately with a loader
|
||||||
options = await widget.optionsLoader();
|
final Future<List<Map<String, dynamic>>> loadFuture = (() async {
|
||||||
} finally {
|
try {
|
||||||
setState(() => _loading = false);
|
_loadError = null;
|
||||||
}
|
final list = await widget.optionsLoader();
|
||||||
|
return list;
|
||||||
|
} catch (e) {
|
||||||
|
_loadError = e.toString();
|
||||||
|
return const <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
final cs = widget.colorScheme;
|
final cs = widget.colorScheme;
|
||||||
await showModalBottomSheet(
|
final selected = await showModalBottomSheet<Map<String, dynamic>>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
@ -90,48 +98,257 @@ class _ValueListPickerWidgetState extends State<_ValueListPickerWidget> {
|
|||||||
),
|
),
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: options.isEmpty
|
children: [
|
||||||
? Center(
|
// Header with close button
|
||||||
child: Text('No data',
|
Padding(
|
||||||
style: TextStyle(color: cs.onSurfaceVariant)))
|
padding:
|
||||||
: ListView.separated(
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
itemCount: options.length,
|
child: Row(
|
||||||
separatorBuilder: (_, __) =>
|
children: [
|
||||||
Divider(height: 1, color: cs.outline.withOpacity(0.08)),
|
Expanded(
|
||||||
itemBuilder: (context, index) {
|
child: Text(
|
||||||
final o = options[index];
|
widget.label,
|
||||||
return ListTile(
|
style: TextStyle(
|
||||||
leading: CircleAvatar(
|
color: cs.onSurface,
|
||||||
child: Text(
|
fontSize: 16,
|
||||||
((o['name'] ?? o['title'] ?? 'N') as String)
|
fontWeight: FontWeight.w700,
|
||||||
.substring(0, 1)
|
),
|
||||||
.toUpperCase())),
|
),
|
||||||
title: Text((o['name'] ?? o['title'] ?? '').toString()),
|
),
|
||||||
subtitle: Text(
|
IconButton(
|
||||||
(o['description'] ?? o['email'] ?? o['phone'] ?? '')
|
tooltip: 'Close',
|
||||||
.toString()),
|
icon:
|
||||||
onTap: () {
|
Icon(Icons.close_rounded, color: cs.onSurfaceVariant),
|
||||||
final scope = EntityFormScope.of(context);
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
if (scope != null) {
|
)
|
||||||
widget.fillMappings
|
],
|
||||||
.forEach((sourceKey, targetFieldKey) {
|
),
|
||||||
final v = o[sourceKey];
|
),
|
||||||
final c = scope.controllers[targetFieldKey];
|
const SizedBox(height: 4),
|
||||||
if (c != null) c.text = v?.toString() ?? '';
|
FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
});
|
future: loadFuture,
|
||||||
scope.notifyParent();
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: cs.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text('Loading...',
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError || _loadError != null) {
|
||||||
|
final String msg = snapshot.error?.toString() ??
|
||||||
|
_loadError ??
|
||||||
|
'Failed to load';
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.errorContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: cs.error.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: cs.error),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(msg,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final options =
|
||||||
|
snapshot.data ?? const <Map<String, dynamic>>[];
|
||||||
|
if (options.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.inbox_rounded,
|
||||||
|
color: cs.onSurfaceVariant, size: 36),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('No data found',
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
label: const Text('Close'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: cs.primary,
|
||||||
|
foregroundColor: cs.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Flexible(
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
itemCount: options.length,
|
||||||
|
separatorBuilder: (_, __) => Divider(
|
||||||
|
height: 1, color: cs.outline.withOpacity(0.08)),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final o = options[index];
|
||||||
|
|
||||||
|
dynamic _readByPath(
|
||||||
|
Map<String, dynamic> m, String path) {
|
||||||
|
if (m.isEmpty || path.isEmpty) return null;
|
||||||
|
if (path.contains('.')) {
|
||||||
|
dynamic current = m;
|
||||||
|
for (final seg in path.split('.')) {
|
||||||
|
if (current is Map && current.containsKey(seg)) {
|
||||||
|
current = current[seg];
|
||||||
|
} else {
|
||||||
|
current = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
Navigator.of(context).pop();
|
return m[path];
|
||||||
},
|
}
|
||||||
);
|
|
||||||
},
|
final List<String> mappingKeys =
|
||||||
),
|
widget.fillMappings.keys.toList();
|
||||||
|
print('mappingKeys: $mappingKeys');
|
||||||
|
final String titleKey =
|
||||||
|
mappingKeys.isNotEmpty ? mappingKeys[0] : 'name';
|
||||||
|
final String subtitleKey =
|
||||||
|
mappingKeys.length > 1 ? mappingKeys[1] : '';
|
||||||
|
|
||||||
|
String title = (_readByPath(o, titleKey) ??
|
||||||
|
o['name'] ??
|
||||||
|
o['title'] ??
|
||||||
|
'')
|
||||||
|
.toString();
|
||||||
|
String subtitle = subtitleKey.isNotEmpty
|
||||||
|
? (_readByPath(o, subtitleKey) ?? '').toString()
|
||||||
|
: (o['description'] ??
|
||||||
|
o['email'] ??
|
||||||
|
o['phone'] ??
|
||||||
|
'')
|
||||||
|
.toString();
|
||||||
|
|
||||||
|
final String leadingText =
|
||||||
|
(title.isNotEmpty ? title[0] : 'N').toUpperCase();
|
||||||
|
return ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
cs.primary,
|
||||||
|
cs.primary.withOpacity(0.7)
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: cs.primary.withOpacity(0.2),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 4)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(leadingText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onPrimary,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
title: Text(title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: subtitle.isEmpty
|
||||||
|
? null
|
||||||
|
: Text(subtitle,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant)),
|
||||||
|
trailing: Icon(Icons.chevron_right_rounded,
|
||||||
|
color: cs.onSurfaceVariant),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop<Map<String, dynamic>>(o);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
|
||||||
|
// After sheet closes, if a selection was returned, fill controllers safely
|
||||||
|
if (selected != null) {
|
||||||
|
final scope = EntityFormScope.of(context);
|
||||||
|
if (scope != null) {
|
||||||
|
dynamic _readByPath(Map<String, dynamic> m, String path) {
|
||||||
|
if (m.isEmpty || path.isEmpty) return null;
|
||||||
|
if (path.contains('.')) {
|
||||||
|
dynamic current = m;
|
||||||
|
for (final seg in path.split('.')) {
|
||||||
|
if (current is Map && current.containsKey(seg)) {
|
||||||
|
current = current[seg];
|
||||||
|
} else {
|
||||||
|
current = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return m[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.fillMappings.forEach((sourceKey, targetFieldKey) {
|
||||||
|
dynamic v = _readByPath(selected, sourceKey);
|
||||||
|
if (v == null) {
|
||||||
|
v = _readByPath(selected, '${sourceKey}1') ??
|
||||||
|
_readByPath(selected, '${sourceKey}2') ??
|
||||||
|
_readByPath(selected, sourceKey.toLowerCase()) ??
|
||||||
|
_readByPath(selected, sourceKey.toUpperCase());
|
||||||
|
}
|
||||||
|
final c = scope.controllers[targetFieldKey];
|
||||||
|
if (c != null) {
|
||||||
|
try {
|
||||||
|
c.text = v?.toString() ?? '';
|
||||||
|
} catch (_) {
|
||||||
|
// Controller may be disposed; ignore safely
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scope.notifyParent();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/dynamic_color_scheme.dart';
|
import '../theme/dynamic_color_scheme.dart';
|
||||||
import 'package:flutter/foundation.dart' show compute;
|
|
||||||
|
|
||||||
class DynamicThemeProvider extends ChangeNotifier {
|
class DynamicThemeProvider extends ChangeNotifier {
|
||||||
ColorScheme? _dynamicColorScheme;
|
ColorScheme? _dynamicColorScheme;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user