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:
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
@@ -28,6 +29,16 @@ class _AdminPageState extends State<AdminPage> {
|
||||
|
||||
bool _sending = false;
|
||||
|
||||
List<XFile> _routeFiles = [];
|
||||
bool _routeUploading = false;
|
||||
String? _routeStatus;
|
||||
String? _routeStatusMessage;
|
||||
String? _routeErrorMessage;
|
||||
int? _routeProcessed;
|
||||
int? _routeTotal;
|
||||
double? _routeProgress;
|
||||
Map<String, dynamic>? _routeResult;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -340,6 +351,191 @@ class _AdminPageState extends State<AdminPage> {
|
||||
}
|
||||
}
|
||||
|
||||
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 _routeStatusLabel() {
|
||||
final status = _routeStatus ?? '';
|
||||
final lower = status.toLowerCase();
|
||||
final base = switch (lower) {
|
||||
'queued' => 'Queued',
|
||||
'running' => 'Processing',
|
||||
'succeeded' => 'Completed',
|
||||
'failed' => 'Failed',
|
||||
_ => status,
|
||||
};
|
||||
final parts = <String>[base];
|
||||
if (_routeProcessed != null && _routeTotal != null) {
|
||||
parts.add('Files $_routeProcessed of $_routeTotal');
|
||||
}
|
||||
if (_routeProgress != null) {
|
||||
parts.add('${(_routeProgress! * 100).toStringAsFixed(0)}%');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
Future<void> _pickRouteFiles() async {
|
||||
final files = await openFiles(
|
||||
acceptedTypeGroups: const [
|
||||
XTypeGroup(
|
||||
label: 'XLSX spreadsheets',
|
||||
extensions: ['xlsx'],
|
||||
),
|
||||
],
|
||||
);
|
||||
if (files.isEmpty) return;
|
||||
setState(() {
|
||||
_routeFiles = files;
|
||||
_routeStatus = null;
|
||||
_routeStatusMessage = null;
|
||||
_routeErrorMessage = null;
|
||||
_routeProcessed = null;
|
||||
_routeTotal = null;
|
||||
_routeProgress = null;
|
||||
_routeResult = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _uploadRouteFiles() async {
|
||||
if (_routeFiles.isEmpty || _routeUploading) return;
|
||||
setState(() {
|
||||
_routeUploading = true;
|
||||
_routeStatus = null;
|
||||
_routeStatusMessage = null;
|
||||
_routeErrorMessage = null;
|
||||
_routeProcessed = null;
|
||||
_routeTotal = null;
|
||||
_routeProgress = null;
|
||||
_routeResult = null;
|
||||
});
|
||||
try {
|
||||
final api = context.read<ApiService>();
|
||||
final payloads = <MultipartFilePayload>[];
|
||||
for (final file in _routeFiles) {
|
||||
final bytes = await file.readAsBytes();
|
||||
payloads.add(
|
||||
MultipartFilePayload(
|
||||
bytes: bytes,
|
||||
filename: file.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
final response = await api.postMultipartFiles(
|
||||
'/route/update',
|
||||
files: payloads,
|
||||
headers: const {'accept': 'application/json'},
|
||||
);
|
||||
if (!mounted) return;
|
||||
final parsed = response is Map
|
||||
? Map<String, dynamic>.from(response)
|
||||
: null;
|
||||
final jobId = parsed?['job_id']?.toString();
|
||||
if (jobId == null || jobId.isEmpty) {
|
||||
setState(() {
|
||||
_routeErrorMessage = 'Upload failed to start.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_routeStatus = parsed?['status']?.toString() ?? 'queued';
|
||||
});
|
||||
var attempt = 0;
|
||||
while (mounted) {
|
||||
final statusResponse = await api.get('/uploads/$jobId');
|
||||
if (!mounted) return;
|
||||
final statusMap = statusResponse is Map
|
||||
? Map<String, dynamic>.from(statusResponse)
|
||||
: null;
|
||||
if (statusMap == null) {
|
||||
setState(() {
|
||||
_routeErrorMessage = 'Upload status unavailable.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final status = statusMap['status']?.toString() ?? 'queued';
|
||||
final processed = _parseCount(statusMap['processed']);
|
||||
final total = _parseCount(statusMap['total']);
|
||||
final percent = _parsePercent(
|
||||
statusMap['percent'],
|
||||
processed: processed,
|
||||
total: total,
|
||||
);
|
||||
setState(() {
|
||||
_routeStatus = status;
|
||||
_routeProcessed = processed;
|
||||
_routeTotal = total;
|
||||
_routeProgress = percent;
|
||||
});
|
||||
if (status == 'succeeded') {
|
||||
final result = statusMap['result'];
|
||||
setState(() {
|
||||
if (result is Map) {
|
||||
_routeResult = Map<String, dynamic>.from(result);
|
||||
}
|
||||
final message = _routeResult?['message']?.toString();
|
||||
_routeStatusMessage = message != null && message.isNotEmpty
|
||||
? message
|
||||
: 'Route update complete.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (status == 'failed') {
|
||||
setState(() {
|
||||
_routeErrorMessage =
|
||||
statusMap['error']?.toString() ?? 'Route update failed.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Future.delayed(_pollDelay(attempt));
|
||||
attempt += 1;
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_routeErrorMessage = e.message;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_routeErrorMessage = e.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => _routeUploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
@@ -443,6 +639,87 @@ class _AdminPageState extends State<AdminPage> {
|
||||
label: Text(_sending ? 'Sending...' : 'Send notification'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
'Route update uploads',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Upload one or more XLSX sheets to update route distances.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_routeFiles.isEmpty
|
||||
? 'No files selected'
|
||||
: '${_routeFiles.length} file(s) selected',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _routeUploading ? null : _pickRouteFiles,
|
||||
icon: const Icon(Icons.upload_file),
|
||||
label: const Text('Choose files'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed:
|
||||
_routeFiles.isEmpty || _routeUploading ? null : _uploadRouteFiles,
|
||||
icon: _routeUploading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.file_upload),
|
||||
label: Text(_routeUploading ? 'Uploading...' : 'Upload files'),
|
||||
),
|
||||
if (_routeStatus != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_routeStatusLabel(),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (_routeProgress != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
LinearProgressIndicator(value: _routeProgress),
|
||||
],
|
||||
],
|
||||
if (_routeStatusMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_routeStatusMessage!,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
if (_routeErrorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_routeErrorMessage!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if ((_routeStatus == 'failed' || _routeErrorMessage != null) &&
|
||||
_routeFiles.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _routeUploading ? null : _uploadRouteFiles,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry upload'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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!),
|
||||
],
|
||||
|
||||
@@ -4,6 +4,18 @@ import 'package:http/http.dart' as http;
|
||||
typedef TokenProvider = String? Function();
|
||||
typedef UnauthorizedHandler = Future<bool> Function();
|
||||
|
||||
class MultipartFilePayload {
|
||||
MultipartFilePayload({
|
||||
required this.bytes,
|
||||
required this.filename,
|
||||
this.fieldName,
|
||||
});
|
||||
|
||||
final List<int> bytes;
|
||||
final String filename;
|
||||
final String? fieldName;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
String _baseUrl;
|
||||
final http.Client _client;
|
||||
@@ -192,6 +204,41 @@ class ApiService {
|
||||
return _processResponse(response);
|
||||
}
|
||||
|
||||
Future<dynamic> postMultipartFiles(
|
||||
String endpoint, {
|
||||
required List<MultipartFilePayload> files,
|
||||
String fieldName = 'files',
|
||||
Map<String, String>? fields,
|
||||
Map<String, String>? headers,
|
||||
bool includeAuth = true,
|
||||
bool allowRetry = true,
|
||||
}) async {
|
||||
Future<http.Response> send() async {
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$baseUrl$endpoint'),
|
||||
);
|
||||
request.headers.addAll(_buildHeaders(headers, includeAuth: includeAuth));
|
||||
if (fields != null && fields.isNotEmpty) {
|
||||
request.fields.addAll(fields);
|
||||
}
|
||||
for (final file in files) {
|
||||
request.files.add(
|
||||
http.MultipartFile.fromBytes(
|
||||
file.fieldName ?? fieldName,
|
||||
file.bytes,
|
||||
filename: file.filename,
|
||||
),
|
||||
);
|
||||
}
|
||||
final streamed = await _client.send(request);
|
||||
return http.Response.fromStream(streamed);
|
||||
}
|
||||
|
||||
final response = await _sendWithRetry(send, allowRetry: allowRetry);
|
||||
return _processResponse(response);
|
||||
}
|
||||
|
||||
Future<dynamic> postForm(
|
||||
String endpoint,
|
||||
Map<String, String> data, {
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.8.0+17
|
||||
version: 0.8.1+18
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
Reference in New Issue
Block a user