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

This commit is contained in:
2026-01-27 21:43:34 +00:00
parent 45bd872b23
commit d8bcde1312
4 changed files with 504 additions and 20 deletions

View File

@@ -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!),
],