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

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