Compare commits
4 Commits
0.8.1-dev.
...
0.8.3-dev.
| Author | SHA1 | Date | |
|---|---|---|---|
| 987e4036e9 | |||
| ecfd63c223 | |||
| de603c46e2 | |||
| 419e2a8766 |
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
part 'loco_timeline/timeline_grid.dart';
|
part 'loco_timeline/timeline_grid.dart';
|
||||||
part 'loco_timeline/event_editor.dart';
|
part 'loco_timeline/event_editor.dart';
|
||||||
|
|
||||||
|
enum _TimelineMenuAction { redate }
|
||||||
|
|
||||||
class LocoTimelinePage extends StatefulWidget {
|
class LocoTimelinePage extends StatefulWidget {
|
||||||
const LocoTimelinePage({
|
const LocoTimelinePage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -537,6 +539,239 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
return _draftEvents.every(_draftIsValid);
|
return _draftEvents.every(_draftIsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleTimelineMenuAction(
|
||||||
|
_TimelineMenuAction action,
|
||||||
|
List<LocoAttrVersion> timeline,
|
||||||
|
) async {
|
||||||
|
switch (action) {
|
||||||
|
case _TimelineMenuAction.redate:
|
||||||
|
await _startMassRedate(timeline);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startMassRedate(List<LocoAttrVersion> timeline) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final eventDate = await _showRedateDateDialog();
|
||||||
|
if (!mounted || eventDate == null) return;
|
||||||
|
|
||||||
|
final earliestByAttr = _earliestEntriesByAttr(timeline);
|
||||||
|
if (earliestByAttr.isEmpty) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('No timeline values available to redate.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _showMassRedateProgressDialog(
|
||||||
|
eventDate: eventDate,
|
||||||
|
entries: earliestByAttr,
|
||||||
|
);
|
||||||
|
if (!mounted || result == null) return;
|
||||||
|
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
final successCount = result.total - result.failures;
|
||||||
|
final message = result.failures == 0
|
||||||
|
? 'Redated $successCount attributes.'
|
||||||
|
: 'Redated $successCount of ${result.total} attributes (${result.failures} failed).';
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LocoAttrVersion> _earliestEntriesByAttr(List<LocoAttrVersion> timeline) {
|
||||||
|
final byAttr = <String, LocoAttrVersion>{};
|
||||||
|
for (final entry in timeline) {
|
||||||
|
if (entry.isPending) continue;
|
||||||
|
final key = entry.attrCode.trim().toLowerCase();
|
||||||
|
if (key.isEmpty) continue;
|
||||||
|
final current = byAttr[key];
|
||||||
|
if (current == null || LocoAttrVersion.compareByStart(entry, current) < 0) {
|
||||||
|
byAttr[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final entries = byAttr.values.toList()
|
||||||
|
..sort((a, b) => a.attrCode.compareTo(b.attrCode));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _showRedateDateDialog() async {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||||
|
);
|
||||||
|
String? validationError;
|
||||||
|
final result = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (dialogContext, setDialogState) {
|
||||||
|
Future<void> pickDate() async {
|
||||||
|
final initial = DateTime.tryParse(controller.text.trim()) ??
|
||||||
|
DateTime.now();
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: dialogContext,
|
||||||
|
initialDate: initial,
|
||||||
|
firstDate: DateTime(1800),
|
||||||
|
lastDate: DateTime(2500),
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
final nextValue = DateFormat('yyyy-MM-dd').format(picked);
|
||||||
|
setDialogState(() {
|
||||||
|
controller.text = nextValue;
|
||||||
|
validationError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Redate timeline attributes'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Enter the target date. The earliest value of each attribute will be submitted at this date.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Date',
|
||||||
|
hintText: 'YYYY-MM-DD',
|
||||||
|
errorText: validationError,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: 'Pick date',
|
||||||
|
icon: const Icon(Icons.calendar_month),
|
||||||
|
onPressed: pickDate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) {
|
||||||
|
final value = controller.text.trim();
|
||||||
|
if (!_isValidDateString(value)) {
|
||||||
|
setDialogState(() {
|
||||||
|
validationError = 'Use format YYYY-MM-DD.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(dialogContext).pop(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final value = controller.text.trim();
|
||||||
|
if (!_isValidDateString(value)) {
|
||||||
|
setDialogState(() {
|
||||||
|
validationError = 'Use format YYYY-MM-DD.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(dialogContext).pop(value);
|
||||||
|
},
|
||||||
|
child: const Text('Run'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
controller.dispose();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_MassRedateResult?> _showMassRedateProgressDialog({
|
||||||
|
required String eventDate,
|
||||||
|
required List<LocoAttrVersion> entries,
|
||||||
|
}) async {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
var started = false;
|
||||||
|
var completed = 0;
|
||||||
|
var failures = 0;
|
||||||
|
var currentAttrLabel = '';
|
||||||
|
|
||||||
|
return showDialog<_MassRedateResult>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (dialogContext, setDialogState) {
|
||||||
|
Future<void> run() async {
|
||||||
|
for (final entry in entries) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setDialogState(() {
|
||||||
|
currentAttrLabel = _formatAttrLabel(entry.attrCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
final value = _valueForEntry(entry);
|
||||||
|
final isBlank = value is String && value.trim().isEmpty;
|
||||||
|
if (value == null || isBlank) {
|
||||||
|
failures += 1;
|
||||||
|
setDialogState(() {
|
||||||
|
completed += 1;
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await data.createLocoEvent(
|
||||||
|
locoId: widget.locoId,
|
||||||
|
eventDate: eventDate,
|
||||||
|
values: {entry.attrCode: value},
|
||||||
|
details: '',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
if (!mounted || !dialogContext.mounted) return;
|
||||||
|
setDialogState(() {
|
||||||
|
completed += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted || !dialogContext.mounted) return;
|
||||||
|
Navigator.of(dialogContext).pop(
|
||||||
|
_MassRedateResult(total: entries.length, failures: failures),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!started) {
|
||||||
|
started = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final total = entries.length;
|
||||||
|
final progress = total == 0 ? 0.0 : completed / total;
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Redating attributes'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
LinearProgressIndicator(value: progress),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('$completed / $total complete'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
currentAttrLabel.isEmpty
|
||||||
|
? 'Preparing requests...'
|
||||||
|
: 'Current: $currentAttrLabel',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
@@ -554,6 +789,20 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
onPressed: () => Navigator.of(context).maybePop(),
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
),
|
),
|
||||||
title: Text('Timeline · ${widget.locoLabel}'),
|
title: Text('Timeline · ${widget.locoLabel}'),
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<_TimelineMenuAction>(
|
||||||
|
tooltip: 'Timeline actions',
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (action) =>
|
||||||
|
_handleTimelineMenuAction(action, visibleTimeline),
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem<_TimelineMenuAction>(
|
||||||
|
value: _TimelineMenuAction.redate,
|
||||||
|
child: Text('Redate'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
@@ -684,3 +933,13 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MassRedateResult {
|
||||||
|
const _MassRedateResult({
|
||||||
|
required this.total,
|
||||||
|
required this.failures,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int total;
|
||||||
|
final int failures;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.1+18
|
version: 0.8.3+20
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user