Add support for file uploads using new async upload jobs, add admin section for uploading distance files
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 59s
Release / web-build (push) Successful in 1m24s
Release / android-build (push) Successful in 6m13s
Release / release-master (push) Successful in 8s
Release / release-dev (push) Successful in 13s
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 59s
Release / web-build (push) Successful in 1m24s
Release / android-build (push) Successful in 6m13s
Release / release-master (push) Successful in 8s
Release / release-dev (push) Successful in 13s
This commit is contained in:
@@ -969,6 +969,10 @@ class _TractionPageState extends State<TractionPage> {
|
||||
Map<String, dynamic>? importResult;
|
||||
String? statusMessage;
|
||||
String? errorMessage;
|
||||
String? jobStatus;
|
||||
int? processed;
|
||||
int? total;
|
||||
double? progressValue;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -977,6 +981,65 @@ class _TractionPageState extends State<TractionPage> {
|
||||
final theme = Theme.of(sheetContext);
|
||||
return StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
int? parseCount(dynamic value) {
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
double? parsePercent(
|
||||
dynamic value, {
|
||||
required int? processed,
|
||||
required int? total,
|
||||
}) {
|
||||
if (value is num) {
|
||||
final raw = value.toDouble();
|
||||
final normalized = raw > 1 ? raw / 100 : raw;
|
||||
return normalized.clamp(0, 1);
|
||||
}
|
||||
if (processed != null && total != null && total > 0) {
|
||||
return (processed / total).clamp(0, 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Duration pollDelay(int attempt) {
|
||||
const delays = [
|
||||
Duration(seconds: 1),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 5),
|
||||
Duration(seconds: 5),
|
||||
Duration(seconds: 8),
|
||||
Duration(seconds: 10),
|
||||
];
|
||||
if (attempt < delays.length) return delays[attempt];
|
||||
return const Duration(seconds: 10);
|
||||
}
|
||||
|
||||
String statusLabel(
|
||||
String status, {
|
||||
required int? processed,
|
||||
required int? total,
|
||||
required double? percent,
|
||||
}) {
|
||||
final lower = status.toLowerCase();
|
||||
final base = switch (lower) {
|
||||
'queued' => 'Queued',
|
||||
'running' => 'Processing',
|
||||
'succeeded' => 'Completed',
|
||||
'failed' => 'Failed',
|
||||
_ => status,
|
||||
};
|
||||
final parts = <String>[base];
|
||||
if (processed != null && total != null) {
|
||||
parts.add('Rows $processed of $total');
|
||||
}
|
||||
if (percent != null) {
|
||||
parts.add('${(percent * 100).toStringAsFixed(0)}%');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
Future<void> pickFile() async {
|
||||
final file = await openFile(
|
||||
acceptedTypeGroups: const [
|
||||
@@ -992,6 +1055,10 @@ class _TractionPageState extends State<TractionPage> {
|
||||
importResult = null;
|
||||
statusMessage = null;
|
||||
errorMessage = null;
|
||||
jobStatus = null;
|
||||
processed = null;
|
||||
total = null;
|
||||
progressValue = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1003,6 +1070,10 @@ class _TractionPageState extends State<TractionPage> {
|
||||
importResult = null;
|
||||
statusMessage = null;
|
||||
errorMessage = null;
|
||||
jobStatus = null;
|
||||
processed = null;
|
||||
total = null;
|
||||
progressValue = null;
|
||||
});
|
||||
try {
|
||||
final data = context.read<DataService>();
|
||||
@@ -1013,32 +1084,96 @@ class _TractionPageState extends State<TractionPage> {
|
||||
filename: file.name,
|
||||
headers: const {'accept': 'application/json'},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!context.mounted) return;
|
||||
final parsed = response is Map
|
||||
? Map<String, dynamic>.from(response)
|
||||
: null;
|
||||
if (parsed != null) {
|
||||
final imported = _importCount(parsed['imported']);
|
||||
final updated = _importCount(parsed['updated']);
|
||||
final errors = _importErrors(parsed);
|
||||
final errorNote =
|
||||
errors.isNotEmpty ? ' (${errors.length} error(s))' : '';
|
||||
statusMessage =
|
||||
'Import complete. Imported $imported, updated $updated$errorNote.';
|
||||
importResult = parsed;
|
||||
} else {
|
||||
statusMessage = 'Import complete.';
|
||||
final jobId = parsed?['job_id']?.toString();
|
||||
if (jobId == null || jobId.isEmpty) {
|
||||
setModalState(() {
|
||||
errorMessage = 'Upload failed to start.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setModalState(() {
|
||||
jobStatus = parsed?['status']?.toString() ?? 'queued';
|
||||
});
|
||||
var attempt = 0;
|
||||
while (context.mounted) {
|
||||
final statusResponse =
|
||||
await data.api.get('/uploads/$jobId');
|
||||
if (!context.mounted) return;
|
||||
final statusMap = statusResponse is Map
|
||||
? Map<String, dynamic>.from(statusResponse)
|
||||
: null;
|
||||
if (statusMap == null) {
|
||||
setModalState(() {
|
||||
errorMessage = 'Upload status unavailable.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final status = statusMap['status']?.toString() ?? 'queued';
|
||||
final processedCount = parseCount(statusMap['processed']);
|
||||
final totalCount = parseCount(statusMap['total']);
|
||||
final percent = parsePercent(
|
||||
statusMap['percent'],
|
||||
processed: processedCount,
|
||||
total: totalCount,
|
||||
);
|
||||
setModalState(() {
|
||||
jobStatus = status;
|
||||
processed = processedCount;
|
||||
total = totalCount;
|
||||
progressValue = percent;
|
||||
});
|
||||
if (status == 'succeeded') {
|
||||
final result = statusMap['result'];
|
||||
Map<String, dynamic>? parsedResult;
|
||||
if (result is Map) {
|
||||
parsedResult = Map<String, dynamic>.from(result);
|
||||
}
|
||||
setModalState(() {
|
||||
importResult = parsedResult;
|
||||
if (importResult != null) {
|
||||
final imported =
|
||||
_importCount(importResult!['imported']);
|
||||
final updated = _importCount(importResult!['updated']);
|
||||
final errors = _importErrors(importResult!);
|
||||
final errorNote = errors.isNotEmpty
|
||||
? ' (${errors.length} error(s))'
|
||||
: '';
|
||||
statusMessage =
|
||||
'Import complete. Imported $imported, updated $updated$errorNote.';
|
||||
} else {
|
||||
statusMessage = 'Import complete.';
|
||||
}
|
||||
});
|
||||
await data.fetchClassList();
|
||||
await _refreshTraction(preservePosition: true);
|
||||
return;
|
||||
}
|
||||
if (status == 'failed') {
|
||||
setModalState(() {
|
||||
errorMessage =
|
||||
statusMap['error']?.toString() ?? 'Import failed.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Future.delayed(pollDelay(attempt));
|
||||
attempt += 1;
|
||||
}
|
||||
await data.fetchClassList();
|
||||
await _refreshTraction(preservePosition: true);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
errorMessage = e.message;
|
||||
if (!context.mounted) return;
|
||||
setModalState(() {
|
||||
errorMessage = e.message;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
errorMessage = e.toString();
|
||||
if (!context.mounted) return;
|
||||
setModalState(() {
|
||||
errorMessage = e.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
if (context.mounted) {
|
||||
setModalState(() => uploading = false);
|
||||
}
|
||||
}
|
||||
@@ -1098,6 +1233,22 @@ class _TractionPageState extends State<TractionPage> {
|
||||
uploading ? 'Importing...' : 'Upload and import',
|
||||
),
|
||||
),
|
||||
if (jobStatus != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
statusLabel(
|
||||
jobStatus!,
|
||||
processed: processed,
|
||||
total: total,
|
||||
percent: progressValue,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
if (progressValue != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
LinearProgressIndicator(value: progressValue),
|
||||
],
|
||||
],
|
||||
if (statusMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -1114,6 +1265,15 @@ class _TractionPageState extends State<TractionPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if ((jobStatus == 'failed' || errorMessage != null) &&
|
||||
selectedFile != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: uploading ? null : uploadFile,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry upload'),
|
||||
),
|
||||
],
|
||||
if (importResult != null)
|
||||
_buildImportSummary(context, importResult!),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user