modern
This commit is contained in:
parent
01be9df2ed
commit
f9043173c0
@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../utils/entity_field_store.dart';
|
||||||
|
|
||||||
|
class AudioUploadField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
AudioUploadField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = 'Select audio file',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (_) {
|
||||||
|
final items =
|
||||||
|
EntityFieldStore.instance.get<List<UploadItem>>(fieldKey) ??
|
||||||
|
const [];
|
||||||
|
if (isRequired && items.isEmpty) return '$label is required';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
final items = EntityFieldStore.instance.get<List<UploadItem>>(fieldKey) ??
|
||||||
|
<UploadItem>[];
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final result =
|
||||||
|
await FilePicker.platform.pickFiles(type: FileType.audio);
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final f = result.files.single;
|
||||||
|
final Uint8List? bytes = f.bytes ??
|
||||||
|
(f.path != null
|
||||||
|
? await File(f.path!).readAsBytes()
|
||||||
|
: null);
|
||||||
|
if (bytes != null) {
|
||||||
|
final updated = List<UploadItem>.from(items)
|
||||||
|
..add(UploadItem(fileName: f.name, bytes: bytes));
|
||||||
|
EntityFieldStore.instance.set(fieldKey, updated);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.audiotrack_rounded),
|
||||||
|
label: const Text('Add Audio'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...items.map((u) => ListTile(
|
||||||
|
leading: const Icon(Icons.music_note_rounded),
|
||||||
|
title: Text(u.fileName, overflow: TextOverflow.ellipsis),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
final updated = List<UploadItem>.from(items)..remove(u);
|
||||||
|
EntityFieldStore.instance.set(fieldKey, updated);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
base_project/lib/BuilderField/shared/fields/base_field.dart
Normal file
33
base_project/lib/BuilderField/shared/fields/base_field.dart
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Base interface for all field types in the Entity system
|
||||||
|
/// This allows UI to be independent of field types and enables reusability
|
||||||
|
abstract class BaseField {
|
||||||
|
/// Unique identifier for the field (e.g., 'name', 'phone')
|
||||||
|
String get fieldKey;
|
||||||
|
|
||||||
|
/// Display label for the field (e.g., 'Name', 'Phone Number')
|
||||||
|
String get label;
|
||||||
|
|
||||||
|
/// Placeholder text for the field
|
||||||
|
String get hint;
|
||||||
|
|
||||||
|
/// Whether the field is required for validation
|
||||||
|
bool get isRequired;
|
||||||
|
|
||||||
|
/// Validation function for the field
|
||||||
|
String? Function(String?)? get validator;
|
||||||
|
|
||||||
|
/// Main method - each field type provides its own implementation
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Optional: Field-specific styling properties
|
||||||
|
Map<String, dynamic>? get customProperties => null;
|
||||||
|
|
||||||
|
/// Optional: Field-specific validation rules
|
||||||
|
String? Function(String?)? get customValidator => null;
|
||||||
|
}
|
||||||
162
base_project/lib/BuilderField/shared/fields/captcha_field.dart
Normal file
162
base_project/lib/BuilderField/shared/fields/captcha_field.dart
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
|
||||||
|
/// Generic Captcha field: shows generated code and asks user to type it
|
||||||
|
class CaptchaField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final int length;
|
||||||
|
|
||||||
|
CaptchaField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = 'Enter CAPTCHA',
|
||||||
|
this.isRequired = true,
|
||||||
|
this.length = 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return _CaptchaFieldWidget(
|
||||||
|
key: ValueKey(fieldKey),
|
||||||
|
label: label,
|
||||||
|
hint: hint,
|
||||||
|
length: length,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
controller: controller,
|
||||||
|
baseValidator: validator,
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generateCaptcha(int length) {
|
||||||
|
final random = Random();
|
||||||
|
const chars =
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
return String.fromCharCodes(Iterable.generate(
|
||||||
|
length,
|
||||||
|
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CaptchaFieldWidget extends StatefulWidget {
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final int length;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? Function(String?)? baseValidator;
|
||||||
|
final VoidCallback? onChanged;
|
||||||
|
|
||||||
|
const _CaptchaFieldWidget({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
required this.length,
|
||||||
|
required this.colorScheme,
|
||||||
|
required this.controller,
|
||||||
|
required this.baseValidator,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CaptchaFieldWidget> createState() => _CaptchaFieldWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CaptchaFieldWidgetState extends State<_CaptchaFieldWidget> {
|
||||||
|
late String _captcha;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_captcha = _generateCaptcha(widget.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.colorScheme.primaryContainer.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border:
|
||||||
|
Border.all(color: widget.colorScheme.primary.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_captcha,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.colorScheme.primary,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_captcha = _generateCaptcha(widget.length);
|
||||||
|
});
|
||||||
|
if (widget.onChanged != null) widget.onChanged!();
|
||||||
|
},
|
||||||
|
icon: Icon(Icons.refresh_rounded,
|
||||||
|
color: widget.colorScheme.primary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ModernTextField(
|
||||||
|
controller: widget.controller,
|
||||||
|
hint: widget.hint,
|
||||||
|
validator: (value) {
|
||||||
|
final base = widget.baseValidator;
|
||||||
|
final err = base != null ? base(value) : null;
|
||||||
|
if (err != null) return err;
|
||||||
|
if ((value ?? '').trim() != _captcha) {
|
||||||
|
return 'CAPTCHA does not match';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged:
|
||||||
|
widget.onChanged != null ? (_) => widget.onChanged!() : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generateCaptcha(int length) {
|
||||||
|
final random = Random();
|
||||||
|
const chars =
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
return String.fromCharCodes(Iterable.generate(
|
||||||
|
length,
|
||||||
|
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
|
||||||
|
class CheckboxField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
CheckboxField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = '',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
final bool checked = controller.text.toLowerCase() == 'true';
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: checked,
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.text = (value ?? false).toString();
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
|
||||||
|
/// Custom text input field implementation (preferred over legacy TextField)
|
||||||
|
class CustomTextField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final int? maxLength;
|
||||||
|
final TextInputType? keyboardType;
|
||||||
|
final Map<String, dynamic>? customProperties;
|
||||||
|
final String? Function(String?)? customValidator;
|
||||||
|
|
||||||
|
CustomTextField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.maxLength,
|
||||||
|
this.keyboardType,
|
||||||
|
this.customProperties,
|
||||||
|
this.customValidator,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
// Custom validator (e.g., range checks)
|
||||||
|
if (customValidator != null) {
|
||||||
|
final err = customValidator!(value);
|
||||||
|
if (err != null) return err;
|
||||||
|
}
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
if (maxLength != null && value != null && value.length > maxLength!) {
|
||||||
|
return '$label must be less than $maxLength characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
final bool isPassword = customProperties?['isPassword'] == true;
|
||||||
|
return ModernTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint,
|
||||||
|
maxLength: maxLength,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
obscureText: isPassword,
|
||||||
|
onChanged: onChanged != null ? (_) => onChanged() : null,
|
||||||
|
validator: validator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
base_project/lib/BuilderField/shared/fields/date_field.dart
Normal file
76
base_project/lib/BuilderField/shared/fields/date_field.dart
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
|
||||||
|
/// Date input field implementation with native date picker
|
||||||
|
class DateField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final DateTime? initialDate;
|
||||||
|
final DateTime firstDate;
|
||||||
|
final DateTime lastDate;
|
||||||
|
|
||||||
|
DateField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.initialDate,
|
||||||
|
DateTime? firstDate,
|
||||||
|
DateTime? lastDate,
|
||||||
|
}) : firstDate = firstDate ?? DateTime(2000),
|
||||||
|
lastDate = lastDate ?? DateTime(2101);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return Builder(
|
||||||
|
builder: (context) {
|
||||||
|
return ModernTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint.isNotEmpty ? hint : label,
|
||||||
|
readOnly: true,
|
||||||
|
validator: validator,
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today_rounded),
|
||||||
|
onTap: () async {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: initialDate ?? now,
|
||||||
|
firstDate: firstDate,
|
||||||
|
lastDate: lastDate,
|
||||||
|
helpText: label,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
final String value = _formatDate(picked);
|
||||||
|
controller.text = value;
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
String two(int n) => n < 10 ? '0$n' : '$n';
|
||||||
|
return '${date.year}-${two(date.month)}-${two(date.day)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No global navigator key required; Builder provides local context
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
|
||||||
|
/// DateTime input field implementation with native date & time pickers
|
||||||
|
class DateTimeField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final DateTime? initialDateTime;
|
||||||
|
final DateTime firstDate;
|
||||||
|
final DateTime lastDate;
|
||||||
|
|
||||||
|
DateTimeField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.initialDateTime,
|
||||||
|
DateTime? firstDate,
|
||||||
|
DateTime? lastDate,
|
||||||
|
}) : firstDate = firstDate ?? DateTime(2000),
|
||||||
|
lastDate = lastDate ?? DateTime(2101);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return Builder(
|
||||||
|
builder: (context) {
|
||||||
|
return ModernTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint.isNotEmpty ? hint : label,
|
||||||
|
readOnly: true,
|
||||||
|
validator: validator,
|
||||||
|
suffixIcon: const Icon(Icons.access_time_rounded),
|
||||||
|
onTap: () async {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final datePicked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: (initialDateTime ?? now),
|
||||||
|
firstDate: firstDate,
|
||||||
|
lastDate: lastDate,
|
||||||
|
helpText: label,
|
||||||
|
);
|
||||||
|
if (datePicked == null) return;
|
||||||
|
final timePicked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(initialDateTime ?? now),
|
||||||
|
helpText: label,
|
||||||
|
);
|
||||||
|
if (timePicked == null) return;
|
||||||
|
final combined = DateTime(
|
||||||
|
datePicked.year,
|
||||||
|
datePicked.month,
|
||||||
|
datePicked.day,
|
||||||
|
timePicked.hour,
|
||||||
|
timePicked.minute,
|
||||||
|
);
|
||||||
|
controller.text = _formatDateTime(combined);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateTime(DateTime dt) {
|
||||||
|
String two(int n) => n < 10 ? '0$n' : '$n';
|
||||||
|
return '${dt.year}-${two(dt.month)}-${two(dt.day)} ${two(dt.hour)}:${two(dt.minute)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
// import 'package:flutter/material.dart';
|
||||||
|
// import 'base_field.dart';
|
||||||
|
// import '../../../Reuseable/reusable_dropdown_field.dart';
|
||||||
|
|
||||||
|
// /// Dropdown selection field implementation
|
||||||
|
// class DropdownField extends BaseField {
|
||||||
|
// final String fieldKey;
|
||||||
|
// final String label;
|
||||||
|
// final String hint;
|
||||||
|
// final bool isRequired;
|
||||||
|
// final List<Map<String, dynamic>> options;
|
||||||
|
// final String valueKey;
|
||||||
|
// final String displayKey;
|
||||||
|
|
||||||
|
// DropdownField({
|
||||||
|
// required this.fieldKey,
|
||||||
|
// required this.label,
|
||||||
|
// required this.hint,
|
||||||
|
// this.isRequired = false,
|
||||||
|
// required this.options,
|
||||||
|
// this.valueKey = 'id',
|
||||||
|
// this.displayKey = 'name',
|
||||||
|
// });
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// String? Function(String?)? get validator => (value) {
|
||||||
|
// if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
// return '$label is required';
|
||||||
|
// }
|
||||||
|
// return null;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// Widget buildField({
|
||||||
|
// required TextEditingController controller,
|
||||||
|
// required ColorScheme colorScheme,
|
||||||
|
// VoidCallback? onChanged,
|
||||||
|
// }) {
|
||||||
|
// return ReusableDropdownField(
|
||||||
|
// controller: controller,
|
||||||
|
// label: label,
|
||||||
|
// hint: hint,
|
||||||
|
// items: options,
|
||||||
|
// valueKey: valueKey,
|
||||||
|
// displayKey: displayKey,
|
||||||
|
// onChanged: onChanged != null ? (_) => onChanged() : null,
|
||||||
|
// validator: validator,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
48
base_project/lib/BuilderField/shared/fields/email_field.dart
Normal file
48
base_project/lib/BuilderField/shared/fields/email_field.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
|
||||||
|
/// Email input field implementation
|
||||||
|
class EmailField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
EmailField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||||
|
if (!emailRegex.hasMatch(value)) {
|
||||||
|
return 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return ModernTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
prefixIcon: const Icon(Icons.email_outlined),
|
||||||
|
onChanged: onChanged != null ? (_) => onChanged() : null,
|
||||||
|
validator: validator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../utils/entity_field_store.dart';
|
||||||
|
|
||||||
|
/// Multi-file upload field; stores files in EntityFieldStore under fieldKey
|
||||||
|
class FileUploadField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
FileUploadField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = 'Select files',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (_) {
|
||||||
|
final items =
|
||||||
|
EntityFieldStore.instance.get<List<UploadItem>>(fieldKey) ??
|
||||||
|
const [];
|
||||||
|
if (isRequired && items.isEmpty) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
final items = EntityFieldStore.instance.get<List<UploadItem>>(fieldKey) ??
|
||||||
|
<UploadItem>[];
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final result =
|
||||||
|
await FilePicker.platform.pickFiles(allowMultiple: true);
|
||||||
|
if (result != null) {
|
||||||
|
final List<UploadItem> updated = List<UploadItem>.from(items);
|
||||||
|
for (final f in result.files) {
|
||||||
|
final Uint8List? bytes = f.bytes;
|
||||||
|
if (bytes == null) continue;
|
||||||
|
updated.add(UploadItem(fileName: f.name, bytes: bytes));
|
||||||
|
}
|
||||||
|
EntityFieldStore.instance.set(fieldKey, updated);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.attach_file_rounded),
|
||||||
|
label: const Text('Add Files'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...items.map((u) => ListTile(
|
||||||
|
leading: const Icon(Icons.insert_drive_file_rounded),
|
||||||
|
title: Text(u.fileName, overflow: TextOverflow.ellipsis),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
final updated = List<UploadItem>.from(items)..remove(u);
|
||||||
|
EntityFieldStore.instance.set(fieldKey, updated);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../utils/entity_field_store.dart';
|
||||||
|
|
||||||
|
class ImageUploadField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
ImageUploadField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = 'Select images',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (_) {
|
||||||
|
final items =
|
||||||
|
EntityFieldStore.instance.get<List<UploadItem>>(fieldKey) ??
|
||||||
|
const [];
|
||||||
|
if (isRequired && items.isEmpty) return '$label is required';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
final items = EntityFieldStore.instance.get<List<UploadItem>>(fieldKey) ??
|
||||||
|
<UploadItem>[];
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final ImagePicker picker = ImagePicker();
|
||||||
|
final XFile? picked =
|
||||||
|
await picker.pickImage(source: ImageSource.gallery);
|
||||||
|
if (picked != null) {
|
||||||
|
final Uint8List bytes = await picked.readAsBytes();
|
||||||
|
final updated = List<UploadItem>.from(items)
|
||||||
|
..add(UploadItem(fileName: picked.name, bytes: bytes));
|
||||||
|
EntityFieldStore.instance.set(fieldKey, updated);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.image_rounded),
|
||||||
|
label: const Text('Add Image'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: items
|
||||||
|
.map((u) => Stack(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.memory(u.bytes,
|
||||||
|
width: 72, height: 72, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
final updated = List<UploadItem>.from(items)
|
||||||
|
..remove(u);
|
||||||
|
EntityFieldStore.instance.set(fieldKey, updated);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.close_rounded,
|
||||||
|
color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
|
||||||
|
/// Number input field implementation
|
||||||
|
class NumberField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final double? min;
|
||||||
|
final double? max;
|
||||||
|
final int? decimalPlaces;
|
||||||
|
|
||||||
|
NumberField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.min,
|
||||||
|
this.max,
|
||||||
|
this.decimalPlaces,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
final number = double.tryParse(value);
|
||||||
|
if (number == null) {
|
||||||
|
return 'Please enter a valid number';
|
||||||
|
}
|
||||||
|
if (min != null && number < min!) {
|
||||||
|
return '$label must be at least $min';
|
||||||
|
}
|
||||||
|
if (max != null && number > max!) {
|
||||||
|
return '$label must be at most $max';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return ModernTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint,
|
||||||
|
keyboardType: TextInputType.numberWithOptions(
|
||||||
|
decimal: decimalPlaces != null && decimalPlaces! > 0,
|
||||||
|
),
|
||||||
|
onChanged: onChanged != null ? (_) => onChanged() : null,
|
||||||
|
validator: validator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
|
||||||
|
/// Reusable password field supporting dynamic pairing with confirm password
|
||||||
|
/// via a shared groupId. Use two instances with the same groupId, one with
|
||||||
|
/// isConfirm=true. EntityForm will handle cross-field validation and exclude
|
||||||
|
/// confirm entries from submission.
|
||||||
|
class PasswordField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final int? maxLength;
|
||||||
|
final String? groupId; // Pair password/confirm dynamically
|
||||||
|
final bool isConfirm; // Mark this instance as a confirm field
|
||||||
|
|
||||||
|
PasswordField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.maxLength,
|
||||||
|
this.groupId,
|
||||||
|
this.isConfirm = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
if (maxLength != null && value != null && value.length > maxLength!) {
|
||||||
|
return '$label must be less than $maxLength characters';
|
||||||
|
}
|
||||||
|
// Cross-field match is handled centrally in EntityForm using groupId
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return ModernTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint,
|
||||||
|
maxLength: maxLength,
|
||||||
|
keyboardType: TextInputType.visiblePassword,
|
||||||
|
obscureText: true,
|
||||||
|
onChanged: onChanged != null ? (_) => onChanged() : null,
|
||||||
|
validator: validator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? get customProperties => {
|
||||||
|
if (groupId != null) 'groupId': groupId,
|
||||||
|
'isConfirm': isConfirm,
|
||||||
|
'isPassword': true,
|
||||||
|
};
|
||||||
|
}
|
||||||
51
base_project/lib/BuilderField/shared/fields/phone_field.dart
Normal file
51
base_project/lib/BuilderField/shared/fields/phone_field.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
|
||||||
|
/// Phone number input field implementation
|
||||||
|
class PhoneField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final String? countryCode;
|
||||||
|
|
||||||
|
PhoneField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.countryCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
// Phone number validation
|
||||||
|
final phoneRegex = RegExp(r'^\+?[\d\s\-\(\)]+$');
|
||||||
|
if (!phoneRegex.hasMatch(value)) {
|
||||||
|
return 'Please enter a valid phone number';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return ModernTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
prefixIcon: countryCode != null ? Text(countryCode!) : null,
|
||||||
|
onChanged: onChanged != null ? (_) => onChanged() : null,
|
||||||
|
validator: validator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
base_project/lib/BuilderField/shared/fields/radio_field.dart
Normal file
65
base_project/lib/BuilderField/shared/fields/radio_field.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
|
||||||
|
class RadioField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
final List<String> options; // e.g., ['yes','no']
|
||||||
|
|
||||||
|
RadioField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = '',
|
||||||
|
this.isRequired = false,
|
||||||
|
required this.options,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
if (value != null && value.isNotEmpty && !options.contains(value)) {
|
||||||
|
return 'Invalid selection';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
final String current = controller.text;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
children: options.map((opt) {
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(opt),
|
||||||
|
selected: current == opt,
|
||||||
|
selectedColor: colorScheme.primary.withOpacity(0.2),
|
||||||
|
onSelected: (_) {
|
||||||
|
controller.text = opt;
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
|
||||||
|
/// Boolean switch field implementation
|
||||||
|
class SwitchField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
SwitchField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = '',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (_) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
final bool current = (controller.text.toLowerCase() == 'true');
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: current,
|
||||||
|
activeColor: colorScheme.onPrimary,
|
||||||
|
activeTrackColor: colorScheme.primary,
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.text = value.toString();
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
base_project/lib/BuilderField/shared/fields/url_field.dart
Normal file
48
base_project/lib/BuilderField/shared/fields/url_field.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../../../shared/widgets/inputs/modern_text_field.dart';
|
||||||
|
|
||||||
|
/// URL input field implementation using ModernTextField
|
||||||
|
class UrlField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
UrlField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (value) {
|
||||||
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
final uri = Uri.tryParse(value);
|
||||||
|
if (uri == null || !(uri.hasScheme && uri.hasAuthority)) {
|
||||||
|
return 'Please enter a valid URL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
return ModernTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
prefixIcon: const Icon(Icons.link_rounded),
|
||||||
|
validator: validator,
|
||||||
|
onChanged: onChanged != null ? (_) => onChanged() : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'base_field.dart';
|
||||||
|
import '../utils/entity_field_store.dart';
|
||||||
|
|
||||||
|
class VideoUploadField extends BaseField {
|
||||||
|
final String fieldKey;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
VideoUploadField({
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.label,
|
||||||
|
this.hint = 'Select video file',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? Function(String?)? get validator => (_) {
|
||||||
|
final items =
|
||||||
|
EntityFieldStore.instance.get<List<UploadItem>>(fieldKey) ??
|
||||||
|
const [];
|
||||||
|
if (isRequired && items.isEmpty) return '$label is required';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
final items = EntityFieldStore.instance.get<List<UploadItem>>(fieldKey) ??
|
||||||
|
<UploadItem>[];
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final result =
|
||||||
|
await FilePicker.platform.pickFiles(type: FileType.video);
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final f = result.files.single;
|
||||||
|
final Uint8List? bytes = f.bytes ??
|
||||||
|
(f.path != null
|
||||||
|
? await File(f.path!).readAsBytes()
|
||||||
|
: null);
|
||||||
|
if (bytes != null) {
|
||||||
|
final updated = List<UploadItem>.from(items)
|
||||||
|
..add(UploadItem(fileName: f.name, bytes: bytes));
|
||||||
|
EntityFieldStore.instance.set(fieldKey, updated);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.video_library_rounded),
|
||||||
|
label: const Text('Add Video'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...items.map((u) => ListTile(
|
||||||
|
leading: const Icon(Icons.videocam_rounded),
|
||||||
|
title: Text(u.fileName, overflow: TextOverflow.ellipsis),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
final updated = List<UploadItem>.from(items)..remove(u);
|
||||||
|
EntityFieldStore.instance.set(fieldKey, updated);
|
||||||
|
if (onChanged != null) onChanged();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
492
base_project/lib/BuilderField/shared/ui/entity_card.dart
Normal file
492
base_project/lib/BuilderField/shared/ui/entity_card.dart
Normal file
@ -0,0 +1,492 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../core/constants/ui_constants.dart';
|
||||||
|
import '../../../core/providers/dynamic_theme_provider.dart';
|
||||||
|
|
||||||
|
/// Reusable card component for displaying entity data
|
||||||
|
/// Uses dynamic theme and provides consistent styling across all entities
|
||||||
|
class EntityCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> entity;
|
||||||
|
final Function(Map<String, dynamic>) onEdit;
|
||||||
|
final Function(Map<String, dynamic>) onDelete;
|
||||||
|
final Function(Map<String, dynamic>)? onTap;
|
||||||
|
final List<Map<String, dynamic>> displayFields;
|
||||||
|
|
||||||
|
const EntityCard({
|
||||||
|
super.key,
|
||||||
|
required this.entity,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
this.onTap,
|
||||||
|
this.displayFields = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<DynamicThemeProvider>(
|
||||||
|
builder: (context, dynamicThemeProvider, child) {
|
||||||
|
final colorScheme = dynamicThemeProvider.getCurrentColorScheme(
|
||||||
|
Theme.of(context).brightness == Brightness.dark,
|
||||||
|
);
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
final isMobile = size.width < 600;
|
||||||
|
final isTablet = size.width >= 600 && size.width < 1024;
|
||||||
|
final double maxCardHeight = isMobile
|
||||||
|
? size.height * 0.34
|
||||||
|
: isTablet
|
||||||
|
? size.height * 0.38
|
||||||
|
: size.height * 0.42;
|
||||||
|
final double horizontalPadding = isMobile
|
||||||
|
? UIConstants.spacing16
|
||||||
|
: isTablet
|
||||||
|
? UIConstants.spacing20
|
||||||
|
: UIConstants.spacing24;
|
||||||
|
final double verticalPadding = isMobile
|
||||||
|
? UIConstants.spacing16
|
||||||
|
: isTablet
|
||||||
|
? UIConstants.spacing16
|
||||||
|
: UIConstants.spacing20;
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: 160,
|
||||||
|
maxHeight: maxCardHeight.clamp(160.0, size.height * 0.8),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface,
|
||||||
|
colorScheme.surface.withOpacity(0.95),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withOpacity(0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withOpacity(0.08),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap != null ? () => onTap!(entity) : null,
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: horizontalPadding,
|
||||||
|
vertical: verticalPadding,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header with avatar and action buttons
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildAvatar(colorScheme,
|
||||||
|
isMobile: isMobile, isTablet: isTablet),
|
||||||
|
const Spacer(),
|
||||||
|
_buildActionButtons(colorScheme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
|
||||||
|
// Dynamic field display (scrollable to avoid overflow)
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: _buildFieldDisplays(colorScheme),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
// Status indicator pinned at bottom
|
||||||
|
_buildStatusIndicator(colorScheme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar(ColorScheme colorScheme,
|
||||||
|
{required bool isMobile, required bool isTablet}) {
|
||||||
|
final double side = isMobile
|
||||||
|
? 44
|
||||||
|
: isTablet
|
||||||
|
? 50
|
||||||
|
: 56;
|
||||||
|
final double icon = isMobile
|
||||||
|
? 20
|
||||||
|
: isTablet
|
||||||
|
? 22
|
||||||
|
: 24;
|
||||||
|
return Container(
|
||||||
|
width: side,
|
||||||
|
height: side,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.primary,
|
||||||
|
colorScheme.primary.withOpacity(0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withOpacity(0.3),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: icon,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButtons(ColorScheme colorScheme) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// Edit Button
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius8),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => onEdit(entity),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.edit_rounded,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
tooltip: 'Edit',
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
// Delete Button
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius8),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.error.withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => onDelete(entity),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete_rounded,
|
||||||
|
color: colorScheme.error,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildFieldDisplays(ColorScheme colorScheme) {
|
||||||
|
// Dynamic field display based on entity data
|
||||||
|
final displayFields = _getDisplayFields();
|
||||||
|
|
||||||
|
return displayFields.map((field) {
|
||||||
|
final value = entity[field['key']]?.toString() ?? '';
|
||||||
|
if (value.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||||
|
child: _buildFieldDisplay(field, value, colorScheme),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFieldDisplay(
|
||||||
|
Map<String, dynamic> field, String value, ColorScheme colorScheme) {
|
||||||
|
switch (field['type']) {
|
||||||
|
case 'phone':
|
||||||
|
return _buildPhoneDisplay(value, colorScheme);
|
||||||
|
case 'email':
|
||||||
|
return _buildEmailDisplay(value, colorScheme);
|
||||||
|
case 'number':
|
||||||
|
return _buildNumberDisplay(field['label'], value, colorScheme);
|
||||||
|
default:
|
||||||
|
return _buildTextDisplay(field['label'], value, colorScheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhoneDisplay(String value, ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing12,
|
||||||
|
vertical: UIConstants.spacing8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius8),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.phone_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.8),
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmailDisplay(String value, ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing12,
|
||||||
|
vertical: UIConstants.spacing8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius8),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.secondary.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.email_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.secondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.8),
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextDisplay(
|
||||||
|
String label, String value, ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (label.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNumberDisplay(
|
||||||
|
String label, String value, ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing12,
|
||||||
|
vertical: UIConstants.spacing8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius8),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.tertiary.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.numbers_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.8),
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusIndicator(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing8,
|
||||||
|
vertical: UIConstants.spacing4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing6),
|
||||||
|
Text(
|
||||||
|
'Active',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _getDisplayFields() {
|
||||||
|
// Use provided displayFields or default fields
|
||||||
|
if (displayFields.isNotEmpty) {
|
||||||
|
return displayFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fields - try to get meaningful fields from entity
|
||||||
|
final defaultFields = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
// Try to find primary fields
|
||||||
|
final primaryFields = ['name', 'title', 'id'];
|
||||||
|
for (final field in primaryFields) {
|
||||||
|
if (entity.containsKey(field) && entity[field] != null) {
|
||||||
|
defaultFields.add({'key': field, 'label': '', 'type': 'text'});
|
||||||
|
break; // Only add first found primary field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other non-empty fields (max 3 for card display)
|
||||||
|
int addedCount = 0;
|
||||||
|
for (final entry in entity.entries) {
|
||||||
|
if (entry.value != null &&
|
||||||
|
entry.value.toString().isNotEmpty &&
|
||||||
|
!primaryFields.contains(entry.key) &&
|
||||||
|
addedCount < 2) {
|
||||||
|
defaultFields.add({
|
||||||
|
'key': entry.key,
|
||||||
|
'label': _formatFieldLabel(entry.key),
|
||||||
|
'type': _getFieldType(entry.value)
|
||||||
|
});
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFieldLabel(String key) {
|
||||||
|
return key
|
||||||
|
.split('_')
|
||||||
|
.map((word) => word[0].toUpperCase() + word.substring(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getFieldType(dynamic value) {
|
||||||
|
if (value is num) return 'number';
|
||||||
|
if (value.toString().contains('@')) return 'email';
|
||||||
|
if (RegExp(r'^\+?[\d\s\-\(\)]+$').hasMatch(value.toString()))
|
||||||
|
return 'phone';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
584
base_project/lib/BuilderField/shared/ui/entity_details.dart
Normal file
584
base_project/lib/BuilderField/shared/ui/entity_details.dart
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../core/providers/dynamic_theme_provider.dart';
|
||||||
|
import '../../../core/constants/ui_constants.dart';
|
||||||
|
import '../../../shared/widgets/app_bar/modern_app_bar.dart';
|
||||||
|
import '../../../shared/widgets/buttons/modern_button.dart';
|
||||||
|
|
||||||
|
/// Generic details component for displaying entity information
|
||||||
|
/// This component works with any entity type and provides consistent UI/UX
|
||||||
|
class EntityDetails extends StatefulWidget {
|
||||||
|
final Map<String, dynamic> entity;
|
||||||
|
final Function(Map<String, dynamic>) onEdit;
|
||||||
|
final Function(Map<String, dynamic>) onDelete;
|
||||||
|
final String title;
|
||||||
|
final List<Map<String, dynamic>> displayFields;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const EntityDetails({
|
||||||
|
super.key,
|
||||||
|
required this.entity,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
required this.title,
|
||||||
|
this.displayFields = const [],
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EntityDetails> createState() => _EntityDetailsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntityDetailsState extends State<EntityDetails>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
late Animation<Offset> _slideAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeAnimations() {
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
|
||||||
|
));
|
||||||
|
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.3),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<DynamicThemeProvider>(
|
||||||
|
builder: (context, dynamicThemeProvider, child) {
|
||||||
|
final colorScheme = dynamicThemeProvider.getCurrentColorScheme(
|
||||||
|
Theme.of(context).brightness == Brightness.dark,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
appBar: ModernAppBar(
|
||||||
|
title: widget.title,
|
||||||
|
showBackButton: true,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => widget.onEdit(widget.entity),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.edit_rounded,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
tooltip: 'Edit',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _showDeleteDialog(colorScheme),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete_rounded,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: widget.isLoading
|
||||||
|
? _buildLoadingState(colorScheme)
|
||||||
|
: _buildContent(colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingState(ColorScheme colorScheme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
Text(
|
||||||
|
'Loading details...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(ColorScheme colorScheme) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: UIConstants.getResponsivePadding(
|
||||||
|
context,
|
||||||
|
mobile: UIConstants.screenPaddingMedium,
|
||||||
|
tablet: UIConstants.screenPaddingLarge,
|
||||||
|
desktop: UIConstants.screenPaddingLarge,
|
||||||
|
),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _slideAnimation,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header Card
|
||||||
|
_buildHeaderCard(colorScheme),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
|
// Details Section
|
||||||
|
_buildDetailsSection(colorScheme),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
_buildActionButtons(colorScheme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderCard(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.primary,
|
||||||
|
colorScheme.primary.withOpacity(0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withOpacity(0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: UIConstants.cardPaddingLarge,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Avatar and Title
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onPrimary.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius16),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: 30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_getPrimaryFieldValue(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing4),
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimary.withOpacity(0.8),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
|
||||||
|
// Status Badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing12,
|
||||||
|
vertical: UIConstants.spacing6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onPrimary.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
'Active',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailsSection(ColorScheme colorScheme) {
|
||||||
|
final displayFields = widget.displayFields.isNotEmpty
|
||||||
|
? widget.displayFields
|
||||||
|
: _getDefaultDisplayFields();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface,
|
||||||
|
colorScheme.surface.withOpacity(0.95),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withOpacity(0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withOpacity(0.08),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: UIConstants.cardPaddingLarge,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Details',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing20),
|
||||||
|
|
||||||
|
// Field Details
|
||||||
|
...displayFields
|
||||||
|
.map((field) => _buildFieldDetail(field, colorScheme)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFieldDetail(
|
||||||
|
Map<String, dynamic> field, ColorScheme colorScheme) {
|
||||||
|
final value = widget.entity[field['key']]?.toString() ?? '';
|
||||||
|
if (value.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: UIConstants.spacing16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
field['label'] ?? field['key'],
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing4),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing16,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getFieldBackgroundColor(field['type'], colorScheme),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _getFieldBorderColor(field['type'], colorScheme),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getFieldIcon(field['type']),
|
||||||
|
size: 18,
|
||||||
|
color: _getFieldIconColor(field['type'], colorScheme),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButtons(ColorScheme colorScheme) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ModernButton(
|
||||||
|
text: 'Edit',
|
||||||
|
type: ModernButtonType.secondary,
|
||||||
|
size: ModernButtonSize.large,
|
||||||
|
onPressed: () => widget.onEdit(widget.entity),
|
||||||
|
icon: Icon(Icons.edit_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing16),
|
||||||
|
Expanded(
|
||||||
|
child: ModernButton(
|
||||||
|
text: 'Delete',
|
||||||
|
type: ModernButtonType.danger,
|
||||||
|
size: ModernButtonSize.large,
|
||||||
|
onPressed: () => _showDeleteDialog(colorScheme),
|
||||||
|
icon: Icon(Icons.delete_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDeleteDialog(ColorScheme colorScheme) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Delete ${widget.title}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
'Are you sure you want to delete this ${widget.title.toLowerCase()}? This action cannot be undone.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(
|
||||||
|
'Cancel',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onDelete(widget.entity);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.error,
|
||||||
|
foregroundColor: colorScheme.onError,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Delete',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPrimaryFieldValue() {
|
||||||
|
// Try to get the primary field value (usually 'name' or first field)
|
||||||
|
final primaryFields = ['name', 'title', 'id'];
|
||||||
|
for (final field in primaryFields) {
|
||||||
|
final value = widget.entity[field]?.toString();
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no primary field found, return first non-empty field
|
||||||
|
for (final entry in widget.entity.entries) {
|
||||||
|
if (entry.value != null && entry.value.toString().isNotEmpty) {
|
||||||
|
return entry.value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _getDefaultDisplayFields() {
|
||||||
|
return widget.entity.entries
|
||||||
|
.where(
|
||||||
|
(entry) => entry.value != null && entry.value.toString().isNotEmpty)
|
||||||
|
.map((entry) => {
|
||||||
|
'key': entry.key,
|
||||||
|
'label': _formatFieldLabel(entry.key),
|
||||||
|
'type': _getFieldType(entry.value),
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFieldLabel(String key) {
|
||||||
|
return key
|
||||||
|
.split('_')
|
||||||
|
.map((word) => word[0].toUpperCase() + word.substring(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getFieldType(dynamic value) {
|
||||||
|
if (value is num) return 'number';
|
||||||
|
if (value.toString().contains('@')) return 'email';
|
||||||
|
if (RegExp(r'^\+?[\d\s\-\(\)]+$').hasMatch(value.toString()))
|
||||||
|
return 'phone';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getFieldBackgroundColor(String type, ColorScheme colorScheme) {
|
||||||
|
switch (type) {
|
||||||
|
case 'phone':
|
||||||
|
return colorScheme.primaryContainer.withOpacity(0.2);
|
||||||
|
case 'email':
|
||||||
|
return colorScheme.secondaryContainer.withOpacity(0.2);
|
||||||
|
case 'number':
|
||||||
|
return colorScheme.tertiaryContainer.withOpacity(0.2);
|
||||||
|
default:
|
||||||
|
return colorScheme.surfaceVariant.withOpacity(0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getFieldBorderColor(String type, ColorScheme colorScheme) {
|
||||||
|
switch (type) {
|
||||||
|
case 'phone':
|
||||||
|
return colorScheme.primary.withOpacity(0.1);
|
||||||
|
case 'email':
|
||||||
|
return colorScheme.secondary.withOpacity(0.1);
|
||||||
|
case 'number':
|
||||||
|
return colorScheme.tertiary.withOpacity(0.1);
|
||||||
|
default:
|
||||||
|
return colorScheme.outline.withOpacity(0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getFieldIcon(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'phone':
|
||||||
|
return Icons.phone_rounded;
|
||||||
|
case 'email':
|
||||||
|
return Icons.email_rounded;
|
||||||
|
case 'number':
|
||||||
|
return Icons.numbers_rounded;
|
||||||
|
default:
|
||||||
|
return Icons.text_fields_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getFieldIconColor(String type, ColorScheme colorScheme) {
|
||||||
|
switch (type) {
|
||||||
|
case 'phone':
|
||||||
|
return colorScheme.primary;
|
||||||
|
case 'email':
|
||||||
|
return colorScheme.secondary;
|
||||||
|
case 'number':
|
||||||
|
return colorScheme.tertiary;
|
||||||
|
default:
|
||||||
|
return colorScheme.onSurface.withOpacity(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
base_project/lib/BuilderField/shared/ui/entity_form.dart
Normal file
146
base_project/lib/BuilderField/shared/ui/entity_form.dart
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import 'package:base_project/core/providers/dynamic_theme_provider.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../fields/base_field.dart';
|
||||||
|
import '../../../shared/widgets/buttons/modern_button.dart';
|
||||||
|
import '../../../core/constants/ui_constants.dart';
|
||||||
|
|
||||||
|
/// Reusable form component that dynamically renders fields based on field definitions
|
||||||
|
/// This allows UI to be independent of field types and enables reusability
|
||||||
|
class EntityForm extends StatefulWidget {
|
||||||
|
final List<BaseField> fields;
|
||||||
|
final Map<String, dynamic>? initialData;
|
||||||
|
final Function(Map<String, dynamic>) onSubmit;
|
||||||
|
final String submitButtonText;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const EntityForm({
|
||||||
|
super.key,
|
||||||
|
required this.fields,
|
||||||
|
this.initialData,
|
||||||
|
required this.onSubmit,
|
||||||
|
this.submitButtonText = 'Submit',
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EntityForm> createState() => _EntityFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntityFormState extends State<EntityForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final Map<String, TextEditingController> _controllers = {};
|
||||||
|
final Map<String, BaseField> _fieldByKey = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeControllers();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeControllers() {
|
||||||
|
for (final field in widget.fields) {
|
||||||
|
_controllers[field.fieldKey] = TextEditingController(
|
||||||
|
text: widget.initialData?[field.fieldKey]?.toString() ?? '',
|
||||||
|
);
|
||||||
|
_fieldByKey[field.fieldKey] = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final controller in _controllers.values) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<DynamicThemeProvider>(
|
||||||
|
builder: (context, dynamicThemeProvider, child) {
|
||||||
|
final colorScheme = dynamicThemeProvider.getCurrentColorScheme(
|
||||||
|
Theme.of(context).brightness == Brightness.dark,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Dynamic field rendering
|
||||||
|
...widget.fields.map((field) => Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(bottom: UIConstants.spacing16),
|
||||||
|
child: field.buildField(
|
||||||
|
controller: _controllers[field.fieldKey]!,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onChanged: () => setState(() {}),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
|
// Submit button
|
||||||
|
ModernButton(
|
||||||
|
text: widget.submitButtonText,
|
||||||
|
type: ModernButtonType.primary,
|
||||||
|
size: ModernButtonSize.large,
|
||||||
|
isLoading: widget.isLoading,
|
||||||
|
onPressed: widget.isLoading ? null : _handleSubmit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSubmit() {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
// Dynamic cross-field match for any password-confirm group
|
||||||
|
final Map<String, String> passwordByGroup = {};
|
||||||
|
final Map<String, String> confirmByGroup = {};
|
||||||
|
for (final entry in _controllers.entries) {
|
||||||
|
final key = entry.key;
|
||||||
|
final field = _fieldByKey[key];
|
||||||
|
final props = field?.customProperties ?? const {};
|
||||||
|
final isPassword = props['isPassword'] == true;
|
||||||
|
if (!isPassword) continue;
|
||||||
|
final String? groupId = props['groupId'];
|
||||||
|
if (groupId == null) continue;
|
||||||
|
final bool isConfirm = props['isConfirm'] == true;
|
||||||
|
if (isConfirm) {
|
||||||
|
confirmByGroup[groupId] = entry.value.text;
|
||||||
|
} else {
|
||||||
|
passwordByGroup[groupId] = entry.value.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final gid in confirmByGroup.keys) {
|
||||||
|
final confirm = confirmByGroup[gid] ?? '';
|
||||||
|
final pass = passwordByGroup[gid] ?? '';
|
||||||
|
if (confirm.isNotEmpty && confirm != pass) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Passwords do not match')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final formData = <String, dynamic>{};
|
||||||
|
for (final entry in _controllers.entries) {
|
||||||
|
final key = entry.key;
|
||||||
|
final field = _fieldByKey[key];
|
||||||
|
final value = entry.value.text.trim();
|
||||||
|
|
||||||
|
// Skip confirm entries for any password group
|
||||||
|
final props = field?.customProperties ?? const {};
|
||||||
|
final bool isPassword = props['isPassword'] == true;
|
||||||
|
final bool isConfirm = props['isConfirm'] == true;
|
||||||
|
if (isPassword && isConfirm) continue;
|
||||||
|
|
||||||
|
formData[key] = value;
|
||||||
|
}
|
||||||
|
widget.onSubmit(formData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
476
base_project/lib/BuilderField/shared/ui/entity_list.dart
Normal file
476
base_project/lib/BuilderField/shared/ui/entity_list.dart
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
import 'package:base_project/core/providers/dynamic_theme_provider.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../core/constants/ui_constants.dart';
|
||||||
|
import '../../../shared/widgets/app_bar/modern_app_bar.dart';
|
||||||
|
import 'entity_card.dart';
|
||||||
|
|
||||||
|
/// Generic list component for displaying entities with search, pagination, and refresh
|
||||||
|
/// This component works with any entity type and provides consistent UI/UX
|
||||||
|
class EntityList extends StatefulWidget {
|
||||||
|
final List<Map<String, dynamic>> entities;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool hasMoreData;
|
||||||
|
final String searchQuery;
|
||||||
|
final Function(String) onSearchChanged;
|
||||||
|
final Function(Map<String, dynamic>) onEdit;
|
||||||
|
final Function(Map<String, dynamic>) onDelete;
|
||||||
|
final Function(Map<String, dynamic>)? onTap;
|
||||||
|
final Function() onRefresh;
|
||||||
|
final Function() onLoadMore;
|
||||||
|
final String title;
|
||||||
|
final Function()? onAddNew;
|
||||||
|
final List<Map<String, dynamic>> displayFields;
|
||||||
|
|
||||||
|
const EntityList({
|
||||||
|
super.key,
|
||||||
|
required this.entities,
|
||||||
|
required this.isLoading,
|
||||||
|
this.errorMessage,
|
||||||
|
required this.hasMoreData,
|
||||||
|
required this.searchQuery,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
this.onTap,
|
||||||
|
required this.onRefresh,
|
||||||
|
required this.onLoadMore,
|
||||||
|
required this.title,
|
||||||
|
this.onAddNew,
|
||||||
|
this.displayFields = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EntityList> createState() => _EntityListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntityListState extends State<EntityList> with TickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeAnimations();
|
||||||
|
_searchController.text = widget.searchQuery;
|
||||||
|
_scrollController.addListener(_scrollListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeAnimations() {
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollListener() {
|
||||||
|
if (_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 200) {
|
||||||
|
if (widget.hasMoreData && !widget.isLoading) {
|
||||||
|
widget.onLoadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<DynamicThemeProvider>(
|
||||||
|
builder: (context, dynamicThemeProvider, child) {
|
||||||
|
final colorScheme = dynamicThemeProvider.getCurrentColorScheme(
|
||||||
|
Theme.of(context).brightness == Brightness.dark,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
appBar: ModernAppBar(
|
||||||
|
title: widget.title,
|
||||||
|
showBackButton: true,
|
||||||
|
actions: [
|
||||||
|
if (widget.onAddNew != null)
|
||||||
|
IconButton(
|
||||||
|
onPressed: widget.onAddNew,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.add_rounded,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
tooltip: 'Add New',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Search Bar
|
||||||
|
_buildSearchBar(colorScheme),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: _buildContent(colorScheme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
margin: UIConstants.getResponsivePadding(
|
||||||
|
context,
|
||||||
|
mobile: UIConstants.screenPaddingMedium,
|
||||||
|
tablet: UIConstants.screenPaddingLarge,
|
||||||
|
desktop: UIConstants.screenPaddingLarge,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface,
|
||||||
|
colorScheme.surface.withOpacity(0.95),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius16),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withOpacity(0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withOpacity(0.08),
|
||||||
|
blurRadius: 15,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: widget.onSearchChanged,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search ${widget.title.toLowerCase()}...',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.search_rounded,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
widget.onSearchChanged('');
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
Icons.clear_rounded,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing16,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: widget.onRefresh,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.refresh_rounded,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.8),
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(ColorScheme colorScheme) {
|
||||||
|
if (widget.isLoading && widget.entities.isEmpty) {
|
||||||
|
return _buildLoadingState(colorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.errorMessage != null && widget.entities.isEmpty) {
|
||||||
|
return _buildErrorState(colorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.entities.isEmpty) {
|
||||||
|
return _buildEmptyState(colorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildEntityGrid(colorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingState(ColorScheme colorScheme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
Text(
|
||||||
|
'Loading ${widget.title.toLowerCase()}...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorState(ColorScheme colorScheme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
color: colorScheme.error,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
Text(
|
||||||
|
'Error Loading Data',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
widget.errorMessage ?? 'Something went wrong',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: widget.onRefresh,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.refresh_rounded,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Retry',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primary,
|
||||||
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: colorScheme.primary.withOpacity(0.3),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing24,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState(ColorScheme colorScheme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.inbox_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
Text(
|
||||||
|
'No ${widget.title.toLowerCase()} found',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
widget.searchQuery.isNotEmpty
|
||||||
|
? 'Try adjusting your search terms'
|
||||||
|
: 'Start by adding your first ${widget.title.toLowerCase()}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (widget.onAddNew != null) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: widget.onAddNew,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.add_rounded,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Add ${widget.title}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primary,
|
||||||
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: colorScheme.primary.withOpacity(0.3),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing24,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEntityGrid(ColorScheme colorScheme) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => widget.onRefresh(),
|
||||||
|
color: colorScheme.primary,
|
||||||
|
child: GridView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: UIConstants.getResponsivePadding(
|
||||||
|
context,
|
||||||
|
mobile: UIConstants.screenPaddingMedium,
|
||||||
|
tablet: UIConstants.screenPaddingLarge,
|
||||||
|
desktop: UIConstants.screenPaddingLarge,
|
||||||
|
),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: UIConstants.getResponsiveInt(
|
||||||
|
context,
|
||||||
|
mobile: 1,
|
||||||
|
tablet: 2,
|
||||||
|
desktop: 3,
|
||||||
|
),
|
||||||
|
childAspectRatio: 0.85,
|
||||||
|
crossAxisSpacing: UIConstants.spacing16,
|
||||||
|
mainAxisSpacing: UIConstants.spacing16,
|
||||||
|
),
|
||||||
|
itemCount: widget.entities.length + (widget.isLoading ? 1 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index < widget.entities.length) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _animationController,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, 20 * (1 - _animationController.value)),
|
||||||
|
child: EntityCard(
|
||||||
|
entity: widget.entities[index],
|
||||||
|
onEdit: widget.onEdit,
|
||||||
|
onDelete: widget.onDelete,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
displayFields: widget.displayFields,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return _buildLoadingCard(colorScheme);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingCard(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface,
|
||||||
|
colorScheme.surface.withOpacity(0.95),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withOpacity(0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
608
base_project/lib/BuilderField/shared/ui/entity_screens.dart
Normal file
608
base_project/lib/BuilderField/shared/ui/entity_screens.dart
Normal file
@ -0,0 +1,608 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../core/providers/dynamic_theme_provider.dart';
|
||||||
|
import '../../../core/constants/ui_constants.dart';
|
||||||
|
import '../../../shared/widgets/app_bar/modern_app_bar.dart';
|
||||||
|
import '../../../shared/widgets/buttons/modern_button.dart';
|
||||||
|
import '../fields/base_field.dart';
|
||||||
|
import 'entity_form.dart';
|
||||||
|
|
||||||
|
/// Generic CRUD screens for entities
|
||||||
|
/// This provides consistent create and update screens for all entity types
|
||||||
|
|
||||||
|
/// Generic Create Entity Screen
|
||||||
|
class EntityCreateScreen extends StatefulWidget {
|
||||||
|
final List<BaseField> fields;
|
||||||
|
final Function(Map<String, dynamic>) onSubmit;
|
||||||
|
final String title;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const EntityCreateScreen({
|
||||||
|
super.key,
|
||||||
|
required this.fields,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.title,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EntityCreateScreen> createState() => _EntityCreateScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntityCreateScreenState extends State<EntityCreateScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
late Animation<Offset> _slideAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeAnimations() {
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
|
||||||
|
));
|
||||||
|
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.3),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<DynamicThemeProvider>(
|
||||||
|
builder: (context, dynamicThemeProvider, child) {
|
||||||
|
final colorScheme = dynamicThemeProvider.getCurrentColorScheme(
|
||||||
|
Theme.of(context).brightness == Brightness.dark,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
appBar: ModernAppBar(
|
||||||
|
title: 'Create ${widget.title}',
|
||||||
|
showBackButton: true,
|
||||||
|
),
|
||||||
|
body: widget.isLoading
|
||||||
|
? _buildLoadingState(colorScheme)
|
||||||
|
: _buildContent(colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingState(ColorScheme colorScheme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
Text(
|
||||||
|
'Creating ${widget.title.toLowerCase()}...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(ColorScheme colorScheme) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: UIConstants.getResponsivePadding(
|
||||||
|
context,
|
||||||
|
mobile: UIConstants.screenPaddingMedium,
|
||||||
|
tablet: UIConstants.screenPaddingLarge,
|
||||||
|
desktop: UIConstants.screenPaddingLarge,
|
||||||
|
),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _slideAnimation,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
_buildHeader(colorScheme),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
|
// Form
|
||||||
|
_buildForm(colorScheme),
|
||||||
|
|
||||||
|
if (widget.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
_buildErrorMessage(colorScheme),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.primary,
|
||||||
|
colorScheme.primary.withOpacity(0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withOpacity(0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: UIConstants.cardPaddingLarge,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onPrimary.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.add_rounded,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Create New ${widget.title}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing4),
|
||||||
|
Text(
|
||||||
|
'Fill in the details below to create a new ${widget.title.toLowerCase()}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimary.withOpacity(0.8),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildForm(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface,
|
||||||
|
colorScheme.surface.withOpacity(0.95),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withOpacity(0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withOpacity(0.08),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: UIConstants.cardPaddingLarge,
|
||||||
|
child: EntityForm(
|
||||||
|
fields: widget.fields,
|
||||||
|
onSubmit: widget.onSubmit,
|
||||||
|
submitButtonText: 'Create ${widget.title}',
|
||||||
|
isLoading: widget.isLoading,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorMessage(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.error.withOpacity(0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
color: colorScheme.error,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.errorMessage!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic Update Entity Screen
|
||||||
|
class EntityUpdateScreen extends StatefulWidget {
|
||||||
|
final List<BaseField> fields;
|
||||||
|
final Map<String, dynamic> initialData;
|
||||||
|
final Function(Map<String, dynamic>) onSubmit;
|
||||||
|
final String title;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const EntityUpdateScreen({
|
||||||
|
super.key,
|
||||||
|
required this.fields,
|
||||||
|
required this.initialData,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.title,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EntityUpdateScreen> createState() => _EntityUpdateScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntityUpdateScreenState extends State<EntityUpdateScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
late Animation<Offset> _slideAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeAnimations() {
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
|
||||||
|
));
|
||||||
|
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.3),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<DynamicThemeProvider>(
|
||||||
|
builder: (context, dynamicThemeProvider, child) {
|
||||||
|
final colorScheme = dynamicThemeProvider.getCurrentColorScheme(
|
||||||
|
Theme.of(context).brightness == Brightness.dark,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
appBar: ModernAppBar(
|
||||||
|
title: 'Update ${widget.title}',
|
||||||
|
showBackButton: true,
|
||||||
|
),
|
||||||
|
body: widget.isLoading
|
||||||
|
? _buildLoadingState(colorScheme)
|
||||||
|
: _buildContent(colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingState(ColorScheme colorScheme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
Text(
|
||||||
|
'Updating ${widget.title.toLowerCase()}...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(ColorScheme colorScheme) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: UIConstants.getResponsivePadding(
|
||||||
|
context,
|
||||||
|
mobile: UIConstants.screenPaddingMedium,
|
||||||
|
tablet: UIConstants.screenPaddingLarge,
|
||||||
|
desktop: UIConstants.screenPaddingLarge,
|
||||||
|
),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _slideAnimation,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
_buildHeader(colorScheme),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
|
// Form
|
||||||
|
_buildForm(colorScheme),
|
||||||
|
|
||||||
|
if (widget.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
_buildErrorMessage(colorScheme),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.secondary,
|
||||||
|
colorScheme.secondary.withOpacity(0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.secondary.withOpacity(0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: UIConstants.cardPaddingLarge,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSecondary.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.edit_rounded,
|
||||||
|
color: colorScheme.onSecondary,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Update ${widget.title}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSecondary,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing4),
|
||||||
|
Text(
|
||||||
|
'Modify the details below to update this ${widget.title.toLowerCase()}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSecondary.withOpacity(0.8),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildForm(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface,
|
||||||
|
colorScheme.surface.withOpacity(0.95),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withOpacity(0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withOpacity(0.08),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: UIConstants.cardPaddingLarge,
|
||||||
|
child: EntityForm(
|
||||||
|
fields: widget.fields,
|
||||||
|
initialData: widget.initialData,
|
||||||
|
onSubmit: widget.onSubmit,
|
||||||
|
submitButtonText: 'Update ${widget.title}',
|
||||||
|
isLoading: widget.isLoading,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorMessage(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.radius12),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.error.withOpacity(0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
color: colorScheme.error,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.errorMessage!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// Temporary in-memory store for complex field data (e.g., uploads)
|
||||||
|
/// Keyed by fieldKey. Values are dynamic but expected shapes are documented.
|
||||||
|
class EntityFieldStore {
|
||||||
|
EntityFieldStore._();
|
||||||
|
static final EntityFieldStore instance = EntityFieldStore._();
|
||||||
|
|
||||||
|
final Map<String, dynamic> _data = {};
|
||||||
|
|
||||||
|
void set(String fieldKey, dynamic value) {
|
||||||
|
_data[fieldKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
T? get<T>(String fieldKey) {
|
||||||
|
final value = _data[fieldKey];
|
||||||
|
if (value is T) return value as T;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String fieldKey) {
|
||||||
|
_data.remove(fieldKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_data.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expected value shapes:
|
||||||
|
/// - For multi-file uploads: List<UploadItem>
|
||||||
|
class UploadItem {
|
||||||
|
final String fileName;
|
||||||
|
final Uint8List bytes;
|
||||||
|
|
||||||
|
UploadItem({required this.fileName, required this.bytes});
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../../../../../core/constants/ui_constants.dart';
|
import '../../../core/constants/ui_constants.dart';
|
||||||
|
|
||||||
class ModernTextField extends StatefulWidget {
|
class ModernTextField extends StatefulWidget {
|
||||||
final String? label;
|
final String? label;
|
||||||
@ -71,6 +71,7 @@ class _ModernTextFieldState extends State<ModernTextField>
|
|||||||
late Animation<double> _scaleAnimation;
|
late Animation<double> _scaleAnimation;
|
||||||
bool _isFocused = false;
|
bool _isFocused = false;
|
||||||
bool _hasError = false;
|
bool _hasError = false;
|
||||||
|
late bool _obscureText;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -79,6 +80,7 @@ class _ModernTextFieldState extends State<ModernTextField>
|
|||||||
duration: UIConstants.durationFast,
|
duration: UIConstants.durationFast,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
_obscureText = widget.obscureText;
|
||||||
|
|
||||||
_fadeAnimation = Tween<double>(
|
_fadeAnimation = Tween<double>(
|
||||||
begin: 0.0,
|
begin: 0.0,
|
||||||
@ -183,7 +185,7 @@ class _ModernTextFieldState extends State<ModernTextField>
|
|||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
focusNode: widget.focusNode,
|
focusNode: widget.focusNode,
|
||||||
keyboardType: widget.keyboardType,
|
keyboardType: widget.keyboardType,
|
||||||
obscureText: widget.obscureText,
|
obscureText: _obscureText,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
maxLines: widget.maxLines,
|
maxLines: widget.maxLines,
|
||||||
@ -229,7 +231,7 @@ class _ModernTextFieldState extends State<ModernTextField>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
suffixIcon: widget.suffixIcon != null
|
suffixIcon: (widget.suffixIcon != null)
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
right: UIConstants.spacing16),
|
right: UIConstants.spacing16),
|
||||||
@ -243,7 +245,20 @@ class _ModernTextFieldState extends State<ModernTextField>
|
|||||||
child: widget.suffixIcon!,
|
child: widget.suffixIcon!,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: (widget.obscureText
|
||||||
|
? IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureText = !_obscureText;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
_obscureText
|
||||||
|
? Icons.visibility_off_outlined
|
||||||
|
: Icons.visibility_outlined,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
contentPadding: widget.contentPadding ??
|
contentPadding: widget.contentPadding ??
|
||||||
const EdgeInsets.symmetric(
|
const EdgeInsets.symmetric(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user