Files
mileograph_flutter/lib/components/pages/traction.dart
Pete Gregory 292163bda6
Some checks failed
Release / meta (push) Successful in 17s
Release / linux-build (push) Successful in 1m33s
Release / android-build (push) Successful in 15m28s
Release / release-dev (push) Failing after 4s
Release / release-master (push) Successful in 29s
Fix trips on entry page, correct order of trips on dashboard and trip page, move to prod api
2025-12-12 09:17:18 +00:00

754 lines
28 KiB
Dart

import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class TractionPage extends StatefulWidget {
const TractionPage({
super.key,
this.selectionMode = false,
this.onSelect,
this.selectedKeys = const {},
});
final bool selectionMode;
final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys;
@override
State<TractionPage> createState() => _TractionPageState();
}
class _TractionPageState extends State<TractionPage> {
final _classController = TextEditingController();
final _classFocusNode = FocusNode();
final _numberController = TextEditingController();
bool _mileageFirst = true;
bool _initialised = false;
bool _showAdvancedFilters = false;
String? _selectedClass;
late Set<String> _selectedKeys;
final _nameController = TextEditingController();
final _operatorController = TextEditingController();
final _statusController = TextEditingController();
final _evnController = TextEditingController();
final _ownerController = TextEditingController();
final _locationController = TextEditingController();
final _liveryController = TextEditingController();
final _domainController = TextEditingController();
final _typeController = TextEditingController();
int offset = 0;
@override
void initState() {
super.initState();
_classController.addListener(_onClassTextChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_selectedKeys = {...widget.selectedKeys};
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<DataService>().fetchClassList();
_refreshTraction();
});
}
}
@override
void dispose() {
_classController.removeListener(_onClassTextChanged);
_classController.dispose();
_classFocusNode.dispose();
_numberController.dispose();
_nameController.dispose();
_operatorController.dispose();
_statusController.dispose();
_evnController.dispose();
_ownerController.dispose();
_locationController.dispose();
_liveryController.dispose();
_domainController.dispose();
_typeController.dispose();
super.dispose();
}
bool get _hasFilters {
return [
_selectedClass,
_classController.text,
_numberController.text,
_nameController.text,
_operatorController.text,
_statusController.text,
_evnController.text,
_ownerController.text,
_locationController.text,
_liveryController.text,
_domainController.text,
_typeController.text,
].any((value) => (value ?? '').toString().trim().isNotEmpty);
}
Future<void> _refreshTraction({bool append = false}) async {
final data = context.read<DataService>();
final filters = {
"name": _nameController.text.trim(),
"operator": _operatorController.text.trim(),
"status": _statusController.text.trim(),
"evn": _evnController.text.trim(),
"owner": _ownerController.text.trim(),
"location": _locationController.text.trim(),
"livery": _liveryController.text.trim(),
"domain": _domainController.text.trim(),
"type": _typeController.text.trim(),
}..removeWhere((key, value) => value.isEmpty);
final hadOnly = !_hasFilters;
await data.fetchTraction(
hadOnly: hadOnly,
locoClass: _selectedClass ?? _classController.text.trim(),
locoNumber: _numberController.text.trim(),
offset: append ? data.traction.length : 0,
append: append,
filters: filters,
mileageFirst: _mileageFirst,
);
}
void _clearFilters() {
for (final controller in [
_classController,
_numberController,
_nameController,
_operatorController,
_statusController,
_evnController,
_ownerController,
_locationController,
_liveryController,
_domainController,
_typeController,
]) {
controller.clear();
}
setState(() {
_selectedClass = null;
_mileageFirst = true;
});
_refreshTraction();
}
void _onClassTextChanged() {
if (_selectedClass != null &&
_classController.text.trim() != (_selectedClass ?? '')) {
setState(() {
_selectedClass = null;
});
}
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final traction = data.traction;
final classOptions = data.locoClasses;
final isMobile = MediaQuery.of(context).size.width < 700;
final listView = RefreshIndicator(
onRefresh: _refreshTraction,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Fleet', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 2),
Text(
'Traction',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
IconButton(
tooltip: 'Refresh',
onPressed: _refreshTraction,
icon: const Icon(Icons.refresh),
),
],
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Filters',
style: Theme.of(context).textTheme.titleMedium,
),
TextButton(
onPressed: _clearFilters,
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: isMobile ? double.infinity : 240,
child: RawAutocomplete<String>(
textEditingController: _classController,
focusNode: _classFocusNode,
optionsBuilder: (TextEditingValue textEditingValue) {
final query = textEditingValue.text.toLowerCase();
if (query.isEmpty) {
return classOptions;
}
return classOptions.where(
(c) => c.toLowerCase().contains(query),
);
},
fieldViewBuilder:
(
context,
controller,
focusNode,
onFieldSubmitted,
) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(
labelText: 'Class',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
);
},
optionsViewBuilder: (context, onSelected, options) {
final optionList = options.toList();
if (optionList.isEmpty) {
return const SizedBox.shrink();
}
final maxWidth = isMobile
? MediaQuery.of(context).size.width - 64
: 240.0;
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: 240,
),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: optionList.length,
itemBuilder: (context, index) {
final option = optionList[index];
return ListTile(
title: Text(option),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
onSelected: (String selection) {
setState(() {
_selectedClass = selection;
_classController.text = selection;
});
_refreshTraction();
},
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _numberController,
decoration: const InputDecoration(
labelText: 'Number',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
FilterChip(
label: Text(
_mileageFirst ? 'Mileage first' : 'Number order',
),
selected: _mileageFirst,
onSelected: (v) {
setState(() => _mileageFirst = v);
_refreshTraction();
},
),
TextButton.icon(
onPressed: () => setState(
() => _showAdvancedFilters = !_showAdvancedFilters,
),
icon: Icon(
_showAdvancedFilters
? Icons.expand_less
: Icons.expand_more,
),
label: Text(
_showAdvancedFilters
? 'Hide filters'
: 'More filters',
),
),
ElevatedButton.icon(
onPressed: _refreshTraction,
icon: const Icon(Icons.search),
label: const Text('Search'),
),
],
),
AnimatedCrossFade(
crossFadeState: _showAdvancedFilters
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _operatorController,
decoration: const InputDecoration(
labelText: 'Operator',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _statusController,
decoration: const InputDecoration(
labelText: 'Status',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _evnController,
decoration: const InputDecoration(
labelText: 'EVN',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _ownerController,
decoration: const InputDecoration(
labelText: 'Owner',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _locationController,
decoration: const InputDecoration(
labelText: 'Location',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _liveryController,
decoration: const InputDecoration(
labelText: 'Livery',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _domainController,
decoration: const InputDecoration(
labelText: 'Domain',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _typeController,
decoration: const InputDecoration(
labelText: 'Type',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
],
),
),
secondChild: const SizedBox.shrink(),
),
],
),
),
),
const SizedBox(height: 12),
if (data.isTractionLoading && traction.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
)
else if (traction.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No traction found',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text('Try relaxing the filters or sync again.'),
],
),
),
)
else
Column(
children: [
...traction.map((loco) => _buildTractionCard(context, loco)),
if (data.tractionHasMore || data.isTractionLoading)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: data.isTractionLoading
? null
: () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isTractionLoading ? 'Loading...' : 'Load more',
),
),
),
],
),
],
),
);
if (widget.selectionMode) {
return Scaffold(
appBar: AppBar(
leadingWidth: 140,
leading: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: TextButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
),
title: null,
),
body: listView,
);
}
return listView;
}
Widget _buildTractionCard(BuildContext context, LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}';
final isSelected = _selectedKeys.contains(keyVal);
final status = loco.status ?? 'Unknown';
final operatorName = loco.operator ?? '';
final domain = loco.domain ?? '';
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${loco.locoClass} ${loco.number}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
if ((loco.name ?? '').isNotEmpty)
Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
),
),
],
),
Chip(
label: Text(status),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
],
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: () => _showLocoInfo(loco),
icon: const Icon(Icons.info_outline),
label: const Text('Details'),
),
const Spacer(),
if (widget.selectionMode)
TextButton.icon(
onPressed: () {
if (widget.onSelect != null) {
widget.onSelect!(loco);
}
setState(() {
if (isSelected) {
_selectedKeys.remove(keyVal);
} else {
_selectedKeys.add(keyVal);
}
});
},
icon: Icon(
isSelected
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label: Text(isSelected ? 'Remove' : 'Add to entry'),
),
],
),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_statPill(
context,
label: 'Miles',
value: _formatNumber(loco.mileage),
),
_statPill(
context,
label: 'Trips',
value: (loco.trips ?? loco.journeys ?? 0).toString(),
),
if (operatorName.isNotEmpty)
_statPill(context, label: 'Operator', value: operatorName),
if (domain.isNotEmpty)
_statPill(context, label: 'Domain', value: domain),
],
),
],
),
),
);
}
Widget _statPill(
BuildContext context, {
required String label,
required String value,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$label: ', style: Theme.of(context).textTheme.labelSmall),
Text(
value,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
),
],
),
);
}
Future<void> _showLocoInfo(LocoSummary loco) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) {
return DraggableScrollableSheet(
expand: false,
maxChildSize: 0.9,
initialChildSize: 0.65,
builder: (_, controller) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(ctx).pop(),
),
const SizedBox(width: 8),
Text(
'${loco.locoClass} ${loco.number}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 52.0, bottom: 12),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(height: 4),
Expanded(
child: ListView(
controller: controller,
children: [
_detailRow('Status', loco.status ?? 'Unknown'),
_detailRow('Operator', loco.operator ?? ''),
_detailRow('Domain', loco.domain ?? ''),
_detailRow('Owner', loco.owner ?? ''),
_detailRow('Livery', loco.livery ?? ''),
_detailRow('Location', loco.location ?? ''),
_detailRow('Mileage', _formatNumber(loco.mileage ?? 0)),
_detailRow(
'Trips',
(loco.trips ?? loco.journeys ?? 0).toString(),
),
_detailRow('EVN', loco.evn ?? ''),
if (loco.notes != null && loco.notes!.isNotEmpty)
_detailRow('Notes', loco.notes!),
],
),
),
],
),
);
},
);
},
);
}
Widget _detailRow(String label, String value) {
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
String _formatNumber(double? value) {
if (value == null) return '0';
return value.toStringAsFixed(1);
}
}