Compare commits
3 Commits
0.8.0-dev.
...
0.8.1-dev.
| Author | SHA1 | Date | |
|---|---|---|---|
| de603c46e2 | |||
| 419e2a8766 | |||
| d8bcde1312 |
@@ -14,6 +14,10 @@ class LegsPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LegsPageState extends State<LegsPage> {
|
class _LegsPageState extends State<LegsPage> {
|
||||||
|
static const int _pageSize = 100;
|
||||||
|
static const double _loadMoreTriggerExtent = 400;
|
||||||
|
static double _persistedScrollOffset = 0;
|
||||||
|
|
||||||
int _sortDirection = 0;
|
int _sortDirection = 0;
|
||||||
DateTime? _startDate;
|
DateTime? _startDate;
|
||||||
DateTime? _endDate;
|
DateTime? _endDate;
|
||||||
@@ -23,6 +27,18 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
bool _loadingNetworks = false;
|
bool _loadingNetworks = false;
|
||||||
List<String> _availableNetworks = [];
|
List<String> _availableNetworks = [];
|
||||||
List<String> _selectedNetworks = [];
|
List<String> _selectedNetworks = [];
|
||||||
|
String? _lastQuerySignature;
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
bool _showBackToTop = false;
|
||||||
|
bool _isAutoLoadingMore = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController = ScrollController(
|
||||||
|
initialScrollOffset: _persistedScrollOffset,
|
||||||
|
)..addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -34,6 +50,49 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_persistedScrollOffset =
|
||||||
|
_scrollController.hasClients ? _scrollController.offset : 0;
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
_persistedScrollOffset = _scrollController.offset;
|
||||||
|
final shouldShow = _scrollController.offset > 500;
|
||||||
|
if (shouldShow != _showBackToTop) {
|
||||||
|
setState(() => _showBackToTop = shouldShow);
|
||||||
|
}
|
||||||
|
_maybeAutoLoadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _maybeAutoLoadMore() async {
|
||||||
|
if (!mounted || !_scrollController.hasClients || _isAutoLoadingMore) return;
|
||||||
|
final position = _scrollController.position;
|
||||||
|
if (position.extentAfter > _loadMoreTriggerExtent) return;
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
if (data.isLegsLoading || !data.legsHasMore) return;
|
||||||
|
|
||||||
|
_isAutoLoadingMore = true;
|
||||||
|
try {
|
||||||
|
await _loadMore();
|
||||||
|
} finally {
|
||||||
|
_isAutoLoadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _scrollToTop() async {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
await _scrollController.animateTo(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadNetworks() async {
|
Future<void> _loadNetworks() async {
|
||||||
setState(() => _loadingNetworks = true);
|
setState(() => _loadingNetworks = true);
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
@@ -47,7 +106,16 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
|
|
||||||
Future<void> _refreshLegs() async {
|
Future<void> _refreshLegs() async {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
|
final signature = _legsQuerySignature();
|
||||||
|
final queryChanged =
|
||||||
|
_lastQuerySignature != null && signature != _lastQuerySignature;
|
||||||
|
_lastQuerySignature = signature;
|
||||||
|
|
||||||
|
final currentCount = data.legs.length;
|
||||||
|
final shouldPreserveLoadedCount = !queryChanged && currentCount > _pageSize;
|
||||||
|
final limit = shouldPreserveLoadedCount ? currentCount : _pageSize;
|
||||||
await data.fetchLegs(
|
await data.fetchLegs(
|
||||||
|
limit: limit,
|
||||||
sortDirection: _sortDirection,
|
sortDirection: _sortDirection,
|
||||||
dateRangeStart: _formatDate(_startDate),
|
dateRangeStart: _formatDate(_startDate),
|
||||||
dateRangeEnd: _formatDate(_endDate),
|
dateRangeEnd: _formatDate(_endDate),
|
||||||
@@ -58,17 +126,30 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
|
|
||||||
Future<void> _loadMore() async {
|
Future<void> _loadMore() async {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
|
final offset = data.legs.length;
|
||||||
await data.fetchLegs(
|
await data.fetchLegs(
|
||||||
sortDirection: _sortDirection,
|
sortDirection: _sortDirection,
|
||||||
dateRangeStart: _formatDate(_startDate),
|
dateRangeStart: _formatDate(_startDate),
|
||||||
dateRangeEnd: _formatDate(_endDate),
|
dateRangeEnd: _formatDate(_endDate),
|
||||||
offset: data.legs.length,
|
offset: offset,
|
||||||
|
limit: _pageSize,
|
||||||
append: true,
|
append: true,
|
||||||
unallocatedOnly: _unallocatedOnly,
|
unallocatedOnly: _unallocatedOnly,
|
||||||
networkFilter: _selectedNetworks,
|
networkFilter: _selectedNetworks,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _legsQuerySignature() {
|
||||||
|
final networks = [..._selectedNetworks]..sort();
|
||||||
|
return [
|
||||||
|
'sort=$_sortDirection',
|
||||||
|
'start=${_formatDate(_startDate) ?? ''}',
|
||||||
|
'end=${_formatDate(_endDate) ?? ''}',
|
||||||
|
'unallocated=$_unallocatedOnly',
|
||||||
|
'networks=${networks.join(',')}',
|
||||||
|
].join(';');
|
||||||
|
}
|
||||||
|
|
||||||
double _pageMileage(List legs) {
|
double _pageMileage(List legs) {
|
||||||
return legs.fold<double>(
|
return legs.fold<double>(
|
||||||
0,
|
0,
|
||||||
@@ -120,9 +201,12 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
final legs = data.legs;
|
final legs = data.legs;
|
||||||
final pageMileage = _pageMileage(legs);
|
final pageMileage = _pageMileage(legs);
|
||||||
|
|
||||||
return RefreshIndicator(
|
return Stack(
|
||||||
|
children: [
|
||||||
|
RefreshIndicator(
|
||||||
onRefresh: _refreshLegs,
|
onRefresh: _refreshLegs,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
@@ -298,28 +382,62 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
children: [
|
children: [
|
||||||
..._buildLegsWithDividers(context, legs, distanceUnits),
|
..._buildLegsWithDividers(context, legs, distanceUnits),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (data.legsHasMore || data.isLegsLoading)
|
if (data.isLegsLoading)
|
||||||
Align(
|
const Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: OutlinedButton.icon(
|
child: Padding(
|
||||||
onPressed:
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
data.isLegsLoading ? null : () => _loadMore(),
|
child: SizedBox(
|
||||||
icon: data.isLegsLoading
|
height: 18,
|
||||||
? const SizedBox(
|
width: 18,
|
||||||
height: 14,
|
|
||||||
width: 14,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
|
||||||
: const Icon(Icons.expand_more),
|
|
||||||
label: Text(
|
|
||||||
data.isLegsLoading ? 'Loading...' : 'Load more',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (data.legsHasMore && !data.isLegsLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Loading',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: AnimatedSlide(
|
||||||
|
offset: _showBackToTop ? Offset.zero : const Offset(0, 1.2),
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _showBackToTop ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
child: FloatingActionButton.small(
|
||||||
|
heroTag: 'legsBackToTop',
|
||||||
|
tooltip: 'Back to top',
|
||||||
|
onPressed: _scrollToTop,
|
||||||
|
child: const Icon(Icons.arrow_upward),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:mileograph_flutter/objects/objects.dart';
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
@@ -28,6 +29,16 @@ class _AdminPageState extends State<AdminPage> {
|
|||||||
|
|
||||||
bool _sending = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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) {
|
void _showSnack(String message) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
@@ -443,6 +639,87 @@ class _AdminPageState extends State<AdminPage> {
|
|||||||
label: Text(_sending ? 'Sending...' : 'Send notification'),
|
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;
|
Map<String, dynamic>? importResult;
|
||||||
String? statusMessage;
|
String? statusMessage;
|
||||||
String? errorMessage;
|
String? errorMessage;
|
||||||
|
String? jobStatus;
|
||||||
|
int? processed;
|
||||||
|
int? total;
|
||||||
|
double? progressValue;
|
||||||
|
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -977,6 +981,65 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
final theme = Theme.of(sheetContext);
|
final theme = Theme.of(sheetContext);
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setModalState) {
|
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 {
|
Future<void> pickFile() async {
|
||||||
final file = await openFile(
|
final file = await openFile(
|
||||||
acceptedTypeGroups: const [
|
acceptedTypeGroups: const [
|
||||||
@@ -992,6 +1055,10 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
importResult = null;
|
importResult = null;
|
||||||
statusMessage = null;
|
statusMessage = null;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
jobStatus = null;
|
||||||
|
processed = null;
|
||||||
|
total = null;
|
||||||
|
progressValue = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,6 +1070,10 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
importResult = null;
|
importResult = null;
|
||||||
statusMessage = null;
|
statusMessage = null;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
jobStatus = null;
|
||||||
|
processed = null;
|
||||||
|
total = null;
|
||||||
|
progressValue = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
@@ -1013,32 +1084,96 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
headers: const {'accept': 'application/json'},
|
headers: const {'accept': 'application/json'},
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!context.mounted) return;
|
||||||
final parsed = response is Map
|
final parsed = response is Map
|
||||||
? Map<String, dynamic>.from(response)
|
? Map<String, dynamic>.from(response)
|
||||||
: null;
|
: null;
|
||||||
if (parsed != null) {
|
final jobId = parsed?['job_id']?.toString();
|
||||||
final imported = _importCount(parsed['imported']);
|
if (jobId == null || jobId.isEmpty) {
|
||||||
final updated = _importCount(parsed['updated']);
|
setModalState(() {
|
||||||
final errors = _importErrors(parsed);
|
errorMessage = 'Upload failed to start.';
|
||||||
final errorNote =
|
});
|
||||||
errors.isNotEmpty ? ' (${errors.length} error(s))' : '';
|
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 =
|
statusMessage =
|
||||||
'Import complete. Imported $imported, updated $updated$errorNote.';
|
'Import complete. Imported $imported, updated $updated$errorNote.';
|
||||||
importResult = parsed;
|
|
||||||
} else {
|
} else {
|
||||||
statusMessage = 'Import complete.';
|
statusMessage = 'Import complete.';
|
||||||
}
|
}
|
||||||
|
});
|
||||||
await data.fetchClassList();
|
await data.fetchClassList();
|
||||||
await _refreshTraction(preservePosition: true);
|
await _refreshTraction(preservePosition: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status == 'failed') {
|
||||||
|
setModalState(() {
|
||||||
|
errorMessage =
|
||||||
|
statusMap['error']?.toString() ?? 'Import failed.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Future.delayed(pollDelay(attempt));
|
||||||
|
attempt += 1;
|
||||||
|
}
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (!mounted) return;
|
if (!context.mounted) return;
|
||||||
|
setModalState(() {
|
||||||
errorMessage = e.message;
|
errorMessage = e.message;
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!context.mounted) return;
|
||||||
|
setModalState(() {
|
||||||
errorMessage = e.toString();
|
errorMessage = e.toString();
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (context.mounted) {
|
||||||
setModalState(() => uploading = false);
|
setModalState(() => uploading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1098,6 +1233,22 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
uploading ? 'Importing...' : 'Upload and import',
|
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) ...[
|
if (statusMessage != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
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)
|
if (importResult != null)
|
||||||
_buildImportSummary(context, importResult!),
|
_buildImportSummary(context, importResult!),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import 'package:http/http.dart' as http;
|
|||||||
typedef TokenProvider = String? Function();
|
typedef TokenProvider = String? Function();
|
||||||
typedef UnauthorizedHandler = Future<bool> 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 {
|
class ApiService {
|
||||||
String _baseUrl;
|
String _baseUrl;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
@@ -192,6 +204,41 @@ class ApiService {
|
|||||||
return _processResponse(response);
|
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(
|
Future<dynamic> postForm(
|
||||||
String endpoint,
|
String endpoint,
|
||||||
Map<String, String> data, {
|
Map<String, String> data, {
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ class _LegFetchOptions {
|
|||||||
this.unallocatedOnly = false,
|
this.unallocatedOnly = false,
|
||||||
this.networkFilter = const [],
|
this.networkFilter = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_LegFetchOptions copyWith({
|
||||||
|
int? limit,
|
||||||
|
String? sortBy,
|
||||||
|
int? sortDirection,
|
||||||
|
String? dateRangeStart,
|
||||||
|
String? dateRangeEnd,
|
||||||
|
bool? unallocatedOnly,
|
||||||
|
List<String>? networkFilter,
|
||||||
|
}) {
|
||||||
|
return _LegFetchOptions(
|
||||||
|
limit: limit ?? this.limit,
|
||||||
|
sortBy: sortBy ?? this.sortBy,
|
||||||
|
sortDirection: sortDirection ?? this.sortDirection,
|
||||||
|
dateRangeStart: dateRangeStart ?? this.dateRangeStart,
|
||||||
|
dateRangeEnd: dateRangeEnd ?? this.dateRangeEnd,
|
||||||
|
unallocatedOnly: unallocatedOnly ?? this.unallocatedOnly,
|
||||||
|
networkFilter: networkFilter ?? this.networkFilter,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DataService extends ChangeNotifier {
|
class DataService extends ChangeNotifier {
|
||||||
@@ -397,11 +417,11 @@ class DataService extends ChangeNotifier {
|
|||||||
List<String> networkFilter = const [],
|
List<String> networkFilter = const [],
|
||||||
}) async {
|
}) async {
|
||||||
_isLegsLoading = true;
|
_isLegsLoading = true;
|
||||||
if (!append) {
|
|
||||||
final normalizedNetworks = networkFilter
|
final normalizedNetworks = networkFilter
|
||||||
.map((network) => network.trim())
|
.map((network) => network.trim())
|
||||||
.where((network) => network.isNotEmpty)
|
.where((network) => network.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
if (!append) {
|
||||||
_lastLegsFetch = _LegFetchOptions(
|
_lastLegsFetch = _LegFetchOptions(
|
||||||
limit: limit,
|
limit: limit,
|
||||||
sortBy: sortBy,
|
sortBy: sortBy,
|
||||||
@@ -437,6 +457,17 @@ class DataService extends ChangeNotifier {
|
|||||||
if (json is List) {
|
if (json is List) {
|
||||||
final newLegs = json.map((e) => Leg.fromJson(e)).toList();
|
final newLegs = json.map((e) => Leg.fromJson(e)).toList();
|
||||||
_legs = append ? [..._legs, ...newLegs] : newLegs;
|
_legs = append ? [..._legs, ...newLegs] : newLegs;
|
||||||
|
if (append) {
|
||||||
|
_lastLegsFetch = _lastLegsFetch.copyWith(
|
||||||
|
limit: _legs.length,
|
||||||
|
sortBy: sortBy,
|
||||||
|
sortDirection: sortDirection,
|
||||||
|
dateRangeStart: dateRangeStart,
|
||||||
|
dateRangeEnd: dateRangeEnd,
|
||||||
|
unallocatedOnly: unallocatedOnly,
|
||||||
|
networkFilter: normalizedNetworks,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Keep "load more" available as long as the server returns items; hide only on empty.
|
// Keep "load more" available as long as the server returns items; hide only on empty.
|
||||||
_legsHasMore = newLegs.isNotEmpty;
|
_legsHasMore = newLegs.isNotEmpty;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ extension DataServiceTraction on DataService {
|
|||||||
payload['class'] = locoClass;
|
payload['class'] = locoClass;
|
||||||
}
|
}
|
||||||
if (locoNumber != null && locoNumber.isNotEmpty) {
|
if (locoNumber != null && locoNumber.isNotEmpty) {
|
||||||
payload['number'] = locoNumber;
|
payload['number'] = _withLikeWildcards(locoNumber);
|
||||||
}
|
}
|
||||||
if (filters != null) {
|
if (filters != null) {
|
||||||
filters.forEach((key, value) {
|
filters.forEach((key, value) {
|
||||||
@@ -67,6 +67,14 @@ extension DataServiceTraction on DataService {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _withLikeWildcards(String rawValue) {
|
||||||
|
final value = rawValue.trim();
|
||||||
|
if (value.isEmpty) return value;
|
||||||
|
final hasLeadingWildcard = value.startsWith('%');
|
||||||
|
final hasTrailingWildcard = value.endsWith('%');
|
||||||
|
return '${hasLeadingWildcard ? '' : '%'}$value${hasTrailingWildcard ? '' : '%'}';
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<LocoAttrVersion>> fetchLocoTimeline(
|
Future<List<LocoAttrVersion>> fetchLocoTimeline(
|
||||||
int locoId, {
|
int locoId, {
|
||||||
bool includeAllPending = false,
|
bool includeAllPending = false,
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user