Initial commit — DLS lab-app Flutter project
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user