645 lines
19 KiB
Dart
645 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart' hide TextDirection;
|
||
import 'package:provider/provider.dart';
|
||
import '../models/user_model.dart';
|
||
import '../providers/user_provider.dart';
|
||
import '../theme/app_theme.dart';
|
||
import '../widgets/app_sidebar.dart';
|
||
import '../widgets/responsive_layout.dart';
|
||
import '../widgets/secret_dialog.dart';
|
||
|
||
class UsersScreen extends StatefulWidget {
|
||
const UsersScreen({super.key});
|
||
|
||
@override
|
||
State<UsersScreen> createState() => _UsersScreenState();
|
||
}
|
||
|
||
class _UsersScreenState extends State<UsersScreen> {
|
||
final _searchCtrl = TextEditingController();
|
||
String _search = '';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
context.read<UserProvider>().loadUsers();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ResponsiveLayout(
|
||
title: 'کاربران',
|
||
sidebar: const AppSidebar(),
|
||
body: _UsersBody(
|
||
search: _search,
|
||
searchCtrl: _searchCtrl,
|
||
onSearch: (v) => setState(() => _search = v.toLowerCase()),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _UsersBody extends StatelessWidget {
|
||
final String search;
|
||
final TextEditingController searchCtrl;
|
||
final ValueChanged<String> onSearch;
|
||
|
||
const _UsersBody({
|
||
required this.search,
|
||
required this.searchCtrl,
|
||
required this.onSearch,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// ── Header ───────────────────────────────────────────────
|
||
Row(
|
||
children: [
|
||
const Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'مدیریت کاربران',
|
||
style: TextStyle(
|
||
fontSize: 24,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppTheme.textPrimary,
|
||
),
|
||
),
|
||
SizedBox(height: 4),
|
||
Text(
|
||
'ایجاد و مدیریت کاربران سیستم',
|
||
style: TextStyle(
|
||
fontSize: 14, color: AppTheme.textSecondary),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
ElevatedButton.icon(
|
||
onPressed: () => _showCreateDialog(context),
|
||
icon: const Icon(Icons.person_add_rounded, size: 18),
|
||
label: const Text('کاربر جدید'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── Search ───────────────────────────────────────────────
|
||
TextField(
|
||
controller: searchCtrl,
|
||
onChanged: onSearch,
|
||
decoration: const InputDecoration(
|
||
hintText: 'جستجوی کاربر...',
|
||
prefixIcon: Icon(Icons.search_rounded),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// ── Table ────────────────────────────────────────────────
|
||
Expanded(
|
||
child: Consumer<UserProvider>(
|
||
builder: (_, provider, __) {
|
||
if (provider.isLoading) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (provider.status.name == 'error') {
|
||
return _ErrorView(
|
||
message: provider.error ?? 'خطا',
|
||
onRetry: () => provider.loadUsers());
|
||
}
|
||
|
||
final filtered = provider.users
|
||
.where((u) =>
|
||
search.isEmpty ||
|
||
u.username.toLowerCase().contains(search))
|
||
.toList();
|
||
|
||
if (filtered.isEmpty) {
|
||
return const _EmptyView(
|
||
icon: Icons.people_outline_rounded,
|
||
message: 'کاربری یافت نشد',
|
||
);
|
||
}
|
||
|
||
return _UsersTable(users: filtered);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showCreateDialog(BuildContext context) {
|
||
showDialog(
|
||
context: context,
|
||
builder: (_) => const _CreateUserDialog(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Create User Dialog ──────────────────────────────────────────────────────
|
||
|
||
class _CreateUserDialog extends StatefulWidget {
|
||
const _CreateUserDialog();
|
||
|
||
@override
|
||
State<_CreateUserDialog> createState() => _CreateUserDialogState();
|
||
}
|
||
|
||
class _CreateUserDialogState extends State<_CreateUserDialog> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
final _usernameCtrl = TextEditingController();
|
||
final _phoneCtrl = TextEditingController();
|
||
bool _loading = false;
|
||
String? _error;
|
||
|
||
@override
|
||
void dispose() {
|
||
_usernameCtrl.dispose();
|
||
_phoneCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
if (!_formKey.currentState!.validate()) return;
|
||
setState(() {
|
||
_loading = true;
|
||
_error = null;
|
||
});
|
||
|
||
final provider = context.read<UserProvider>();
|
||
final result = await provider.createUser(
|
||
_usernameCtrl.text.trim(),
|
||
phoneNumber:
|
||
_phoneCtrl.text.trim().isEmpty ? null : _phoneCtrl.text.trim(),
|
||
);
|
||
|
||
if (!mounted) return;
|
||
setState(() => _loading = false);
|
||
|
||
if (result != null) {
|
||
Navigator.of(context).pop();
|
||
await SecretDialog.show(
|
||
context,
|
||
username: result.user.username,
|
||
secret: result.secret,
|
||
);
|
||
} else {
|
||
setState(() => _error = provider.error);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: const Row(
|
||
children: [
|
||
Icon(Icons.person_add_rounded, color: AppTheme.primary),
|
||
SizedBox(width: 10),
|
||
Text('ایجاد کاربر جدید'),
|
||
],
|
||
),
|
||
content: SizedBox(
|
||
width: 400,
|
||
child: Form(
|
||
key: _formKey,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
TextFormField(
|
||
controller: _usernameCtrl,
|
||
textDirection: TextDirection.ltr,
|
||
decoration: const InputDecoration(
|
||
labelText: 'نام کاربری',
|
||
prefixIcon: Icon(Icons.person_outline_rounded),
|
||
),
|
||
validator: (v) => (v == null || v.trim().isEmpty)
|
||
? 'نام کاربری الزامی است'
|
||
: null,
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextFormField(
|
||
controller: _phoneCtrl,
|
||
textDirection: TextDirection.ltr,
|
||
decoration: const InputDecoration(
|
||
labelText: 'شماره همراه (اختیاری)',
|
||
prefixIcon: Icon(Icons.phone_android_rounded),
|
||
),
|
||
validator: (v) {
|
||
if (v != null && v.trim().isNotEmpty) {
|
||
if (v.trim().length != 11 ||
|
||
!RegExp(r'^\d+$').hasMatch(v.trim())) {
|
||
return 'شماره همراه معتبر نیست (۱۱ رقم)';
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
),
|
||
if (_error != null) ...[
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: AppTheme.danger.withValues(alpha: 0.08),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(
|
||
color: AppTheme.danger.withValues(alpha: 0.3)),
|
||
),
|
||
child: Text(
|
||
_error!,
|
||
style:
|
||
const TextStyle(color: AppTheme.danger, fontSize: 13),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: _loading ? null : () => Navigator.of(context).pop(),
|
||
child: const Text('انصراف'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: _loading ? null : _submit,
|
||
child: _loading
|
||
? const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(
|
||
color: Colors.white, strokeWidth: 2))
|
||
: const Text('ایجاد'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Users Table ──────────────────────────────────────────────────────────────
|
||
|
||
class _UsersTable extends StatelessWidget {
|
||
final List<UserModel> users;
|
||
|
||
const _UsersTable({required this.users});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: SingleChildScrollView(
|
||
child: SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: DataTable(
|
||
columns: const [
|
||
DataColumn(label: Text('نام کاربری')),
|
||
DataColumn(label: Text('شماره همراه')),
|
||
DataColumn(label: Text('نقش')),
|
||
DataColumn(label: Text('وضعیت')),
|
||
DataColumn(label: Text('تاریخ ایجاد')),
|
||
DataColumn(label: Text('عملیات')),
|
||
],
|
||
rows: users.map((user) => _buildRow(context, user)).toList(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
DataRow _buildRow(BuildContext context, UserModel user) {
|
||
return DataRow(
|
||
cells: [
|
||
DataCell(
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 16,
|
||
backgroundColor: AppTheme.primary.withValues(alpha: 0.12),
|
||
child: Text(
|
||
user.username[0].toUpperCase(),
|
||
style: const TextStyle(
|
||
color: AppTheme.primary,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
user.username,
|
||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
DataCell(
|
||
Text(
|
||
user.phoneNumber ?? '—',
|
||
style: const TextStyle(color: AppTheme.textSecondary),
|
||
),
|
||
),
|
||
DataCell(_RoleBadge(role: user.role)),
|
||
DataCell(_StatusBadge(isActive: user.isActive)),
|
||
DataCell(
|
||
Text(
|
||
user.createdAt != null
|
||
? DateFormat('yyyy/MM/dd').format(user.createdAt!)
|
||
: '—',
|
||
style: const TextStyle(color: AppTheme.textSecondary),
|
||
),
|
||
),
|
||
DataCell(
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_ResetSecretButton(user: user),
|
||
const SizedBox(width: 8),
|
||
_LogoutUserButton(user: user),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ResetSecretButton extends StatefulWidget {
|
||
final UserModel user;
|
||
const _ResetSecretButton({required this.user});
|
||
|
||
@override
|
||
State<_ResetSecretButton> createState() => _ResetSecretButtonState();
|
||
}
|
||
|
||
class _ResetSecretButtonState extends State<_ResetSecretButton> {
|
||
bool _loading = false;
|
||
|
||
Future<void> _reset() async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (_) => AlertDialog(
|
||
title: const Text('تأیید ریست رمز'),
|
||
content: Text(
|
||
'آیا مطمئن هستید که میخواهید رمز «${widget.user.username}» را ریست کنید؟'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('انصراف'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.warning),
|
||
child: const Text('ریست'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed != true || !mounted) return;
|
||
setState(() => _loading = true);
|
||
|
||
final provider = context.read<UserProvider>();
|
||
final secret = await provider.resetSecret(widget.user.id);
|
||
if (!mounted) return;
|
||
setState(() => _loading = false);
|
||
|
||
if (secret != null) {
|
||
await SecretDialog.show(
|
||
context,
|
||
username: widget.user.username,
|
||
secret: secret,
|
||
isReset: true,
|
||
);
|
||
} else if (provider.error != null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(provider.error!),
|
||
backgroundColor: AppTheme.danger,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_loading) {
|
||
return const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2));
|
||
}
|
||
return TextButton.icon(
|
||
onPressed: _reset,
|
||
icon: const Icon(Icons.lock_reset_rounded, size: 16),
|
||
label: const Text('ریست رمز'),
|
||
style: TextButton.styleFrom(foregroundColor: AppTheme.warning),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Shared small widgets ─────────────────────────────────────────────────────
|
||
|
||
class _RoleBadge extends StatelessWidget {
|
||
final UserRole role;
|
||
const _RoleBadge({required this.role});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final color = AppTheme.roleColor(role.name);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Text(
|
||
role.label,
|
||
style: TextStyle(
|
||
color: color,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StatusBadge extends StatelessWidget {
|
||
final bool isActive;
|
||
const _StatusBadge({required this.isActive});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final color = isActive ? AppTheme.success : AppTheme.danger;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
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: 5),
|
||
Text(
|
||
isActive ? 'فعال' : 'غیرفعال',
|
||
style: TextStyle(
|
||
color: color,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EmptyView extends StatelessWidget {
|
||
final IconData icon;
|
||
final String message;
|
||
const _EmptyView({required this.icon, required this.message});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(icon, size: 64, color: AppTheme.border),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
message,
|
||
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 16),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ErrorView extends StatelessWidget {
|
||
final String message;
|
||
final VoidCallback onRetry;
|
||
const _ErrorView({required this.message, required this.onRetry});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.error_outline_rounded,
|
||
size: 64, color: AppTheme.danger),
|
||
const SizedBox(height: 16),
|
||
Text(message, style: const TextStyle(color: AppTheme.textSecondary)),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton.icon(
|
||
onPressed: onRetry,
|
||
icon: const Icon(Icons.refresh_rounded),
|
||
label: const Text('تلاش مجدد'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LogoutUserButton extends StatefulWidget {
|
||
final UserModel user;
|
||
const _LogoutUserButton({required this.user});
|
||
|
||
@override
|
||
State<_LogoutUserButton> createState() => _LogoutUserButtonState();
|
||
}
|
||
|
||
class _LogoutUserButtonState extends State<_LogoutUserButton> {
|
||
bool _loading = false;
|
||
|
||
Future<void> _logout() async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (_) => AlertDialog(
|
||
title: const Text('تأیید خروج اجباری'),
|
||
content: Text(
|
||
'آیا مطمئن هستید که میخواهید «${widget.user.username}» را از تمام دستگاهها خارج کنید؟'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('انصراف'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.danger),
|
||
child: const Text('خروج اجباری'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed != true || !mounted) return;
|
||
setState(() => _loading = true);
|
||
|
||
final provider = context.read<UserProvider>();
|
||
final success = await provider.logoutUser(widget.user.id);
|
||
if (!mounted) return;
|
||
setState(() => _loading = false);
|
||
|
||
if (success) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('کاربر با موفقیت خارج شد'),
|
||
backgroundColor: AppTheme.success,
|
||
),
|
||
);
|
||
} else if (provider.error != null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(provider.error!),
|
||
backgroundColor: AppTheme.danger,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_loading) {
|
||
return const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2));
|
||
}
|
||
return TextButton.icon(
|
||
onPressed: _logout,
|
||
icon: const Icon(Icons.logout_rounded, size: 16),
|
||
label: const Text('خروج'),
|
||
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
|
||
);
|
||
}
|
||
}
|