674 lines
18 KiB
Dart
674 lines
18 KiB
Dart
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../../core/theme/app_typography.dart';
|
|
import '../../../../shared/widgets/app_data_table.dart';
|
|
import '../../../../shared/widgets/app_table_action_icon.dart';
|
|
|
|
import '../../../../shared/models/user_management_models.dart';
|
|
import '../../../../shared/widgets/app_card.dart';
|
|
|
|
bool isProtectedRole(RoleCardModel role) =>
|
|
role.id == '1' || role.name.toLowerCase() == 'super admin';
|
|
|
|
({Color color, IconData icon}) roleCardAppearance(int index) {
|
|
const styles = [
|
|
(color: Color(0xFF2563EB), icon: Icons.shield_outlined),
|
|
(color: Color(0xFF16A34A), icon: Icons.admin_panel_settings_outlined),
|
|
(color: Color(0xFFCA8A04), icon: Icons.receipt_long_outlined),
|
|
(color: Color(0xFF0891B2), icon: Icons.inventory_outlined),
|
|
(color: Color(0xFF7C3AED), icon: Icons.account_balance_wallet_outlined),
|
|
(color: Color(0xFFEA580C), icon: Icons.inventory_2_outlined),
|
|
];
|
|
return styles[index % styles.length];
|
|
}
|
|
|
|
class RbacStatCard extends StatelessWidget {
|
|
const RbacStatCard({
|
|
super.key,
|
|
required this.label,
|
|
required this.value,
|
|
required this.icon,
|
|
required this.color,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
final IconData icon;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return AppCard(
|
|
elevation: 0,
|
|
enableHover: true,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(icon, size: 20, color: color),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
value,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class StatusBadge extends StatelessWidget {
|
|
const StatusBadge({super.key, required this.label, required this.color});
|
|
|
|
final String label;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: color,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class RoleBadge extends StatelessWidget {
|
|
const RoleBadge({super.key, required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final secondary = Theme.of(context).colorScheme.secondary;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: secondary.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(color: secondary, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: secondary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserRolesCell extends StatelessWidget {
|
|
const UserRolesCell({super.key, required this.roles});
|
|
|
|
final List<String> roles;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (roles.isEmpty) {
|
|
return const Text('—');
|
|
}
|
|
|
|
final theme = Theme.of(context);
|
|
final extraCount = roles.length - 1;
|
|
final tooltipMessage = roles.join('\n');
|
|
|
|
return Tooltip(
|
|
message: tooltipMessage,
|
|
preferBelow: true,
|
|
waitDuration: const Duration(milliseconds: 250),
|
|
child: SizedBox(
|
|
height: 28,
|
|
child: Row(
|
|
children: [
|
|
Flexible(
|
|
child: RoleBadge(label: roles.first),
|
|
),
|
|
if (extraCount > 0) ...[
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest
|
|
.withValues(alpha: 0.8),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'+$extraCount',
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class EmployeeCodeBadge extends StatelessWidget {
|
|
const EmployeeCodeBadge({super.key, required this.code});
|
|
|
|
/// Uniform width for employee code pills in data tables.
|
|
static const double tableWidth = 108;
|
|
|
|
final String code;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final display = code.trim().isEmpty ? '—' : code.trim();
|
|
|
|
return SizedBox(
|
|
width: tableWidth,
|
|
child: Container(
|
|
height: 22,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Center(
|
|
child: Tooltip(
|
|
message: display == '—' ? '' : display,
|
|
child: Text(
|
|
display,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
height: 1,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserTableUserCell extends StatelessWidget {
|
|
const UserTableUserCell({super.key, required this.user});
|
|
|
|
final ManagedUserModel user;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Row(
|
|
children: [
|
|
UserAvatarChip(
|
|
name: user.fullName,
|
|
initials: user.initialsDisplay,
|
|
radius: 16,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AppTableCell.text(
|
|
user.fullName,
|
|
style: AppTypography.label2(weight: AppTypography.semiBold),
|
|
),
|
|
AppTableCell.text(
|
|
user.email,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserAvatarChip extends StatelessWidget {
|
|
const UserAvatarChip({
|
|
super.key,
|
|
required this.name,
|
|
this.initials,
|
|
this.radius = 20,
|
|
});
|
|
|
|
final String name;
|
|
final String? initials;
|
|
final double radius;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = [
|
|
const Color(0xFF2563EB),
|
|
const Color(0xFF0891B2),
|
|
const Color(0xFF7C3AED),
|
|
const Color(0xFFEA580C),
|
|
const Color(0xFF16A34A),
|
|
];
|
|
final color = colors[name.hashCode.abs() % colors.length];
|
|
final display = initials ??
|
|
(name.isNotEmpty ? name.trim()[0].toUpperCase() : 'U');
|
|
|
|
return CircleAvatar(
|
|
radius: radius,
|
|
backgroundColor: color.withValues(alpha: 0.12),
|
|
child: Text(
|
|
display.length > 2 ? display.substring(0, 2) : display,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontFamily,
|
|
color: color,
|
|
fontWeight: AppTypography.bold,
|
|
fontSize: radius * 0.6,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserTableActions extends StatelessWidget {
|
|
const UserTableActions({
|
|
super.key,
|
|
required this.user,
|
|
required this.onEdit,
|
|
required this.onResetPassword,
|
|
required this.onDeactivate,
|
|
this.canEdit = true,
|
|
this.canResetPassword = true,
|
|
this.canDeactivate = true,
|
|
});
|
|
|
|
static const columnWidth = 120.0;
|
|
|
|
final ManagedUserModel user;
|
|
final VoidCallback onEdit;
|
|
final VoidCallback onResetPassword;
|
|
final VoidCallback onDeactivate;
|
|
final bool canEdit;
|
|
final bool canResetPassword;
|
|
final bool canDeactivate;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
|
|
|
return AppTableActions(
|
|
children: [
|
|
if (canEdit)
|
|
AppTableActionIcon(
|
|
tooltip: 'Edit user',
|
|
icon: Icons.edit_outlined,
|
|
color: muted,
|
|
onPressed: onEdit,
|
|
),
|
|
if (canResetPassword)
|
|
AppTableActionIcon(
|
|
tooltip: 'Reset password',
|
|
icon: Icons.vpn_key_outlined,
|
|
color: muted,
|
|
onPressed: onResetPassword,
|
|
),
|
|
if (canDeactivate)
|
|
AppTableActionIcon(
|
|
tooltip: 'Deactivate user',
|
|
icon: Icons.person_off_outlined,
|
|
color: muted,
|
|
onPressed: onDeactivate,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserTableActionsHeader extends StatelessWidget {
|
|
const UserTableActionsHeader({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const SizedBox(
|
|
width: UserTableActions.columnWidth,
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Text('ACTIONS'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserTableActionsCell extends StatelessWidget {
|
|
const UserTableActionsCell({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: UserTableActions.columnWidth,
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 4),
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class RolePill extends StatelessWidget {
|
|
const RolePill({
|
|
super.key,
|
|
required this.label,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final primary = Theme.of(context).colorScheme.primary;
|
|
return Material(
|
|
color: selected ? primary : Theme.of(context).colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 200),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(
|
|
color: selected
|
|
? primary
|
|
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.35),
|
|
),
|
|
),
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
color: selected
|
|
? Theme.of(context).colorScheme.onPrimary
|
|
: Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ArrowScrollRow extends StatefulWidget {
|
|
const ArrowScrollRow({
|
|
super.key,
|
|
required this.height,
|
|
required this.itemCount,
|
|
required this.itemBuilder,
|
|
this.separatorWidth = 8,
|
|
this.scrollStep = 200,
|
|
});
|
|
|
|
final double height;
|
|
final int itemCount;
|
|
final IndexedWidgetBuilder itemBuilder;
|
|
final double separatorWidth;
|
|
final double scrollStep;
|
|
|
|
@override
|
|
State<ArrowScrollRow> createState() => _ArrowScrollRowState();
|
|
}
|
|
|
|
class _ArrowScrollRowState extends State<ArrowScrollRow> {
|
|
final _controller = ScrollController();
|
|
bool _canScrollLeft = false;
|
|
bool _canScrollRight = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller.addListener(_updateScrollButtons);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollButtons());
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant ArrowScrollRow oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollButtons());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.removeListener(_updateScrollButtons);
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _updateScrollButtons() {
|
|
if (!_controller.hasClients) return;
|
|
final position = _controller.position;
|
|
final canLeft = position.pixels > position.minScrollExtent + 0.5;
|
|
final canRight = position.pixels < position.maxScrollExtent - 0.5;
|
|
if (canLeft == _canScrollLeft && canRight == _canScrollRight) return;
|
|
setState(() {
|
|
_canScrollLeft = canLeft;
|
|
_canScrollRight = canRight;
|
|
});
|
|
}
|
|
|
|
void _scrollBy(double delta) {
|
|
if (!_controller.hasClients) return;
|
|
final target = (_controller.offset + delta).clamp(
|
|
_controller.position.minScrollExtent,
|
|
_controller.position.maxScrollExtent,
|
|
);
|
|
_controller.animateTo(
|
|
target,
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
height: widget.height,
|
|
child: Row(
|
|
children: [
|
|
_ScrollArrowButton(
|
|
icon: Icons.chevron_left,
|
|
enabled: _canScrollLeft,
|
|
onPressed: () => _scrollBy(-widget.scrollStep),
|
|
),
|
|
Expanded(
|
|
child: ScrollConfiguration(
|
|
behavior: ScrollConfiguration.of(context).copyWith(
|
|
dragDevices: {
|
|
PointerDeviceKind.touch,
|
|
PointerDeviceKind.mouse,
|
|
PointerDeviceKind.trackpad,
|
|
PointerDeviceKind.stylus,
|
|
},
|
|
),
|
|
child: ListView.separated(
|
|
controller: _controller,
|
|
scrollDirection: Axis.horizontal,
|
|
physics: const BouncingScrollPhysics(),
|
|
itemCount: widget.itemCount,
|
|
separatorBuilder: (_, __) =>
|
|
SizedBox(width: widget.separatorWidth),
|
|
itemBuilder: widget.itemBuilder,
|
|
),
|
|
),
|
|
),
|
|
_ScrollArrowButton(
|
|
icon: Icons.chevron_right,
|
|
enabled: _canScrollRight,
|
|
onPressed: () => _scrollBy(widget.scrollStep),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScrollArrowButton extends StatelessWidget {
|
|
const _ScrollArrowButton({
|
|
required this.icon,
|
|
required this.enabled,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final IconData icon;
|
|
final bool enabled;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return SizedBox(
|
|
width: 32,
|
|
height: 32,
|
|
child: IconButton(
|
|
padding: EdgeInsets.zero,
|
|
visualDensity: VisualDensity.compact,
|
|
tooltip: icon == Icons.chevron_left ? 'Scroll left' : 'Scroll right',
|
|
onPressed: enabled ? onPressed : null,
|
|
icon: Icon(
|
|
icon,
|
|
size: 22,
|
|
color: enabled
|
|
? theme.colorScheme.onSurface
|
|
: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ModulePermissionRow extends StatelessWidget {
|
|
const ModulePermissionRow({
|
|
super.key,
|
|
required this.icon,
|
|
required this.color,
|
|
required this.label,
|
|
required this.value,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final IconData icon;
|
|
final Color color;
|
|
final String label;
|
|
final bool value;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, size: 16, color: color),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(label)),
|
|
Text(
|
|
'View only',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Checkbox(
|
|
value: value,
|
|
onChanged: (v) => onChanged(v ?? false),
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|