Initial Commit

This commit is contained in:
ImBenji
2025-12-27 18:24:05 +00:00
commit 21d95e7a9f
142 changed files with 8062 additions and 0 deletions

643
lib/pages/audit.dart Normal file
View 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
View 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
View 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,
),
);
}
}