diff --git a/lib/components/pages/traction/traction.dart b/lib/components/pages/traction/traction.dart index 955d720..cb30625 100644 --- a/lib/components/pages/traction/traction.dart +++ b/lib/components/pages/traction/traction.dart @@ -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'; diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index 0d045f1..b266b44 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -5,6 +5,7 @@ enum _TractionMoreAction { classLeaderboard, adminPending, adminPendingChanges, + adminImport, } class TractionPage extends StatefulWidget { @@ -74,6 +75,7 @@ class _TractionPageState extends State { String? _lastQuerySignature; String? _transferFromLabel; bool _isSearching = false; + bool _classExporting = false; @override void initState() { @@ -649,8 +651,11 @@ class _TractionPageState extends State { 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 { ); 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 { ); } break; + case _TractionMoreAction.adminImport: + await _showTractionImportSheet(); + break; } }, itemBuilder: (context) { @@ -793,6 +818,12 @@ class _TractionPageState extends State { 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 { 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 { ); } + Future _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(); + 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 _showTractionImportSheet() async { + final isElevated = context.read().isElevated; + if (!isElevated) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Import is available to admins only.')), + ); + return; + } + + XFile? selectedFile; + bool uploading = false; + Map? importResult; + String? statusMessage; + String? errorMessage; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) { + final theme = Theme.of(sheetContext); + return StatefulBuilder( + builder: (context, setModalState) { + Future 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 uploadFile() async { + final file = selectedFile; + if (file == null || uploading) return; + setModalState(() { + uploading = true; + importResult = null; + statusMessage = null; + errorMessage = null; + }); + try { + final data = context.read(); + 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.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 _importErrors(Map 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 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 _toggleClassStatsPanel() async { if (!_hasClassQuery) return; final targetState = !_showClassStatsPanel; diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 0f0b56f..615f21b 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -105,6 +105,34 @@ class ApiService { return _processResponse(response); } + Future postMultipartFile( + String endpoint, { + required List bytes, + required String filename, + String fieldName = 'file', + Map? fields, + Map? 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 postForm(String endpoint, Map data) async { final response = await _client .post( diff --git a/pubspec.yaml b/pubspec.yaml index a7ffcc1..e9d27dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/helpers/fake_services.dart b/test/helpers/fake_services.dart new file mode 100644 index 0000000..8617ce3 --- /dev/null +++ b/test/helpers/fake_services.dart @@ -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 getResponses = {}; + final Map postResponses = {}; + + @override + Future get(String endpoint, {Map? headers}) async { + return getResponses[endpoint]; + } + + @override + Future post( + String endpoint, + dynamic data, { + Map? 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 tripsValue = []; + List onThisDayValue = []; + bool isOnThisDayLoadingValue = false; + List classClearanceProgressValue = []; + bool isClassClearanceProgressLoadingValue = false; + List tractionValue = []; + bool isTractionLoadingValue = false; + bool tractionHasMoreValue = false; + List latestLocoChangesValue = []; + bool isLatestLocoChangesLoadingValue = false; + bool latestLocoChangesHasMoreValue = false; + List legsValue = []; + bool isLegsLoadingValue = false; + bool legsHasMoreValue = false; + List tripDetailsValue = []; + List tripListValue = []; + bool isTripDetailsLoadingValue = false; + List locoClassesValue = []; + List eventFieldsValue = []; + bool isEventFieldsLoadingValue = false; + int pendingLocoCountValue = 0; + double currentYearMileageValue = 0; + + @override + HomepageStats? get homepageStats => homepageStatsValue; + + @override + bool get isHomepageLoading => isHomepageLoadingValue; + + @override + List get trips => tripsValue; + + @override + List get onThisDay => onThisDayValue; + + @override + bool get isOnThisDayLoading => isOnThisDayLoadingValue; + + @override + List get classClearanceProgress => + classClearanceProgressValue; + + @override + bool get isClassClearanceProgressLoading => + isClassClearanceProgressLoadingValue; + + @override + List get traction => tractionValue; + + @override + bool get isTractionLoading => isTractionLoadingValue; + + @override + bool get tractionHasMore => tractionHasMoreValue; + + @override + List get latestLocoChanges => latestLocoChangesValue; + + @override + bool get isLatestLocoChangesLoading => isLatestLocoChangesLoadingValue; + + @override + bool get latestLocoChangesHasMore => latestLocoChangesHasMoreValue; + + @override + List get legs => legsValue; + + @override + bool get isLegsLoading => isLegsLoadingValue; + + @override + bool get legsHasMore => legsHasMoreValue; + + @override + List get tripDetails => tripDetailsValue; + + @override + List get tripList => tripListValue; + + @override + bool get isTripDetailsLoading => isTripDetailsLoadingValue; + + @override + List get locoClasses => locoClassesValue; + + @override + List get eventFields => eventFieldsValue; + + @override + bool get isEventFieldsLoading => isEventFieldsLoadingValue; + + @override + int get pendingLocoCount => pendingLocoCountValue; + + @override + double getMileageForCurrentYear() => currentYearMileageValue; + + @override + Future fetchHomepageStats() async {} + + @override + Future fetchOnThisDay({DateTime? date}) async {} + + @override + Future fetchTripDetails() async {} + + @override + Future fetchHadTraction({int offset = 0, int limit = 100}) async {} + + @override + Future fetchLatestLocoChanges({ + int limit = 100, + int offset = 0, + bool append = false, + }) async {} + + @override + Future fetchClassClearanceProgress({ + int offset = 0, + int limit = 20, + bool append = false, + bool onlyIncomplete = false, + }) async {} + + @override + Future 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> fetchTripLocoStats(int tripId) async { + return const []; + } + + @override + Future fetchTraction({ + bool hadOnly = false, + int offset = 0, + int limit = 100, + String? locoClass, + String? locoNumber, + bool mileageFirst = true, + bool append = false, + Map? filters, + }) async {} + + @override + Future> fetchClassList({bool force = false}) async { + return locoClassesValue; + } + + @override + Future fetchEventFields({bool force = false}) async {} + + @override + Future fetchPendingLocoCount() async {} + + @override + Future?> fetchClassStats(String locoClass) async { + return TestData.classStats; + } + + @override + Future> 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); + } +} diff --git a/test/helpers/test_app.dart b/test/helpers/test_app.dart new file mode 100644 index 0000000..95e4355 --- /dev/null +++ b/test/helpers/test_app.dart @@ -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.value(value: authService), + ChangeNotifierProvider.value(value: dataService), + ChangeNotifierProvider.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.value(value: authService), + ChangeNotifierProvider.value(value: dataService), + ChangeNotifierProvider.value( + value: distanceUnitService ?? DistanceUnitService(), + ), + ], + child: MaterialApp.router(routerConfig: router), + ); +} diff --git a/test/helpers/test_data.dart b/test/helpers/test_data.dart new file mode 100644 index 0000000..be87490 --- /dev/null +++ b/test/helpers/test_data.dart @@ -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 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 = { + '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, + ); +} diff --git a/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart new file mode 100644 index 0000000..0aaf731 --- /dev/null +++ b/test/pages/dashboard_page_test.dart @@ -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); + }); +} diff --git a/test/pages/entries_page_test.dart b/test/pages/entries_page_test.dart new file mode 100644 index 0000000..a48af06 --- /dev/null +++ b/test/pages/entries_page_test.dart @@ -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); + }); +} diff --git a/test/pages/traction_page_test.dart b/test/pages/traction_page_test.dart new file mode 100644 index 0000000..5ed66fb --- /dev/null +++ b/test/pages/traction_page_test.dart @@ -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); + }); +} diff --git a/test/pages/trips_page_test.dart b/test/pages/trips_page_test.dart new file mode 100644 index 0000000..64897bc --- /dev/null +++ b/test/pages/trips_page_test.dart @@ -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, + ); + }); +}