609 lines
18 KiB
Dart
609 lines
18 KiB
Dart
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;
|
|
}
|
|
|