QoL changes
All checks were successful
Release / meta (push) Successful in 22s
Release / linux-build (push) Successful in 4m32s
Release / android-build (push) Successful in 7m10s
Release / release-dev (push) Successful in 9s
Release / release-master (push) Successful in 9s

This commit is contained in:
2025-12-14 09:45:32 +00:00
parent 8116cfe7b1
commit f0dfbd185b
11 changed files with 887 additions and 321 deletions

View File

@@ -23,22 +23,15 @@ class _TractionPageState extends State<TractionPage> {
final _classController = TextEditingController();
final _classFocusNode = FocusNode();
final _numberController = TextEditingController();
final _nameController = 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;
final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
@override
void initState() {
@@ -53,7 +46,9 @@ class _TractionPageState extends State<TractionPage> {
_initialised = true;
_selectedKeys = {...widget.selectedKeys};
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<DataService>().fetchClassList();
final data = context.read<DataService>();
data.fetchClassList();
data.fetchEventFields();
_refreshTraction();
});
}
@@ -66,47 +61,41 @@ class _TractionPageState extends State<TractionPage> {
_classFocusNode.dispose();
_numberController.dispose();
_nameController.dispose();
_operatorController.dispose();
_statusController.dispose();
_evnController.dispose();
_ownerController.dispose();
_locationController.dispose();
_liveryController.dispose();
_domainController.dispose();
_typeController.dispose();
for (final controller in _dynamicControllers.values) {
controller.dispose();
}
super.dispose();
}
bool get _hasFilters {
final dynamicFieldsUsed = _dynamicControllers.values
.any((controller) => controller.text.trim().isNotEmpty) ||
_enumSelections.values
.any((value) => (value ?? '').toString().trim().isNotEmpty);
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);
].any((value) => (value ?? '').toString().trim().isNotEmpty) ||
dynamicFieldsUsed;
}
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 filters = <String, dynamic>{};
final name = _nameController.text.trim();
if (name.isNotEmpty) filters['name'] = name;
_dynamicControllers.forEach((key, controller) {
final value = controller.text.trim();
if (value.isNotEmpty) filters[key] = value;
});
_enumSelections.forEach((key, value) {
if (value != null && value.toString().trim().isNotEmpty) {
filters[key] = value;
}
});
final hadOnly = !_hasFilters;
await data.fetchTraction(
hadOnly: hadOnly,
@@ -120,21 +109,13 @@ class _TractionPageState extends State<TractionPage> {
}
void _clearFilters() {
for (final controller in [
_classController,
_numberController,
_nameController,
_operatorController,
_statusController,
_evnController,
_ownerController,
_locationController,
_liveryController,
_domainController,
_typeController,
]) {
for (final controller in [_classController, _numberController, _nameController]) {
controller.clear();
}
for (final controller in _dynamicControllers.values) {
controller.clear();
}
_enumSelections.clear();
setState(() {
_selectedClass = null;
_mileageFirst = true;
@@ -151,12 +132,34 @@ class _TractionPageState extends State<TractionPage> {
}
}
List<EventField> _activeEventFields(List<EventField> fields) {
return fields
.where(
(field) =>
!['class', 'number', 'name', 'build date', 'build_date']
.contains(field.name.toLowerCase()),
)
.toList();
}
void _ensureControllersForFields(List<EventField> fields) {
for (final field in fields) {
if (field.enumValues != null) {
_enumSelections.putIfAbsent(field.name, () => null);
} else {
_dynamicControllers.putIfAbsent(field.name, () => TextEditingController());
}
}
}
@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;
_ensureControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields);
final listView = RefreshIndicator(
onRefresh: _refreshTraction,
@@ -225,22 +228,17 @@ class _TractionPageState extends State<TractionPage> {
);
},
fieldViewBuilder:
(
context,
controller,
focusNode,
onFieldSubmitted,
) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(
labelText: 'Class',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
);
},
(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) {
@@ -325,9 +323,7 @@ class _TractionPageState extends State<TractionPage> {
: Icons.expand_more,
),
label: Text(
_showAdvancedFilters
? 'Hide filters'
: 'More filters',
_showAdvancedFilters ? 'Hide filters' : 'More filters',
),
),
ElevatedButton.icon(
@@ -344,100 +340,28 @@ class _TractionPageState extends State<TractionPage> {
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(),
child: data.isEventFieldsLoading
? const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
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(),
),
),
],
),
)
: extraFields.isEmpty
? const Text('No extra filters available right now.')
: Wrap(
spacing: 12,
runSpacing: 12,
children: extraFields
.map(
(field) => _buildFilterInput(
context,
field,
isMobile,
),
)
.toList(),
),
),
secondChild: const SizedBox.shrink(),
),
@@ -480,9 +404,8 @@ class _TractionPageState extends State<TractionPage> {
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: data.isTractionLoading
? null
: () => _refreshTraction(append: true),
onPressed:
data.isTractionLoading ? null : () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
@@ -535,6 +458,7 @@ class _TractionPageState extends State<TractionPage> {
final status = loco.status ?? 'Unknown';
final operatorName = loco.operator ?? '';
final domain = loco.domain ?? '';
final statusColors = _statusChipColors(context, status);
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
@@ -564,9 +488,8 @@ class _TractionPageState extends State<TractionPage> {
),
Chip(
label: Text(status),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
backgroundColor: statusColors.$1,
labelStyle: TextStyle(color: statusColors.$2),
),
],
),
@@ -654,6 +577,44 @@ class _TractionPageState extends State<TractionPage> {
);
}
(Color, Color) _statusChipColors(BuildContext context, String status) {
final scheme = Theme.of(context).colorScheme;
final isDark = scheme.brightness == Brightness.dark;
Color blend(Color base, {double bgOpacity = 0.18, double fgOpacity = 0.82}) {
final bg = Color.alphaBlend(
base.withOpacity(isDark ? bgOpacity + 0.07 : bgOpacity),
scheme.surface,
);
final fg = Color.alphaBlend(
base.withOpacity(isDark ? fgOpacity : fgOpacity * 0.8),
scheme.onSurface,
);
return Color.lerp(bg, fg, 0.0) ?? bg;
}
Color background;
Color foreground;
final key = status.toLowerCase();
if (key.contains('scrap')) {
background = blend(Colors.red);
foreground = Colors.red.shade200.withOpacity(isDark ? 0.85 : 0.9);
} else if (key.contains('active')) {
background = blend(scheme.primary);
foreground = scheme.primary.withOpacity(isDark ? 0.9 : 0.8);
} else if (key.contains('withdrawn')) {
background = blend(Colors.amber);
foreground = Colors.amber.shade800.withOpacity(isDark ? 0.9 : 0.8);
} else if (key.contains('stored') || key.contains('unknown')) {
background = blend(Colors.grey);
foreground = Colors.grey.shade700.withOpacity(isDark ? 0.85 : 0.75);
} else {
background = scheme.surfaceContainerHighest;
foreground = scheme.onSurface;
}
return (background, foreground);
}
Future<void> _showLocoInfo(LocoSummary loco) async {
await showModalBottomSheet(
context: context,
@@ -750,4 +711,70 @@ class _TractionPageState extends State<TractionPage> {
if (value == null) return '0';
return value.toStringAsFixed(1);
}
Widget _buildFilterInput(
BuildContext context,
EventField field,
bool isMobile,
) {
final width = isMobile ? double.infinity : 220.0;
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!.map((e) => e.toString()).toSet().toList();
final currentValue = _enumSelections[field.name];
if (currentValue != null && !options.contains(currentValue)) {
options.insert(0, currentValue);
}
return SizedBox(
width: width,
child: DropdownButtonFormField<String?>(
value: currentValue,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: null, child: Text('Any')),
...options
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value),
),
)
.toList(),
],
onChanged: (val) {
setState(() {
_enumSelections[field.name] = val;
});
_refreshTraction();
},
),
);
}
final controller =
_dynamicControllers[field.name] ?? TextEditingController();
_dynamicControllers[field.name] = controller;
TextInputType? inputType;
if (field.type != null) {
final type = field.type!.toLowerCase();
if (type.contains('int') || type.contains('num') || type.contains('double')) {
inputType = const TextInputType.numberWithOptions(decimal: true);
}
}
return SizedBox(
width: width,
child: TextField(
controller: controller,
keyboardType: inputType,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
);
}
}