514 lines
16 KiB
Dart
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,
|
|
});
|
|
}
|