Neda/admin_panel/lib/screens/users_screen.dart

645 lines
19 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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