This commit is contained in:
Phuoc Nguyen
2025-10-17 17:22:28 +07:00
parent 2125e85d40
commit 628c81ce13
86 changed files with 31339 additions and 1710 deletions

View 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);
}

View 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,
),
);
}
}

View 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,
),
);
},
);
}
}

View 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,
),
),
],
);
}
}

View 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,
),
),
],
),
);
}
}

View 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');
},
);
}
}