import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../../core/api/pocketbase_client.dart'; import '../../core/theme/app_theme.dart'; import '../../models/job.dart'; import '../../models/job_file.dart'; import 'job_files_repository.dart'; const _maxFileSizeBytes = 50 * 1024 * 1024; // 50 MB per file const _maxFilesPerJob = 10; class JobFilesPanel extends StatefulWidget { const JobFilesPanel({ super.key, required this.job, required this.filesFuture, required this.onRefresh, }); final Job job; final Future> filesFuture; final VoidCallback onRefresh; @override State createState() => _JobFilesPanelState(); } class _JobFilesPanelState extends State { _UploadState? _upload; List? _files; bool _loadingFiles = false; String? _filesError; @override void initState() { super.initState(); _subscribeToFuture(widget.filesFuture); } @override void didUpdateWidget(JobFilesPanel oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.filesFuture != widget.filesFuture) { _subscribeToFuture(widget.filesFuture); } } void _subscribeToFuture(Future> future) { setState(() { _loadingFiles = true; _filesError = null; }); future.then((files) { if (mounted) setState(() { _files = files; _loadingFiles = false; }); }).catchError((e) { if (mounted) setState(() { _filesError = _friendlyError(e); _loadingFiles = false; }); }); } static String _friendlyError(Object e) { final s = e.toString(); // Strip full ClientException URL dumps — show only the message part final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s); if (msgMatch != null) return msgMatch.group(1)!.trim(); if (s.length > 100) return 'Sunucu hatası'; return s; } Future _pickAndUpload() async { final result = await FilePicker.platform.pickFiles( allowMultiple: true, withData: true, type: FileType.custom, allowedExtensions: [ 'pdf', 'jpg', 'jpeg', 'png', 'webp', 'stl', 'obj', 'ply', 'zip', 'opus', 'mp3', 'mp4' ], ); if (result == null || result.files.isEmpty || !mounted) return; // Client-side size validation for (final pf in result.files) { if (pf.size > _maxFileSizeBytes) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('${pf.name} 50 MB sınırını aşıyor (${_formatSize(pf.size)}).'), backgroundColor: AppColors.cancelled, )); return; } } final existingCount = _files?.length ?? 0; final remaining = _maxFilesPerJob - existingCount; if (result.files.length > remaining) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Bu iş en fazla $_maxFilesPerJob dosya alabilir. Şu an $existingCount dosya var; en fazla $remaining dosya daha ekleyebilirsiniz.'), backgroundColor: AppColors.cancelled, )); return; } int uploadedCount = 0; for (var i = 0; i < result.files.length; i++) { final pf = result.files[i]; if (pf.bytes == null) continue; setState(() { _upload = _UploadState( fileName: pf.name, fileIndex: i + 1, totalFiles: result.files.length, progress: 0, speedBytesPerSec: 0, ); }); try { await _uploadWithProgress( pf: pf, onProgress: (progress, speed) { if (mounted) { setState(() { _upload = _upload?.copyWith(progress: progress, speedBytesPerSec: speed); }); } }, ); uploadedCount++; } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${pf.name} yüklenemedi: ${_friendlyError(e)}'), backgroundColor: AppColors.cancelled), ); } setState(() => _upload = null); return; } } setState(() => _upload = null); widget.onRefresh(); if (mounted && uploadedCount > 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('$uploadedCount dosya yüklendi.')), ); } } Future _uploadWithProgress({ required PlatformFile pf, required void Function(double progress, double speedBytesPerSec) onProgress, }) async { final bytes = pf.bytes!; final pb = PocketBaseClient.instance.pb; final baseUrl = 'https://pocket.kovaksoft.com'; final uri = Uri.parse('$baseUrl/api/collections/job_files/records'); final ext = (pf.extension ?? '').toLowerCase(); final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply') ? JobFileKind.scan : (ext == 'pdf') ? JobFileKind.document : JobFileKind.image; final mimeType = _mimeFromExt(ext); final currentUserId = (pb.authStore.record?.id) ?? ''; final token = pb.authStore.token; final startTime = DateTime.now().millisecondsSinceEpoch; int sentBytes = 0; Stream> progressStream(List src) async* { const chunkSize = 65536; var offset = 0; while (offset < src.length) { final end = (offset + chunkSize).clamp(0, src.length); final chunk = src.sublist(offset, end); yield chunk; offset = end; sentBytes = offset; final elapsedMs = DateTime.now().millisecondsSinceEpoch - startTime; final speed = elapsedMs > 0 ? sentBytes / elapsedMs * 1000 : 0.0; onProgress(sentBytes / src.length, speed); // yield control so Flutter can rebuild await Future.delayed(Duration.zero); } } final request = http.MultipartRequest('POST', uri) ..headers['Authorization'] = 'Bearer $token' ..fields['job_id'] = widget.job.id ..fields['clinic_tenant_id'] = widget.job.clinicTenantId ..fields['lab_tenant_id'] = widget.job.labTenantId ..fields['uploaded_by'] = currentUserId ..fields['kind'] = kind.value ..fields['name'] = pf.name ..fields['size'] = bytes.length.toString() ..fields['mime_type'] = mimeType ..files.add(http.MultipartFile( 'file', http.ByteStream(progressStream(bytes)), bytes.length, filename: pf.name, )); final streamed = await request.send(); final body = await streamed.stream.bytesToString(); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { String msg = 'HTTP ${streamed.statusCode}'; try { final j = jsonDecode(body) as Map; msg = j['message'] as String? ?? msg; } catch (_) {} throw Exception(msg); } } Future _bulkDownload(List files) async { if (files.isEmpty) return; final messenger = ScaffoldMessenger.of(context); try { final pb = PocketBaseClient.instance.pb; final fileToken = await pb.files.getToken(); final dir = await getTemporaryDirectory(); await dir.create(recursive: true); // Download all files in parallel final results = await Future.wait( files.where((f) => f.downloadUrl.isNotEmpty).map((file) async { final uri = Uri.parse('${file.downloadUrl}?token=$fileToken'); final response = await http.get(uri); if (response.statusCode != 200) return null; final path = '${dir.path}/${file.name}'; await File(path).writeAsBytes(response.bodyBytes); return XFile(path, mimeType: file.mimeType ?? 'application/octet-stream'); }), ); final xFiles = results.whereType().toList(); if (xFiles.isEmpty) return; await Share.shareXFiles( xFiles, subject: '${widget.job.patientCode} dosyaları', sharePositionOrigin: const Rect.fromLTWH(100, 100, 200, 1), ); } catch (e) { if (mounted) { messenger.showSnackBar( SnackBar(content: Text('İndirilemedi: $e'), backgroundColor: AppColors.cancelled), ); } } } Future _deleteFile(JobFile file) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Dosyayı Sil'), content: Text(file.name), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')), FilledButton( style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), onPressed: () => Navigator.pop(ctx, true), child: const Text('Sil'), ), ], ), ); if (confirmed != true || !mounted) return; // Optimistic: remove immediately from local list setState(() => _files = _files?.where((f) => f.id != file.id).toList()); try { await JobFilesRepository.instance.deleteFile(file.id); } catch (e) { final is404 = e.toString().contains('404') || e.toString().contains('wasn\'t found'); if (!is404) { // Revert only on transient errors (network, 500) — not when already deleted setState(() => _files = [...?_files, file]); } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(is404 ? 'Dosya zaten silinmiş.' : 'Silinemedi: ${_friendlyError(e)}'), backgroundColor: AppColors.cancelled, ), ); } } } Future _downloadFile(JobFile file, Rect shareOrigin) async { if (file.downloadUrl.isEmpty) return; final messenger = ScaffoldMessenger.of(context); try { final pb = PocketBaseClient.instance.pb; final fileToken = await pb.files.getToken(); final uri = Uri.parse('${file.downloadUrl}?token=$fileToken'); final response = await http.get(uri); if (response.statusCode != 200) { throw Exception('HTTP ${response.statusCode}'); } final dir = await getTemporaryDirectory(); await dir.create(recursive: true); final path = '${dir.path}/${file.name}'; await File(path).writeAsBytes(response.bodyBytes); await Share.shareXFiles( [XFile(path, mimeType: file.mimeType ?? 'application/octet-stream')], subject: file.name, sharePositionOrigin: shareOrigin, ); } catch (e) { if (mounted) { messenger.showSnackBar( SnackBar( content: Text('İndirilemedi: $e'), backgroundColor: AppColors.cancelled, ), ); } } } @override Widget build(BuildContext context) { final uploading = _upload != null; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.attach_file_rounded, size: 18, color: AppColors.accent), const SizedBox(width: 6), const Expanded( child: Text('Dosyalar', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), ), if (!uploading) ...[ if ((_files?.length ?? 0) >= 2) TextButton.icon( onPressed: () => _bulkDownload(_files!), icon: const Icon(Icons.download_for_offline_outlined, size: 16), label: const Text('Hepsini İndir'), style: TextButton.styleFrom( foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8), ), ), TextButton.icon( onPressed: _pickAndUpload, icon: const Icon(Icons.upload_rounded, size: 16), label: const Text('Yükle'), style: TextButton.styleFrom( foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8), ), ), ], ], ), if (_upload != null) ...[ const SizedBox(height: 10), _UploadProgressBar(state: _upload!), ], const SizedBox(height: 8), Builder(builder: (ctx) { if (_loadingFiles && _files == null) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } if (_filesError != null && _files == null) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( 'Dosyalar yüklenemedi: $_filesError', style: const TextStyle(color: AppColors.cancelled, fontSize: 12), ), ); } final files = _files ?? []; if (files.isEmpty && !uploading) { return const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Text( 'Henüz dosya eklenmemiş.', style: TextStyle(color: AppColors.textMuted, fontSize: 13), ), ); } return Column( children: files .map((f) => _FileRow( file: f, onDelete: () => _deleteFile(f), onDownload: (origin) => _downloadFile(f, origin), )) .toList(), ); }, ), ], ), ); } static String _formatSize(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } static String _mimeFromExt(String ext) => switch (ext) { 'jpg' || 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'pdf' => 'application/pdf', 'stl' => 'model/stl', 'zip' => 'application/zip', 'mp3' => 'audio/mpeg', 'mp4' => 'video/mp4', 'opus' => 'audio/opus', _ => 'application/octet-stream', }; } // ── Upload Progress Bar ─────────────────────────────────────────────────────── class _UploadProgressBar extends StatelessWidget { const _UploadProgressBar({required this.state}); final _UploadState state; @override Widget build(BuildContext context) { final pct = (state.progress * 100).toStringAsFixed(0); final speed = state.speedBytesPerSec; final speedLabel = speed >= 1024 * 1024 ? '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s' : '${(speed / 1024).toStringAsFixed(0)} KB/s'; final fileLabel = state.totalFiles > 1 ? '${state.fileIndex}/${state.totalFiles} — ${state.fileName}' : state.fileName; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( fileLabel, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Text( '$pct% · $speedLabel', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.accent, fontFeatures: [FontFeature.tabularFigures()], ), ), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: state.progress, minHeight: 6, backgroundColor: AppColors.background, valueColor: const AlwaysStoppedAnimation(AppColors.accent), ), ), ], ); } } // ── File Row ────────────────────────────────────────────────────────────────── class _FileRow extends StatefulWidget { const _FileRow({ required this.file, required this.onDelete, required this.onDownload, }); final JobFile file; final VoidCallback onDelete; final Future Function(Rect) onDownload; @override State<_FileRow> createState() => _FileRowState(); } class _FileRowState extends State<_FileRow> { bool _downloading = false; IconData get _icon => switch (widget.file.kind) { JobFileKind.scan => Icons.view_in_ar_rounded, JobFileKind.image => Icons.image_outlined, JobFileKind.document => Icons.description_outlined, }; final _downloadKey = GlobalKey(); Future _handleDownload() async { setState(() => _downloading = true); final box = _downloadKey.currentContext?.findRenderObject() as RenderBox?; final origin = box != null ? box.localToGlobal(Offset.zero) & box.size : const Rect.fromLTWH(100, 100, 200, 1); try { await widget.onDownload(origin); } finally { if (mounted) setState(() => _downloading = false); } } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row( children: [ Icon(_icon, size: 18, color: AppColors.textMuted), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.file.name, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( '${widget.file.kind.label} · ${widget.file.sizeLabel}', style: const TextStyle(fontSize: 11, color: AppColors.textMuted), ), ], ), ), if (_downloading) const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent), ) else IconButton( key: _downloadKey, onPressed: _handleDownload, icon: const Icon(Icons.download_outlined, size: 18, color: AppColors.accent), padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: 'İndir', ), const SizedBox(width: 4), IconButton( onPressed: widget.onDelete, icon: const Icon(Icons.delete_outline_rounded, size: 18, color: AppColors.cancelled), padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: 'Sil', ), ], ), ); } } // ── Upload State ────────────────────────────────────────────────────────────── class _UploadState { const _UploadState({ required this.fileName, required this.fileIndex, required this.totalFiles, required this.progress, required this.speedBytesPerSec, }); final String fileName; final int fileIndex; final int totalFiles; final double progress; final double speedBytesPerSec; _UploadState copyWith({double? progress, double? speedBytesPerSec}) => _UploadState( fileName: fileName, fileIndex: fileIndex, totalFiles: totalFiles, progress: progress ?? this.progress, speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec, ); }