405 lines
13 KiB
Dart
405 lines
13 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:mileograph_flutter/objects/objects.dart';
|
|
import 'package:mileograph_flutter/services/dataService.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
class LegsPage extends StatefulWidget {
|
|
const LegsPage({super.key});
|
|
|
|
@override
|
|
State<LegsPage> createState() => _LegsPageState();
|
|
}
|
|
|
|
class _LegsPageState extends State<LegsPage> {
|
|
int _sortDirection = 0;
|
|
DateTime? _startDate;
|
|
DateTime? _endDate;
|
|
bool _initialised = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (!_initialised) {
|
|
_initialised = true;
|
|
_refreshLegs();
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshLegs() async {
|
|
final data = context.read<DataService>();
|
|
await data.fetchLegs(
|
|
sortDirection: _sortDirection,
|
|
dateRangeStart: _formatDate(_startDate),
|
|
dateRangeEnd: _formatDate(_endDate),
|
|
);
|
|
}
|
|
|
|
Future<void> _loadMore() async {
|
|
final data = context.read<DataService>();
|
|
await data.fetchLegs(
|
|
sortDirection: _sortDirection,
|
|
dateRangeStart: _formatDate(_startDate),
|
|
dateRangeEnd: _formatDate(_endDate),
|
|
offset: data.legs.length,
|
|
append: true,
|
|
);
|
|
}
|
|
|
|
double _pageMileage(List legs) {
|
|
return legs.fold<double>(
|
|
0,
|
|
(prev, leg) => prev + (leg.mileage as double? ?? 0),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickDate({required bool start}) async {
|
|
final initial = start
|
|
? _startDate ?? DateTime.now()
|
|
: _endDate ?? _startDate ?? DateTime.now();
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: initial,
|
|
firstDate: DateTime(1970),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (picked != null) {
|
|
setState(() {
|
|
if (start) {
|
|
_startDate = picked;
|
|
if (_endDate != null && _endDate!.isBefore(picked)) {
|
|
_endDate = picked;
|
|
}
|
|
} else {
|
|
_endDate = picked;
|
|
}
|
|
});
|
|
await _refreshLegs();
|
|
}
|
|
}
|
|
|
|
void _clearFilters() {
|
|
setState(() {
|
|
_startDate = null;
|
|
_endDate = null;
|
|
_sortDirection = 0;
|
|
});
|
|
_refreshLegs();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final data = context.watch<DataService>();
|
|
final legs = data.legs;
|
|
final pageMileage = _pageMileage(legs);
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: _refreshLegs,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Logbook',
|
|
style: Theme.of(context).textTheme.labelMedium),
|
|
const SizedBox(height: 2),
|
|
Text('Entries',
|
|
style: Theme.of(context).textTheme.headlineSmall),
|
|
],
|
|
),
|
|
Card(
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text('Page mileage',
|
|
style: Theme.of(context).textTheme.labelSmall),
|
|
Text('${pageMileage.toStringAsFixed(1)} mi',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w700)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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.icon(
|
|
onPressed: _clearFilters,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Clear'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
FilledButton.tonalIcon(
|
|
onPressed: () => _pickDate(start: true),
|
|
icon: const Icon(Icons.calendar_month),
|
|
label: Text(
|
|
_startDate == null
|
|
? 'Start date'
|
|
: _formatDate(_startDate!)!,
|
|
),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
onPressed: () => _pickDate(start: false),
|
|
icon: const Icon(Icons.event),
|
|
label: Text(
|
|
_endDate == null
|
|
? 'End date'
|
|
: _formatDate(_endDate!)!,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (data.isLegsLoading && legs.isEmpty)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
)
|
|
else if (legs.isEmpty)
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'No entries found',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text('Adjust the filters or add a new leg.'),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Column(
|
|
children: [
|
|
...legs.map((leg) => _buildLegCard(context, leg)),
|
|
const SizedBox(height: 8),
|
|
if (data.legsHasMore || data.isLegsLoading)
|
|
Align(
|
|
alignment: Alignment.center,
|
|
child: OutlinedButton.icon(
|
|
onPressed:
|
|
data.isLegsLoading ? null : () => _loadMore(),
|
|
icon: data.isLegsLoading
|
|
? const SizedBox(
|
|
height: 14,
|
|
width: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.expand_more),
|
|
label: Text(
|
|
data.isLegsLoading ? 'Loading...' : 'Load more',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String? _formatDate(DateTime? date) {
|
|
if (date == null) return null;
|
|
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
String _formatDateTime(DateTime date) {
|
|
final dateStr = _formatDate(date) ?? '';
|
|
final timeStr =
|
|
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
|
return '$dateStr · $timeStr';
|
|
}
|
|
|
|
Widget _buildLegCard(BuildContext context, Leg leg) {
|
|
final routeSegments = _parseRouteSegments(leg.route);
|
|
final textTheme = Theme.of(context).textTheme;
|
|
return Card(
|
|
child: ExpansionTile(
|
|
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
leading: const Icon(Icons.train),
|
|
title: Text('${leg.start} → ${leg.end}'),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(_formatDateTime(leg.beginTime)),
|
|
if (leg.headcode.isNotEmpty)
|
|
Text(
|
|
'Headcode: ${leg.headcode}',
|
|
style: textTheme.labelSmall,
|
|
),
|
|
if (leg.network.isNotEmpty)
|
|
Text(
|
|
leg.network,
|
|
style: textTheme.labelSmall,
|
|
),
|
|
],
|
|
),
|
|
trailing: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Edit entry',
|
|
icon: const Icon(Icons.edit),
|
|
onPressed: () => context.push('/legs/edit/${leg.id}'),
|
|
),
|
|
Text(
|
|
'${leg.mileage.toStringAsFixed(1)} mi',
|
|
style:
|
|
textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
if (leg.tripId != 0)
|
|
Text(
|
|
'Trip #${leg.tripId}',
|
|
style: textTheme.labelSmall,
|
|
),
|
|
],
|
|
),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (leg.notes.isNotEmpty) ...[
|
|
Text('Notes', style: textTheme.titleSmall),
|
|
const SizedBox(height: 4),
|
|
Text(leg.notes),
|
|
const SizedBox(height: 12),
|
|
],
|
|
if (leg.locos.isNotEmpty) ...[
|
|
Text('Locos', style: textTheme.titleSmall),
|
|
const SizedBox(height: 6),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: _buildLocoChips(context, leg),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
if (routeSegments.isNotEmpty) ...[
|
|
Text('Route', style: textTheme.titleSmall),
|
|
const SizedBox(height: 6),
|
|
_buildRouteList(routeSegments),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildLocoChips(BuildContext context, Leg leg) {
|
|
final theme = Theme.of(context);
|
|
return leg.locos
|
|
.map(
|
|
(loco) => Chip(
|
|
label: Text('${loco.locoClass} ${loco.number}'),
|
|
avatar: const Icon(Icons.directions_railway, size: 16),
|
|
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
Widget _buildRouteList(List<String> segments) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: segments
|
|
.map(
|
|
(segment) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.circle, size: 10),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(segment)),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
List<String> _parseRouteSegments(String route) {
|
|
final trimmed = route.trim();
|
|
if (trimmed.isEmpty) return [];
|
|
try {
|
|
final decoded = jsonDecode(trimmed);
|
|
if (decoded is List) {
|
|
return decoded.map((e) => e.toString()).toList();
|
|
}
|
|
} catch (_) {
|
|
// ignore and try alternative parsing
|
|
}
|
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
try {
|
|
final replaced = trimmed.replaceAll("'", '"');
|
|
final decoded = jsonDecode(replaced);
|
|
if (decoded is List) {
|
|
return decoded.map((e) => e.toString()).toList();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
if (trimmed.contains('->')) {
|
|
return trimmed
|
|
.split('->')
|
|
.map((e) => e.trim())
|
|
.where((e) => e.isNotEmpty)
|
|
.toList();
|
|
}
|
|
if (trimmed.contains(',')) {
|
|
return trimmed
|
|
.split(',')
|
|
.map((e) => e.trim())
|
|
.where((e) => e.isNotEmpty)
|
|
.toList();
|
|
}
|
|
return [trimmed];
|
|
}
|
|
}
|