Files
lab-app/lib/features/shared/job_files_panel.dart
T
Emre Emir 8bbc9dbff2 Initial commit: DLS - Dental Lab System
- Flutter + PocketBase dental lab management system
- Clinic & lab dashboards, job tracking, patient management
- Product catalog, finance tracking, multi-language support
- AI assistant integration, realtime notifications
- Windows installer (Inno Setup) included
- Developed by kovakyazilim.com
2026-06-11 15:57:31 +03:00

620 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<List<JobFile>> filesFuture;
final VoidCallback onRefresh;
@override
State<JobFilesPanel> createState() => _JobFilesPanelState();
}
class _JobFilesPanelState extends State<JobFilesPanel> {
_UploadState? _upload;
List<JobFile>? _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<List<JobFile>> 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<void> _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<void> _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<List<int>> progressStream(List<int> 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<void>.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<String, dynamic>;
msg = j['message'] as String? ?? msg;
} catch (_) {}
throw Exception(msg);
}
}
Future<void> _bulkDownload(List<JobFile> 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<XFile>().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<void> _deleteFile(JobFile file) async {
final confirmed = await showDialog<bool>(
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<void> _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<Color>(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<void> 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<void> _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,
);
}