diff --git a/lib/components/pages/more/admin_page.dart b/lib/components/pages/more/admin_page.dart index 1325e2f..40b683c 100644 --- a/lib/components/pages/more/admin_page.dart +++ b/lib/components/pages/more/admin_page.dart @@ -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 { bool _sending = false; + List _routeFiles = []; + bool _routeUploading = false; + String? _routeStatus; + String? _routeStatusMessage; + String? _routeErrorMessage; + int? _routeProcessed; + int? _routeTotal; + double? _routeProgress; + Map? _routeResult; + @override void initState() { super.initState(); @@ -340,6 +351,191 @@ class _AdminPageState extends State { } } + 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 = [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 _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 _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(); + final payloads = []; + 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.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.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.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 { 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'), + ), + ], ], ), ); diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index 0bfd250..6090c45 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -969,6 +969,10 @@ class _TractionPageState extends State { Map? importResult; String? statusMessage; String? errorMessage; + String? jobStatus; + int? processed; + int? total; + double? progressValue; await showModalBottomSheet( context: context, @@ -977,6 +981,65 @@ class _TractionPageState extends State { 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 = [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 pickFile() async { final file = await openFile( acceptedTypeGroups: const [ @@ -992,6 +1055,10 @@ class _TractionPageState extends State { importResult = null; statusMessage = null; errorMessage = null; + jobStatus = null; + processed = null; + total = null; + progressValue = null; }); } @@ -1003,6 +1070,10 @@ class _TractionPageState extends State { importResult = null; statusMessage = null; errorMessage = null; + jobStatus = null; + processed = null; + total = null; + progressValue = null; }); try { final data = context.read(); @@ -1013,32 +1084,96 @@ class _TractionPageState extends State { filename: file.name, headers: const {'accept': 'application/json'}, ); - if (!mounted) return; + if (!context.mounted) return; final parsed = response is Map ? Map.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.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? parsedResult; + if (result is Map) { + parsedResult = Map.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 { 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 { ), ), ], + 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!), ], diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 74a5be2..3bc2ef5 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -4,6 +4,18 @@ import 'package:http/http.dart' as http; typedef TokenProvider = String? Function(); typedef UnauthorizedHandler = Future Function(); +class MultipartFilePayload { + MultipartFilePayload({ + required this.bytes, + required this.filename, + this.fieldName, + }); + + final List 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 postMultipartFiles( + String endpoint, { + required List files, + String fieldName = 'files', + Map? fields, + Map? headers, + bool includeAuth = true, + bool allowRetry = true, + }) async { + Future 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 postForm( String endpoint, Map data, { diff --git a/pubspec.yaml b/pubspec.yaml index 0fe74f8..d443126 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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