runable
This commit is contained in:
145
lib/shared/widgets/custom_app_bar.dart
Normal file
145
lib/shared/widgets/custom_app_bar.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
/// Custom App Bar Widget
|
||||
///
|
||||
/// Reusable app bar with consistent styling across the app
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/constants/ui_constants.dart';
|
||||
|
||||
/// Custom app bar with consistent styling
|
||||
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool centerTitle;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
final double elevation;
|
||||
final PreferredSizeWidget? bottom;
|
||||
final bool automaticallyImplyLeading;
|
||||
|
||||
const CustomAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.centerTitle = true,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.elevation = AppElevation.none,
|
||||
this.bottom,
|
||||
this.automaticallyImplyLeading = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
title: Text(title),
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
centerTitle: centerTitle,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
elevation: elevation,
|
||||
bottom: bottom,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(
|
||||
AppBarSpecs.height + (bottom?.preferredSize.height ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
/// Transparent app bar for overlay scenarios
|
||||
class TransparentAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String? title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool centerTitle;
|
||||
final Color? foregroundColor;
|
||||
|
||||
const TransparentAppBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.centerTitle = true,
|
||||
this.foregroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
title: title != null ? Text(title!) : null,
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
centerTitle: centerTitle,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: foregroundColor ?? Colors.white,
|
||||
elevation: 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(AppBarSpecs.height);
|
||||
}
|
||||
|
||||
/// Search app bar with search field
|
||||
class SearchAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final VoidCallback? onClear;
|
||||
final TextEditingController? controller;
|
||||
final bool autofocus;
|
||||
final Widget? leading;
|
||||
|
||||
const SearchAppBar({
|
||||
super.key,
|
||||
this.hintText = 'Tìm kiếm...',
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onClear,
|
||||
this.controller,
|
||||
this.autofocus = false,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
leading: leading ??
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: TextField(
|
||||
controller: controller,
|
||||
autofocus: autofocus,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: TextStyle(color: Colors.white.withOpacity(0.7)),
|
||||
border: InputBorder.none,
|
||||
suffixIcon: controller?.text.isNotEmpty ?? false
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, color: Colors.white),
|
||||
onPressed: () {
|
||||
controller?.clear();
|
||||
onClear?.call();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
elevation: AppElevation.none,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(AppBarSpecs.height);
|
||||
}
|
||||
375
lib/shared/widgets/date_picker_field.dart
Normal file
375
lib/shared/widgets/date_picker_field.dart
Normal file
@@ -0,0 +1,375 @@
|
||||
/// Date Picker Input Field
|
||||
///
|
||||
/// Input field that opens a date picker dialog when tapped.
|
||||
/// Displays dates in Vietnamese format (dd/MM/yyyy).
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../core/utils/validators.dart';
|
||||
import '../../core/constants/ui_constants.dart';
|
||||
|
||||
/// Date picker input field
|
||||
class DatePickerField extends StatefulWidget {
|
||||
final TextEditingController? controller;
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
final DateTime? initialDate;
|
||||
final DateTime? firstDate;
|
||||
final DateTime? lastDate;
|
||||
final ValueChanged<DateTime>? onDateSelected;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final bool enabled;
|
||||
final bool required;
|
||||
final Widget? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final InputDecoration? decoration;
|
||||
|
||||
const DatePickerField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.initialDate,
|
||||
this.firstDate,
|
||||
this.lastDate,
|
||||
this.onDateSelected,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.required = true,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.decoration,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DatePickerField> createState() => _DatePickerFieldState();
|
||||
}
|
||||
|
||||
class _DatePickerFieldState extends State<DatePickerField> {
|
||||
late TextEditingController _controller;
|
||||
bool _isControllerInternal = false;
|
||||
DateTime? _selectedDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedDate = widget.initialDate;
|
||||
|
||||
if (widget.controller == null) {
|
||||
_controller = TextEditingController(
|
||||
text: _selectedDate != null
|
||||
? DateFormatter.formatDate(_selectedDate!)
|
||||
: '',
|
||||
);
|
||||
_isControllerInternal = true;
|
||||
} else {
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_isControllerInternal) {
|
||||
_controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectDate(BuildContext context) async {
|
||||
if (!widget.enabled) return;
|
||||
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate ?? DateTime.now(),
|
||||
firstDate: widget.firstDate ?? DateTime(1900),
|
||||
lastDate: widget.lastDate ?? DateTime(2100),
|
||||
locale: const Locale('vi', 'VN'),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null && picked != _selectedDate) {
|
||||
setState(() {
|
||||
_selectedDate = picked;
|
||||
_controller.text = DateFormatter.formatDate(picked);
|
||||
});
|
||||
widget.onDateSelected?.call(picked);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
enabled: widget.enabled,
|
||||
onTap: () => _selectDate(context),
|
||||
decoration: widget.decoration ??
|
||||
InputDecoration(
|
||||
labelText: widget.labelText ?? 'Ngày',
|
||||
hintText: widget.hintText ?? 'dd/MM/yyyy',
|
||||
prefixIcon: widget.prefixIcon ?? const Icon(Icons.calendar_today),
|
||||
suffixIcon: widget.suffixIcon,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
),
|
||||
validator: widget.validator ??
|
||||
(widget.required ? Validators.date : null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Date range picker field
|
||||
class DateRangePickerField extends StatefulWidget {
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
final DateTimeRange? initialRange;
|
||||
final DateTime? firstDate;
|
||||
final DateTime? lastDate;
|
||||
final ValueChanged<DateTimeRange>? onRangeSelected;
|
||||
final bool enabled;
|
||||
final Widget? prefixIcon;
|
||||
|
||||
const DateRangePickerField({
|
||||
super.key,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.initialRange,
|
||||
this.firstDate,
|
||||
this.lastDate,
|
||||
this.onRangeSelected,
|
||||
this.enabled = true,
|
||||
this.prefixIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DateRangePickerField> createState() => _DateRangePickerFieldState();
|
||||
}
|
||||
|
||||
class _DateRangePickerFieldState extends State<DateRangePickerField> {
|
||||
late TextEditingController _controller;
|
||||
DateTimeRange? _selectedRange;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedRange = widget.initialRange;
|
||||
_controller = TextEditingController(
|
||||
text: _selectedRange != null
|
||||
? '${DateFormatter.formatDate(_selectedRange!.start)} - ${DateFormatter.formatDate(_selectedRange!.end)}'
|
||||
: '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectDateRange(BuildContext context) async {
|
||||
if (!widget.enabled) return;
|
||||
|
||||
final DateTimeRange? picked = await showDateRangePicker(
|
||||
context: context,
|
||||
initialDateRange: _selectedRange,
|
||||
firstDate: widget.firstDate ?? DateTime(1900),
|
||||
lastDate: widget.lastDate ?? DateTime(2100),
|
||||
locale: const Locale('vi', 'VN'),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null && picked != _selectedRange) {
|
||||
setState(() {
|
||||
_selectedRange = picked;
|
||||
_controller.text =
|
||||
'${DateFormatter.formatDate(picked.start)} - ${DateFormatter.formatDate(picked.end)}';
|
||||
});
|
||||
widget.onRangeSelected?.call(picked);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
enabled: widget.enabled,
|
||||
onTap: () => _selectDateRange(context),
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText ?? 'Khoảng thời gian',
|
||||
hintText: widget.hintText ?? 'Chọn khoảng thời gian',
|
||||
prefixIcon: widget.prefixIcon ?? const Icon(Icons.date_range),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Date of birth picker field
|
||||
class DateOfBirthField extends StatelessWidget {
|
||||
final TextEditingController? controller;
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
final ValueChanged<DateTime>? onDateSelected;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final bool enabled;
|
||||
final int minAge;
|
||||
|
||||
const DateOfBirthField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.onDateSelected,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.minAge = 18,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
final maxDate = DateTime(now.year - minAge, now.month, now.day);
|
||||
final minDate = DateTime(now.year - 100, now.month, now.day);
|
||||
|
||||
return DatePickerField(
|
||||
controller: controller,
|
||||
labelText: labelText ?? 'Ngày sinh',
|
||||
hintText: hintText ?? 'dd/MM/yyyy',
|
||||
initialDate: maxDate,
|
||||
firstDate: minDate,
|
||||
lastDate: maxDate,
|
||||
onDateSelected: onDateSelected,
|
||||
validator: validator ?? (value) => Validators.age(value, minAge: minAge),
|
||||
enabled: enabled,
|
||||
prefixIcon: const Icon(Icons.cake),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Time picker field
|
||||
class TimePickerField extends StatefulWidget {
|
||||
final TextEditingController? controller;
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
final TimeOfDay? initialTime;
|
||||
final ValueChanged<TimeOfDay>? onTimeSelected;
|
||||
final bool enabled;
|
||||
final Widget? prefixIcon;
|
||||
|
||||
const TimePickerField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.initialTime,
|
||||
this.onTimeSelected,
|
||||
this.enabled = true,
|
||||
this.prefixIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TimePickerField> createState() => _TimePickerFieldState();
|
||||
}
|
||||
|
||||
class _TimePickerFieldState extends State<TimePickerField> {
|
||||
late TextEditingController _controller;
|
||||
bool _isControllerInternal = false;
|
||||
TimeOfDay? _selectedTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedTime = widget.initialTime;
|
||||
|
||||
if (widget.controller == null) {
|
||||
_controller = TextEditingController(
|
||||
text: _selectedTime != null
|
||||
? '${_selectedTime!.hour.toString().padLeft(2, '0')}:${_selectedTime!.minute.toString().padLeft(2, '0')}'
|
||||
: '',
|
||||
);
|
||||
_isControllerInternal = true;
|
||||
} else {
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_isControllerInternal) {
|
||||
_controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectTime(BuildContext context) async {
|
||||
if (!widget.enabled) return;
|
||||
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _selectedTime ?? TimeOfDay.now(),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null && picked != _selectedTime) {
|
||||
setState(() {
|
||||
_selectedTime = picked;
|
||||
_controller.text =
|
||||
'${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
|
||||
});
|
||||
widget.onTimeSelected?.call(picked);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
enabled: widget.enabled,
|
||||
onTap: () => _selectTime(context),
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText ?? 'Thời gian',
|
||||
hintText: widget.hintText ?? 'HH:mm',
|
||||
prefixIcon: widget.prefixIcon ?? const Icon(Icons.access_time),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
270
lib/shared/widgets/gradient_card.dart
Normal file
270
lib/shared/widgets/gradient_card.dart
Normal file
@@ -0,0 +1,270 @@
|
||||
/// Gradient Card Widget
|
||||
///
|
||||
/// Reusable card with gradient background used for member cards
|
||||
/// and other gradient-based UI elements.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/constants/ui_constants.dart';
|
||||
|
||||
/// Card with gradient background
|
||||
class GradientCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final Gradient gradient;
|
||||
final double borderRadius;
|
||||
final double elevation;
|
||||
final EdgeInsets padding;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final VoidCallback? onTap;
|
||||
final List<BoxShadow>? shadows;
|
||||
|
||||
const GradientCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.gradient,
|
||||
this.borderRadius = AppRadius.card,
|
||||
this.elevation = AppElevation.card,
|
||||
this.padding = const EdgeInsets.all(AppSpacing.md),
|
||||
this.width,
|
||||
this.height,
|
||||
this.onTap,
|
||||
this.shadows,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cardContent = Container(
|
||||
width: width,
|
||||
height: height,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
boxShadow: shadows ??
|
||||
[
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1 * (elevation / 4)),
|
||||
blurRadius: elevation,
|
||||
offset: Offset(0, elevation / 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: cardContent,
|
||||
);
|
||||
}
|
||||
|
||||
return cardContent;
|
||||
}
|
||||
}
|
||||
|
||||
/// Diamond tier gradient card
|
||||
class DiamondGradientCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double borderRadius;
|
||||
final double elevation;
|
||||
final EdgeInsets padding;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const DiamondGradientCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = MemberCardSpecs.borderRadius,
|
||||
this.elevation = MemberCardSpecs.elevation,
|
||||
this.padding = MemberCardSpecs.padding,
|
||||
this.width = MemberCardSpecs.width,
|
||||
this.height = MemberCardSpecs.height,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientCard(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF4A00E0), Color(0xFF8E2DE2)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
elevation: elevation,
|
||||
padding: padding,
|
||||
width: width,
|
||||
height: height,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platinum tier gradient card
|
||||
class PlatinumGradientCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double borderRadius;
|
||||
final double elevation;
|
||||
final EdgeInsets padding;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const PlatinumGradientCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = MemberCardSpecs.borderRadius,
|
||||
this.elevation = MemberCardSpecs.elevation,
|
||||
this.padding = MemberCardSpecs.padding,
|
||||
this.width = MemberCardSpecs.width,
|
||||
this.height = MemberCardSpecs.height,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientCard(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF7F8C8D), Color(0xFFBDC3C7)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
elevation: elevation,
|
||||
padding: padding,
|
||||
width: width,
|
||||
height: height,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gold tier gradient card
|
||||
class GoldGradientCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double borderRadius;
|
||||
final double elevation;
|
||||
final EdgeInsets padding;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const GoldGradientCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = MemberCardSpecs.borderRadius,
|
||||
this.elevation = MemberCardSpecs.elevation,
|
||||
this.padding = MemberCardSpecs.padding,
|
||||
this.width = MemberCardSpecs.width,
|
||||
this.height = MemberCardSpecs.height,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientCard(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFf7b733), Color(0xFFfc4a1a)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
elevation: elevation,
|
||||
padding: padding,
|
||||
width: width,
|
||||
height: height,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated gradient card with shimmer effect
|
||||
class ShimmerGradientCard extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Gradient gradient;
|
||||
final double borderRadius;
|
||||
final double elevation;
|
||||
final EdgeInsets padding;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final Duration shimmerDuration;
|
||||
|
||||
const ShimmerGradientCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.gradient,
|
||||
this.borderRadius = AppRadius.card,
|
||||
this.elevation = AppElevation.card,
|
||||
this.padding = const EdgeInsets.all(AppSpacing.md),
|
||||
this.width,
|
||||
this.height,
|
||||
this.shimmerDuration = AppDuration.shimmer,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShimmerGradientCard> createState() => _ShimmerGradientCardState();
|
||||
}
|
||||
|
||||
class _ShimmerGradientCardState extends State<ShimmerGradientCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.shimmerDuration,
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return ShaderMask(
|
||||
shaderCallback: (bounds) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.1),
|
||||
Colors.white.withOpacity(0.3),
|
||||
Colors.white.withOpacity(0.1),
|
||||
],
|
||||
stops: [
|
||||
_controller.value - 0.3,
|
||||
_controller.value,
|
||||
_controller.value + 0.3,
|
||||
],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.srcATop,
|
||||
child: GradientCard(
|
||||
gradient: widget.gradient,
|
||||
borderRadius: widget.borderRadius,
|
||||
elevation: widget.elevation,
|
||||
padding: widget.padding,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
269
lib/shared/widgets/price_display.dart
Normal file
269
lib/shared/widgets/price_display.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
/// Price Display Widget
|
||||
///
|
||||
/// Formats and displays prices in Vietnamese currency format
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
/// Price display with Vietnamese currency formatting
|
||||
class PriceDisplay extends StatelessWidget {
|
||||
final double price;
|
||||
final TextStyle? style;
|
||||
final bool showSymbol;
|
||||
final int decimalDigits;
|
||||
final Color? color;
|
||||
final FontWeight? fontWeight;
|
||||
final double? fontSize;
|
||||
|
||||
const PriceDisplay({
|
||||
super.key,
|
||||
required this.price,
|
||||
this.style,
|
||||
this.showSymbol = true,
|
||||
this.decimalDigits = 0,
|
||||
this.color,
|
||||
this.fontWeight,
|
||||
this.fontSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedPrice = CurrencyFormatter.formatWithDecimals(
|
||||
price,
|
||||
decimalDigits: decimalDigits,
|
||||
showSymbol: showSymbol,
|
||||
);
|
||||
|
||||
return Text(
|
||||
formattedPrice,
|
||||
style: style ??
|
||||
TextStyle(
|
||||
color: color,
|
||||
fontWeight: fontWeight ?? FontWeight.w600,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Price display with sale price comparison
|
||||
class SalePriceDisplay extends StatelessWidget {
|
||||
final double originalPrice;
|
||||
final double salePrice;
|
||||
final TextStyle? originalPriceStyle;
|
||||
final TextStyle? salePriceStyle;
|
||||
final bool showSymbol;
|
||||
final MainAxisAlignment alignment;
|
||||
|
||||
const SalePriceDisplay({
|
||||
super.key,
|
||||
required this.originalPrice,
|
||||
required this.salePrice,
|
||||
this.originalPriceStyle,
|
||||
this.salePriceStyle,
|
||||
this.showSymbol = true,
|
||||
this.alignment = MainAxisAlignment.start,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: alignment,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
// Sale price (larger, prominent)
|
||||
Text(
|
||||
CurrencyFormatter.format(salePrice, showSymbol: showSymbol),
|
||||
style: salePriceStyle ??
|
||||
const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Original price (smaller, strikethrough)
|
||||
Text(
|
||||
CurrencyFormatter.format(originalPrice, showSymbol: showSymbol),
|
||||
style: originalPriceStyle ??
|
||||
TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.grey[600],
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Price display with discount percentage badge
|
||||
class PriceWithDiscount extends StatelessWidget {
|
||||
final double originalPrice;
|
||||
final double salePrice;
|
||||
final bool showSymbol;
|
||||
final TextStyle? salePriceStyle;
|
||||
final TextStyle? originalPriceStyle;
|
||||
|
||||
const PriceWithDiscount({
|
||||
super.key,
|
||||
required this.originalPrice,
|
||||
required this.salePrice,
|
||||
this.showSymbol = true,
|
||||
this.salePriceStyle,
|
||||
this.originalPriceStyle,
|
||||
});
|
||||
|
||||
double get discountPercentage {
|
||||
return ((originalPrice - salePrice) / originalPrice * 100);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Sale price
|
||||
Text(
|
||||
CurrencyFormatter.format(salePrice, showSymbol: showSymbol),
|
||||
style: salePriceStyle ??
|
||||
const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Discount badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'-${discountPercentage.toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Original price
|
||||
Text(
|
||||
CurrencyFormatter.format(originalPrice, showSymbol: showSymbol),
|
||||
style: originalPriceStyle ??
|
||||
TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.grey[600],
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact price display for lists/grids
|
||||
class CompactPriceDisplay extends StatelessWidget {
|
||||
final double price;
|
||||
final double? salePrice;
|
||||
final bool showSymbol;
|
||||
|
||||
const CompactPriceDisplay({
|
||||
super.key,
|
||||
required this.price,
|
||||
this.salePrice,
|
||||
this.showSymbol = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isOnSale = salePrice != null && salePrice! < price;
|
||||
|
||||
if (isOnSale) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
CurrencyFormatter.format(salePrice!, showSymbol: showSymbol),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
CurrencyFormatter.format(price, showSymbol: showSymbol),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Text(
|
||||
CurrencyFormatter.format(price, showSymbol: showSymbol),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Large price display for checkout/order summary
|
||||
class LargePriceDisplay extends StatelessWidget {
|
||||
final double price;
|
||||
final String? label;
|
||||
final bool showSymbol;
|
||||
final Color? color;
|
||||
|
||||
const LargePriceDisplay({
|
||||
super.key,
|
||||
required this.price,
|
||||
this.label,
|
||||
this.showSymbol = true,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label != null) ...[
|
||||
Text(
|
||||
label!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
CurrencyFormatter.format(price, showSymbol: showSymbol),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color ?? Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
245
lib/shared/widgets/status_badge.dart
Normal file
245
lib/shared/widgets/status_badge.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
/// Status Badge Widget
|
||||
///
|
||||
/// Displays status indicators with color-coded badges for orders,
|
||||
/// projects, payments, and other status-based entities.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/constants/ui_constants.dart';
|
||||
import '../../core/theme/colors.dart';
|
||||
|
||||
/// Status badge with color-coded indicators
|
||||
class StatusBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color? textColor;
|
||||
final double borderRadius;
|
||||
final EdgeInsets padding;
|
||||
final double fontSize;
|
||||
final FontWeight fontWeight;
|
||||
|
||||
const StatusBadge({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
this.textColor,
|
||||
this.borderRadius = StatusBadgeSpecs.borderRadius,
|
||||
this.padding = StatusBadgeSpecs.padding,
|
||||
this.fontSize = StatusBadgeSpecs.fontSize,
|
||||
this.fontWeight = StatusBadgeSpecs.fontWeight,
|
||||
});
|
||||
|
||||
/// Order status badges
|
||||
factory StatusBadge.orderPending() => const StatusBadge(
|
||||
label: 'Chờ xử lý',
|
||||
color: AppColors.info,
|
||||
);
|
||||
|
||||
factory StatusBadge.orderProcessing() => const StatusBadge(
|
||||
label: 'Đang xử lý',
|
||||
color: AppColors.warning,
|
||||
);
|
||||
|
||||
factory StatusBadge.orderShipping() => const StatusBadge(
|
||||
label: 'Đang giao',
|
||||
color: AppColors.lightBlue,
|
||||
);
|
||||
|
||||
factory StatusBadge.orderCompleted() => const StatusBadge(
|
||||
label: 'Hoàn thành',
|
||||
color: AppColors.success,
|
||||
);
|
||||
|
||||
factory StatusBadge.orderCancelled() => const StatusBadge(
|
||||
label: 'Đã hủy',
|
||||
color: AppColors.danger,
|
||||
);
|
||||
|
||||
/// Payment status badges
|
||||
factory StatusBadge.paymentPending() => const StatusBadge(
|
||||
label: 'Chờ thanh toán',
|
||||
color: AppColors.warning,
|
||||
);
|
||||
|
||||
factory StatusBadge.paymentProcessing() => const StatusBadge(
|
||||
label: 'Đang xử lý',
|
||||
color: AppColors.info,
|
||||
);
|
||||
|
||||
factory StatusBadge.paymentCompleted() => const StatusBadge(
|
||||
label: 'Đã thanh toán',
|
||||
color: AppColors.success,
|
||||
);
|
||||
|
||||
factory StatusBadge.paymentFailed() => const StatusBadge(
|
||||
label: 'Thất bại',
|
||||
color: AppColors.danger,
|
||||
);
|
||||
|
||||
/// Project status badges
|
||||
factory StatusBadge.projectPlanning() => const StatusBadge(
|
||||
label: 'Lập kế hoạch',
|
||||
color: AppColors.info,
|
||||
);
|
||||
|
||||
factory StatusBadge.projectInProgress() => const StatusBadge(
|
||||
label: 'Đang thực hiện',
|
||||
color: AppColors.warning,
|
||||
);
|
||||
|
||||
factory StatusBadge.projectCompleted() => const StatusBadge(
|
||||
label: 'Hoàn thành',
|
||||
color: AppColors.success,
|
||||
);
|
||||
|
||||
factory StatusBadge.projectOnHold() => const StatusBadge(
|
||||
label: 'Tạm dừng',
|
||||
color: AppColors.grey500,
|
||||
);
|
||||
|
||||
/// Gift status badges
|
||||
factory StatusBadge.giftActive() => const StatusBadge(
|
||||
label: 'Còn hạn',
|
||||
color: AppColors.success,
|
||||
);
|
||||
|
||||
factory StatusBadge.giftUsed() => const StatusBadge(
|
||||
label: 'Đã sử dụng',
|
||||
color: AppColors.grey500,
|
||||
);
|
||||
|
||||
factory StatusBadge.giftExpired() => const StatusBadge(
|
||||
label: 'Hết hạn',
|
||||
color: AppColors.danger,
|
||||
);
|
||||
|
||||
/// Member tier badges
|
||||
factory StatusBadge.tierDiamond() => const StatusBadge(
|
||||
label: 'Kim Cương',
|
||||
color: Color(0xFF4A00E0),
|
||||
);
|
||||
|
||||
factory StatusBadge.tierPlatinum() => const StatusBadge(
|
||||
label: 'Bạch Kim',
|
||||
color: Color(0xFF7F8C8D),
|
||||
);
|
||||
|
||||
factory StatusBadge.tierGold() => const StatusBadge(
|
||||
label: 'Vàng',
|
||||
color: Color(0xFFf7b733),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: textColor ?? Colors.white,
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Outlined status badge
|
||||
class OutlinedStatusBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final double borderRadius;
|
||||
final EdgeInsets padding;
|
||||
final double fontSize;
|
||||
final FontWeight fontWeight;
|
||||
final double borderWidth;
|
||||
|
||||
const OutlinedStatusBadge({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
this.borderRadius = StatusBadgeSpecs.borderRadius,
|
||||
this.padding = StatusBadgeSpecs.padding,
|
||||
this.fontSize = StatusBadgeSpecs.fontSize,
|
||||
this.fontWeight = StatusBadgeSpecs.fontWeight,
|
||||
this.borderWidth = 1.5,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: borderWidth),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Status badge with icon
|
||||
class IconStatusBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final Color? textColor;
|
||||
final double borderRadius;
|
||||
final EdgeInsets padding;
|
||||
final double fontSize;
|
||||
final double iconSize;
|
||||
|
||||
const IconStatusBadge({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
this.textColor,
|
||||
this.borderRadius = StatusBadgeSpecs.borderRadius,
|
||||
this.padding = StatusBadgeSpecs.padding,
|
||||
this.fontSize = StatusBadgeSpecs.fontSize,
|
||||
this.iconSize = 14.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: textColor ?? Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: textColor ?? Colors.white,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
271
lib/shared/widgets/vietnamese_phone_field.dart
Normal file
271
lib/shared/widgets/vietnamese_phone_field.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
/// Vietnamese Phone Number Input Field
|
||||
///
|
||||
/// Specialized input field for Vietnamese phone numbers with
|
||||
/// auto-formatting and validation.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../core/utils/validators.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../core/constants/ui_constants.dart';
|
||||
|
||||
/// Phone number input field with Vietnamese formatting
|
||||
class VietnamesePhoneField extends StatefulWidget {
|
||||
final TextEditingController? controller;
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
final String? initialValue;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final bool enabled;
|
||||
final bool autoFocus;
|
||||
final TextInputAction? textInputAction;
|
||||
final FocusNode? focusNode;
|
||||
final bool required;
|
||||
final Widget? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
|
||||
const VietnamesePhoneField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.initialValue,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.autoFocus = false,
|
||||
this.textInputAction,
|
||||
this.focusNode,
|
||||
this.required = true,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VietnamesePhoneField> createState() => _VietnamesePhoneFieldState();
|
||||
}
|
||||
|
||||
class _VietnamesePhoneFieldState extends State<VietnamesePhoneField> {
|
||||
late TextEditingController _controller;
|
||||
bool _isControllerInternal = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.controller == null) {
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
_isControllerInternal = true;
|
||||
} else {
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_isControllerInternal) {
|
||||
_controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: _controller,
|
||||
focusNode: widget.focusNode,
|
||||
enabled: widget.enabled,
|
||||
autofocus: widget.autoFocus,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: widget.textInputAction ?? TextInputAction.next,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(11),
|
||||
_PhoneNumberFormatter(),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText ?? 'Số điện thoại',
|
||||
hintText: widget.hintText ?? '0xxx xxx xxx',
|
||||
prefixIcon: widget.prefixIcon ??
|
||||
const Icon(Icons.phone),
|
||||
suffixIcon: widget.suffixIcon,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
),
|
||||
validator: widget.validator ??
|
||||
(widget.required ? Validators.phone : Validators.phoneOptional),
|
||||
onChanged: widget.onChanged,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone number text input formatter
|
||||
class _PhoneNumberFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final text = newValue.text;
|
||||
|
||||
if (text.isEmpty) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
// Format as: 0xxx xxx xxx
|
||||
String formatted = text;
|
||||
if (text.length > 4 && text.length <= 7) {
|
||||
formatted = '${text.substring(0, 4)} ${text.substring(4)}';
|
||||
} else if (text.length > 7) {
|
||||
formatted =
|
||||
'${text.substring(0, 4)} ${text.substring(4, 7)} ${text.substring(7)}';
|
||||
}
|
||||
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only phone display field
|
||||
class PhoneDisplayField extends StatelessWidget {
|
||||
final String phoneNumber;
|
||||
final String? labelText;
|
||||
final Widget? prefixIcon;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const PhoneDisplayField({
|
||||
super.key,
|
||||
required this.phoneNumber,
|
||||
this.labelText,
|
||||
this.prefixIcon,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
initialValue: PhoneFormatter.format(phoneNumber),
|
||||
readOnly: true,
|
||||
enabled: onTap != null,
|
||||
onTap: onTap,
|
||||
decoration: InputDecoration(
|
||||
labelText: labelText ?? 'Số điện thoại',
|
||||
prefixIcon: prefixIcon ?? const Icon(Icons.phone),
|
||||
suffixIcon: onTap != null ? const Icon(Icons.edit) : null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone field with country code selector
|
||||
class InternationalPhoneField extends StatefulWidget {
|
||||
final TextEditingController? controller;
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final bool enabled;
|
||||
final String defaultCountryCode;
|
||||
|
||||
const InternationalPhoneField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.onChanged,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.defaultCountryCode = '+84',
|
||||
});
|
||||
|
||||
@override
|
||||
State<InternationalPhoneField> createState() =>
|
||||
_InternationalPhoneFieldState();
|
||||
}
|
||||
|
||||
class _InternationalPhoneFieldState extends State<InternationalPhoneField> {
|
||||
late TextEditingController _controller;
|
||||
late String _selectedCountryCode;
|
||||
bool _isControllerInternal = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedCountryCode = widget.defaultCountryCode;
|
||||
|
||||
if (widget.controller == null) {
|
||||
_controller = TextEditingController();
|
||||
_isControllerInternal = true;
|
||||
} else {
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_isControllerInternal) {
|
||||
_controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.next,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText ?? 'Số điện thoại',
|
||||
hintText: widget.hintText ?? 'xxx xxx xxx',
|
||||
prefixIcon: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedCountryCode,
|
||||
underline: const SizedBox(),
|
||||
items: const [
|
||||
DropdownMenuItem(value: '+84', child: Text('+84')),
|
||||
DropdownMenuItem(value: '+1', child: Text('+1')),
|
||||
DropdownMenuItem(value: '+86', child: Text('+86')),
|
||||
],
|
||||
onChanged: widget.enabled
|
||||
? (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedCountryCode = value;
|
||||
});
|
||||
widget.onChanged?.call('$value${_controller.text}');
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
),
|
||||
validator: widget.validator,
|
||||
onChanged: (value) {
|
||||
widget.onChanged?.call('$_selectedCountryCode$value');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user