add traction import export
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m1s
Release / web-build (push) Successful in 1m22s
Release / android-build (push) Successful in 6m49s
Release / release-master (push) Successful in 5s
Release / release-dev (push) Successful in 8s

This commit is contained in:
2026-01-23 13:21:08 +00:00
parent 56cc7c0902
commit 9896b6f1f8
11 changed files with 1348 additions and 2 deletions

View File

@@ -1,13 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -5,6 +5,7 @@ enum _TractionMoreAction {
classLeaderboard,
adminPending,
adminPendingChanges,
adminImport,
}
class TractionPage extends StatefulWidget {
@@ -74,6 +75,7 @@ class _TractionPageState extends State<TractionPage> {
String? _lastQuerySignature;
String? _transferFromLabel;
bool _isSearching = false;
bool _classExporting = false;
@override
void initState() {
@@ -649,8 +651,11 @@ class _TractionPageState extends State<TractionPage> {
return content;
}
String get _currentClassLabel =>
(_selectedClass ?? _classController.text).trim();
bool get _hasClassQuery {
return (_selectedClass ?? _classController.text).trim().isNotEmpty;
return _currentClassLabel.isNotEmpty;
}
Widget _buildHeaderActions(BuildContext context, bool isMobile) {
@@ -668,6 +673,23 @@ class _TractionPageState extends State<TractionPage> {
);
final hasClassActions = _hasClassQuery;
final classLabel = _currentClassLabel;
final exportClassButton = !hasClassActions
? null
: FilledButton.tonalIcon(
onPressed: _classExporting ? null : _exportSelectedClass,
icon: _classExporting
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download),
label: Text(
_classExporting ? 'Exporting...' : 'Export $classLabel',
),
);
final newTractionButton = FilledButton.icon(
onPressed: () async {
@@ -730,6 +752,9 @@ class _TractionPageState extends State<TractionPage> {
);
}
break;
case _TractionMoreAction.adminImport:
await _showTractionImportSheet();
break;
}
},
itemBuilder: (context) {
@@ -793,6 +818,12 @@ class _TractionPageState extends State<TractionPage> {
child: Text('Pending changes'),
),
);
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminImport,
child: Text('Import traction'),
),
);
}
return items;
},
@@ -816,12 +847,14 @@ class _TractionPageState extends State<TractionPage> {
final desktopActions = [
refreshButton,
if (exportClassButton != null) exportClassButton,
newTractionButton,
if (moreButton != null) moreButton,
];
final mobileActions = [
if (moreButton != null) moreButton,
if (exportClassButton != null) exportClassButton,
newTractionButton,
refreshButton,
];
@@ -849,6 +882,331 @@ class _TractionPageState extends State<TractionPage> {
);
}
Future<void> _exportSelectedClass() async {
if (_classExporting) return;
final classLabel = _currentClassLabel;
if (classLabel.isEmpty) return;
setState(() => _classExporting = true);
final messenger = ScaffoldMessenger.of(context);
try {
final data = context.read<DataService>();
final encodedClass = Uri.encodeComponent(classLabel);
final response = await data.api.getBytes(
'/loco/class/export/$encodedClass',
headers: const {'accept': '*/*'},
);
final safeClassName =
classLabel.replaceAll(RegExp(r'[^a-zA-Z0-9-_]+'), '_');
final fallbackName = 'traction-${safeClassName.isEmpty ? 'class' : safeClassName}.xlsx';
final filename = response.filename ?? fallbackName;
final saveResult = await saveBytes(
Uint8List.fromList(response.bytes),
filename,
mimeType: response.contentType,
);
if (!mounted) return;
if (saveResult.canceled) {
messenger.showSnackBar(
const SnackBar(content: Text('Export canceled.')),
);
} else {
final path = saveResult.path;
messenger.showSnackBar(
SnackBar(
content: Text(
path == null || path.isEmpty
? 'Export started.'
: 'Export saved to $path',
),
),
);
}
} on ApiException catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: ${e.message}')),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
} finally {
if (mounted) setState(() => _classExporting = false);
}
}
Future<void> _showTractionImportSheet() async {
final isElevated = context.read<AuthService>().isElevated;
if (!isElevated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Import is available to admins only.')),
);
return;
}
XFile? selectedFile;
bool uploading = false;
Map<String, dynamic>? importResult;
String? statusMessage;
String? errorMessage;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return StatefulBuilder(
builder: (context, setModalState) {
Future<void> pickFile() async {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(
label: 'Spreadsheets',
extensions: ['xlsx', 'xls', 'ods', 'odf'],
),
],
);
if (file == null) return;
setModalState(() {
selectedFile = file;
importResult = null;
statusMessage = null;
errorMessage = null;
});
}
Future<void> uploadFile() async {
final file = selectedFile;
if (file == null || uploading) return;
setModalState(() {
uploading = true;
importResult = null;
statusMessage = null;
errorMessage = null;
});
try {
final data = context.read<DataService>();
final bytes = await file.readAsBytes();
final response = await data.api.postMultipartFile(
'/loco/class/import',
bytes: bytes,
filename: file.name,
headers: const {'accept': 'application/json'},
);
if (!mounted) return;
final parsed = response is Map
? Map<String, dynamic>.from(response)
: null;
if (parsed != null) {
final imported = _importCount(parsed['imported']);
final updated = _importCount(parsed['updated']);
final errors = _importErrors(parsed);
final errorNote =
errors.isNotEmpty ? ' (${errors.length} error(s))' : '';
statusMessage =
'Import complete. Imported $imported, updated $updated$errorNote.';
importResult = parsed;
} else {
statusMessage = 'Import complete.';
}
await data.fetchClassList();
await _refreshTraction(preservePosition: true);
} on ApiException catch (e) {
if (!mounted) return;
errorMessage = e.message;
} catch (e) {
if (!mounted) return;
errorMessage = e.toString();
} finally {
if (!mounted) return;
setModalState(() => uploading = false);
}
}
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Import traction data',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Upload a spreadsheet to import traction records.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
selectedFile?.name ?? 'No file selected',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: uploading ? null : pickFile,
icon: const Icon(Icons.upload_file),
label: const Text('Choose file'),
),
],
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed:
selectedFile == null || uploading ? null : uploadFile,
icon: uploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.file_upload),
label: Text(
uploading ? 'Importing...' : 'Upload and import',
),
),
if (statusMessage != null) ...[
const SizedBox(height: 12),
Text(
statusMessage!,
style: theme.textTheme.bodyMedium,
),
],
if (errorMessage != null) ...[
const SizedBox(height: 12),
Text(
errorMessage!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (importResult != null)
_buildImportSummary(context, importResult!),
],
),
),
),
);
},
);
},
);
}
int _importCount(dynamic value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
List<dynamic> _importErrors(Map<String, dynamic> result) {
final errors = result['errors'];
if (errors == null) return const [];
if (errors is List) return errors;
return [errors];
}
String _stringifyImportError(dynamic err) {
if (err == null) return '';
if (err is String) return err;
try {
return jsonEncode(err);
} catch (_) {
return err.toString();
}
}
Widget _buildImportSummary(
BuildContext context,
Map<String, dynamic> result,
) {
final imported = _importCount(result['imported']);
final updated = _importCount(result['updated']);
final errors = _importErrors(result);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'Import summary',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
_buildImportChip(context, 'Imported', imported.toString()),
_buildImportChip(context, 'Updated', updated.toString()),
_buildImportChip(context, 'Errors', errors.length.toString()),
],
),
if (errors.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Errors',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: errors
.map(
(err) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(_stringifyImportError(err)),
),
)
.toList(),
),
],
],
);
}
Widget _buildImportChip(
BuildContext context,
String label,
String value,
) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
);
}
Future<void> _toggleClassStatsPanel() async {
if (!_hasClassQuery) return;
final targetState = !_showClassStatsPanel;

View File

@@ -105,6 +105,34 @@ class ApiService {
return _processResponse(response);
}
Future<dynamic> postMultipartFile(
String endpoint, {
required List<int> bytes,
required String filename,
String fieldName = 'file',
Map<String, String>? fields,
Map<String, String>? headers,
}) async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl$endpoint'),
);
request.headers.addAll(_buildHeaders(headers));
if (fields != null && fields.isNotEmpty) {
request.fields.addAll(fields);
}
request.files.add(
http.MultipartFile.fromBytes(
fieldName,
bytes,
filename: filename,
),
);
final streamed = await _client.send(request).timeout(timeout);
final response = await http.Response.fromStream(streamed);
return _processResponse(response);
}
Future<dynamic> postForm(String endpoint, Map<String, String> data) async {
final response = await _client
.post(

View File

@@ -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
# 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.
version: 0.7.5+13
version: 0.7.6+14
environment:
sdk: ^3.8.1

View File

@@ -0,0 +1,265 @@
import 'package:flutter/foundation.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'test_data.dart';
class FakeApiService extends ApiService {
FakeApiService({String baseUrl = 'https://example.com'})
: super(baseUrl: baseUrl);
final Map<String, dynamic> getResponses = {};
final Map<String, dynamic> postResponses = {};
@override
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async {
return getResponses[endpoint];
}
@override
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
return postResponses[endpoint] ?? {};
}
}
class FakeAuthService extends AuthService {
FakeAuthService({
required super.api,
this.userIdValue = 'user-123',
this.usernameValue = 'railfan',
this.fullNameValue = 'Alex Rider',
this.isElevatedValue = true,
this.isLoggedInValue = true,
});
final String? userIdValue;
final String? usernameValue;
final String? fullNameValue;
final bool isElevatedValue;
final bool isLoggedInValue;
@override
bool get isLoggedIn => isLoggedInValue;
@override
String? get userId => userIdValue;
@override
String? get username => usernameValue;
@override
String? get fullName => fullNameValue;
@override
bool get isElevated => isElevatedValue;
}
class FakeDataService extends DataService {
FakeDataService({ApiService? api})
: super(api: api ?? FakeApiService());
HomepageStats? homepageStatsValue;
bool isHomepageLoadingValue = false;
List<TripSummary> tripsValue = [];
List<Leg> onThisDayValue = [];
bool isOnThisDayLoadingValue = false;
List<ClassClearanceProgress> classClearanceProgressValue = [];
bool isClassClearanceProgressLoadingValue = false;
List<LocoSummary> tractionValue = [];
bool isTractionLoadingValue = false;
bool tractionHasMoreValue = false;
List<LocoChange> latestLocoChangesValue = [];
bool isLatestLocoChangesLoadingValue = false;
bool latestLocoChangesHasMoreValue = false;
List<Leg> legsValue = [];
bool isLegsLoadingValue = false;
bool legsHasMoreValue = false;
List<TripDetail> tripDetailsValue = [];
List<TripSummary> tripListValue = [];
bool isTripDetailsLoadingValue = false;
List<String> locoClassesValue = [];
List<EventField> eventFieldsValue = [];
bool isEventFieldsLoadingValue = false;
int pendingLocoCountValue = 0;
double currentYearMileageValue = 0;
@override
HomepageStats? get homepageStats => homepageStatsValue;
@override
bool get isHomepageLoading => isHomepageLoadingValue;
@override
List<TripSummary> get trips => tripsValue;
@override
List<Leg> get onThisDay => onThisDayValue;
@override
bool get isOnThisDayLoading => isOnThisDayLoadingValue;
@override
List<ClassClearanceProgress> get classClearanceProgress =>
classClearanceProgressValue;
@override
bool get isClassClearanceProgressLoading =>
isClassClearanceProgressLoadingValue;
@override
List<LocoSummary> get traction => tractionValue;
@override
bool get isTractionLoading => isTractionLoadingValue;
@override
bool get tractionHasMore => tractionHasMoreValue;
@override
List<LocoChange> get latestLocoChanges => latestLocoChangesValue;
@override
bool get isLatestLocoChangesLoading => isLatestLocoChangesLoadingValue;
@override
bool get latestLocoChangesHasMore => latestLocoChangesHasMoreValue;
@override
List<Leg> get legs => legsValue;
@override
bool get isLegsLoading => isLegsLoadingValue;
@override
bool get legsHasMore => legsHasMoreValue;
@override
List<TripDetail> get tripDetails => tripDetailsValue;
@override
List<TripSummary> get tripList => tripListValue;
@override
bool get isTripDetailsLoading => isTripDetailsLoadingValue;
@override
List<String> get locoClasses => locoClassesValue;
@override
List<EventField> get eventFields => eventFieldsValue;
@override
bool get isEventFieldsLoading => isEventFieldsLoadingValue;
@override
int get pendingLocoCount => pendingLocoCountValue;
@override
double getMileageForCurrentYear() => currentYearMileageValue;
@override
Future<void> fetchHomepageStats() async {}
@override
Future<void> fetchOnThisDay({DateTime? date}) async {}
@override
Future<void> fetchTripDetails() async {}
@override
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {}
@override
Future<void> fetchLatestLocoChanges({
int limit = 100,
int offset = 0,
bool append = false,
}) async {}
@override
Future<void> fetchClassClearanceProgress({
int offset = 0,
int limit = 20,
bool append = false,
bool onlyIncomplete = false,
}) async {}
@override
Future<void> fetchLegs({
int offset = 0,
int limit = 100,
String sortBy = 'date',
int sortDirection = 0,
String? dateRangeStart,
String? dateRangeEnd,
bool append = false,
bool unallocatedOnly = false,
}) async {}
@override
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
return const [];
}
@override
Future<void> fetchTraction({
bool hadOnly = false,
int offset = 0,
int limit = 100,
String? locoClass,
String? locoNumber,
bool mileageFirst = true,
bool append = false,
Map<String, dynamic>? filters,
}) async {}
@override
Future<List<String>> fetchClassList({bool force = false}) async {
return locoClassesValue;
}
@override
Future<void> fetchEventFields({bool force = false}) async {}
@override
Future<void> fetchPendingLocoCount() async {}
@override
Future<Map<String, dynamic>?> fetchClassStats(String locoClass) async {
return TestData.classStats;
}
@override
Future<List<LeaderboardEntry>> fetchClassLeaderboard(
String locoClass, {
bool friends = false,
}) async {
return TestData.classLeaderboard;
}
}
class FakeDistanceUnitService extends DistanceUnitService {
FakeDistanceUnitService({this.unitOverride});
final DistanceUnit? unitOverride;
@override
DistanceUnit get unit => unitOverride ?? super.unit;
@override
String format(
double miles, {
int decimals = 1,
bool includeUnit = true,
}) {
final formatter = DistanceFormatter(unitOverride ?? super.unit);
return formatter.format(miles, decimals: decimals, includeUnit: includeUnit);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
Widget buildTestApp({
required Widget child,
required DataService dataService,
required AuthService authService,
DistanceUnitService? distanceUnitService,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: authService),
ChangeNotifierProvider<DataService>.value(value: dataService),
ChangeNotifierProvider<DistanceUnitService>.value(
value: distanceUnitService ?? DistanceUnitService(),
),
],
child: MaterialApp(home: child),
);
}
Widget buildTestRouterApp({
required GoRouter router,
required DataService dataService,
required AuthService authService,
DistanceUnitService? distanceUnitService,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: authService),
ChangeNotifierProvider<DataService>.value(value: dataService),
ChangeNotifierProvider<DistanceUnitService>.value(
value: distanceUnitService ?? DistanceUnitService(),
),
],
child: MaterialApp.router(routerConfig: router),
);
}

