630 lines
19 KiB
Dart
630 lines
19 KiB
Dart
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.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_page.dart';
|
|
import 'ticket_page.dart';
|
|
import 'transaction_page.dart';
|
|
|
|
class CourseDetailsPage extends StatefulWidget {
|
|
final int courseId;
|
|
|
|
const CourseDetailsPage({super.key, required this.courseId});
|
|
|
|
@override
|
|
State<CourseDetailsPage> createState() => _CourseDetailsPageState();
|
|
}
|
|
|
|
class _CourseDetailsPageState extends State<CourseDetailsPage> {
|
|
bool _isLoading = true;
|
|
String? _errorMessage;
|
|
Map<String, dynamic>? _course;
|
|
|
|
final TextEditingController _videoTitleController = TextEditingController();
|
|
|
|
List<Map<String, dynamic>> _courseVideos = <Map<String, dynamic>>[];
|
|
Uint8List? _pickedVideoBytes;
|
|
String? _pickedVideoName;
|
|
int? _pickedVideoSize;
|
|
|
|
bool _isUploadingVideo = false;
|
|
int _uploadPercent = 0;
|
|
String? _uploadErrorMessage;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadCourse();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_videoTitleController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadCourse() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final apiService = Provider.of<ApiService>(context, listen: false);
|
|
final response = await apiService.getCourseById(widget.courseId);
|
|
final data = response['data'];
|
|
final course = data is Map<String, dynamic> ? data : <String, dynamic>{};
|
|
|
|
setState(() {
|
|
_course = course;
|
|
_isLoading = false;
|
|
});
|
|
|
|
_initVideosFromCourse(course);
|
|
} catch (e) {
|
|
setState(() {
|
|
_errorMessage = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _initVideosFromCourse(Map<String, dynamic> course) {
|
|
final previewUrl = course['preview_video_url']?.toString();
|
|
if (previewUrl == null || previewUrl.isEmpty) {
|
|
setState(() {
|
|
_courseVideos = <Map<String, dynamic>>[];
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_courseVideos = <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'title': 'ویدیوی پیشنمایش',
|
|
'preview_video_url': previewUrl,
|
|
'created_at': '-',
|
|
'updated_at': '-',
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
Future<void> _pickVideoFile() async {
|
|
final result = await FilePicker.platform.pickFiles(
|
|
type: FileType.video,
|
|
withData: true,
|
|
);
|
|
|
|
if (result == null || result.files.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final file = result.files.first;
|
|
if (file.bytes == null) {
|
|
setState(() {
|
|
_uploadErrorMessage = 'خواندن فایل انتخابشده ناموفق بود.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_pickedVideoBytes = file.bytes;
|
|
_pickedVideoName = file.name;
|
|
_pickedVideoSize = file.size;
|
|
_uploadErrorMessage = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _uploadVideo() async {
|
|
final title = _videoTitleController.text.trim();
|
|
if (title.isEmpty) {
|
|
setState(() {
|
|
_uploadErrorMessage = 'عنوان ویدیو الزامی است.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (title.length > 255) {
|
|
setState(() {
|
|
_uploadErrorMessage = 'حداکثر طول عنوان ویدیو 255 کاراکتر است.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (_pickedVideoBytes == null) {
|
|
setState(() {
|
|
_uploadErrorMessage = 'فایل ویدیو را انتخاب کنید.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isUploadingVideo = true;
|
|
_uploadPercent = 0;
|
|
_uploadErrorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final apiService = Provider.of<ApiService>(context, listen: false);
|
|
final response = await apiService.uploadEncryptedCourseVideo(
|
|
courseId: widget.courseId,
|
|
title: title,
|
|
sourceVideoBytes: _pickedVideoBytes!,
|
|
onSendProgress: (sent, total) {
|
|
if (!mounted || total <= 0) return;
|
|
setState(() {
|
|
_uploadPercent = ((sent / total) * 100).round();
|
|
});
|
|
},
|
|
);
|
|
|
|
final data = response['data'];
|
|
final created = data is Map<String, dynamic>
|
|
? Map<String, dynamic>.from(data)
|
|
: <String, dynamic>{'title': title};
|
|
|
|
setState(() {
|
|
_courseVideos = <Map<String, dynamic>>[created, ..._courseVideos];
|
|
_isUploadingVideo = false;
|
|
_uploadPercent = 100;
|
|
_videoTitleController.clear();
|
|
_pickedVideoBytes = null;
|
|
_pickedVideoName = null;
|
|
_pickedVideoSize = null;
|
|
});
|
|
|
|
if (!mounted) return;
|
|
final message =
|
|
(response['message'] ?? 'ویدیوی رمزنگاری شده با موفقیت دریافت و ثبت شد.')
|
|
.toString();
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
} catch (e) {
|
|
setState(() {
|
|
_isUploadingVideo = false;
|
|
_uploadErrorMessage = e.toString();
|
|
});
|
|
}
|
|
}
|
|
|
|
@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: () {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (context) => const CoursePage()),
|
|
);
|
|
},
|
|
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: Padding(
|
|
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: _buildContent(isDesktop),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(bool isDesktop) {
|
|
if (_isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (_errorMessage != null) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
_errorMessage!,
|
|
style: AppTextStyles.bodyMedium.copyWith(color: Colors.red),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 12),
|
|
OutlinedButton(
|
|
onPressed: _loadCourse,
|
|
child: const Text('تلاش مجدد'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_course == null || _course!.isEmpty) {
|
|
return const Center(child: Text('اطلاعات دوره یافت نشد.'));
|
|
}
|
|
|
|
final title = (_course!['title'] ?? '-').toString();
|
|
final description = (_course!['description'] ?? '-').toString();
|
|
final price = (_course!['price'] ?? 0).toString();
|
|
final coverUrl = _course!['cover_url']?.toString();
|
|
final previewVideoUrl = _course!['preview_video_url']?.toString();
|
|
final teacher = _extractTeacherName(_course!['teacher_name']);
|
|
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
InkWell(
|
|
onTap: () => Navigator.of(context).pop(),
|
|
child: const Icon(Icons.arrow_back, color: AppColors.sidebarBg),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
'نمایش دوره',
|
|
style: AppTextStyles.headlineMedium.copyWith(
|
|
color: AppColors.sidebarBg,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Container(height: 1, color: Colors.black12),
|
|
const SizedBox(height: 20),
|
|
if (isDesktop)
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(flex: 3, child: _buildCover(coverUrl)),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
flex: 5,
|
|
child: _buildInfoBox(
|
|
title: title,
|
|
teacher: teacher,
|
|
price: price,
|
|
description: description,
|
|
previewVideoUrl: previewVideoUrl,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else ...[
|
|
_buildCover(coverUrl),
|
|
const SizedBox(height: 12),
|
|
_buildInfoBox(
|
|
title: title,
|
|
teacher: teacher,
|
|
price: price,
|
|
description: description,
|
|
previewVideoUrl: previewVideoUrl,
|
|
),
|
|
],
|
|
const SizedBox(height: 20),
|
|
_buildVideoUploadSection(),
|
|
const SizedBox(height: 12),
|
|
_buildVideoListSection(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCover(String? coverUrl) {
|
|
return Container(
|
|
height: 280,
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.black12),
|
|
color: Colors.white,
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: coverUrl == null || coverUrl.isEmpty
|
|
? const Center(
|
|
child: Icon(Icons.image_not_supported_outlined, size: 56),
|
|
)
|
|
: Image.network(
|
|
coverUrl,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, _, _) => const Center(
|
|
child: Icon(Icons.broken_image_outlined, size: 56),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoBox({
|
|
required String title,
|
|
required String teacher,
|
|
required String price,
|
|
required String description,
|
|
required String? previewVideoUrl,
|
|
}) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.black12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_InfoRow(label: 'عنوان دوره', value: title),
|
|
const SizedBox(height: 10),
|
|
_InfoRow(label: 'مدرس', value: teacher),
|
|
const SizedBox(height: 10),
|
|
_InfoRow(label: 'قیمت', value: '$price تومان'),
|
|
const SizedBox(height: 10),
|
|
_InfoRow(label: 'توضیحات', value: description, multiLine: true),
|
|
const SizedBox(height: 10),
|
|
_buildPreviewVideoSection(previewVideoUrl),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPreviewVideoSection(String? previewVideoUrl) {
|
|
final hasUrl = previewVideoUrl != null && previewVideoUrl.isNotEmpty;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('لینک ویدیوی پیشنمایش', style: AppTextStyles.bodyLarge),
|
|
const SizedBox(height: 8),
|
|
SelectableText(
|
|
hasUrl ? previewVideoUrl : '-',
|
|
style: AppTextStyles.bodyMedium,
|
|
),
|
|
if (hasUrl) ...[
|
|
const SizedBox(height: 8),
|
|
TextButton.icon(
|
|
onPressed: () async {
|
|
await Clipboard.setData(ClipboardData(text: previewVideoUrl));
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('لینک کپی شد.')));
|
|
},
|
|
icon: const Icon(Icons.copy, size: 16),
|
|
label: const Text('کپی لینک'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVideoUploadSection() {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.black12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('آپلود ویدیوی جدید', style: AppTextStyles.headlineMedium),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _videoTitleController,
|
|
decoration: InputDecoration(
|
|
labelText: 'عنوان ویدیو *',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_pickedVideoName == null
|
|
? 'فایلی انتخاب نشده'
|
|
: '$_pickedVideoName (${((_pickedVideoSize ?? 0) / (1024 * 1024)).toStringAsFixed(2)} MB)',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: AppTextStyles.bodyMedium,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: _isUploadingVideo ? null : _pickVideoFile,
|
|
icon: const Icon(Icons.upload_file),
|
|
label: const Text('انتخاب فایل'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
if (_isUploadingVideo) ...[
|
|
LinearProgressIndicator(value: _uploadPercent / 100),
|
|
const SizedBox(height: 6),
|
|
Text('پیشرفت آپلود: $_uploadPercent%', style: AppTextStyles.bodyMedium),
|
|
],
|
|
if (_uploadErrorMessage != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_uploadErrorMessage!,
|
|
style: AppTextStyles.bodyMedium.copyWith(color: Colors.red),
|
|
),
|
|
],
|
|
const SizedBox(height: 10),
|
|
ElevatedButton.icon(
|
|
onPressed: _isUploadingVideo ? null : _uploadVideo,
|
|
icon: const Icon(Icons.cloud_upload_outlined),
|
|
label: const Text('رمزنگاری و آپلود ویدیو'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.sidebarBg,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVideoListSection() {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.black12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('لیست ویدیوهای آپلودشده', style: AppTextStyles.headlineMedium),
|
|
const SizedBox(height: 10),
|
|
if (_courseVideos.isEmpty)
|
|
Text(
|
|
'فعلاً ویدیویی برای این دوره ثبت نشده است.',
|
|
style: AppTextStyles.bodyMedium,
|
|
)
|
|
else
|
|
ListView.separated(
|
|
itemCount: _courseVideos.length,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
separatorBuilder: (_, _) => const Divider(height: 16),
|
|
itemBuilder: (context, index) {
|
|
final item = _courseVideos[index];
|
|
final title = (item['title'] ?? 'بدون عنوان').toString();
|
|
final createdAt = (item['created_at'] ?? '-').toString();
|
|
final previewUrl = item['preview_video_url']?.toString();
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.sidebarBg.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.ondemand_video, size: 18),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: AppTextStyles.bodyLarge.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text('تاریخ ثبت: $createdAt', style: AppTextStyles.bodyMedium),
|
|
if (previewUrl != null && previewUrl.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
SelectableText(
|
|
previewUrl,
|
|
style: AppTextStyles.bodyMedium,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _extractTeacherName(dynamic teacherObj) {
|
|
if (teacherObj is Map<String, dynamic>) {
|
|
final value = teacherObj['user_name'];
|
|
if (value is String && value.trim().isNotEmpty) {
|
|
return value;
|
|
}
|
|
}
|
|
return '-';
|
|
}
|
|
}
|
|
|
|
class _InfoRow extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final bool multiLine;
|
|
|
|
const _InfoRow({
|
|
required this.label,
|
|
required this.value,
|
|
this.multiLine = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
crossAxisAlignment: multiLine
|
|
? CrossAxisAlignment.start
|
|
: CrossAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 90,
|
|
child: Text(
|
|
'$label:',
|
|
style: AppTextStyles.bodyMedium.copyWith(color: Colors.black54),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
maxLines: multiLine ? null : 2,
|
|
overflow: multiLine ? null : TextOverflow.ellipsis,
|
|
style: AppTextStyles.bodyLarge,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|