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

514 lines
16 KiB
Dart

import 'dart:typed_data';
import 'package:file_picker/file_picker.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 'dashboard_page.dart';
import 'license_page.dart';
import 'ticket_page.dart';
import 'transaction_page.dart';
class NewCoursePage extends StatefulWidget {
const NewCoursePage({super.key});
@override
State<NewCoursePage> createState() => _NewCoursePageState();
}
class _NewCoursePageState extends State<NewCoursePage> {
final TextEditingController _titleController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
final TextEditingController _priceController = TextEditingController(
text: '0',
);
final TextEditingController _teacherPhoneController = TextEditingController();
final TextEditingController _altController = TextEditingController();
// Not in current API contract. Keep for future backend support.
// final TextEditingController _supportLinkController = TextEditingController();
// final TextEditingController _accessLimitController = TextEditingController(text: '-1');
// final TextEditingController _noteController = TextEditingController();
_PickedLocalFile? _coverFile;
_PickedLocalFile? _previewVideoFile;
bool _isSubmitting = false;
String? _errorMessage;
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_priceController.dispose();
_teacherPhoneController.dispose();
_altController.dispose();
super.dispose();
}
Future<void> _pickCover() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp'],
withData: true,
);
if (result == null || result.files.isEmpty) {
return;
}
final picked = result.files.first;
if (picked.bytes == null) {
setState(() {
_errorMessage = 'خواندن فایل تصویر ناموفق بود.';
});
return;
}
setState(() {
_coverFile = _PickedLocalFile(
name: picked.name,
bytes: picked.bytes!,
sizeBytes: picked.size,
);
_errorMessage = null;
});
}
Future<void> _pickPreviewVideo() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['mp4', 'mov', 'mkv', 'avi', 'webm'],
withData: true,
);
if (result == null || result.files.isEmpty) {
return;
}
final picked = result.files.first;
if (picked.bytes == null) {
setState(() {
_errorMessage = 'خواندن فایل ویدیو ناموفق بود.';
});
return;
}
const maxVideoBytes = 50 * 1024 * 1024;
if (picked.size > maxVideoBytes) {
setState(() {
_errorMessage = 'حجم ویدیوی پیش‌نمایش باید حداکثر 50 مگابایت باشد.';
});
return;
}
setState(() {
_previewVideoFile = _PickedLocalFile(
name: picked.name,
bytes: picked.bytes!,
sizeBytes: picked.size,
);
_errorMessage = null;
});
}
Future<void> _submit() async {
final title = _titleController.text.trim();
final description = _descriptionController.text.trim();
final priceRaw = _priceController.text.trim();
final teacherPhone = _teacherPhoneController.text.trim();
final alt = _altController.text.trim();
if (title.isEmpty) {
setState(() => _errorMessage = 'عنوان دوره الزامی است.');
return;
}
if (title.length > 255) {
setState(() => _errorMessage = 'حداکثر طول عنوان 255 کاراکتر است.');
return;
}
if (description.isEmpty) {
setState(() => _errorMessage = 'توضیحات دوره الزامی است.');
return;
}
if (alt.isEmpty) {
setState(() => _errorMessage = 'Alt الزامی است.');
return;
}
if (alt.length > 225) {
setState(() => _errorMessage = 'حداکثر طول alt 225 کاراکتر است.');
return;
}
final price = num.tryParse(priceRaw);
if (price == null || price < 0) {
setState(
() => _errorMessage = 'قیمت باید عدد معتبر بزرگ‌تر یا مساوی صفر باشد.',
);
return;
}
if (_coverFile == null) {
setState(() => _errorMessage = 'تصویر دوره الزامی است.');
return;
}
if (_previewVideoFile == null) {
setState(() => _errorMessage = 'ویدیوی پیش‌نمایش الزامی است.');
return;
}
if (teacherPhone.isEmpty) {
setState(() => _errorMessage = 'شماره موبایل مدرس الزامی است.');
return;
}
if (!RegExp(r'^09\d{9}$').hasMatch(teacherPhone)) {
setState(() => _errorMessage = 'فرمت شماره مدرس صحیح نیست.');
return;
}
setState(() {
_isSubmitting = true;
_errorMessage = null;
});
try {
final apiService = Provider.of<ApiService>(context, listen: false);
final response = await apiService.createCourse(
title: title,
description: description,
price: price,
coverBytes: _coverFile!.bytes,
coverFileName: _coverFile!.name,
previewVideoBytes: _previewVideoFile!.bytes,
previewVideoFileName: _previewVideoFile!.name,
teacherPhone: teacherPhone,
alt: alt,
);
if (!mounted) return;
final serverMessage = (response['message'] ?? 'دوره با موفقیت ساخته شد.')
.toString();
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(serverMessage)));
Navigator.of(context).pop();
} catch (e) {
setState(() {
_errorMessage = e.toString();
});
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width > 900;
return TeacherPanelScaffold(
selectedMenu: PanelMenu.course,
onDashboardTap: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const DashboardPage()),
);
},
onCourseTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
},
onLicenseTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const PanelLicensePage()),
);
},
onTransactionTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const TransactionPage()),
);
},
onTicketTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => const TicketPage()));
},
child: SingleChildScrollView(
padding: EdgeInsets.all(isDesktop ? 24 : 12),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
),
padding: EdgeInsets.all(isDesktop ? 18 : 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => Navigator.pop(context),
child: const Icon(Icons.arrow_back, color: AppColors.sidebarBg),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Icon(
Icons.school,
color: AppColors.sidebarBg,
size: 18,
),
const SizedBox(width: 6),
Text(
'دوره جدید',
style: AppTextStyles.headlineMedium.copyWith(
color: AppColors.sidebarBg,
fontSize: 20,
),
),
],
),
const SizedBox(height: 14),
Container(height: 1, color: Colors.black12),
const SizedBox(height: 16),
Text('اطلاعات دوره', style: AppTextStyles.headlineMedium),
const SizedBox(height: 14),
_buildLabel('نام دوره *'),
const SizedBox(height: 6),
_buildInput(_titleController),
const SizedBox(height: 12),
_buildLabel('توضیحات *'),
const SizedBox(height: 6),
_buildInput(_descriptionController, minLines: 3, maxLines: 5),
const SizedBox(height: 12),
_buildLabel('Alt *'),
const SizedBox(height: 6),
_buildInput(_altController),
const SizedBox(height: 12),
_buildLabel('قیمت *'),
const SizedBox(height: 6),
_buildInput(
_priceController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
const SizedBox(height: 12),
_buildLabel('شماره موبایل مدرس *'),
const SizedBox(height: 6),
_buildInput(
_teacherPhoneController,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
_buildLabel('تصویر دوره (cover) *'),
const SizedBox(height: 6),
_buildFilePickerRow(
fileName: _coverFile?.name ?? 'فایلی انتخاب نشده',
sizeBytes: _coverFile?.sizeBytes,
onPick: _pickCover,
buttonLabel: 'انتخاب تصویر',
),
const SizedBox(height: 12),
_buildLabel('ویدیوی پیش‌نمایش (preview_video) *'),
const SizedBox(height: 6),
_buildFilePickerRow(
fileName: _previewVideoFile?.name ?? 'فایلی انتخاب نشده',
sizeBytes: _previewVideoFile?.sizeBytes,
onPick: _pickPreviewVideo,
buttonLabel: 'انتخاب ویدیو',
),
const SizedBox(height: 6),
Text(
'حداکثر حجم ویدیوی پیش‌نمایش: 50 مگابایت',
style: AppTextStyles.bodyMedium,
),
// Not in current API contract. Keep for future backend support.
// const SizedBox(height: 22),
// Text('تنظیمات پیش‌فرض لایسنس جدید', style: AppTextStyles.headlineMedium),
// const SizedBox(height: 14),
// _buildLabel('حد دسترسی'),
// const SizedBox(height: 6),
// _buildInput(_accessLimitController),
// const SizedBox(height: 12),
// _buildLabel('لینک پشتیبانی'),
// const SizedBox(height: 6),
// _buildInput(_supportLinkController),
// const SizedBox(height: 22),
// _buildLabel('یادداشت'),
// const SizedBox(height: 8),
// _buildInput(_noteController, minLines: 4, maxLines: 6),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
_errorMessage!,
style: AppTextStyles.bodyMedium.copyWith(color: Colors.red),
),
],
const SizedBox(height: 22),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
SizedBox(
width: isDesktop ? 140 : double.infinity,
height: 44,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF43C61C),
foregroundColor: Colors.white,
),
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('تایید'),
),
),
SizedBox(
width: isDesktop ? 140 : double.infinity,
height: 44,
child: ElevatedButton(
onPressed: _isSubmitting
? null
: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFC95757),
foregroundColor: Colors.white,
),
child: const Text('انصراف'),
),
),
],
),
],
),
),
),
);
}
Widget _buildLabel(String text) {
return Text(text, style: AppTextStyles.bodyLarge);
}
Widget _buildInput(
TextEditingController controller, {
TextInputType? keyboardType,
int minLines = 1,
int maxLines = 1,
}) {
return TextField(
controller: controller,
keyboardType: keyboardType,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.sidebarBg.withValues(alpha: 0.7),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.sidebarBg.withValues(alpha: 0.7),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
);
}
Widget _buildFilePickerRow({
required String fileName,
required int? sizeBytes,
required VoidCallback onPick,
required String buttonLabel,
}) {
return Row(
children: [
Expanded(
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColors.sidebarBg.withValues(alpha: 0.7),
),
),
alignment: Alignment.centerRight,
child: Text(
sizeBytes == null
? fileName
: '$fileName (${(sizeBytes / (1024 * 1024)).toStringAsFixed(2)} MB)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.bodyLarge.copyWith(
color: AppColors.sidebarBg,
),
),
),
),
const SizedBox(width: 8),
SizedBox(
height: 44,
child: OutlinedButton(
onPressed: onPick,
style: OutlinedButton.styleFrom(
side: BorderSide(
color: AppColors.sidebarBg.withValues(alpha: 0.7),
),
),
child: Text(buttonLabel),
),
),
],
);
}
}
class _PickedLocalFile {
final String name;
final Uint8List bytes;
final int sizeBytes;
_PickedLocalFile({
required this.name,
required this.bytes,
required this.sizeBytes,
});
}