Initial commit — DLS lab-app Flutter project

This commit is contained in:
egecankomur
2026-06-10 23:22:15 +03:00
commit d1acc1d367
225 changed files with 31294 additions and 0 deletions
+619
View File
@@ -0,0 +1,619 @@
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,
);
}