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':
|
||||
acc += nums[i];
|
||||
break;
|
||||
case 'sub':
|
||||
case 'Subtraction':
|
||||
acc -= nums[i];
|
||||
break;
|
||||
case 'mul':
|
||||
case 'Multiplication':
|
||||
acc *= nums[i];
|
||||
break;
|
||||
case 'div':
|
||||
case 'Division':
|
||||
if (nums[i] != 0) acc /= nums[i];
|
||||
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> {
|
||||
bool _loading = false;
|
||||
String? _loadError;
|
||||
|
||||
Future<void> _open() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = true);
|
||||
List<Map<String, dynamic>> options = const [];
|
||||
try {
|
||||
options = await widget.optionsLoader();
|
||||
} finally {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
// Kick off the load but render the sheet immediately with a loader
|
||||
final Future<List<Map<String, dynamic>>> loadFuture = (() async {
|
||||
try {
|
||||
_loadError = null;
|
||||
final list = await widget.optionsLoader();
|
||||
return list;
|
||||
} catch (e) {
|
||||
_loadError = e.toString();
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
})();
|
||||
|
||||
final cs = widget.colorScheme;
|
||||
await showModalBottomSheet(
|
||||
final selected = await showModalBottomSheet<Map<String, dynamic>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surface,
|
||||
@ -90,48 +98,257 @@ class _ValueListPickerWidgetState extends State<_ValueListPickerWidget> {
|
||||
),
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: options.isEmpty
|
||||
? Center(
|
||||
child: Text('No data',
|
||||
style: TextStyle(color: cs.onSurfaceVariant)))
|
||||
: ListView.separated(
|
||||
itemCount: options.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
Divider(height: 1, color: cs.outline.withOpacity(0.08)),
|
||||
itemBuilder: (context, index) {
|
||||
final o = options[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Text(
|
||||
((o['name'] ?? o['title'] ?? 'N') as String)
|
||||
.substring(0, 1)
|
||||
.toUpperCase())),
|
||||
title: Text((o['name'] ?? o['title'] ?? '').toString()),
|
||||
subtitle: Text(
|
||||
(o['description'] ?? o['email'] ?? o['phone'] ?? '')
|
||||
.toString()),
|
||||
onTap: () {
|
||||
final scope = EntityFormScope.of(context);
|
||||
if (scope != null) {
|
||||
widget.fillMappings
|
||||
.forEach((sourceKey, targetFieldKey) {
|
||||
final v = o[sourceKey];
|
||||
final c = scope.controllers[targetFieldKey];
|
||||
if (c != null) c.text = v?.toString() ?? '';
|
||||
});
|
||||
scope.notifyParent();
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header with close button
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
icon:
|
||||
Icon(Icons.close_rounded, color: cs.onSurfaceVariant),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: loadFuture,
|
||||
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
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/dynamic_color_scheme.dart';
|
||||
import 'package:flutter/foundation.dart' show compute;
|
||||
|
||||
class DynamicThemeProvider extends ChangeNotifier {
|
||||
ColorScheme? _dynamicColorScheme;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user