328
test/helpers/test_data.dart Normal file
View File

@@ -0,0 +1,328 @@
import 'package:mileograph_flutter/objects/objects.dart';
class TestData {
static final user = UserData(
username: 'railfan',
fullName: 'Alex Rider',
userId: 'user-123',
email: 'alex@example.com',
entriesVisibility: 'public',
mileageVisibility: 'public',
elevated: true,
disabled: false,
);
static final topLocos = [
LocoSummary(
locoId: 100,
locoType: 'D',
locoNumber: '001',
locoName: 'Atlas',
locoClass: '67',
locoOperator: 'TestRail',
mileage: 1200.5,
journeys: 18,
trips: 6,
status: 'Active',
domain: 'mainline',
owner: 'TestRail',
livery: 'Blue',
location: 'Depot',
),
LocoSummary(
locoId: 101,
locoType: 'D',
locoNumber: '002',
locoName: 'Orion',
locoClass: '68',
locoOperator: 'TestRail',
mileage: 980.2,
journeys: 12,
trips: 4,
status: 'Active',
domain: 'mainline',
owner: 'TestRail',
livery: 'Orange',
location: 'Central',
),
];
static final leaderboard = [
LeaderboardEntry(
userId: 'u1',
username: 'driver_one',
userFullName: 'Driver One',
mileage: 3456.7,
),
LeaderboardEntry(
userId: 'u2',
username: 'driver_two',
userFullName: 'Driver Two',
mileage: 2999.1,
),
];
static final tripSummaries = [
TripSummary(
tripId: 1,
tripName: 'North Run',
tripMileage: 220.4,
legCount: 3,
locoStats: [
TripLocoStat(locoClass: '67', number: '001', won: true),
TripLocoStat(locoClass: '68', number: '002', won: false),
],
startDate: DateTime(2023, 10, 11),
endDate: DateTime(2023, 10, 13),
),
TripSummary(
tripId: 2,
tripName: 'Harbor Loop',
tripMileage: 85.6,
legCount: 1,
locoStats: const [],
startDate: DateTime(2023, 11, 5),
endDate: DateTime(2023, 11, 5),
),
];
static final tripDetails = [
TripDetail(
id: 1,
name: 'North Run',
mileage: 220.4,
legCount: 3,
locoStats: [
TripLocoStat(locoClass: '67', number: '001', won: true),
],
legs: [
TripLeg(
id: 10,
start: 'London',
end: 'Leeds',
beginTime: DateTime(2023, 10, 11, 9, 15),
network: 'National',
route: 'London → Leeds',
mileage: 185.2,
notes: 'Clear run',
locos: [
Loco(
id: 100,
type: 'D',
number: '001',
name: 'Atlas',
locoClass: '67',
operator: 'TestRail',
notes: '',
evn: '',
),
],
),
],
),
];
static final legs = [
Leg(
id: 501,
tripId: 1,
start: 'London',
end: 'Oxford',
beginTime: DateTime(2023, 9, 12, 7, 45),
timezone: 0,
network: 'National',
route: ['London', 'Oxford'],
mileage: 62.3,
notes: 'Morning service',
headcode: '1A00',
driving: 0,
user: 'railfan',
locos: [
Loco(
id: 100,
type: 'D',
number: '001',
name: 'Atlas',
locoClass: '67',
operator: 'TestRail',
notes: '',
evn: '',
),
],
),
Leg(
id: 502,
tripId: 2,
start: 'Oxford',
end: 'Bristol',
beginTime: DateTime(2023, 9, 13, 16, 10),
timezone: 0,
network: 'National',
route: ['Oxford', 'Bristol'],
mileage: 74.8,
notes: 'Evening run',
headcode: '1B10',
driving: 0,
user: 'railfan',
locos: [
Loco(
id: 101,
type: 'D',
number: '002',
name: 'Orion',
locoClass: '68',
operator: 'TestRail',
notes: '',
evn: '',
),
],
),
];
static List<Leg> onThisDayLegs() {
final pastYear = DateTime.now().year - 1;
return [
Leg(
id: 900,
tripId: 3,
start: 'York',
end: 'Durham',
beginTime: DateTime(pastYear, 7, 4, 10, 30),
timezone: 0,
network: 'National',
route: ['York', 'Durham'],
mileage: 60.0,
notes: 'Anniversary run',
headcode: '1C11',
driving: 0,
user: 'railfan',
locos: [
Loco(
id: 102,
type: 'D',
number: '003',
name: 'Nova',
locoClass: '66',
operator: 'TestRail',
notes: '',
evn: '',
),
],
),
];
}
static final latestLocoChanges = [
LocoChange(
locoId: 200,
locoClass: '67',
locoNumber: '001',
locoName: 'Atlas',
attrCode: 'loco_status',
attrDisplay: 'Status',
valueDisplay: 'Active',
validFrom: DateTime(2023, 10, 1),
approvedAt: DateTime(2023, 10, 2),
approvedBy: 'moderator',
),
];
static final classClearance = [
ClassClearanceProgress(
className: 'Class 67',
completed: 12,
total: 30,
percentComplete: 40.0,
activeCompleted: 8,
activeTotal: 20,
activePercent: 40.0,
),
];
static final traction = [
LocoSummary(
locoId: 100,
locoType: 'D',
locoNumber: '001',
locoName: 'Atlas',
locoClass: '67',
locoOperator: 'TestRail',
mileage: 1200.5,
journeys: 18,
trips: 6,
status: 'Active',
domain: 'mainline',
owner: 'TestRail',
livery: 'Blue',
location: 'Depot',
),
LocoSummary(
locoId: 101,
locoType: 'D',
locoNumber: '002',
locoName: 'Orion',
locoClass: '68',
locoOperator: 'TestRail',
mileage: 980.2,
journeys: 12,
trips: 4,
status: 'Active',
domain: 'mainline',
owner: 'TestRail',
livery: 'Orange',
location: 'Central',
),
];
static final eventFields = [
const EventField(name: 'power_unit', display: 'Power Unit', type: 'text'),
const EventField(
name: 'cab_air_conditioning',
display: 'Cab A/C',
enumValues: ['Yes', 'No'],
),
];
static final classStats = <String, dynamic>{
'loco_class': '67',
'total_mileage_with_class': 5500.5,
'avg_mileage_per_entry': 80.2,
'avg_mileage_per_loco_had': 420.0,
'had_count': 12,
'entries_with_class': 42,
'class_stats': {
'total': 30,
'status': [
{'status': 'Active', 'count': 18},
{'status': 'Stored', 'count': 12},
],
'domain': [
{'domain': 'mainline', 'count': 20},
{'domain': 'heritage', 'count': 10},
],
},
};
static final classLeaderboard = [
LeaderboardEntry(
userId: 'u3',
username: 'fan_three',
userFullName: 'Fan Three',
mileage: 1500.0,
),
];
static final homepageStats = HomepageStats(
totalMileage: 4523.8,
yearlyMileage: [
YearlyMileage(year: DateTime.now().year, mileage: 1200.5),
YearlyMileage(year: DateTime.now().year - 1, mileage: 990.2),
],
topLocos: topLocos,
leaderboard: leaderboard,
friendsLeaderboard: leaderboard,
trips: tripSummaries,
legCount: 27,
user: user,
);
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mileograph_flutter/components/pages/dashboard.dart';
import '../helpers/fake_services.dart';
import '../helpers/test_app.dart';
import '../helpers/test_data.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Dashboard renders stats and panels', (tester) async {
final data = FakeDataService()
..homepageStatsValue = TestData.homepageStats
..tripsValue = TestData.tripSummaries
..onThisDayValue = TestData.onThisDayLegs()
..classClearanceProgressValue = TestData.classClearance
..latestLocoChangesValue = TestData.latestLocoChanges
..tractionValue = TestData.traction
..currentYearMileageValue = 1200.5;
final auth = FakeAuthService(api: FakeApiService());
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Dashboard(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget);
expect(find.textContaining('Welcome back, Alex Rider'), findsOneWidget);
expect(find.text('Top traction'), findsOneWidget);
expect(find.text('67 001'), findsOneWidget);
expect(find.text('On this day'), findsOneWidget);
expect(find.text('York → Durham'), findsOneWidget);
expect(find.text('Latest Loco Changes'), findsOneWidget);
expect(find.text('Status'), findsWidgets);
expect(find.text('Active'), findsWidgets);
});
testWidgets('Dashboard shows loading overlay without stats', (tester) async {
final data = FakeDataService()
..homepageStatsValue = null
..isHomepageLoadingValue = true;
final auth = FakeAuthService(api: FakeApiService());
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Dashboard(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pump();
expect(find.text('Loading dashboard data...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsWidgets);
});
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import '../helpers/fake_services.dart';
import '../helpers/test_app.dart';
import '../helpers/test_data.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Entries page shows legs and mileage summary', (tester) async {
final data = FakeDataService()..legsValue = TestData.legs;
final auth = FakeAuthService(api: FakeApiService());
final distanceUnits = FakeDistanceUnitService(
unitOverride: DistanceUnit.milesDecimal,
);
await tester.pumpWidget(
buildTestApp(
child: const LegsPage(),
dataService: data,
authService: auth,
distanceUnitService: distanceUnits,
),
);
await tester.pumpAndSettle();
expect(find.text('Entries'), findsOneWidget);
expect(find.text('Page mileage'), findsOneWidget);
expect(find.textContaining('mi'), findsWidgets);
expect(find.text('London'), findsOneWidget);
expect(find.text('Oxford'), findsOneWidget);
});
testWidgets('Entries page shows empty state', (tester) async {
final data = FakeDataService()
..legsValue = []
..isLegsLoadingValue = false;
final auth = FakeAuthService(api: FakeApiService());
await tester.pumpWidget(
buildTestApp(
child: const LegsPage(),
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.text('No entries found'), findsOneWidget);
expect(find.text('Adjust the filters or add a new leg.'), findsOneWidget);
});
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import '../helpers/fake_services.dart';
import '../helpers/test_app.dart';
import '../helpers/test_data.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Traction page shows list data', (tester) async {
final data = FakeDataService()
..tractionValue = TestData.traction
..locoClassesValue = ['67', '68']
..eventFieldsValue = TestData.eventFields;
final auth = FakeAuthService(api: FakeApiService(), isElevatedValue: true);
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const TractionPage(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.text('Traction'), findsOneWidget);
expect(find.text('67'), findsWidgets);
expect(find.text('001'), findsWidgets);
});
testWidgets('Traction page shows export when class filter set', (tester) async {
final data = FakeDataService()
..tractionValue = TestData.traction
..locoClassesValue = ['67', '68']
..eventFieldsValue = TestData.eventFields;
final auth = FakeAuthService(api: FakeApiService(), isElevatedValue: true);
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const TractionPage(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Export'), findsNothing);
final classField = find.byWidgetPredicate(
(widget) =>
widget is TextField && widget.decoration?.labelText == 'Class',
);
await tester.enterText(classField, '67');
await tester.pump();
expect(find.text('Export 67'), findsOneWidget);
});
testWidgets('Traction page shows admin import option', (tester) async {
final data = FakeDataService()
..tractionValue = TestData.traction
..locoClassesValue = ['67', '68']
..eventFieldsValue = TestData.eventFields
..pendingLocoCountValue = 2;
final auth = FakeAuthService(api: FakeApiService(), isElevatedValue: true);
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const TractionPage(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton));
await tester.pumpAndSettle();
expect(find.text('Import traction'), findsOneWidget);
});
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import '../helpers/fake_services.dart';
import '../helpers/test_app.dart';
import '../helpers/test_data.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Trips page shows trip details', (tester) async {
final data = FakeDataService()
..tripDetailsValue = TestData.tripDetails
..tripListValue = TestData.tripSummaries;
final auth = FakeAuthService(api: FakeApiService());
final distanceUnits = FakeDistanceUnitService(
unitOverride: DistanceUnit.milesDecimal,
);
await tester.pumpWidget(
buildTestApp(
child: const TripsPage(),
dataService: data,
authService: auth,
distanceUnitService: distanceUnits,
),
);
await tester.pumpAndSettle();
expect(find.text('Trips'), findsOneWidget);
expect(find.text('North Run'), findsOneWidget);
expect(find.textContaining('mi'), findsWidgets);
});
testWidgets('Trips page shows empty state', (tester) async {
final data = FakeDataService()
..tripDetailsValue = []
..tripListValue = []
..isTripDetailsLoadingValue = false;
final auth = FakeAuthService(api: FakeApiService());
await tester.pumpWidget(
buildTestApp(
child: const TripsPage(),
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.text('No trips yet'), findsOneWidget);
expect(
find.text('Use the Add entry flow to start grouping legs into trips.'),
findsOneWidget,
);
});
}