Initial Commit
This commit is contained in:
63
lib/main.dart
Normal file
63
lib/main.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:proto_portal/pages/audit.dart';
|
||||
import 'package:proto_portal/pages/home.dart';
|
||||
import 'package:proto_portal/pages/login.dart';
|
||||
import 'package:proto_portal/scraper.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
GtiAuth _auth = GtiAuth();
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await _auth.init();
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<GtiAuth>.value(
|
||||
value: _auth,
|
||||
),
|
||||
],
|
||||
child: ShadcnApp.router(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorSchemes.darkViolet,
|
||||
surfaceOpacity: 1
|
||||
),
|
||||
routerConfig: _router,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GoRouter _router = GoRouter(
|
||||
initialLocation: "/station",
|
||||
redirect: (context, state) {
|
||||
|
||||
if (!_auth.isAuthenticated) {
|
||||
return LoginPage.route.path;
|
||||
}
|
||||
|
||||
if (state.uri.path == "/") {
|
||||
return StationPage.route.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
LoginPage.route,
|
||||
StationPage.route,
|
||||
AuditPage.route,
|
||||
],
|
||||
);
|
||||
643
lib/pages/audit.dart
Normal file
643
lib/pages/audit.dart
Normal file
@@ -0,0 +1,643 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:proto_portal/scraper.dart';
|
||||
import 'package:proto_portal/widgets/service_card.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class AuditPage extends StatefulWidget {
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/audit",
|
||||
builder: (context, state) {
|
||||
final serviceBase64 = state.uri.queryParameters['service'] ?? '';
|
||||
final stationBase64 = state.uri.queryParameters['station'] ?? '';
|
||||
|
||||
GtiService? service;
|
||||
String? stationLabel;
|
||||
String? crs;
|
||||
|
||||
try {
|
||||
// Decode service from base64
|
||||
final serviceJson = utf8.decode(base64Decode(serviceBase64));
|
||||
service = GtiService.fromJson(jsonDecode(serviceJson));
|
||||
|
||||
// Decode station from base64
|
||||
final stationJson = utf8.decode(base64Decode(stationBase64));
|
||||
final stationData = jsonDecode(stationJson);
|
||||
stationLabel = stationData['label'];
|
||||
crs = stationData['crs'];
|
||||
} catch (e) {
|
||||
print('Error decoding params: $e');
|
||||
}
|
||||
|
||||
return AuditPage(
|
||||
service: service,
|
||||
stationLabel: stationLabel ?? '',
|
||||
crs: crs ?? '',
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
final GtiService? service;
|
||||
final String stationLabel;
|
||||
final String crs;
|
||||
|
||||
AuditPage({
|
||||
required this.service,
|
||||
required this.stationLabel,
|
||||
required this.crs,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AuditPage> createState() => _AuditPageState();
|
||||
}
|
||||
|
||||
class _AuditPageState extends State<AuditPage> {
|
||||
|
||||
// Form controllers
|
||||
final TextEditingController _vehicleRegController = TextEditingController();
|
||||
|
||||
// Form values
|
||||
int _seats1 = 5;
|
||||
int _seats2 = 1;
|
||||
int _pax1 = 0;
|
||||
int _pax2 = 0;
|
||||
String _luggage = '0';
|
||||
String _paceNotes = '1';
|
||||
CheckboxState _vehicleTracking = CheckboxState.unchecked;
|
||||
CheckboxState _destBanner = CheckboxState.checked;
|
||||
CheckboxState _callingPatternFront = CheckboxState.unchecked;
|
||||
CheckboxState _callingPatternMiddle = CheckboxState.unchecked;
|
||||
CheckboxState _callingPatternRear = CheckboxState.unchecked;
|
||||
CheckboxState _audioEquipment = CheckboxState.unchecked;
|
||||
CheckboxState _audioWorking = CheckboxState.unchecked;
|
||||
CheckboxState _wheelchair = CheckboxState.checked;
|
||||
String _exemption = 'X';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_vehicleRegController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveAudit() async {
|
||||
final auth = GtiAuth.of(context);
|
||||
|
||||
print('=== AUDIT FORM DEBUG ===');
|
||||
print('Service: ${widget.service}');
|
||||
print('Service vehicleId: ${widget.service?.vehicleId}');
|
||||
print('Service auditId: ${widget.service?.auditId}');
|
||||
print('Station CRS: ${widget.crs}');
|
||||
print('======================');
|
||||
|
||||
if (widget.service == null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Error'),
|
||||
content: Text('Service data not loaded'),
|
||||
actions: [
|
||||
PrimaryButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final success = await auth.saveAudit(
|
||||
time: widget.service!.time,
|
||||
crs: widget.crs,
|
||||
vehicleId: widget.service!.vehicleId,
|
||||
auditId: widget.service!.auditId ?? '0',
|
||||
vehicleReg: _vehicleRegController.text,
|
||||
seats: '$_seats1$_seats2',
|
||||
pax: '$_pax1$_pax2',
|
||||
luggage: _luggage,
|
||||
paceNotes: _paceNotes,
|
||||
vehicleTracking: _vehicleTracking == CheckboxState.checked ? 'Y' : 'N',
|
||||
destBanner: _destBanner == CheckboxState.checked ? 'Y' : 'N',
|
||||
callingPatternFront: _callingPatternFront == CheckboxState.checked ? 'Y' : 'N',
|
||||
callingPatternMiddle: _callingPatternMiddle == CheckboxState.checked ? 'Y' : 'N',
|
||||
callingPatternRear: _callingPatternRear == CheckboxState.checked ? 'Y' : 'N',
|
||||
audioEquipment: _audioEquipment == CheckboxState.checked ? 'Y' : 'N',
|
||||
audioWorking: _audioWorking == CheckboxState.checked ? 'Y' : 'N',
|
||||
wheelchair: _wheelchair == CheckboxState.checked ? 'Y' : 'N',
|
||||
exemption: _exemption,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Success'),
|
||||
content: Text('Audit saved successfully!\n\nCheck console for request details.'),
|
||||
actions: [
|
||||
PrimaryButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Error'),
|
||||
content: Text('Failed to save audit.\n\nCheck console for details.\n\nVehicleId: ${widget.service!.vehicleId}\nAuditId: ${widget.service!.auditId}'),
|
||||
actions: [
|
||||
PrimaryButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Error'),
|
||||
content: Text('Error saving audit: $e'),
|
||||
actions: [
|
||||
PrimaryButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
double topPadding = MediaQuery.of(context).padding.top;
|
||||
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
if (widget.service == null) {
|
||||
return _scaffoldBgAndTing(
|
||||
context: context,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.triangleAlert, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text('Service not found').h4,
|
||||
SizedBox(height: 8),
|
||||
OutlineButton(
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
child: Text('Go Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _scaffoldBgAndTing(
|
||||
context: context,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: topPadding),
|
||||
|
||||
// Header
|
||||
Container(
|
||||
height: 60,
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 8),
|
||||
Button.ghost(
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
child: Icon(LucideIcons.arrowLeft),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Audit Form').h4,
|
||||
Text('${widget.service?.coach} - ${widget.service?.time}').small.muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 8),
|
||||
|
||||
// Form Content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: OutlinedContainer(
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
ServiceCard.fromService(
|
||||
service: widget.service!,
|
||||
),
|
||||
|
||||
// Read-only fields
|
||||
_buildReadOnlyRow('Schedule Depart Time:', widget.service?.time ?? ''),
|
||||
Divider(),
|
||||
_buildReadOnlyRow('Diagram Number:', widget.service?.coach ?? ''),
|
||||
Divider(),
|
||||
_buildReadOnlyRow('End Destination on Route:', widget.service?.destination ?? 'N/A'),
|
||||
Divider(),
|
||||
_buildReadOnlyRow('Coach Operator:', widget.service?.operator ?? ''),
|
||||
|
||||
Divider(thickness: 2),
|
||||
|
||||
// Editable fields
|
||||
_buildTextFieldRow(
|
||||
label: 'Vehicle Reg:',
|
||||
controller: _vehicleRegController,
|
||||
maxLength: 8,
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildTwoDropdownRow(
|
||||
label: 'Vehicle (No. Seats):',
|
||||
value1: _seats1,
|
||||
value2: _seats2,
|
||||
onChanged1: (val) => setState(() => _seats1 = val!),
|
||||
onChanged2: (val) => setState(() => _seats2 = val!),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildTwoDropdownRow(
|
||||
label: 'Pax No.:',
|
||||
value1: _pax1,
|
||||
value2: _pax2,
|
||||
onChanged1: (val) => setState(() => _pax1 = val!),
|
||||
onChanged2: (val) => setState(() => _pax2 = val!),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildDropdownRow(
|
||||
label: 'Luggage Hold:',
|
||||
value: _luggage,
|
||||
items: [
|
||||
('0', 'Select'),
|
||||
('1', 'OK'),
|
||||
('2', 'Nearly Full'),
|
||||
('3', 'Full'),
|
||||
],
|
||||
onChanged: (val) => setState(() => _luggage = val!),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildDropdownRow(
|
||||
label: 'Vehicle (Pace Notes):',
|
||||
value: _paceNotes,
|
||||
items: [
|
||||
('1', 'Yes - FTS Supplied'),
|
||||
('2', 'Yes - Company Supplied'),
|
||||
('3', 'Yes - Sat Nav'),
|
||||
('4', 'No - Nothing'),
|
||||
],
|
||||
onChanged: (val) => setState(() => _paceNotes = val!),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildCheckboxRow(
|
||||
label: 'Does the service have Vehicle Tracking:',
|
||||
state: _vehicleTracking,
|
||||
onChanged: (val) => setState(() => _vehicleTracking = val),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildCheckboxRow(
|
||||
label: 'Diagram Number & Destination Banner:',
|
||||
state: _destBanner,
|
||||
onChanged: (val) => setState(() => _destBanner = val),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildCheckboxRow(
|
||||
label: 'Destination & Calling Pattern (Front):',
|
||||
state: _callingPatternFront,
|
||||
onChanged: (val) => setState(() => _callingPatternFront = val),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildCheckboxRow(
|
||||
label: 'Destination & Calling Pattern (Middle):',
|
||||
state: _callingPatternMiddle,
|
||||
onChanged: (val) => setState(() => _callingPatternMiddle = val),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildCheckboxRow(
|
||||
label: 'Destination & Calling Pattern (Rear):',
|
||||
state: _callingPatternRear,
|
||||
onChanged: (val) => setState(() => _callingPatternRear = val),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildCheckboxRow(
|
||||
label: 'Audio Announcement Equipment:',
|
||||
state: _audioEquipment,
|
||||
onChanged: (val) => setState(() => _audioEquipment = val),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildCheckboxRow(
|
||||
label: 'Audio Announcement Working:',
|
||||
state: _audioWorking,
|
||||
onChanged: (val) => setState(() => _audioWorking = val),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Where Audio Announcement is \'No\', please instruct the driver to perform verbal announcements'
|
||||
).small.muted,
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildCheckboxRow(
|
||||
label: 'Wheelchair Accessible:',
|
||||
state: _wheelchair,
|
||||
onChanged: (val) => setState(() => _wheelchair = val),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
_buildDropdownRow(
|
||||
label: 'Where Wheelchair Accessible is \'No\', is an MTE Exemption certificate visible?:',
|
||||
value: _exemption,
|
||||
items: [
|
||||
('X', 'N/A'),
|
||||
('N', 'N'),
|
||||
('Y', 'Y'),
|
||||
],
|
||||
onChanged: (val) => setState(() => _exemption = val!),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
// Save button
|
||||
Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: PrimaryButton(
|
||||
onPressed: _saveAudit,
|
||||
child: Text('Save Audit'),
|
||||
).sized(width: double.infinity),
|
||||
),
|
||||
|
||||
],
|
||||
).withPadding(all: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: bottomPadding),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadOnlyRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Text(label).small,
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(value).small,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextFieldRow({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
int? maxLength,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Text(label).small,
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
maxLength: maxLength,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTwoDropdownRow({
|
||||
required String label,
|
||||
required int value1,
|
||||
required int value2,
|
||||
required ValueChanged<int?> onChanged1,
|
||||
required ValueChanged<int?> onChanged2,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Text(label).small,
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Select<int>(
|
||||
itemBuilder: (context, item) => Text('$item'),
|
||||
value: value1,
|
||||
onChanged: onChanged1,
|
||||
popup: SelectPopup.noVirtualization(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
for (int i = 0; i <= 9; i++)
|
||||
SelectItemButton(value: i, child: Text('$i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Select<int>(
|
||||
itemBuilder: (context, item) => Text('$item'),
|
||||
value: value2,
|
||||
onChanged: onChanged2,
|
||||
popup: SelectPopup.noVirtualization(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
for (int i = 0; i <= 9; i++)
|
||||
SelectItemButton(value: i, child: Text('$i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownRow({
|
||||
required String label,
|
||||
required String value,
|
||||
required List<(String, String)> items,
|
||||
required ValueChanged<String?> onChanged,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Text(label).small,
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Select<String>(
|
||||
itemBuilder: (context, item) {
|
||||
final itemData = items.firstWhere((i) => i.$1 == item);
|
||||
return Text(itemData.$2);
|
||||
},
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
popup: SelectPopup.noVirtualization(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
for (final item in items)
|
||||
SelectItemButton(value: item.$1, child: Text(item.$2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckboxRow({
|
||||
required String label,
|
||||
required CheckboxState state,
|
||||
required ValueChanged<CheckboxState> onChanged,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: Checkbox(
|
||||
state: state,
|
||||
onChanged: onChanged,
|
||||
trailing: Text(label).small,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _scaffoldBgAndTing({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
}) {
|
||||
Color bgColor = Colors.black;
|
||||
if (Theme.of(context).brightness == Brightness.light) {
|
||||
bgColor = Colors.white;
|
||||
} else {
|
||||
bgColor = Colors.black;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage("assets/background-1.jpeg"),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaY: 10.0,
|
||||
sigmaX: 10.0,
|
||||
),
|
||||
child: Container(
|
||||
color: bgColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.8),
|
||||
Colors.black.withOpacity(0.5),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
393
lib/pages/home.dart
Normal file
393
lib/pages/home.dart
Normal file
@@ -0,0 +1,393 @@
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:proto_portal/scraper.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import '../widgets/faded_scroll_view.dart';
|
||||
import '../widgets/service_card.dart';
|
||||
|
||||
class StationPage extends StatefulWidget {
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/station",
|
||||
builder: (context, state) => StationPage(stationName: state.uri.queryParameters["station"],)
|
||||
);
|
||||
|
||||
String? stationName;
|
||||
|
||||
StationPage({
|
||||
this.stationName
|
||||
});
|
||||
|
||||
@override
|
||||
State<StationPage> createState() => _StationPageState();
|
||||
}
|
||||
|
||||
class _StationPageState extends State<StationPage> {
|
||||
|
||||
GtiStation? _station = null;
|
||||
List<GtiService>? services = null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// TODO: implement initState
|
||||
super.initState();
|
||||
if (widget.stationName != null) {
|
||||
setStation(widget.stationName!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setStation(String stationName) async {
|
||||
if (true) {
|
||||
List<GtiStation> results = await GtiAuth.of(context).searchStations(stationName.trim());
|
||||
|
||||
if (results.isNotEmpty) {
|
||||
setState(() {
|
||||
_station = results.first;
|
||||
widget.stationName = _station!.label;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
widget.stationName = null;
|
||||
_station = null;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get Services.
|
||||
List<GtiService> fetchedServices = await GtiAuth.of(context).getServicesForStation(_station!);
|
||||
|
||||
|
||||
|
||||
setState(() {
|
||||
services = fetchedServices;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectStation(GtiStation station) async {
|
||||
setState(() {
|
||||
_station = station;
|
||||
widget.stationName = station.label;
|
||||
});
|
||||
|
||||
// Get Services.
|
||||
List<GtiService> fetchedServices = await GtiAuth.of(context).getServicesForStation(station);
|
||||
|
||||
setState(() {
|
||||
services = fetchedServices;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
double topPadding = MediaQuery.of(context).padding.top;
|
||||
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
print("Built page page");
|
||||
|
||||
|
||||
if (_station == null) {
|
||||
return StationSearch(
|
||||
onSelected: (station) {
|
||||
// GoRouter.of(context).go(
|
||||
// "${StationPage.route.path}?station=${station.code}"
|
||||
// );
|
||||
selectStation(station);
|
||||
print("Selected station: ${station.label}");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return _scaffoldBgAndTing(
|
||||
context: context,
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
SizedBox(height: topPadding),
|
||||
|
||||
Column(
|
||||
children: [
|
||||
// Container(
|
||||
// height: 40,
|
||||
// width: double.infinity,
|
||||
// alignment: Alignment.center,
|
||||
// child: SvgPicture.asset(
|
||||
// "assets/logo.svg",
|
||||
// alignment: AlignmentGeometry.center,
|
||||
// width: 250,
|
||||
// colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
// ),
|
||||
// ),
|
||||
|
||||
Container(
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_station?.label ?? "Unknown Station"
|
||||
).h4,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4,),
|
||||
|
||||
// Content
|
||||
|
||||
Expanded(
|
||||
child: services == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: services!.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.trainTrack, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text("No services available").muted,
|
||||
],
|
||||
),
|
||||
)
|
||||
: FadedScrollView(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
fadeHeight: 20,
|
||||
easingDistance: 5,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final service in services!)
|
||||
ServiceCard.fromService(
|
||||
service: service,
|
||||
station: _station,
|
||||
showActions: true,
|
||||
).withMargin(horizontal: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
NavigationBar(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: bottomPadding
|
||||
),
|
||||
children: [
|
||||
NavigationItem(
|
||||
child: Icon(
|
||||
LucideIcons.house
|
||||
),
|
||||
),
|
||||
|
||||
NavigationItem(
|
||||
child: Icon(
|
||||
LucideIcons.house
|
||||
),
|
||||
),
|
||||
|
||||
NavigationItem(
|
||||
child: Icon(
|
||||
LucideIcons.house
|
||||
),
|
||||
),
|
||||
|
||||
NavigationItem(
|
||||
child: Icon(
|
||||
LucideIcons.house
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StationSearch extends StatefulWidget{
|
||||
|
||||
Function(GtiStation) onSelected;
|
||||
|
||||
StationSearch({
|
||||
required this.onSelected
|
||||
});
|
||||
|
||||
@override
|
||||
State<StationSearch> createState() => _StationSearchState();
|
||||
}
|
||||
|
||||
class _StationSearchState extends State<StationSearch> {
|
||||
TextEditingController _controller = TextEditingController();
|
||||
|
||||
List<GtiStation> _results = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
double topPadding = MediaQuery.of(context).padding.top;
|
||||
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
|
||||
return _scaffoldBgAndTing(
|
||||
context: context,
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
SizedBox(height: topPadding),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
if (_results.isEmpty)
|
||||
Text(
|
||||
"No results"
|
||||
).bold.large.withPadding(all: 16)
|
||||
else for (final GtiStation station in _results.sublist(0, _results.length-1)) ...[
|
||||
|
||||
_stationButton(station),
|
||||
|
||||
Divider()
|
||||
],
|
||||
|
||||
if (_results.isNotEmpty)
|
||||
_stationButton(_results.last)
|
||||
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
|
||||
Divider(
|
||||
color: Theme.of(context).colorScheme.border,
|
||||
),
|
||||
|
||||
OutlinedContainer(
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderWidth: 0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Type station name"
|
||||
).small.semiBold,
|
||||
|
||||
const SizedBox(height: 10,),
|
||||
|
||||
TextField(
|
||||
controller: _controller,
|
||||
placeholder: Text(
|
||||
"e.g. Redhill"
|
||||
),
|
||||
onChanged: (value) async {
|
||||
|
||||
List<GtiStation> results = await GtiAuth.of(context).searchStations(value);
|
||||
|
||||
setState(() {
|
||||
_results = results;
|
||||
});
|
||||
}
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
height: bottomPadding,
|
||||
)
|
||||
],
|
||||
).withPadding(all: 16),
|
||||
)
|
||||
|
||||
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stationButton(GtiStation station) {
|
||||
return Button.ghost(
|
||||
onPressed: () {
|
||||
widget.onSelected(station);
|
||||
},
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Basic(
|
||||
title: Text(
|
||||
station.label
|
||||
).h4,
|
||||
),
|
||||
).sized(width: double.infinity).withPadding(all: 8);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Widget _scaffoldBgAndTing({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
}) {
|
||||
|
||||
// light/dark mode color
|
||||
Color bgColor = Colors.black;
|
||||
if (Theme.of(context).brightness == Brightness.light) {
|
||||
bgColor = Colors.white;
|
||||
} else {
|
||||
bgColor = Colors.black;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Image goes BEHIND the BackdropFilter
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage("assets/background-1.jpeg"),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// BackdropFilter blurs the image above
|
||||
BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaY: 10.0,
|
||||
sigmaX: 10.0,
|
||||
),
|
||||
child: Container(
|
||||
color: bgColor.withOpacity(0.3), // Or add a tint if desired
|
||||
),
|
||||
),
|
||||
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.8),
|
||||
Colors.black.withOpacity(0.5),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
135
lib/pages/login.dart
Normal file
135
lib/pages/login.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
|
||||
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:proto_portal/scraper.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class LoginPage extends StatelessWidget {
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/login",
|
||||
builder: (context, state) => LoginPage()
|
||||
);
|
||||
|
||||
GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
InputKey _usernameKey = InputKey("username");
|
||||
InputKey _passwordKey = InputKey("password");
|
||||
CheckboxState rememberMe = CheckboxState.checked;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
child: Center(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
onSubmit: (context, values) async {
|
||||
|
||||
try {
|
||||
|
||||
await GtiAuth.of(context).login(
|
||||
values[_usernameKey] as String,
|
||||
values[_passwordKey] as String,
|
||||
remember: rememberMe == CheckboxState.checked
|
||||
);
|
||||
|
||||
GoRouter.of(context).go("/");
|
||||
|
||||
} catch (e) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Error logging in'),
|
||||
content: Text("The username or password provided is incorrect."),
|
||||
actions: [
|
||||
PrimaryButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 50,
|
||||
width: double.infinity,
|
||||
alignment: Alignment.center,
|
||||
child: SvgPicture.asset(
|
||||
"assets/logo.svg",
|
||||
alignment: AlignmentGeometry.center,
|
||||
// width: 400,
|
||||
colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Text(
|
||||
"Coordination Portal"
|
||||
).h4.center(),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
FormField(
|
||||
label: Text("Username"),
|
||||
key: _usernameKey,
|
||||
child: TextField(
|
||||
|
||||
),
|
||||
validator: ConditionalValidator((value) {
|
||||
return value != null && (value as String).isNotEmpty;
|
||||
}, message: "Username cannot be empty"),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
FormField(
|
||||
label: Text("Password"),
|
||||
key: _passwordKey,
|
||||
child: TextField(
|
||||
obscureText: true,
|
||||
),
|
||||
validator: ConditionalValidator((value) {
|
||||
return value != null && (value as String).isNotEmpty;
|
||||
}, message: "Password cannot be empty"),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Checkbox(
|
||||
state: rememberMe,
|
||||
onChanged: null,
|
||||
trailing: Text("Remember me"),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SubmitButton(
|
||||
child: Text("Login"),
|
||||
loadingTrailing: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
).withPadding(
|
||||
all: 16,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
608
lib/scraper.dart
Normal file
608
lib/scraper.dart
Normal file
@@ -0,0 +1,608 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:html/dom.dart';
|
||||
|
||||
class GtiAuth extends ChangeNotifier { // Add ChangeNotifier for reactive updates
|
||||
String? _SESSID;
|
||||
|
||||
String? get sessionId => _SESSID; // Dart convention: lowercase property names
|
||||
|
||||
bool get isAuthenticated => _SESSID != null;
|
||||
|
||||
static GtiAuth of(BuildContext context, {bool listen = false}) { // Add listen parameter
|
||||
return Provider.of<GtiAuth>(context, listen: listen);
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
String? storedSessionId = prefs.getString('PHPSESSID');
|
||||
|
||||
if (storedSessionId != null) {
|
||||
_SESSID = storedSessionId;
|
||||
notifyListeners(); // Notify widgets that auth state changed
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(String username, String password, {bool remember = false}) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('https://gti.fts-tech.co.uk/coordv2/login.php'),
|
||||
body: {
|
||||
'action': 'login',
|
||||
'user': username,
|
||||
'pass': password,
|
||||
},
|
||||
);
|
||||
|
||||
// Check if login successful (response is "1")
|
||||
if (response.body.trim() != '1') {
|
||||
throw Exception('Invalid username or password');
|
||||
}
|
||||
|
||||
// Extract PHPSESSID from cookies
|
||||
final cookies = response.headers['set-cookie'];
|
||||
if (cookies == null) {
|
||||
throw Exception('No session cookie received from server');
|
||||
}
|
||||
|
||||
_SESSID = _extractSessionId(cookies);
|
||||
if (_SESSID == null) {
|
||||
throw Exception('Failed to extract PHPSESSID from cookies');
|
||||
}
|
||||
|
||||
// If remember is true, use shared_preferences to store session persistently
|
||||
if (remember) {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('PHPSESSID', _SESSID!);
|
||||
}
|
||||
|
||||
notifyListeners(); // Notify widgets that auth state changed
|
||||
|
||||
} catch (e) {
|
||||
if (e is Exception) {
|
||||
rethrow;
|
||||
}
|
||||
throw Exception('Login failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_SESSID = null;
|
||||
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('PHPSESSID');
|
||||
|
||||
notifyListeners(); // Notify widgets that auth state changed
|
||||
}
|
||||
|
||||
// Validate if the current session is still valid
|
||||
Future<bool> validateSession() async {
|
||||
if (_SESSID == null) return false;
|
||||
|
||||
try {
|
||||
final response = await getAuthenticatedPage(
|
||||
'https://gti.fts-tech.co.uk/coordv2/home.php'
|
||||
);
|
||||
|
||||
// If redirected to login or unauthorized, session is invalid
|
||||
if (response.statusCode != 200 || response.body.contains('login')) {
|
||||
await logout(); // Clear invalid session
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
await logout(); // Clear invalid session
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String? _extractSessionId(String cookieHeader) {
|
||||
// Parse the Set-Cookie header to extract PHPSESSID
|
||||
// Example: "PHPSESSID=rm6rdoanhvcbo9cigsq1b5he17; path=/; HttpOnly"
|
||||
final cookies = cookieHeader.split(';');
|
||||
for (var cookie in cookies) {
|
||||
if (cookie.trim().startsWith('PHPSESSID=')) {
|
||||
return cookie.trim().substring('PHPSESSID='.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper method to create authenticated requests
|
||||
Map<String, String> getAuthHeaders() {
|
||||
if (_SESSID == null) {
|
||||
throw Exception('Not authenticated - SESSID is null');
|
||||
}
|
||||
return {
|
||||
'Cookie': 'PHPSESSID=$_SESSID',
|
||||
};
|
||||
}
|
||||
|
||||
// Example authenticated request
|
||||
Future<http.Response> getAuthenticatedPage(String url) async {
|
||||
if (_SESSID == null) {
|
||||
throw Exception('Not authenticated');
|
||||
}
|
||||
|
||||
return await http.get(
|
||||
Uri.parse(url),
|
||||
headers: getAuthHeaders(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<GtiStation>> searchStations(String term) async {
|
||||
if (term.length < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('https://gti.fts-tech.co.uk/ajax/station_lookup_mobile.php?term=$term'),
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
print('Search failed - Status: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
print('Search term: $term');
|
||||
throw Exception('Failed to search stations');
|
||||
}
|
||||
|
||||
try {
|
||||
final List<dynamic> data = jsonDecode(response.body);
|
||||
return data.map((json) => GtiStation.fromJson(json)).toList();
|
||||
} catch (e) {
|
||||
print('JSON parse error: $e');
|
||||
print('Response body: ${response.body}');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<GtiService>> getServicesForStation(GtiStation station, {String filter = ''}) async {
|
||||
// Step 1: Set station context by navigating to home.php (as a page load, not AJAX)
|
||||
print('Setting station to ${station.label} (${station.crs})...');
|
||||
|
||||
final homeUri = Uri.https(
|
||||
'gti.fts-tech.co.uk',
|
||||
'/coordv2/home.php',
|
||||
{
|
||||
'crs': station.crs,
|
||||
'stationpc': station.postcode,
|
||||
'stationname': station.label,
|
||||
'stationloc': '${station.stationName},${station.postcode}',
|
||||
},
|
||||
);
|
||||
|
||||
final homeResponse = await http.get(
|
||||
homeUri,
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'referer': 'https://gti.fts-tech.co.uk/coordv2/home.php',
|
||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
||||
},
|
||||
);
|
||||
|
||||
print('home.php status: ${homeResponse.statusCode}');
|
||||
|
||||
// Extract any new cookies from the response
|
||||
String cookieHeader = getAuthHeaders()['Cookie']!;
|
||||
final setCookie = homeResponse.headers['set-cookie'];
|
||||
if (setCookie != null) {
|
||||
print('Got new cookies: $setCookie');
|
||||
// Append new cookies to existing ones
|
||||
cookieHeader += '; $setCookie';
|
||||
}
|
||||
|
||||
// Step 2: Now fetch services via AJAX with updated cookies
|
||||
final random = Random().nextInt(1000000000);
|
||||
print('Fetching services...');
|
||||
final response = await http.post(
|
||||
Uri.parse('https://gti.fts-tech.co.uk/coordv2/station_adlist_quick.php?nocache=$random&filter=$filter'),
|
||||
headers: {
|
||||
'Cookie': cookieHeader,
|
||||
'accept': '*/*',
|
||||
'referer': 'https://gti.fts-tech.co.uk/coordv2/home.php',
|
||||
'origin': 'https://gti.fts-tech.co.uk',
|
||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
);
|
||||
|
||||
print('Services response status: ${response.statusCode}');
|
||||
print('Response length: ${response.body.length}');
|
||||
|
||||
final services = _parseServicesFromHtml(response.body);
|
||||
print('Parsed ${services.length} services');
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
Future<bool> saveAudit({
|
||||
required String time,
|
||||
required String crs,
|
||||
required String vehicleId,
|
||||
required String auditId,
|
||||
required String vehicleReg,
|
||||
required String seats,
|
||||
required String pax,
|
||||
required String luggage,
|
||||
required String paceNotes,
|
||||
required String vehicleTracking,
|
||||
required String destBanner,
|
||||
required String callingPatternFront,
|
||||
required String callingPatternMiddle,
|
||||
required String callingPatternRear,
|
||||
required String audioEquipment,
|
||||
required String audioWorking,
|
||||
required String wheelchair,
|
||||
required String exemption,
|
||||
}) async {
|
||||
try {
|
||||
final requestBody = {
|
||||
'txtVehicleReg': vehicleReg,
|
||||
'cboSeats1': seats[0],
|
||||
'cboSeats2': seats[1],
|
||||
'cboPax1': pax[0],
|
||||
'cboPax2': pax[1],
|
||||
'cboLuggage': luggage,
|
||||
'cboPaceNotes': paceNotes,
|
||||
'cboVehicleTracking': vehicleTracking,
|
||||
'cboDestBanner': destBanner,
|
||||
'cboCallingPatternFront': callingPatternFront,
|
||||
'cboCallingPatternMiddle': callingPatternMiddle,
|
||||
'cboCallingPatternRear': callingPatternRear,
|
||||
'cboAudioEquipment': audioEquipment,
|
||||
'cboAudioWorking': audioWorking,
|
||||
'cboWheelchair': wheelchair,
|
||||
'cboExemption': exemption,
|
||||
'time': time,
|
||||
'crs': crs,
|
||||
'vid': vehicleId,
|
||||
'audit_id': auditId,
|
||||
};
|
||||
|
||||
print('=== AUDIT SAVE DEBUG ===');
|
||||
print('URL: https://gti.fts-tech.co.uk/coordv2/update_audit_psvair.php');
|
||||
print('Request body:');
|
||||
requestBody.forEach((key, value) {
|
||||
print(' $key: $value');
|
||||
});
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('https://gti.fts-tech.co.uk/coordv2/update_audit_psvair.php'),
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: requestBody,
|
||||
);
|
||||
|
||||
print('Response status: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
print('======================');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Check if response is a positive integer (success)
|
||||
final result = int.tryParse(response.body.trim());
|
||||
final success = result != null && result > 0;
|
||||
print('Parse result: $result, Success: $success');
|
||||
return success;
|
||||
}
|
||||
|
||||
print('Non-200 status code: ${response.statusCode}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('Error saving audit: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GtiStation {
|
||||
final String label; // "Redhill (RDH)"
|
||||
final String stationName; // "Redhill"
|
||||
final String area; // "Redstone Hill"
|
||||
final String town; // "Redhill"
|
||||
final String county; // "Surrey"
|
||||
final String postcode; // "RH1 1RB"
|
||||
final String crs; // "RDH"
|
||||
|
||||
GtiStation({
|
||||
required this.label,
|
||||
required this.stationName,
|
||||
required this.area,
|
||||
required this.town,
|
||||
required this.county,
|
||||
required this.postcode,
|
||||
required this.crs,
|
||||
});
|
||||
|
||||
factory GtiStation.fromJson(Map<String, dynamic> json) {
|
||||
// Format: "StationName|Area|Town|County|Postcode|CRS"
|
||||
final parts = (json['id'] as String).split('|');
|
||||
|
||||
return GtiStation(
|
||||
label: json['label'],
|
||||
stationName: parts[0],
|
||||
area: parts[1],
|
||||
town: parts[2],
|
||||
county: parts[3],
|
||||
postcode: parts[4],
|
||||
crs: parts[5],
|
||||
);
|
||||
}
|
||||
|
||||
// Build the URL that the web portal uses
|
||||
String getHomeUrl() {
|
||||
return 'https://gti.fts-tech.co.uk/coordv2/home.php?'
|
||||
'crs=$crs&'
|
||||
'stationpc=$postcode&'
|
||||
'stationname=${Uri.encodeComponent(label)}&'
|
||||
'stationloc=${Uri.encodeComponent('$stationName,$postcode')}';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => label;
|
||||
}
|
||||
|
||||
class GtiService {
|
||||
final String time; // "14:13"
|
||||
final String type; // "T" (Type - possibly Train replacement?)
|
||||
final String coach; // "3" (Coach/diagram number)
|
||||
final String vehicleType; // "Wheelchair Coach"
|
||||
final String customer; // "Great Western Railway"
|
||||
final String destination; // "GTWU - Gatwick Airport (Upper Level)"
|
||||
final String operator; // "Passenger Plus Ltd"
|
||||
final String vrm; // "YT23BTY" (Vehicle Registration Mark)
|
||||
final int passengers; // 0-99
|
||||
final String vehicleId; // "3314973"
|
||||
final String timeKey; // "1413"
|
||||
final String? auditId; // "438769" (if service has been audited)
|
||||
final bool hasGps; // Whether GPS tracking is available
|
||||
final bool hasStandbys; // Whether there are standby services
|
||||
final List<CallingPoint> callingPattern; // List of stops
|
||||
|
||||
GtiService({
|
||||
required this.time,
|
||||
required this.type,
|
||||
required this.coach,
|
||||
required this.vehicleType,
|
||||
required this.customer,
|
||||
required this.destination,
|
||||
required this.operator,
|
||||
required this.vrm,
|
||||
required this.passengers,
|
||||
required this.vehicleId,
|
||||
required this.timeKey,
|
||||
this.auditId,
|
||||
required this.hasGps,
|
||||
required this.hasStandbys,
|
||||
required this.callingPattern,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'time': time,
|
||||
'type': type,
|
||||
'coach': coach,
|
||||
'vehicleType': vehicleType,
|
||||
'customer': customer,
|
||||
'destination': destination,
|
||||
'operator': operator,
|
||||
'vrm': vrm,
|
||||
'passengers': passengers,
|
||||
'vehicleId': vehicleId,
|
||||
'timeKey': timeKey,
|
||||
'hasGps': hasGps,
|
||||
'hasStandbys': hasStandbys,
|
||||
'callingPattern': callingPattern.map((cp) => cp.toJson()).toList(),
|
||||
'auditId': auditId,
|
||||
};
|
||||
}
|
||||
|
||||
factory GtiService.fromJson(Map<String, dynamic> json) {
|
||||
return GtiService(
|
||||
time: json['time'] ?? '',
|
||||
type: json['type'] ?? '',
|
||||
coach: json['coach'] ?? '',
|
||||
vehicleType: json['vehicleType'] ?? '',
|
||||
customer: json['customer'] ?? '',
|
||||
destination: json['destination'] ?? '',
|
||||
operator: json['operator'] ?? '',
|
||||
vrm: json['vrm'] ?? '',
|
||||
passengers: json['passengers'] ?? 0,
|
||||
vehicleId: json['vehicleId'] ?? '',
|
||||
timeKey: json['timeKey'] ?? '',
|
||||
hasGps: json['hasGps'] ?? false,
|
||||
hasStandbys: json['hasStandbys'] ?? false,
|
||||
callingPattern: (json['callingPattern'] as List?)
|
||||
?.map((cp) => CallingPoint.fromJson(cp))
|
||||
.toList() ?? [],
|
||||
auditId: json['auditId'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CallingPoint {
|
||||
final String time;
|
||||
final String location;
|
||||
final String postcode;
|
||||
|
||||
CallingPoint({
|
||||
required this.time,
|
||||
required this.location,
|
||||
required this.postcode,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'Time': time,
|
||||
'Loc': location,
|
||||
'Postcode': postcode,
|
||||
};
|
||||
}
|
||||
|
||||
factory CallingPoint.fromJson(Map<String, dynamic> json) {
|
||||
return CallingPoint(
|
||||
time: json['Time'],
|
||||
location: json['Loc'],
|
||||
postcode: json['Postcode'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
List<GtiService> _parseServicesFromHtml(String htmlContent) {
|
||||
final document = html_parser.parse(htmlContent);
|
||||
final services = <GtiService>[];
|
||||
|
||||
// Extract calling patterns from script tags
|
||||
final callingPatterns = <String, List<CallingPoint>>{};
|
||||
final scripts = document.querySelectorAll('script');
|
||||
|
||||
for (final script in scripts) {
|
||||
final text = script.text;
|
||||
final cpMatch = RegExp(r"aCP\['(\d+-\d+)'\] = '(\[.*?\])'").firstMatch(text);
|
||||
if (cpMatch != null) {
|
||||
try {
|
||||
final key = cpMatch.group(1)!;
|
||||
final jsonStr = cpMatch.group(2)!;
|
||||
final List<dynamic> data = jsonDecode(jsonStr);
|
||||
callingPatterns[key] = data.map((cp) => CallingPoint.fromJson(cp)).toList();
|
||||
} catch (e) {
|
||||
// Skip invalid JSON
|
||||
print('Failed to parse calling pattern: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse each service list item
|
||||
final listItems = document.querySelectorAll('li.list-group-item-action');
|
||||
print('Found ${listItems.length} list items');
|
||||
|
||||
for (final item in listItems) {
|
||||
try {
|
||||
final table = item.querySelector('table');
|
||||
if (table == null) continue;
|
||||
|
||||
final rows = table.querySelectorAll('tr');
|
||||
|
||||
// Extract data from table rows
|
||||
String? time, type, coach, vehicleType, customer, destination, operator, vrm;
|
||||
int passengers = 0;
|
||||
String? vehicleId, auditId;
|
||||
bool hasGps = false;
|
||||
bool hasStandbys = false;
|
||||
|
||||
for (final row in rows) {
|
||||
// Get all labels and values in this row (some rows have multiple pairs)
|
||||
final labels = row.querySelectorAll('.adlabel');
|
||||
final values = row.querySelectorAll('.adtext');
|
||||
|
||||
// Process each label-value pair in this row
|
||||
for (int i = 0; i < labels.length && i < values.length; i++) {
|
||||
final label = labels[i].text.trim();
|
||||
final value = values[i].text.trim();
|
||||
|
||||
switch (label) {
|
||||
case 'Time:':
|
||||
time = value;
|
||||
break;
|
||||
case 'Type:':
|
||||
type = value;
|
||||
break;
|
||||
case 'Coach:':
|
||||
coach = value;
|
||||
break;
|
||||
case 'Vehicle Type:':
|
||||
vehicleType = value;
|
||||
break;
|
||||
case 'Customer:':
|
||||
customer = value;
|
||||
break;
|
||||
case 'Destination:':
|
||||
destination = value;
|
||||
break;
|
||||
case 'Operator:':
|
||||
operator = value;
|
||||
break;
|
||||
case 'VRM:':
|
||||
vrm = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for GPS button
|
||||
if (row.text.contains('btn-opengps')) {
|
||||
hasGps = true;
|
||||
}
|
||||
|
||||
// Check for standbys
|
||||
if (row.text.contains('Standbys')) {
|
||||
hasStandbys = true;
|
||||
}
|
||||
|
||||
// Extract passenger count from selects
|
||||
final select1 = row.querySelector('select[id^="cboPax1_"]');
|
||||
final select2 = row.querySelector('select[id^="cboPax2_"]');
|
||||
if (select1 != null && select2 != null) {
|
||||
final pax1 = select1.querySelector('option[selected]')?.attributes['value'] ?? '0';
|
||||
final pax2 = select2.querySelector('option[selected]')?.attributes['value'] ?? '0';
|
||||
passengers = int.parse(pax1 + pax2);
|
||||
|
||||
// Extract vehicle ID from onchange attribute
|
||||
final onChange = select1.attributes['onchange'];
|
||||
if (onChange != null) {
|
||||
final vidMatch = RegExp(r"'(\d+)'").allMatches(onChange).toList();
|
||||
if (vidMatch.length >= 2) {
|
||||
auditId = vidMatch[0].group(1);
|
||||
vehicleId = vidMatch[1].group(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (time != null && type != null && coach != null) {
|
||||
final timeKey = time.replaceAll(':', '');
|
||||
final cpKey = '$vehicleId-$timeKey';
|
||||
|
||||
services.add(GtiService(
|
||||
time: time,
|
||||
type: type,
|
||||
coach: coach,
|
||||
vehicleType: vehicleType ?? '',
|
||||
customer: customer ?? '',
|
||||
destination: destination ?? '',
|
||||
operator: operator ?? '',
|
||||
vrm: vrm ?? '',
|
||||
passengers: passengers,
|
||||
vehicleId: vehicleId ?? '',
|
||||
timeKey: timeKey,
|
||||
auditId: auditId,
|
||||
hasGps: hasGps,
|
||||
hasStandbys: hasStandbys,
|
||||
callingPattern: callingPatterns[cpKey] ?? [],
|
||||
));
|
||||
} else {
|
||||
print('Skipping service - time: $time, type: $type, coach: $coach');
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip malformed items
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
115
lib/widgets/faded_scroll_view.dart
Normal file
115
lib/widgets/faded_scroll_view.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FadedScrollView extends StatefulWidget {
|
||||
final Widget child;
|
||||
final double fadeHeight;
|
||||
final double easingDistance;
|
||||
final double maxBlur;
|
||||
final ScrollController? controller;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const FadedScrollView({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.fadeHeight = 20.0,
|
||||
this.easingDistance = 10.0,
|
||||
this.maxBlur = 3.0,
|
||||
this.controller,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FadedScrollView> createState() => _FadedScrollViewState();
|
||||
}
|
||||
|
||||
class _FadedScrollViewState extends State<FadedScrollView> {
|
||||
late ScrollController _scrollController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = widget.controller ?? ScrollController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.controller == null) {
|
||||
_scrollController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _scrollController,
|
||||
builder: (context, child) {
|
||||
final double offset = _scrollController.hasClients ? _scrollController.offset : 0.0;
|
||||
final double fadeProgress = (offset / widget.easingDistance).clamp(0.0, 1.0);
|
||||
final double currentBlur = widget.maxBlur * fadeProgress;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Main content
|
||||
ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
final double fadeStop = widget.fadeHeight / bounds.height;
|
||||
final Color transparentColor = Colors.white.withOpacity(1.0 - fadeProgress);
|
||||
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
transparentColor,
|
||||
Colors.white,
|
||||
Colors.white,
|
||||
],
|
||||
stops: [0.0, fadeStop, 1.0],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: widget.padding,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
|
||||
// Single blur with extended fade area for smoother transition
|
||||
if (currentBlur > 0)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: widget.fadeHeight * 1.5, // Extend beyond fade area
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: currentBlur * 0.7, // Reduce intensity slightly
|
||||
sigmaY: currentBlur * 0.7,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.0),
|
||||
],
|
||||
stops: [0.0, 0.6, 0.9, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
219
lib/widgets/service_card.dart
Normal file
219
lib/widgets/service_card.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart' hide Colors, Divider, VerticalDivider;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:proto_portal/scraper.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ServiceCard extends StatelessWidget {
|
||||
final String type;
|
||||
final String time;
|
||||
final String diagram;
|
||||
final String customer;
|
||||
final String destination;
|
||||
final String operator;
|
||||
final String vrm;
|
||||
final GtiService? service;
|
||||
final GtiStation? station;
|
||||
final bool showActions;
|
||||
|
||||
// Constructor for individual fields
|
||||
ServiceCard({
|
||||
required this.type,
|
||||
required this.time,
|
||||
required this.diagram,
|
||||
required this.customer,
|
||||
required this.destination,
|
||||
required this.operator,
|
||||
required this.vrm,
|
||||
this.service,
|
||||
this.station,
|
||||
this.showActions = false,
|
||||
});
|
||||
|
||||
// Constructor that takes a GtiService
|
||||
ServiceCard.fromService({
|
||||
required GtiService service,
|
||||
GtiStation? station,
|
||||
bool showActions = false,
|
||||
}) : this.service = service,
|
||||
this.station = station,
|
||||
this.showActions = showActions,
|
||||
type = service.type,
|
||||
time = service.time,
|
||||
diagram = service.coach,
|
||||
customer = service.customer,
|
||||
destination = service.destination,
|
||||
operator = service.operator,
|
||||
vrm = service.vrm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Convert type code to full word
|
||||
String typeLabel = type;
|
||||
if (type == 'D') {
|
||||
typeLabel = 'Departure';
|
||||
} else if (type == 'A') {
|
||||
typeLabel = 'Arrival';
|
||||
}
|
||||
|
||||
return OutlinedContainer(
|
||||
width: double.infinity,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2)
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(typeLabel,
|
||||
style: TextStyle(fontSize: 22))
|
||||
.textLarge,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
SizedBox(
|
||||
width: 94,
|
||||
child: _keyvalueRow(
|
||||
key: "Time",
|
||||
value: time,
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: _keyvalueRow(
|
||||
key: "Diagram",
|
||||
value: diagram,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: _keyvalueRow(
|
||||
key: "Customer",
|
||||
value: customer,
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: _keyvalueRow(
|
||||
key: "Destination",
|
||||
value: destination,
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _keyvalueRow(
|
||||
key: "Operator",
|
||||
value: operator,
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: _keyvalueRow(
|
||||
key: "VRM",
|
||||
value: vrm.isNotEmpty ? vrm : "N/A",
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (showActions) ...[
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ButtonGroup(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlineButton(
|
||||
onPressed: service != null && station != null
|
||||
? () {
|
||||
// Encode service as base64
|
||||
final serviceJson = jsonEncode(service!.toJson());
|
||||
final serviceBase64 = base64Encode(utf8.encode(serviceJson));
|
||||
|
||||
final stationJson = jsonEncode({
|
||||
'label': station!.label,
|
||||
'crs': station!.crs,
|
||||
});
|
||||
final stationBase64 = base64Encode(utf8.encode(stationJson));
|
||||
|
||||
GoRouter.of(context).push(
|
||||
Uri(
|
||||
path: '/audit',
|
||||
queryParameters: {
|
||||
'service': serviceBase64,
|
||||
'station': stationBase64,
|
||||
},
|
||||
).toString(),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: Text("Audit"),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
onPressed: () {},
|
||||
child: Text("Depart"),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _keyvalueRow({
|
||||
required String key,
|
||||
required String value,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(key).h4,
|
||||
|
||||
Text(value).small
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user