frontendPlayer/lib/presentation/pages/license_page.dart
2026-04-10 09:55:19 +03:30

801 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_colors.dart';
import '../../core/constants/app_text_styles.dart';
import '../../data/services/api_service.dart';
import '../widgets/teacher_panel_scaffold.dart';
import 'course_page.dart';
import 'dashboard_page.dart';
import 'license_builder_page.dart';
import 'new_license_page.dart';
import 'ticket_page.dart';
import 'transaction_page.dart';
class PanelLicensePage extends StatefulWidget {
const PanelLicensePage({super.key});
@override
State<PanelLicensePage> createState() => _PanelLicensePageState();
}
class _PanelLicensePageState extends State<PanelLicensePage> {
bool _isLoadingCourses = true;
bool _isLoadingLicenses = false;
String? _errorMessage;
List<Map<String, dynamic>> _courses = <Map<String, dynamic>>[];
List<Map<String, dynamic>> _licenses = <Map<String, dynamic>>[];
List<Map<String, dynamic>> _filteredLicenses = <Map<String, dynamic>>[];
int? _selectedCourseId;
int _currentPage = 1;
int _lastPage = 1;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadCoursesAndInitialLicenses();
_searchController.addListener(_applySearchFilter);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadCoursesAndInitialLicenses() async {
setState(() {
_isLoadingCourses = true;
_errorMessage = null;
});
try {
final apiService = Provider.of<ApiService>(context, listen: false);
final response = await apiService.getCourses();
final data = response['data'];
final courses = data is List
? data
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: <Map<String, dynamic>>[];
setState(() {
_courses = courses;
_selectedCourseId = _extractCourseId(courses.isNotEmpty ? courses.first : null);
_isLoadingCourses = false;
});
if (_selectedCourseId != null) {
await _loadLicensesForSelectedCourse(page: 1);
}
} catch (e) {
setState(() {
_isLoadingCourses = false;
_errorMessage = e.toString();
});
}
}
Future<void> _loadLicensesForSelectedCourse({int page = 1}) async {
if (_selectedCourseId == null) return;
setState(() {
_isLoadingLicenses = true;
_errorMessage = null;
});
try {
final apiService = Provider.of<ApiService>(context, listen: false);
final response = await apiService.getCourseLicenses(
courseId: _selectedCourseId!,
page: page,
);
final data = response['data'];
final meta = response['meta'];
final licenses = data is List
? data
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: <Map<String, dynamic>>[];
setState(() {
_licenses = licenses;
if (meta is Map<String, dynamic>) {
_currentPage = (meta['current_page'] as num?)?.toInt() ?? page;
_lastPage = (meta['last_page'] as num?)?.toInt() ?? 1;
} else {
_currentPage = page;
_lastPage = 1;
}
_isLoadingLicenses = false;
});
_applySearchFilter();
} catch (e) {
setState(() {
_isLoadingLicenses = false;
_errorMessage = e.toString();
});
}
}
void _applySearchFilter() {
final query = _searchController.text.trim().toLowerCase();
if (query.isEmpty) {
setState(() {
_filteredLicenses = List<Map<String, dynamic>>.from(_licenses);
});
return;
}
setState(() {
_filteredLicenses = _licenses.where((item) {
final code = (item['activation_code'] ?? '').toString().toLowerCase();
final status = (item['status'] ?? '').toString().toLowerCase();
final userName = _extractUserName(item['user']).toLowerCase();
return code.contains(query) || status.contains(query) || userName.contains(query);
}).toList();
});
}
int? _extractCourseId(Map<String, dynamic>? course) {
if (course == null) return null;
return (course['id'] as num?)?.toInt();
}
String _extractCourseTitle(Map<String, dynamic>? course) {
if (course == null) return '-';
final title = course['title'];
if (title is String && title.trim().isNotEmpty) {
return title;
}
return '-';
}
String _extractUserName(dynamic userObj) {
if (userObj is Map<String, dynamic>) {
final value = userObj['user_name'];
if (value is String && value.trim().isNotEmpty) {
return value;
}
}
return '-';
}
int _extractDeviceCount(dynamic limitsObj) {
if (limitsObj is List) {
int sum = 0;
for (final item in limitsObj) {
if (item is Map<String, dynamic>) {
final count = item['count'] ?? item['device_count'] ?? item['limit'];
if (count is num) {
sum += count.toInt();
}
}
}
if (sum > 0) return sum;
return limitsObj.length;
}
return 0;
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width > 900;
return TeacherPanelScaffold(
selectedMenu: PanelMenu.license,
onDashboardTap: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const DashboardPage()),
);
},
onCourseTap: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const CoursePage()),
);
},
onLicenseTap: () {},
onTransactionTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const TransactionPage()),
);
},
onTicketTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const TicketPage()),
);
},
child: Padding(
padding: EdgeInsets.all(isDesktop ? 24 : 12),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Padding(
padding: EdgeInsets.all(isDesktop ? 14 : 10),
child: _LicenseToolbar(
isDesktop: isDesktop,
searchController: _searchController,
courses: _courses,
selectedCourseId: _selectedCourseId,
onCreateNewLicense: () async {
if (_selectedCourseId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('ابتدا یک دوره انتخاب کنید.'),
),
);
return;
}
final selectedCourse = _courses.firstWhere(
(e) => _extractCourseId(e) == _selectedCourseId,
orElse: () => <String, dynamic>{},
);
final created = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => NewLicensePage(
courseId: _selectedCourseId!,
courseTitle: _extractCourseTitle(selectedCourse),
),
),
);
if (created == true) {
await _loadLicensesForSelectedCourse(page: _currentPage);
}
},
onCourseChanged: (courseId) async {
setState(() {
_selectedCourseId = courseId;
_currentPage = 1;
});
await _loadLicensesForSelectedCourse(page: 1);
},
),
),
Expanded(
child: Container(
margin: EdgeInsets.fromLTRB(
isDesktop ? 14 : 8,
0,
isDesktop ? 14 : 8,
isDesktop ? 14 : 8,
),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black12),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Container(
height: 46,
decoration: const BoxDecoration(
color: Color(0xFFD5D5D5),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
child: const Row(
children: [
_HeaderCell(title: 'کد فعال‌سازی', flex: 4),
_HeaderCell(title: 'کاربر', flex: 3),
_HeaderCell(title: 'دوره', flex: 4),
_HeaderCell(title: 'دستگاه', flex: 2),
_HeaderCell(title: 'وضعیت', flex: 2),
],
),
),
Expanded(child: _buildContent()),
_PaginationBar(
currentPage: _currentPage,
lastPage: _lastPage,
onPrevious: _currentPage > 1
? () => _loadLicensesForSelectedCourse(page: _currentPage - 1)
: null,
onNext: _currentPage < _lastPage
? () => _loadLicensesForSelectedCourse(page: _currentPage + 1)
: null,
),
],
),
),
),
],
),
),
),
);
}
Widget _buildContent() {
if (_isLoadingCourses || _isLoadingLicenses) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_errorMessage!,
style: AppTextStyles.bodyMedium.copyWith(color: Colors.red),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: _selectedCourseId == null
? _loadCoursesAndInitialLicenses
: () => _loadLicensesForSelectedCourse(page: _currentPage),
child: const Text('تلاش مجدد'),
),
],
),
),
);
}
if (_courses.isEmpty) {
return const Center(child: _EmptyLicenseState(message: 'هیچ دوره‌ای یافت نشد.'));
}
if (_selectedCourseId == null) {
return const Center(child: _EmptyLicenseState(message: 'دوره‌ای برای نمایش انتخاب نشده است.'));
}
if (_filteredLicenses.isEmpty) {
return const Center(child: _EmptyLicenseState(message: 'برای این دوره لایسنسی ثبت نشده است.'));
}
return ListView.separated(
itemCount: _filteredLicenses.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = _filteredLicenses[index];
final activationCode = (item['activation_code'] ?? '-').toString();
final userName = _extractUserName(item['user']);
final courseTitle = item['course'] is Map<String, dynamic>
? _extractCourseTitle(item['course'] as Map<String, dynamic>)
: _extractCourseTitle(
_courses.firstWhere(
(e) => _extractCourseId(e) == _selectedCourseId,
orElse: () => <String, dynamic>{},
),
);
final deviceCount = _extractDeviceCount(item['device_limits']);
final status = (item['status'] ?? '-').toString();
return SizedBox(
height: 62,
child: Row(
children: [
_BodyCell(text: activationCode, flex: 4),
_BodyCell(text: userName, flex: 3),
_BodyCell(text: courseTitle, flex: 4),
_BodyCell(text: deviceCount.toString(), flex: 2),
_BodyCell(text: status, flex: 2),
],
),
);
},
);
}
}
class _LicenseToolbar extends StatelessWidget {
final bool isDesktop;
final TextEditingController searchController;
final List<Map<String, dynamic>> courses;
final int? selectedCourseId;
final VoidCallback onCreateNewLicense;
final ValueChanged<int?> onCourseChanged;
const _LicenseToolbar({
required this.isDesktop,
required this.searchController,
required this.courses,
required this.selectedCourseId,
required this.onCreateNewLicense,
required this.onCourseChanged,
});
@override
Widget build(BuildContext context) {
if (!isDesktop) {
return Column(
children: [
_searchBox(double.infinity),
const SizedBox(height: 8),
_courseDropdown(width: double.infinity),
const SizedBox(height: 8),
_staticDropdown('تاریخ ایجاد', width: double.infinity),
const SizedBox(height: 8),
_staticDropdown('تنظیمات', width: double.infinity),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 36,
child: _buildActionButton(context, 'تنظیمات پیش فرض'),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 36,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.sidebarBg,
foregroundColor: Colors.white,
),
child: const Text('دانلود اکسل'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 36,
child: ElevatedButton.icon(
onPressed: onCreateNewLicense,
icon: const Icon(Icons.add, size: 18),
label: const Text('لایسنس جدید'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF56B33E),
foregroundColor: Colors.white,
),
),
),
],
);
}
return LayoutBuilder(
builder: (context, constraints) {
const fixedWidth = 160 + 120 + 100 + 146 + 146 + 146;
const gaps = 10.0 * 6;
const minSearch = 170.0;
final canExpandSearch = constraints.maxWidth >= (fixedWidth + gaps + minSearch);
if (canExpandSearch) {
return Row(
textDirection: TextDirection.rtl,
children: [
Expanded(child: _searchBox(double.infinity)),
const SizedBox(width: 10),
_courseDropdown(width: 160),
const SizedBox(width: 10),
_staticDropdown('تاریخ ایجاد', width: 120),
const SizedBox(width: 10),
_staticDropdown('تنظیمات', width: 100),
const SizedBox(width: 10),
SizedBox(
width: 146,
height: 36,
child: _buildActionButton(context, 'تنظیمات پیش فرض'),
),
const SizedBox(width: 10),
SizedBox(
height: 36,
width: 146,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.sidebarBg,
foregroundColor: Colors.white,
textStyle: AppTextStyles.bodyMedium.copyWith(color: Colors.white),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: const Text('دانلود اکسل'),
),
),
const SizedBox(width: 10),
SizedBox(
height: 36,
width: 146,
child: ElevatedButton.icon(
onPressed: onCreateNewLicense,
icon: const Icon(Icons.add, size: 18),
label: const Text('لایسنس جدید'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF56B33E),
foregroundColor: Colors.white,
),
),
),
],
);
}
return Align(
alignment: Alignment.centerRight,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
textDirection: TextDirection.rtl,
children: [
_searchBox(170),
const SizedBox(width: 10),
_courseDropdown(width: 160),
const SizedBox(width: 10),
_staticDropdown('تاریخ ایجاد', width: 120),
const SizedBox(width: 10),
_staticDropdown('تنظیمات', width: 100),
const SizedBox(width: 10),
SizedBox(
width: 146,
height: 36,
child: _buildActionButton(context, 'تنظیمات پیش فرض'),
),
const SizedBox(width: 10),
SizedBox(
height: 36,
width: 146,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.sidebarBg,
foregroundColor: Colors.white,
),
child: const Text('دانلود اکسل'),
),
),
const SizedBox(width: 10),
SizedBox(
height: 36,
width: 146,
child: ElevatedButton.icon(
onPressed: onCreateNewLicense,
icon: const Icon(Icons.add, size: 18),
label: const Text('لایسنس جدید'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF56B33E),
foregroundColor: Colors.white,
),
),
),
],
),
),
);
},
);
}
Widget _courseDropdown({required double width}) {
final items = courses
.where((c) => (c['id'] as num?) != null)
.map(
(c) => DropdownMenuItem<int>(
value: (c['id'] as num).toInt(),
child: Text((c['title'] ?? 'بدون عنوان').toString()),
),
)
.toList();
final hasSelected = selectedCourseId != null && items.any((e) => e.value == selectedCourseId);
return SizedBox(
width: width,
height: 36,
child: DropdownButtonFormField<int>(
initialValue: hasSelected ? selectedCourseId : null,
isExpanded: true,
hint: const Text('فیلتر دوره'),
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade400),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
items: items,
onChanged: onCourseChanged,
),
);
}
Widget _staticDropdown(String hint, {required double width}) {
return SizedBox(
width: width,
height: 36,
child: DropdownButtonFormField<String>(
initialValue: hint,
isExpanded: true,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade400),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
items: [
DropdownMenuItem(value: hint, child: Text(hint)),
const DropdownMenuItem(value: 'همه', child: Text('همه')),
],
onChanged: (_) {},
),
);
}
Widget _buildActionButton(BuildContext context, String text) {
return OutlinedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const LicenseBuilderPage()),
);
},
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.sidebarBg),
foregroundColor: AppColors.sidebarBg,
),
child: Text(text, maxLines: 1, overflow: TextOverflow.ellipsis),
);
}
Widget _searchBox(double width) {
return SizedBox(
width: width,
height: 36,
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: 'جست و جو',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade400),
),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
),
);
}
}
class _HeaderCell extends StatelessWidget {
final String title;
final int flex;
const _HeaderCell({required this.title, required this.flex});
@override
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: Center(
child: Text(
title,
style: AppTextStyles.bodyLarge.copyWith(fontWeight: FontWeight.w700),
),
),
);
}
}
class _BodyCell extends StatelessWidget {
final String text;
final int flex;
const _BodyCell({required this.text, required this.flex});
@override
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
}
class _PaginationBar extends StatelessWidget {
final int currentPage;
final int lastPage;
final VoidCallback? onPrevious;
final VoidCallback? onNext;
const _PaginationBar({
required this.currentPage,
required this.lastPage,
this.onPrevious,
this.onNext,
});
@override
Widget build(BuildContext context) {
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Colors.black12)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('صفحه $currentPage از $lastPage', style: AppTextStyles.bodyMedium),
Row(
children: [
TextButton(onPressed: onPrevious, child: const Text('قبلی')),
const SizedBox(width: 8),
TextButton(onPressed: onNext, child: const Text('بعدی')),
],
),
],
),
);
}
}
class _EmptyLicenseState extends StatelessWidget {
final String message;
const _EmptyLicenseState({required this.message});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 340,
constraints: const BoxConstraints(maxWidth: 340),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
children: [
Icon(
Icons.desktop_windows_outlined,
size: 120,
color: AppColors.sidebarBg.withValues(alpha: 0.7),
),
const SizedBox(height: 6),
Text(
'NO DATA',
style: AppTextStyles.headlineMedium.copyWith(
color: AppColors.sidebarBg,
fontSize: 18,
),
),
],
),
),
const SizedBox(height: 22),
Text(
message,
style: AppTextStyles.bodyMedium.copyWith(color: AppColors.sidebarBg),
),
],
);
}
}