17 Commits

Author SHA1 Message Date
Sven laptop 3c35999a84 feat: add employee API integration and request lab component
Build and Push Docker Images / detect-changes (push) Successful in 13s
Build and Push Docker Images / build-auth-api (push) Successful in 1m19s
Build and Push Docker Images / build-employee-api (push) Successful in 1m1s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 57s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m12s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
- Introduced EMPLOYEE_API endpoint in api.js for employee service.
- Added RequestLab component for testing API routes with a user-friendly interface.
- Updated monitoring_ui documentation to reflect changes in features and authentication.
- Removed unnecessary login functionality from monitoring_ui to maintain unauthenticated access.
- Enhanced styles for new components and improved overall UI consistency.
2026-10-05 20:03:53 +02:00
Sven laptop 30f4ca6701 Fix error logging
Build and Push Docker Images / detect-changes (push) Successful in 23s
Build and Push Docker Images / build-employee-api (push) Successful in 2m20s
Build and Push Docker Images / build-auth-api (push) Successful in 3m27s
Build and Push Docker Images / build-transit-api (push) Successful in 3m4s
Build and Push Docker Images / build-error-log-api (push) Successful in 2m38s
Build and Push Docker Images / build-admin-ui (push) Successful in 3m48s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 3m45s
Build and Push Docker Images / deploy-test-node (push) Successful in 9s
2026-09-25 00:02:14 +02:00
Sven laptop 46fb9b9d6b Added docviewer for docks on the monitoring UI
Build and Push Docker Images / detect-changes (push) Successful in 29s
Build and Push Docker Images / build-auth-api (push) Successful in 2m1s
Build and Push Docker Images / build-employee-api (push) Successful in 2m1s
Build and Push Docker Images / build-transit-api (push) Successful in 2m9s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m57s
Build and Push Docker Images / build-admin-ui (push) Successful in 2m50s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 2m39s
Build and Push Docker Images / deploy-test-node (push) Successful in 6s
2026-09-24 20:24:53 +02:00
Sven laptop e169e37b5e Refactored all api endpoints to /bus/xxxxx
Build and Push Docker Images / detect-changes (push) Successful in 20s
Build and Push Docker Images / build-auth-api (push) Successful in 2m2s
Build and Push Docker Images / build-employee-api (push) Successful in 2m2s
Build and Push Docker Images / build-transit-api (push) Successful in 2m8s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m58s
Build and Push Docker Images / build-monitoring-ui (push) Has been cancelled
Build and Push Docker Images / build-admin-ui (push) Has been cancelled
Build and Push Docker Images / deploy-test-node (push) Has been cancelled
2026-09-24 20:20:43 +02:00
Sven laptop 7d55580d87 Refactor transit_api into a repo/routes module split
Split the 5,441-line index.js into an 86-line bootstrap, thin domain
route modules, shared lib/ helpers, and a repo/ layer that isolates the
DEV_MOCK branches (repo/*.mock.js). Repo methods return {statusCode, body};
routes are one-line wrappers. Pin search_path to "transit.bus" and
document the /bus/ prefix with legacy root aliases (Deprecation header).

Parity verified: 57/57 byte-identical on the stub differential harness
plus 24/24 live read-only responses vs the original against real
PostgreSQL. The only intentional behavior change: mock reorder/delete
bus-line endpoints no longer require a live DB (previously 500 in
DEV_MOCK=1 without PG). DEV_MOCK gate and node --check clean.
2026-09-24 20:18:32 +02:00
Sven laptop 2ae1720383 Redesign admin UI as transit operations command center with sidebar navigation, design system, and dashboard 2026-09-24 16:54:37 +02:00
Sven laptop b766725b3b Remove .env.example 2026-09-23 20:12:21 +02:00
Sven laptop 2707bff6fd Add INTERNAL_API_KEY authentication for error_log_api; update all services to send key 2026-09-23 20:09:38 +02:00
Sven laptop b36975b680 Add comprehensive local logging to all microservices; log all errors locally and forward to error_log_api 2026-09-23 20:00:30 +02:00
Sven laptop 50634a3ed7 Remove Deprecated bus terminal
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 57s
Build and Push Docker Images / build-transit-api (push) Successful in 57s
Build and Push Docker Images / build-error-log-api (push) Successful in 59s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m11s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 19:31:22 +02:00
Sven laptop 18c330854e help
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 1m14s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 1m0s
Build and Push Docker Images / build-error-log-api (push) Successful in 59s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m15s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m10s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-23 18:44:36 +02:00
Sven laptop 0da6d07283 Add local logging to all microservices; auto-create error_log table on startup
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Successful in 1m3s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 56s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m4s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m20s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m12s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 18:29:46 +02:00
Sven laptop 04bb0e3347 Add error reporting middleware to error_log_api; fix boolean isAdmin in auth_api 2026-09-23 18:21:27 +02:00
Sven laptop a5924086f4 Fixed payload check
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 58s
Build and Push Docker Images / build-transit-api (push) Successful in 58s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m9s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m15s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m12s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 17:31:23 +02:00
Sven laptop 89e4f90fc3 Fix monitoring UI 2026-09-23 17:23:04 +02:00
Sven laptop 39b0b07a77 updated docker build to always deplyo 2026-09-23 17:22:14 +02:00
Sven laptop 48c710bfbf Fix admin check on monitoring UI
Build and Push Docker Images / detect-changes (push) Successful in 12s
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 17:12:41 +02:00
235 changed files with 11067 additions and 16802 deletions
+10 -3
View File
@@ -1,13 +1,20 @@
<!doctype html>
<html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transit Admin</title>
<meta name="theme-color" content="#0e1526" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;550;600;700;800&display=swap"
rel="stylesheet"
/>
<title>Transit Beheercentrum</title>
</head>
<body>
<div id="root"></div>
<script src="/env-config.js"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
</html>
+91 -25
View File
@@ -1,15 +1,37 @@
import React, { useState, useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import Login from './components/Login'
import Overview from './components/Overview'
import StopsManager from './components/StopsManager'
import LinkStop from './components/LinkStop'
import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager'
import ScheduleMaker from './components/ScheduleMaker'
import ErrorLogViewer from './components/ErrorLogViewer'
import RequestLab from './components/RequestLab'
import { IconAlert, IconBus, IconCalendar, IconGrid, IconLink, IconLogout, IconMapPin, IconRoute, IconTerminal } from './components/icons'
const SECTIONS = [
{ id: 'overview', label: 'Overzicht', eyebrow: 'Operations', title: 'Dagelijks overzicht', desc: 'Vloot, netwerk en dienstrooster in één oogopslag.', icon: IconGrid },
{ id: 'stops', label: 'Haltes', eyebrow: 'Netwerkbeheer', title: 'Haltes', desc: 'Maak herbruikbare haltes aan en bewerk ze.', icon: IconMapPin },
{ id: 'lines', label: 'Buslijnen', eyebrow: 'Netwerkbeheer', title: 'Buslijnen', desc: 'Definieer lijnen tussen start- en eindhalte.', icon: IconRoute },
{ id: 'routes', label: 'Lijnroutes', eyebrow: 'Netwerkbeheer', title: 'Stops per lijn', desc: 'Koppel haltes aan lijnen en bepaal de volgorde.', icon: IconLink },
{ id: 'fleet', label: 'Vloot', eyebrow: 'Vlootbeheer', title: 'Vloot', desc: 'Beheer de voertuigen die deel uitmaken van de vloot.', icon: IconBus },
{ id: 'schedules', label: 'Diensten', eyebrow: 'Planning', title: 'Dienstenplanning', desc: 'Plan wekelijkse schema’s en vaste diensten.', icon: IconCalendar },
{ id: 'logs', label: 'Foutmeldingen', eyebrow: 'Systeem', title: 'Centrale foutmeldingen', desc: 'Bekijk fouten die door alle diensten zijn gemeld.', icon: IconAlert },
{ id: 'lab', label: 'API-lab', eyebrow: 'Systeem', title: 'API-testomgeving', desc: 'Voer goedgekeurde verzoeken uit en inspecteer live antwoorden.', icon: IconTerminal },
]
const NAV_GROUPS = [
{ label: 'Algemeen', items: ['overview'] },
{ label: 'Netwerkbeheer', items: ['stops', 'lines', 'routes', 'fleet'] },
{ label: 'Planning', items: ['schedules'] },
{ label: 'Systeem', items: ['logs', 'lab'] },
]
export default function App() {
const [token, setToken] = useState(localStorage.getItem('token'))
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'))
const [section, setSection] = useState('overview')
useEffect(() => {
if (token) localStorage.setItem('token', token)
@@ -21,34 +43,78 @@ export default function App() {
else localStorage.removeItem('user')
}, [user])
if (!token) return <Login onLogin={(t,u) => { setToken(t); setUser(u); }} />
if (!token) return <Login onLogin={(t, u) => { setToken(t); setUser(u); setSection('overview') }} />
const active = SECTIONS.find((s) => s.id === section) || SECTIONS[0]
const initials = (user?.name || user?.userCode || 'U').toString().slice(0, 1).toUpperCase()
return (
<div className="app">
<header className="topbar">
<div>
<p className="eyebrow">Transit operations</p>
<h1>Admin control center</h1>
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">🚌</div>
<div className="brand-text">
<div className="brand-name">Transit Ops</div>
<div className="brand-sub">Beheercentrum</div>
</div>
</div>
<div className="userbox">
<strong>{user?.userCode ? `User ${user.userCode}` : 'User'}</strong>
<button onClick={() => { setToken(null); setUser(null); }}>Logout</button>
</div>
</header>
<div className="hero">
<div>
<h2>Fleet, lijnen en stops</h2>
<p>Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.</p>
<nav>
{NAV_GROUPS.map((group) => (
<div key={group.label}>
<div className="nav-label">{group.label}</div>
{group.items.map((id) => {
const item = SECTIONS.find((s) => s.id === id)
const Icon = item.icon
return (
<button
key={id}
className={`nav-item ${section === id ? 'active' : ''}`}
onClick={() => setSection(id)}
>
<Icon size={18} />
<span>{item.label}</span>
</button>
)
})}
</div>
))}
</nav>
<div className="sidebar-footer">
<div className="user-chip">
<div className="user-avatar">{initials}</div>
<div className="user-meta">
<div className="user-name">{user?.name || user?.userCode || 'Gebruiker'}</div>
<div className="user-role">{user?.isAdmin ? 'Beheerder' : 'Medewerker'}</div>
</div>
<button className="logout-btn" title="Uitloggen" onClick={() => { setToken(null); setUser(null) }}>
<IconLogout size={17} />
</button>
</div>
</div>
</aside>
<div className="main">
<header className="topbar">
<div className="topbar-title">
<p className="eyebrow">{active.eyebrow}</p>
<h1>{active.title}</h1>
<p>{active.desc}</p>
</div>
</header>
<main className="content">
{section === 'overview' && <Overview token={token} user={user} />}
{section === 'stops' && <StopsManager token={token} user={user} />}
{section === 'lines' && <BusLinesManager token={token} user={user} />}
{section === 'routes' && <LinkStop token={token} user={user} />}
{section === 'fleet' && <FleetManager token={token} user={user} />}
{section === 'schedules' && <ScheduleMaker token={token} user={user} />}
{section === 'logs' && <ErrorLogViewer token={token} />}
{section === 'lab' && <RequestLab token={token} />}
</main>
</div>
<main>
<StopsManager token={token} user={user} />
<BusLinesManager token={token} user={user} />
<FleetManager token={token} user={user} />
<LinkStop token={token} user={user} />
<ScheduleMaker token={token} user={user} />
<ErrorLogViewer token={token} />
</main>
</div>
)
}
}
+12 -1
View File
@@ -25,11 +25,22 @@ const TRANSIT_API = normalizeApiBaseUrl(
RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API,
"https://transit.xtransit.testinstance.nl",
);
const EMPLOYEE_API = normalizeApiBaseUrl(
RUNTIME.EMPLOYEE_API || import.meta.env.VITE_EMPLOYEE_API,
"https://employees.xtransit.testinstance.nl",
);
const ERROR_LOG_API = normalizeApiBaseUrl(
RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API,
"https://error.xtransit.testinstance.nl",
);
export const API_BASE = {
auth: AUTH_API,
employee: EMPLOYEE_API,
transit: TRANSIT_API,
errors: ERROR_LOG_API,
};
const reportClientError = async ({ service, partofservice, error }) => {
try {
await fetch(`${ERROR_LOG_API}/error_log`, {
@@ -63,7 +74,7 @@ authClient.interceptors.response.use(
export const transitClient = (token) => {
const client = axios.create({
baseURL: TRANSIT_API,
baseURL: `${TRANSIT_API}/bus`,
headers: { Authorization: `Bearer ${token}` },
});
+100 -148
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
import Modal from './Modal'
export default function BusLinesManager({ token, user }) {
const client = transitClient(token)
@@ -11,16 +12,19 @@ export default function BusLinesManager({ token, user }) {
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
const [description, setDescription] = useState('')
const [message, setMessage] = useState(null)
const [messageType, setMessageType] = useState('')
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingLine, setEditingLine] = useState(null)
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -29,7 +33,7 @@ export default function BusLinesManager({ token, user }) {
const res = await client.get('/admin/stops')
setStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -38,43 +42,56 @@ export default function BusLinesManager({ token, user }) {
loadStops()
}, [])
const stopNameById = (id) => stops.find(s => String(s.id) === String(id))?.name
const stopIdByName = (name) => (stops.find(s => s.name === name) || {}).id
const createBusLine = async (e) => {
e.preventDefault()
setMessage(null)
notify(null)
try {
// Map selected stop ids to names (server currently stores names)
const startStopName = stops.find(s => String(s.id) === String(startStop))?.name || startStop
const endStopName = stops.find(s => String(s.id) === String(endStop))?.name || endStop
const res = await client.post('/admin/bus-lines', {
lineNumber: Number(lineNumber),
startStop: startStopName,
endStop: endStopName,
startStop: stopNameById(startStop) || startStop,
endStop: stopNameById(endStop) || endStop,
estimatedDurationMinutes: Number(estimatedDurationMinutes),
description,
})
setLineNumber('')
setStartStop('')
setEndStop('')
setEstimatedDurationMinutes('')
setDescription('')
setMessage('Bus line created: #' + (res.data.busLine?.id || 'ok'))
setLineNumber(''); setStartStop(''); setEndStop(''); setEstimatedDurationMinutes(''); setDescription('')
notify('Buslijn aangemaakt: #' + (res.data.busLine?.id || 'ok'))
await loadBusLines()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const saveBusLine = async (e) => {
e.preventDefault()
notify(null)
try {
await client.patch(`/admin/bus-lines/${editingLine.id}`, {
lineNumber: Number(editingLine.lineNumber),
startStop: stopNameById(editingLine.startStop) || editingLine.startStop,
endStop: stopNameById(editingLine.endStop) || editingLine.endStop,
estimatedDurationMinutes: Number(editingLine.estimatedDurationMinutes),
description: editingLine.description,
})
notify('Buslijn bijgewerkt')
setEditingLine(null)
await loadBusLines()
} catch (err) {
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const deleteBusLine = async (lineId, lineNumber) => {
setMessage(null)
notify(null)
try {
await client.delete(`/admin/bus-lines/${lineId}`)
setMessage(`Buslijn ${lineNumber} verwijderd`)
notify(`Buslijn ${lineNumber} verwijderd`)
setDeleteConfirm(null)
await loadBusLines()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -83,42 +100,31 @@ export default function BusLinesManager({ token, user }) {
<div className="section-header">
<div>
<h2>Buslijnen</h2>
<p>Maak en bewerk buslijnen.</p>
<p>Definieer lijnen tussen start- en eindhalte.</p>
</div>
<button className="secondary" onClick={loadBusLines}>Refresh</button>
<button className="secondary" onClick={loadBusLines}>Ververs</button>
</div>
<div className="list-grid">
{busLines.map(line => (
<article className="mini-card" key={line.id}>
<strong>#{line.id} Lijn {line.lineNumber}</strong>
<div>{line.startStop} → {line.endStop}</div>
<div>{line.estimatedDurationMinutes} min</div>
{line.description ? <div className="muted">{line.description}</div> : null}
<strong><span className="badge badge-primary">Lijn {line.lineNumber}</span></strong>
<div className="meta-line">#{line.id}</div>
<div className="meta-line">{line.startStop} → {line.endStop}</div>
<div className="meta-line"><span className="badge badge-accent">{line.estimatedDurationMinutes} min</span></div>
{line.description ? <div className="muted" style={{ marginTop: 4 }}>{line.description}</div> : null}
{isAdmin && (
<>
<button
className="danger"
onClick={() => setDeleteConfirm(line)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Delete
</button>
<button
className="secondary"
onClick={() => setEditingLine({
id: line.id,
lineNumber: line.lineNumber,
startStop: (stops.find(s => s.name === line.startStop) || {}).id || line.startStop,
endStop: (stops.find(s => s.name === line.endStop) || {}).id || line.endStop,
estimatedDurationMinutes: line.estimatedDurationMinutes,
description: line.description || '',
})}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Edit
</button>
</>
<div className="card-actions" style={{ gridTemplateColumns: '1fr 1fr' }}>
<button className="secondary" onClick={() => setEditingLine({
id: line.id,
lineNumber: line.lineNumber,
startStop: stopIdByName(line.startStop) || line.startStop,
endStop: stopIdByName(line.endStop) || line.endStop,
estimatedDurationMinutes: line.estimatedDurationMinutes,
description: line.description || '',
})}>Bewerken</button>
<button className="danger" onClick={() => setDeleteConfirm(line)}>Verwijderen</button>
</div>
)}
</article>
))}
@@ -127,120 +133,66 @@ export default function BusLinesManager({ token, user }) {
{isAdmin ? (
<form onSubmit={createBusLine} className="stack-form">
<div className="form-row">
<input placeholder="Line number" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
<input placeholder="Lijnnummer" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
<select value={startStop} onChange={e => setStartStop(e.target.value)}>
<option value="">Start stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
))}
<option value="">Start halte</option>
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
<select value={endStop} onChange={e => setEndStop(e.target.value)}>
<option value="">End stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
))}
<option value="">Eind halte</option>
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
</div>
<div className="form-row">
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
<input placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} />
<button type="submit">Create bus line</button>
<input placeholder="Reistijd in minuten" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
<input placeholder="Omschrijving (optioneel)" value={description} onChange={e => setDescription(e.target.value)} />
<button type="submit" className="no-grow">Buslijn aanmaken</button>
</div>
</form>
) : <div>You must be admin to create bus lines.</div>}
) : <div className="muted">Alleen beheerders kunnen buslijnen aanmaken.</div>}
{message && <div className="message">{message}</div>}
{message && <div className={`message ${messageType}`}>{message}</div>}
{editingLine && (
<div style={overlay}>
<div style={modal}>
<h3>Edit Bus Line</h3>
<form onSubmit={async (e) => {
e.preventDefault()
setMessage(null)
try {
const startStopName = stops.find(s => String(s.id) === String(editingLine.startStop))?.name || editingLine.startStop
const endStopName = stops.find(s => String(s.id) === String(editingLine.endStop))?.name || editingLine.endStop
await client.patch(`/admin/bus-lines/${editingLine.id}`, {
lineNumber: Number(editingLine.lineNumber),
startStop: startStopName,
endStop: endStopName,
estimatedDurationMinutes: Number(editingLine.estimatedDurationMinutes),
description: editingLine.description,
})
setMessage('Bus line updated')
setEditingLine(null)
await loadBusLines()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}} className="stack-form">
<div className="form-row">
<input value={editingLine.lineNumber} onChange={e => setEditingLine({...editingLine, lineNumber: e.target.value})} />
<select value={editingLine.startStop} onChange={e => setEditingLine({...editingLine, startStop: e.target.value})}>
<option value="">Start stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
))}
</select>
<select value={editingLine.endStop} onChange={e => setEditingLine({...editingLine, endStop: e.target.value})}>
<option value="">End stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
))}
</select>
</div>
<div className="form-row">
<input value={editingLine.estimatedDurationMinutes} onChange={e => setEditingLine({...editingLine, estimatedDurationMinutes: e.target.value})} />
<input value={editingLine.description} onChange={e => setEditingLine({...editingLine, description: e.target.value})} />
</div>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" type="button" onClick={() => setEditingLine(null)}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
<Modal title="Buslijn bewerken" onClose={() => setEditingLine(null)}
footer={
<>
<button className="secondary" onClick={() => setEditingLine(null)}>Annuleren</button>
<button onClick={saveBusLine}>Opslaan</button>
</>
}>
<div className="form-row">
<input value={editingLine.lineNumber} onChange={e => setEditingLine({ ...editingLine, lineNumber: e.target.value })} />
</div>
</div>
<div className="form-row">
<select value={editingLine.startStop} onChange={e => setEditingLine({ ...editingLine, startStop: e.target.value })}>
<option value="">Start halte</option>
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
<select value={editingLine.endStop} onChange={e => setEditingLine({ ...editingLine, endStop: e.target.value })}>
<option value="">Eind halte</option>
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
</div>
<div className="form-row">
<input value={editingLine.estimatedDurationMinutes} onChange={e => setEditingLine({ ...editingLine, estimatedDurationMinutes: e.target.value })} />
<input value={editingLine.description} onChange={e => setEditingLine({ ...editingLine, description: e.target.value })} />
</div>
</Modal>
)}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Bus Line?</h3>
<p><strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop})</p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will also delete all stops on this line. Cannot be undone.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>
Delete
</button>
</div>
</div>
</div>
<Modal title="Buslijn verwijderen?" onClose={() => setDeleteConfirm(null)}
footer={
<>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>Verwijderen</button>
</>
}>
<p>Weet je zeker dat je <strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop}) wilt verwijderen?</p>
<p className="faint">Hiermee worden ook alle gekoppelde stops op deze lijn verwijderd. Dit kan niet worden ongedaan gemaakt.</p>
</Modal>
)}
</section>
)
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
}
+52 -31
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { errorLogClient } from "../api";
import { IconAlert } from "./icons";
const formatDate = (value) => {
if (!value) return "—";
@@ -10,6 +11,14 @@ const formatDate = (value) => {
}
};
const serviceTone = (service = "") => {
if (service.includes("auth")) return "badge-primary";
if (service.includes("employee")) return "badge-success";
if (service.includes("transit")) return "badge-accent";
if (service.includes("error")) return "badge-warning";
return "badge-muted";
};
export default function ErrorLogViewer({ token }) {
const client = useMemo(() => errorLogClient(token), [token]);
const [logs, setLogs] = useState([]);
@@ -28,9 +37,7 @@ export default function ErrorLogViewer({ token }) {
const res = await client.get("/error_log", { params });
setLogs(res.data.errors || []);
} catch (err) {
setMessage(
err.response?.data?.error || err.message || "Kon fouten niet laden",
);
setMessage(err.response?.data?.error || err.message || "Kon fouten niet laden");
} finally {
setLoading(false);
}
@@ -48,41 +55,55 @@ export default function ErrorLogViewer({ token }) {
<p>Laatste foutmeldingen uit de centrale logging service.</p>
</div>
<button className="secondary" onClick={loadLogs} disabled={loading}>
{loading ? "Laden..." : "Ververs"}
{loading ? "Laden…" : "Ververs"}
</button>
</div>
<div className="form-row">
<input
placeholder="Service filter"
value={service}
onChange={(e) => setService(e.target.value)}
/>
<input
placeholder="Part of service filter"
value={partofservice}
onChange={(e) => setPartofservice(e.target.value)}
/>
<input placeholder="Filter op service" value={service} onChange={(e) => setService(e.target.value)} />
<input placeholder="Filter op locatie" value={partofservice} onChange={(e) => setPartofservice(e.target.value)} />
{(service || partofservice) && (
<button type="button" className="ghost no-grow" onClick={() => { setService(""); setPartofservice("") }}>
Wissen
</button>
)}
</div>
{message && <div className="message">{message}</div>}
{message && <div className="message error">{message}</div>}
<div className="list-grid">
{logs.map((log) => (
<article className="mini-card" key={log.id}>
<strong>{log.service}</strong>
<div>{formatDate(log.date)}</div>
<div className="muted" style={{ marginTop: 6 }}>
{log.partofservice || "—"}
</div>
<div style={{ marginTop: 8 }}>{log.error}</div>
</article>
))}
</div>
{!logs.length && !message && (
<div className="muted">Nog geen foutmeldingen gevonden.</div>
{logs.length > 0 ? (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Datum</th>
<th>Service</th>
<th>Locatie</th>
<th>Fout</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(log.date)}</td>
<td>
<span className={`badge ${serviceTone(log.service)}`}>{log.service}</span>
</td>
<td className="truncate-cell">{log.partofservice || "—"}</td>
<td>
<span className="error-cell" title={log.error}>{log.error}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{ textAlign: 'center', padding: '36px 0', color: 'var(--text-faint)' }}>
<IconAlert size={28} />
<p style={{ margin: '10px 0 0' }}>Nog geen foutmeldingen gevonden.</p>
</div>
)}
</section>
);
}
}
@@ -1082,19 +1082,24 @@ const overlay = {
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
backgroundColor: 'rgba(15,23,42,0.55)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: 20,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
padding: '1.5rem 1.75rem 1.75rem',
borderRadius: '16px',
maxWidth: '440px',
width: '100%',
boxShadow: '0 20px 44px rgba(15,23,42,0.16)',
maxHeight: '88vh',
overflowY: 'auto',
}
export default FixedScheduleEditor
+82 -98
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
import Modal from './Modal'
import { IconBus } from './icons'
export default function FleetManager({ token, user }) {
const client = transitClient(token)
@@ -8,67 +10,74 @@ export default function FleetManager({ token, user }) {
const [seatCapacity, setSeatCapacity] = useState('')
const [licensePlate, setLicensePlate] = useState('')
const [message, setMessage] = useState(null)
const [messageType, setMessageType] = useState('')
const [canCreateBus, setCanCreateBus] = useState(true)
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [editingBus, setEditingBus] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
const loadBuses = async () => {
try {
const res = await client.get('/admin/fleet/buses')
setBuses(res.data.buses || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
useEffect(() => {
loadBuses()
}, [])
const createBus = async (e) => {
e.preventDefault()
setMessage(null)
notify(null)
try {
const res = await client.post('/admin/fleet/buses', {
name,
seatCapacity: Number(seatCapacity),
licensePlate,
name, seatCapacity: Number(seatCapacity), licensePlate,
})
setName('')
setSeatCapacity('')
setLicensePlate('')
setMessage('Bus created: #' + (res.data.bus?.id || 'ok'))
setName(''); setSeatCapacity(''); setLicensePlate('')
notify('Bus aangemaakt: #' + (res.data.bus?.id || 'ok'))
await loadBuses()
} catch (err) {
const details = err.response?.data?.details || err.response?.data?.error || err.message
if (String(details).includes('permission denied') || err.response?.status === 403) {
setCanCreateBus(false)
setMessage('Fleet create is read-only on this database user.')
notify('Vloot is read-only voor deze database-gebruiker.', 'error')
return
}
setMessage('Error: ' + details)
notify('Error: ' + details, 'error')
}
}
const saveBus = async (e) => {
e.preventDefault()
notify(null)
try {
await client.patch(`/admin/fleet/buses/${editingBus.id}`, {
name: editingBus.name,
seatCapacity: Number(editingBus.seatCapacity),
licensePlate: editingBus.licensePlate,
})
notify('Bus bijgewerkt')
setEditingBus(null)
await loadBuses()
} catch (err) {
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const deleteBus = async () => {
notify(null)
try {
await client.delete(`/admin/fleet/buses/${deleteConfirm.id}`)
notify('Bus verwijderd')
setDeleteConfirm(null)
await loadBuses()
} catch (err) {
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -76,23 +85,24 @@ export default function FleetManager({ token, user }) {
<section className="card">
<div className="section-header">
<div>
<h2>Fleet</h2>
<p>Beheer voertuigen voor de vloot.</p>
<h2>Vloot</h2>
<p>Beheer voertuigen die deel uitmaken van de vloot.</p>
</div>
<button className="secondary" onClick={loadBuses}>Refresh</button>
<button className="secondary" onClick={loadBuses}>Ververs</button>
</div>
<div className="list-grid">
{buses.map(bus => (
<article className="mini-card" key={bus.id}>
<strong>#{bus.id} {bus.name}</strong>
<div>Seats: {bus.seatCapacity}</div>
<div>Plate: {bus.licensePlate}</div>
<strong><IconBus size={16} style={{ verticalAlign: '-2px', color: 'var(--primary)' }} /> {bus.name}</strong>
<div className="meta-line">#{bus.id}</div>
<div className="meta-line"><span className="badge badge-success">{bus.seatCapacity} zitplaatsen</span></div>
<div className="meta-line" style={{ fontFamily: 'var(--font-mono)' }}>{bus.licensePlate}</div>
{isAdmin && (
<>
<button className="secondary" style={{ marginTop: '0.5rem', width: '100%' }} onClick={() => setEditingBus(bus)}>Edit</button>
<button className="danger" style={{ marginTop: '0.5rem', width: '100%' }} onClick={() => setDeleteConfirm(bus)}>Delete</button>
</>
<div className="card-actions">
<button className="secondary" onClick={() => setEditingBus(bus)}>Bewerken</button>
<button className="danger" onClick={() => setDeleteConfirm(bus)}>Verwijderen</button>
</div>
)}
</article>
))}
@@ -101,73 +111,47 @@ export default function FleetManager({ token, user }) {
{isAdmin && canCreateBus ? (
<form onSubmit={createBus} className="stack-form">
<div className="form-row">
<input placeholder="Bus name" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Seat capacity" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
<input placeholder="License plate" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
<button type="submit">Create bus</button>
<input placeholder="Busnaam" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Zitplaatsen" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
<input placeholder="Kenteken" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
<button type="submit" className="no-grow">Bus toevoegen</button>
</div>
</form>
) : (
<div className="muted">Fleet create is read-only on this database user.</div>
<div className="muted">Vloot is read-only voor deze database-gebruiker.</div>
)}
{message && <div className="message">{message}</div>}
{message && <div className={`message ${messageType}`}>{message}</div>}
{editingBus && (
<div style={overlay}>
<div style={modal}>
<h3>Edit Bus</h3>
<form onSubmit={async (e) => {
e.preventDefault()
setMessage(null)
try {
await client.patch(`/admin/fleet/buses/${editingBus.id}`, {
name: editingBus.name,
seatCapacity: Number(editingBus.seatCapacity),
licensePlate: editingBus.licensePlate,
})
setMessage('Bus updated')
setEditingBus(null)
await loadBuses()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}} className="stack-form">
<div className="form-row">
<input value={editingBus.name} onChange={e => setEditingBus({...editingBus, name: e.target.value})} />
<input value={editingBus.seatCapacity} onChange={e => setEditingBus({...editingBus, seatCapacity: e.target.value})} />
<input value={editingBus.licensePlate} onChange={e => setEditingBus({...editingBus, licensePlate: e.target.value})} />
</div>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" type="button" onClick={() => setEditingBus(null)}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
<Modal title="Bus bewerken" onClose={() => setEditingBus(null)}
footer={
<>
<button className="secondary" onClick={() => setEditingBus(null)}>Annuleren</button>
<button onClick={saveBus}>Opslaan</button>
</>
}>
<div className="form-row">
<input value={editingBus.name} onChange={e => setEditingBus({ ...editingBus, name: e.target.value })} />
</div>
</div>
<div className="form-row">
<input value={editingBus.seatCapacity} onChange={e => setEditingBus({ ...editingBus, seatCapacity: e.target.value })} />
<input value={editingBus.licensePlate} onChange={e => setEditingBus({ ...editingBus, licensePlate: e.target.value })} />
</div>
</Modal>
)}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Bus?</h3>
<p><strong>{deleteConfirm.name}</strong></p>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={async () => {
try {
await client.delete(`/admin/fleet/buses/${deleteConfirm.id}`)
setMessage('Bus deleted')
setDeleteConfirm(null)
await loadBuses()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}}>Delete</button>
</div>
</div>
</div>
<Modal title="Bus verwijderen?" onClose={() => setDeleteConfirm(null)}
footer={
<>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
<button className="danger" onClick={deleteBus}>Verwijderen</button>
</>
}>
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> ({deleteConfirm.licensePlate}) wilt verwijderen?</p>
</Modal>
)}
</section>
)
}
}
+83 -122
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
import { transitClient } from '../api'
import Modal from './Modal'
export default function LinkStop({ token, user }) {
const client = transitClient(token)
@@ -9,31 +10,33 @@ export default function LinkStop({ token, user }) {
const [stopId, setStopId] = useState('')
const [eta, setEta] = useState('')
const [message, setMessage] = useState(null)
const [messageType, setMessageType] = useState('')
const [stops, setStops] = useState([])
const [draggedItem, setDraggedItem] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const isAdmin = Boolean(user?.isAdmin)
const nextOrder = useMemo(() => stops.length + 1, [stops])
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const loadStopsForLine = async (lineId = busLineId) => {
if (!lineId) return
setMessage(null)
notify(null)
try {
const res = await client.get(`/bus-lines/${lineId}/stops`)
setStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -42,62 +45,48 @@ export default function LinkStop({ token, user }) {
const res = await client.get('/admin/stops')
setAvailableStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const refreshBoard = async () => {
await Promise.all([
loadBusLines(),
loadAvailableStops(),
loadStopsForLine(),
])
await Promise.all([loadBusLines(), loadAvailableStops(), loadStopsForLine()])
}
useEffect(() => {
loadBusLines()
loadAvailableStops()
}, [])
useEffect(() => {
loadStopsForLine()
}, [busLineId])
useEffect(() => { loadBusLines(); loadAvailableStops() }, [])
useEffect(() => { loadStopsForLine() }, [busLineId])
const link = async (e) => {
e.preventDefault()
setMessage(null)
notify(null)
try {
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
stopId: parseInt(stopId, 10),
stopOrder: nextOrder,
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
})
setMessage('Linked: ' + res.data.routeStop.id)
notify('Gekoppeld: ' + res.data.routeStop.id)
setStopId('')
await loadStopsForLine()
} catch (err) {
if (err.response?.status === 403) setMessage('Forbidden: not admin')
else setMessage('Error: ' + (err.response?.data?.error || err.message))
if (err.response?.status === 403) notify('Forbidden: not admin', 'error')
else notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const appendDraggedStop = async (stopToLink) => {
if (!busLineId) {
setMessage('Select a bus line first')
return
}
setMessage(null)
if (!busLineId) { notify('Kies eerst een buslijn', 'error'); return }
notify(null)
try {
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
stopId: Number(stopToLink.id),
stopOrder: nextOrder,
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
})
setMessage(`Linked: ${stopToLink.name} as stop #${res.data.routeStop.stopOrder}`)
notify(`Gekoppeld: ${stopToLink.name} als stop #${res.data.routeStop.stopOrder}`)
await loadStopsForLine()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -119,12 +108,12 @@ export default function LinkStop({ token, user }) {
nextStops.splice(toIndex, 0, moved)
const normalized = nextStops.map((stop, index) => ({ ...stop, order: index + 1 }))
setStops(normalized)
setMessage('Saving new route order...')
notify('Nieuwe volgorde opslaan…')
try {
await persistOrder(normalized)
setMessage('Route order updated')
notify('Routenvolgorde bijgewerkt')
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
await loadStopsForLine()
}
}
@@ -139,23 +128,22 @@ export default function LinkStop({ token, user }) {
setStops(normalized)
try {
await persistOrder(normalized)
setMessage('Route order updated')
notify('Routenvolgorde bijgewerkt')
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
await loadStopsForLine()
}
}
const deleteRouteStop = async (routeStopId, stopName) => {
setMessage(null)
notify(null)
try {
await client.delete(`/admin/bus-lines/${busLineId}/stops/${routeStopId}`)
setMessage(`Halte "${stopName}" verwijderd uit de lijn`)
notify(`Halte "${stopName}" verwijderd uit de lijn`)
setDeleteConfirm(null)
await loadStopsForLine()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -164,28 +152,27 @@ export default function LinkStop({ token, user }) {
<div className="section-header">
<div>
<h2>Stops per lijn</h2>
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
<p>Koppel haltes aan een lijn en bepaal de rijvolgorde.</p>
</div>
<button className="secondary" onClick={refreshBoard}>Refresh</button>
<button className="secondary" onClick={refreshBoard}>Ververs</button>
</div>
<div className="form-row">
<select
value={busLineId}
onChange={e => setBusLineId(e.target.value)}
>
<option value="">Choose line</option>
<select value={busLineId} onChange={e => setBusLineId(e.target.value)}>
<option value="">Kies lijn</option>
{busLines.map(line => (
<option key={line.id} value={line.id}>
#{line.id} lijn {line.lineNumber} - {line.startStop} → {line.endStop}
#{line.id} Lijn {line.lineNumber} — {line.startStop} → {line.endStop}
</option>
))}
</select>
<button type="button" onClick={() => loadStopsForLine()} disabled={!busLineId}>Load Stops</button>
<button type="button" className="secondary no-grow" onClick={() => loadStopsForLine()} disabled={!busLineId}>
Laad stops
</button>
</div>
<div className="muted" style={{ marginBottom: 10 }}>
Drag a stop onto the route board to append it to the line. Use the arrows to nudge the order.
Sleep een halte op het routepane om hem toe te voegen. Gebruik de pijltjes om de volgorde aan te passen.
</div>
<div
@@ -199,6 +186,11 @@ export default function LinkStop({ token, user }) {
setDraggedItem(null)
}}
>
{stops.length === 0 && (
<p className="muted" style={{ textAlign: 'center', padding: '12px 0' }}>
Nog geen haltes gekoppeld — sleep een halte hierheen.
</p>
)}
{stops.map((s, index) => (
<article
className={`route-stop-item ${draggedItem?.type === 'route' && Number(draggedItem.id) === Number(s.id) ? 'dragging' : ''}`}
@@ -208,9 +200,7 @@ export default function LinkStop({ token, user }) {
onDragEnd={() => setDraggedItem(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
if (draggedItem?.type === 'route') {
reorderStops(draggedItem.id, s.id)
}
if (draggedItem?.type === 'route') reorderStops(draggedItem.id, s.id)
if (draggedItem?.type === 'available') {
const stop = availableStops.find(item => Number(item.id) === Number(draggedItem.id))
if (stop) appendDraggedStop(stop)
@@ -219,25 +209,26 @@ export default function LinkStop({ token, user }) {
}}
>
<div className="route-stop-handle" aria-hidden="true">⋮⋮</div>
<div className="route-order-badge">{s.order}</div>
<div className="route-stop-content">
<strong>{s.order}. {s.name}</strong>
<strong>{s.name}</strong>
<div className="muted">{s.latitude}, {s.longitude}</div>
{s.estimatedArrivalMinutes !== null && s.estimatedArrivalMinutes !== undefined ? (
<div className="muted">ETA {s.estimatedArrivalMinutes} min</div>
) : null}
</div>
<div className="route-stop-actions">
<button type="button" className="secondary" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
<button type="button" className="secondary" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
<button type="button" className="secondary sm" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
<button type="button" className="secondary sm" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
{isAdmin && (
<button type="button" className="danger" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
<button type="button" className="danger sm" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
)}
</div>
</article>
))}
</div>
<h3>Beschikbare haltes</h3>
<h3 style={{ marginBottom: 0 }}>Beschikbare haltes</h3>
<div className="available-stops-grid">
{availableStops.map(stop => (
<article
@@ -246,82 +237,52 @@ export default function LinkStop({ token, user }) {
draggable
onDragStart={() => setDraggedItem({ type: 'available', id: stop.id })}
onDragEnd={() => setDraggedItem(null)}
onClick={() => {
if (busLineId) {
setStopId(String(stop.id))
appendDraggedStop(stop)
}
}}
onClick={() => { if (busLineId) { setStopId(String(stop.id)); appendDraggedStop(stop) } }}
role="button"
tabIndex={0}
>
<strong>#{stop.id} {stop.name}</strong>
<div className="muted">{stop.latitude}, {stop.longitude}</div>
<div className="muted">Sleep of klik om toe te voegen</div>
<div className="faint">Sleep of klik om toe te voegen</div>
</article>
))}
</div>
<h3>Link a Stop to Line</h3>
{isAdmin ? (
<form onSubmit={link} className="stack-form">
<div className="form-row">
<select value={stopId} onChange={e=>setStopId(e.target.value)}>
<option value="">Select stop</option>
{availableStops.map(stop => (
<option key={stop.id} value={stop.id}>
#{stop.id} {stop.name}
</option>
))}
</select>
<input value={`Next order: ${nextOrder}`} readOnly />
<input placeholder="ETA minutes (optional)" value={eta} onChange={e=>setEta(e.target.value)} />
</div>
<div className="form-row">
<button type="submit">Link Stop</button>
</div>
</form>
) : (
<div>You must be admin to link stops.</div>
)}
{message && <div className="message">{message}</div>}
<div className="stack-form">
<h3 style={{ marginTop: 0 }}>Halte koppelen aan lijn</h3>
{isAdmin ? (
<form onSubmit={link} className="stack-form" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="form-row">
<select value={stopId} onChange={e => setStopId(e.target.value)}>
<option value="">Selecteer halte</option>
{availableStops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
<input value={`Volgende: ${nextOrder}`} readOnly className="no-grow" />
<input placeholder="ETA minuten (optioneel)" value={eta} onChange={e => setEta(e.target.value)} />
</div>
<div className="form-row">
<button type="submit" className="no-grow">Halte koppelen</button>
</div>
</form>
) : (
<div className="muted">Alleen beheerders kunnen haltes koppelen.</div>
)}
</div>
{message && <div className={`message ${messageType}`}>{message}</div>}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Remove Stop from Line?</h3>
<p><strong>{deleteConfirm.name}</strong></p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will remove the stop from the route and re-order the remaining stops.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>
Remove
</button>
</div>
</div>
</div>
<Modal title="Halte verwijderen uit lijn?" onClose={() => setDeleteConfirm(null)}
footer={
<>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>Verwijderen</button>
</>
}>
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> uit deze lijn wilt verwijderen?</p>
<p className="faint">De halte wordt uit de route gehaald en de resterende stops worden opnieuw geordend.</p>
</Modal>
)}
</section>
)
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
}
+40 -17
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'
import { authClient } from '../api'
import { IconBusFront } from './icons'
export default function Login({ onLogin }) {
const [userCode, setUserCode] = useState('')
@@ -14,29 +15,51 @@ export default function Login({ onLogin }) {
try {
const res = await authClient.post('/login', { userCode, password })
const token = res.data.token
// fetch /me to get payload
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
onLogin(token, me.data.user)
} catch (err) {
setError(err.response?.data?.error || 'login_failed')
} finally { setLoading(false) }
} finally {
setLoading(false)
}
}
return (
<div className="login">
<h2>Admin Login</h2>
<form onSubmit={submit}>
<label>
User Code
<input value={userCode} onChange={e => setUserCode(e.target.value)} />
</label>
<label>
Password
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
</label>
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
{error && <div className="error">{error}</div>}
</form>
<div className="auth-page">
<div className="auth-card">
<div className="auth-brand">
<div className="brand-mark"><IconBusFront size={26} /></div>
<div>
<h1>Transit Beheer</h1>
<p>Operationeel dashboard voor netwerkbeheer</p>
</div>
</div>
<form onSubmit={submit}>
<label>
Gebruikercode
<input
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="Bijv. 1234"
autoFocus
/>
</label>
<label>
Wachtwoord
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
</label>
{error && <div className="error">{error}</div>}
<button type="submit" className="auth-submit" disabled={loading}>
{loading ? 'Bezig met inloggen…' : 'Inloggen'}
</button>
</form>
<div className="auth-footer">Alleen beheerders hebben toegang</div>
</div>
</div>
)
}
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
export default function Modal({ title, onClose, children, footer, wide }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className={`modal ${wide ? 'modal-wide' : ''}`} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<header className="modal-header">
<h3>{title}</h3>
<button type="button" className="icon-btn" onClick={onClose} aria-label="Sluiten">✕</button>
</header>
<div className="modal-body">{children}</div>
{footer && <footer className="modal-footer">{footer}</footer>}
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react'
import { errorLogClient, transitClient } from '../api'
import TimetableDisplay from './TimetableDisplay'
import { IconAlert, IconBusFront, IconCalendar, IconMapPin, IconRoute } from './icons'
export default function Overview({ token }) {
const client = transitClient(token)
const [stats, setStats] = useState({ stops: null, lines: null, buses: null, drivers: null, logs: null })
const [drivers, setDrivers] = useState([])
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
const [driverFilter, setDriverFilter] = useState('')
useEffect(() => {
const load = async () => {
try {
const [stops, lines, buses, shifts] = await Promise.all([
client.get('/admin/stops'),
client.get('/admin/bus-lines'),
client.get('/admin/fleet/buses'),
client.get('/admin/shift-drivers'),
])
let logs = null
try {
const res = await errorLogClient(token).get('/error_log', { params: { limit: 1 } })
logs = res.data.errors?.length ?? null
} catch (_) {
logs = null
}
setStats({
stops: stops.data.stops?.length ?? null,
lines: lines.data.busLines?.length ?? null,
buses: buses.data.buses?.length ?? null,
drivers: shifts.data.drivers?.length ?? null,
logs,
})
setDrivers(shifts.data.drivers || [])
} catch (_) {
// stats stay null; individual sections handle real errors
}
}
load()
}, [])
const statCards = [
{ label: 'Haltes', value: stats.stops, icon: IconMapPin, tone: 'primary' },
{ label: 'Buslijnen', value: stats.lines, icon: IconRoute, tone: 'accent' },
{ label: 'Voertuigen', value: stats.buses, icon: IconBusFront, tone: 'success' },
{ label: 'Chauffeurs', value: stats.drivers, icon: IconCalendar, tone: 'warning' },
{ label: 'Foutmeldingen', value: stats.logs, icon: IconAlert, tone: 'danger' },
]
return (
<>
<section className="stats-grid">
{statCards.map((card) => {
const Icon = card.icon
return (
<article className="stat-card" key={card.label}>
<div className={`stat-icon ${card.tone}`}><Icon size={22} /></div>
<div>
<div className="stat-value">{card.value === null ? '…' : card.value}</div>
<div className="stat-label">{card.label}</div>
</div>
</article>
)
})}
</section>
<TimetableDisplay
client={client}
overviewDate={overviewDate}
setOverviewDate={setOverviewDate}
selectedDriverFilter={driverFilter}
setSelectedDriverFilter={setDriverFilter}
drivers={drivers}
/>
</>
)
}
+164
View File
@@ -0,0 +1,164 @@
import React, { useState } from 'react'
import { API_BASE } from '../api'
import { IconTerminal } from './icons'
const SERVICE_OPTIONS = [
{ key: 'auth', label: 'Auth API' },
{ key: 'employee', label: 'Employee API' },
{ key: 'transit', label: 'Transit API' },
{ key: 'errors', label: 'Error Log API' },
]
const ROUTES = [
{ service: 'transit', method: 'GET', path: '/health', label: 'Transit health' },
{ service: 'transit', method: 'GET', path: '/bus/admin/stops', label: 'Haltes (admin)' },
{ service: 'transit', method: 'GET', path: '/bus/schedules', label: 'Dienstrooster-overzicht' },
{ service: 'transit', method: 'GET', path: '/bus/schedule/daily?date=2026-09-25', label: 'Dagelijkse dienstregeling' },
{ service: 'transit', method: 'GET', path: '/bus/public/schedules/1/progress', label: 'Openbare ritvoortgang' },
{ service: 'auth', method: 'GET', path: '/me', label: 'Huidige sessie' },
{ service: 'auth', method: 'POST', path: '/login', label: 'Login (userCode + wachtwoord)' },
{ service: 'employee', method: 'GET', path: '/health', label: 'Employee health' },
{ service: 'errors', method: 'GET', path: '/error_log?limit=50', label: 'Recente foutmeldingen' },
]
const pretty = (value) => {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
export default function RequestLab({ token }) {
const [route, setRoute] = useState(ROUTES[0])
const [body, setBody] = useState('')
const [result, setResult] = useState(null)
const [busy, setBusy] = useState(false)
const routesForService = ROUTES.filter((r) => r.service === route.service)
const pickService = (next) => {
const first = ROUTES.find((r) => r.service === next)
if (first) setRoute(first)
}
const run = async () => {
if (route.method !== 'GET' && !window.confirm('Deze aanvraag kan gegevens wijzigen. Doorgaan?')) return
setBusy(true)
setResult(null)
const started = performance.now()
try {
const response = await fetch(`${API_BASE[route.service]}${route.path}`, {
method: route.method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body || undefined,
})
const text = await response.text()
let parsed
try {
parsed = JSON.parse(text)
} catch {
parsed = text
}
setResult({
ok: response.ok,
status: response.status,
latency: Math.round(performance.now() - started),
body: parsed,
label: route.label,
})
} catch (err) {
setResult({
ok: false,
status: 0,
latency: Math.round(performance.now() - started),
body: { error: err.message },
label: route.label,
})
} finally {
setBusy(false)
}
}
return (
<div className="stack">
<section className="card">
<div className="section-header">
<div>
<h2>API-testomgeving</h2>
<p>Voer een van de goedgekeurde verzoeken uit om het live antwoord te bekijken.</p>
</div>
</div>
<div className="form-row">
<select value={route.service} onChange={(e) => pickService(e.target.value)} className="no-grow">
{SERVICE_OPTIONS.map((s) => (
<option key={s.key} value={s.key}>{s.label}</option>
))}
</select>
<select
value={`${route.method} ${route.path}`}
onChange={(e) => {
const found = routesForService.find((r) => `${r.method} ${r.path}` === e.target.value)
if (found) setRoute(found)
}}
className="flex-1"
>
{routesForService.map((r) => (
<option key={`${r.method} ${r.path}`} value={`${r.method} ${r.path}`}>
{r.method} {r.path} — {r.label}
</option>
))}
</select>
</div>
<div className="form-row">
<textarea
className="lab-body flex-1"
placeholder="Optionele request body (JSON)"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</div>
<div className="form-row">
<button className="no-grow" onClick={run} disabled={busy}>
{busy ? 'Uitvoeren…' : 'Verzoek uitvoeren'}
</button>
<span className="muted minw-0">
Verzoeken gaan rechtstreeks van deze browser naar de geconfigureerde service. Schrijfverzoeken vereisen een bevestiging.
</span>
</div>
</section>
<section className="card">
<div className="section-header">
<div>
<h2>Antwoord</h2>
<p>
{result
? `${result.label} · ${result.status || 'Netwerk'} · ${result.latency} ms`
: 'Nog geen verzoek uitgevoerd.'}
</p>
</div>
{result && (
<span className={`badge ${result.ok ? 'badge-success' : 'badge-danger'}`}>
{result.ok ? 'Succes' : 'Mislukt'}
</span>
)}
</div>
{result ? (
<pre className="lab-response">{pretty(result.body)}</pre>
) : (
<div className="lab-placeholder">
<IconTerminal size={28} />
<p>Kies een goedgekeurde route en voer deze uit om het live antwoord te inspecteren.</p>
</div>
)}
</section>
</div>
)
}
+69 -20
View File
@@ -1,20 +1,33 @@
import React, { useEffect, useState } from "react";
import { transitClient } from "../api";
import WeeklyScheduleEditor from "./WeeklyScheduleEditor";
import FixedScheduleEditor from "./FixedScheduleEditor";
export default function ScheduleMaker({ token }) {
export default function ScheduleMaker({ token, user }) {
const client = transitClient(token);
const [schedules, setSchedules] = useState([]);
const [tab, setTab] = useState("weekly");
const [weeklySchedules, setWeeklySchedules] = useState([]);
const [templates, setTemplates] = useState([]);
const [busLines, setBusLines] = useState([]);
const [buses, setBuses] = useState([]);
const [drivers, setDrivers] = useState([]);
const [message, setMessage] = useState(null);
const loadSchedules = async () => {
const loadWeekly = async () => {
try {
const res = await client.get("/admin/weekly-schedules");
setSchedules(res.data.schedules || []);
setWeeklySchedules(res.data.schedules || []);
} catch (err) {
setMessage("Could not load weekly schedules.");
setMessage("Kon wekelijkse diensten niet laden.");
}
};
const loadTemplates = async () => {
try {
const res = await client.get("/admin/schedules");
setTemplates(res.data.schedules || []);
} catch (err) {
setTemplates([]);
}
};
@@ -23,7 +36,16 @@ export default function ScheduleMaker({ token }) {
const res = await client.get("/admin/bus-lines");
setBusLines(res.data.busLines || []);
} catch (err) {
setMessage("Could not load bus lines.");
setMessage("Kon buslijnen niet laden.");
}
};
const loadBuses = async () => {
try {
const res = await client.get("/admin/fleet/buses");
setBuses(res.data.buses || []);
} catch (err) {
setBuses([]);
}
};
@@ -32,30 +54,57 @@ export default function ScheduleMaker({ token }) {
const res = await client.get("/admin/shift-drivers");
setDrivers(res.data.drivers || []);
} catch (err) {
setMessage("Could not load drivers.");
setMessage("Kon chauffeurs niet laden.");
}
};
useEffect(() => {
loadSchedules();
loadWeekly();
loadTemplates();
loadBusLines();
loadBuses();
loadDrivers();
}, []);
const handleScheduleChange = async () => {
await loadSchedules();
const handleScheduleChange = () => {
loadWeekly();
loadTemplates();
};
return (
<div>
<WeeklyScheduleEditor
client={client}
busLines={busLines}
drivers={drivers}
schedules={schedules}
onChange={handleScheduleChange}
/>
{message && <div className="message">{message}</div>}
<div className="stack">
<div className="segmented">
<button type="button" className={tab === "weekly" ? "active" : ""} onClick={() => setTab("weekly")}>
Wekelijks schema
</button>
<button type="button" className={tab === "fixed" ? "active" : ""} onClick={() => setTab("fixed")}>
Vaste diensten
</button>
</div>
{tab === "weekly" ? (
<WeeklyScheduleEditor
client={client}
busLines={busLines}
drivers={drivers}
schedules={weeklySchedules}
onChange={handleScheduleChange}
/>
) : (
<FixedScheduleEditor
client={client}
user={user}
scheduleTemplates={templates}
busLines={busLines}
buses={buses}
drivers={drivers}
message={message}
setMessage={setMessage}
onScheduleChange={handleScheduleChange}
/>
)}
{tab === "weekly" && message && <div className="message">{message}</div>}
</div>
);
}
}
+72 -107
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
import Modal from './Modal'
export default function StopsManager({ token, user }) {
const client = transitClient(token)
@@ -8,17 +9,19 @@ export default function StopsManager({ token, user }) {
const [lat, setLat] = useState('')
const [lon, setLon] = useState('')
const [message, setMessage] = useState(null)
const [messageType, setMessageType] = useState('')
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingStop, setEditingStop] = useState(null)
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
const loadStops = async () => {
try {
const res = await client.get('/admin/stops')
setStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -28,38 +31,50 @@ export default function StopsManager({ token, user }) {
const createStop = async (e) => {
e.preventDefault()
setMessage(null)
notify(null)
try {
const res = await client.post('/admin/stops', {
name,
latitude: parseFloat(lat),
longitude: parseFloat(lon)
})
setMessage('Stop created: #' + res.data.stop.id)
notify('Halte aangemaakt: #' + res.data.stop.id)
setName(''); setLat(''); setLon('')
await loadStops()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
if (err.response?.status === 401) {
setMessage('Unauthorized: invalid token')
} else if (err.response?.status === 403) {
setMessage('Forbidden: you are not an admin')
} else {
setMessage('Error: ' + errMsg)
}
if (err.response?.status === 401) notify('Unauthorized: invalid token', 'error')
else if (err.response?.status === 403) notify('Forbidden: you are not an admin', 'error')
else notify('Error: ' + errMsg, 'error')
}
}
const deleteStop = async (stopId, stopName) => {
setMessage(null)
notify(null)
try {
await client.delete(`/admin/stops/${stopId}`)
setMessage(`Halte "${stopName}" verwijderd`)
notify(`Halte "${stopName}" verwijderd`)
setDeleteConfirm(null)
await loadStops()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const saveStop = async (e) => {
e.preventDefault()
notify(null)
try {
await client.patch(`/admin/stops/${editingStop.id}`, {
name: editingStop.name,
latitude: parseFloat(editingStop.latitude),
longitude: parseFloat(editingStop.longitude),
})
notify('Halte bijgewerkt')
setEditingStop(null)
await loadStops()
} catch (err) {
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -67,122 +82,72 @@ export default function StopsManager({ token, user }) {
<section className="card">
<div className="section-header">
<div>
<h2>Stops</h2>
<p>Maak herbruikbare haltes aan.</p>
<h2>Haltes</h2>
<p>Maak herbruikbare haltes aan en bewerk hun coördinaten.</p>
</div>
<button className="secondary" onClick={loadStops}>Refresh</button>
<button className="secondary" onClick={loadStops}>Ververs</button>
</div>
<div className="list-grid">
{stops.map(stop => (
<article className="mini-card" key={stop.id}>
<strong>#{stop.id} {stop.name}</strong>
<div>{stop.latitude}, {stop.longitude}</div>
<strong>Halte #{stop.id} · {stop.name}</strong>
<div className="meta-line"><span className="badge badge-primary">#</span> {stop.latitude}, {stop.longitude}</div>
{isAdmin && (
<button
className="danger"
onClick={() => setDeleteConfirm(stop)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Delete
</button>
)}
{isAdmin && (
<button
className="secondary"
onClick={() => setEditingStop(stop)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Edit
</button>
<div className="card-actions">
<button className="secondary" onClick={() => setEditingStop(stop)}>Bewerken</button>
<button className="danger" onClick={() => setDeleteConfirm(stop)}>Verwijderen</button>
</div>
)}
</article>
))}
</div>
{isAdmin ? (
<form onSubmit={createStop} className="stack-form">
<div className="form-row">
<input placeholder="Name" value={name} onChange={e=>setName(e.target.value)} />
<input placeholder="Latitude" value={lat} onChange={e=>setLat(e.target.value)} />
<input placeholder="Longitude" value={lon} onChange={e=>setLon(e.target.value)} />
<button type="submit">Create Stop</button>
<input placeholder="Naam halte" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Latitude" value={lat} onChange={e => setLat(e.target.value)} />
<input placeholder="Longitude" value={lon} onChange={e => setLon(e.target.value)} />
<button type="submit" className="no-grow">Halte aanmaken</button>
</div>
</form>
) : (
<div>You must be admin to create stops.</div>
<div className="muted">Alleen beheerders kunnen haltes aanmaken.</div>
)}
{message && <div className="message">{message}</div>}
{message && <div className={`message ${messageType}`}>{message}</div>}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Stop?</h3>
<p><strong>{deleteConfirm.name}</strong></p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This cannot be undone.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>
Delete
</button>
</div>
</div>
</div>
<Modal title="Halte verwijderen?" onClose={() => setDeleteConfirm(null)}
footer={
<>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>Verwijderen</button>
</>
}>
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> wilt verwijderen?</p>
<p className="faint">Dit kan niet ongedaan worden gemaakt.</p>
</Modal>
)}
{editingStop && (
<div style={overlay}>
<div style={modal}>
<h3>Edit Stop</h3>
<form onSubmit={async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.patch(`/admin/stops/${editingStop.id}`, {
name: editingStop.name,
latitude: parseFloat(editingStop.latitude),
longitude: parseFloat(editingStop.longitude),
})
setMessage('Stop updated')
setEditingStop(null)
await loadStops()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}} className="stack-form">
<div className="form-row">
<input value={editingStop.name} onChange={e => setEditingStop({...editingStop, name: e.target.value})} />
<input value={editingStop.latitude} onChange={e => setEditingStop({...editingStop, latitude: e.target.value})} />
<input value={editingStop.longitude} onChange={e => setEditingStop({...editingStop, longitude: e.target.value})} />
</div>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" type="button" onClick={() => setEditingStop(null)}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
<Modal title="Halte bewerken" onClose={() => setEditingStop(null)}
footer={
<>
<button className="secondary" onClick={() => setEditingStop(null)}>Annuleren</button>
<button onClick={saveStop}>Opslaan</button>
</>
}>
<div className="form-row">
<input value={editingStop.name} onChange={e => setEditingStop({ ...editingStop, name: e.target.value })} />
</div>
</div>
<div className="form-row">
<input value={editingStop.latitude} onChange={e => setEditingStop({ ...editingStop, latitude: e.target.value })} />
<input value={editingStop.longitude} onChange={e => setEditingStop({ ...editingStop, longitude: e.target.value })} />
</div>
</Modal>
)}
</section>
)
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
}
+14 -20
View File
@@ -84,19 +84,22 @@ const TimetableDisplay = ({ client, overviewDate, setOverviewDate, selectedDrive
<h3>Dagelijks Overzicht</h3>
<p>Alle geplande diensten voor de geselecteerde dag</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="date"
value={overviewDate}
onChange={e => setOverviewDate(e.target.value)}
className="no-grow"
style={{ minWidth: 150 }}
/>
<button type="button" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
<button type="button" className="secondary no-grow" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
Vandaag
</button>
<select
value={selectedDriverFilter}
onChange={e => setSelectedDriverFilter(e.target.value)}
style={{ padding: '6px', borderRadius: '6px', border: '1px solid #ccc' }}
className="no-grow"
style={{ minWidth: 170 }}
>
<option value="">Alle chauffeurs</option>
{drivers.map(d => (
@@ -148,46 +151,37 @@ const TimetableDisplay = ({ client, overviewDate, setOverviewDate, selectedDrive
{/* Expandable stops list */}
<button
type="button"
className="schedule-expand"
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
style={{
marginTop: '0.5rem',
width: '100%',
padding: '0.4rem',
fontSize: '0.85rem',
backgroundColor: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '3px',
cursor: 'pointer',
}}
>
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
</button>
{expandedSchedule === schedule.id && (
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #eee' }}>
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid var(--border)' }}>
{(() => {
const lineId = schedule.busLineId
const cached = routeStopsByLine[lineId]
if (!cached) {
loadRouteStops(lineId)
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Laden haltes...</p>
return <p className="faint">Laden haltes…</p>
}
if (cached.length === 0) {
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
return <p className="faint">Geen haltes gekoppeld</p>
}
return (
<table style={{ width: '100%', fontSize: '0.8rem' }}>
<table className="stop-table">
<tbody>
{cached.map(rs => {
const departureDate = new Date(schedule.startTime)
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
return (
<tr key={rs.id}>
<td style={{ paddingRight: '0.5rem' }}><strong>{rs.stopOrder}.</strong></td>
<td style={{ flex: 1 }}>{rs.name}</td>
<td style={{ textAlign: 'right', color: '#666' }}>{formatTime(departureDate.toISOString())}</td>
<td style={{ width: 24 }}><strong>{rs.stopOrder}.</strong></td>
<td>{rs.name}</td>
<td style={{ textAlign: 'right', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{formatTime(departureDate.toISOString())}</td>
</tr>
)
})}
+2 -2
View File
@@ -17,7 +17,7 @@ export default function TimetableViewer({
useEffect(() => {
const fetchLines = async () => {
try {
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`);
const res = await fetch(`${apiBaseUrl}/bus/admin/bus-lines`);
const data = await res.json();
setLines(data.busLines || []);
} catch (err) {
@@ -33,7 +33,7 @@ export default function TimetableViewer({
setLoading(true);
setError(null);
try {
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`;
let url = `${apiBaseUrl}/bus/schedules/overview?date=${selectedDate}`;
if (selectedLineId) {
url += `&lineId=${selectedLineId}`;
}
+57 -130
View File
@@ -1,21 +1,13 @@
import React, { useMemo, useState } from "react";
const DAY_GROUPS = [
{ value: "weekdays", label: "Weekdays", days: [1, 2, 3, 4, 5] },
{ value: "saturday", label: "Saturday", days: [6] },
{ value: "sunday", label: "Sunday", days: [0] },
{ value: "weekdays", label: "Weekdagen", days: [1, 2, 3, 4, 5] },
{ value: "saturday", label: "Zaterdag", days: [6] },
{ value: "sunday", label: "Zondag", days: [0] },
];
const dayLabel = (day) =>
[
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
][day];
["Zondag", "Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag"][day];
export default function WeeklyScheduleEditor({
client,
@@ -30,8 +22,11 @@ export default function WeeklyScheduleEditor({
const [newStartTime, setNewStartTime] = useState("");
const [driverId, setDriverId] = useState("");
const [message, setMessage] = useState(null);
const [messageType, setMessageType] = useState("");
const [saving, setSaving] = useState(false);
const notify = (text, type = "") => { setMessage(text); setMessageType(type) };
const groupedSchedules = useMemo(() => {
return schedules.reduce((groups, schedule) => {
const key =
@@ -47,9 +42,9 @@ export default function WeeklyScheduleEditor({
const createSchedules = async (event) => {
event.preventDefault();
setMessage(null);
notify(null);
if (!lineId || startTimes.length === 0) {
setMessage("Choose a line and add at least one departure time.");
notify("Kies een lijn en voeg minstens één vertrektijd toe.", "error");
return;
}
@@ -62,14 +57,12 @@ export default function WeeklyScheduleEditor({
startTimes,
driverId: driverId ? Number(driverId) : null,
});
setMessage(
`${response.data.createdCount} departures created for ${group.label}.`,
);
notify(`${response.data.createdCount} vertrekken aangemaakt voor ${group.label}.`);
setStartTimes(["08:00"]);
setNewStartTime("");
onChange();
} catch (error) {
setMessage(error.response?.data?.error || "Could not save the schedule.");
notify(error.response?.data?.error || "Kon het schema niet opslaan.", "error");
} finally {
setSaving(false);
}
@@ -90,26 +83,21 @@ export default function WeeklyScheduleEditor({
await client.put(`/admin/weekly-schedules/${scheduleId}/shift`, {
driverId: value ? Number(value) : null,
});
setMessage("Driver assignment updated.");
notify("Chauffeurstoewijzing bijgewerkt.");
onChange();
} catch (error) {
setMessage(
error.response?.data?.error ||
"Could not update the driver assignment.",
);
notify(error.response?.data?.error || "Kon de toewijzing niet bijwerken.", "error");
}
};
const removeSchedule = async (scheduleId) => {
if (!window.confirm("Delete this weekly schedule?")) return;
if (!window.confirm("Dit wekelijkse schema verwijderen?")) return;
try {
await client.delete(`/admin/weekly-schedules/${scheduleId}`);
setMessage("Weekly schedule deleted.");
notify("Wekelijks schema verwijderd.");
onChange();
} catch (error) {
setMessage(
error.response?.data?.error || "Could not delete the schedule.",
);
notify(error.response?.data?.error || "Kon het schema niet verwijderen.", "error");
}
};
@@ -117,163 +105,102 @@ export default function WeeklyScheduleEditor({
<section className="card">
<div className="section-header">
<div>
<h3>Weekly schedules</h3>
<h3>Wekelijkse schema's</h3>
<p>
Add multiple daily departures for weekdays, Saturday, or Sunday,
then assign a driver to the generated templates.
Voeg meerdere dagelijkse vertrekken toe voor weekdagen, zaterdag of zondag en wijs
daarna een chauffeur toe.
</p>
</div>
</div>
<form onSubmit={createSchedules} className="stack-form">
<div className="form-row">
<select
value={lineId}
onChange={(event) => setLineId(event.target.value)}
>
<option value="">Choose bus line</option>
<select value={lineId} onChange={(event) => setLineId(event.target.value)}>
<option value="">Kies buslijn</option>
{busLines.map((line) => (
<option key={line.id} value={line.id}>
Line {line.lineNumber} ({line.startStop} to {line.endStop})
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
</option>
))}
</select>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<select value={category} onChange={(event) => setCategory(event.target.value)}>
{DAY_GROUPS.map((group) => (
<option key={group.value} value={group.value}>
{group.label}
</option>
<option key={group.value} value={group.value}>{group.label}</option>
))}
</select>
</div>
<div className="form-row" style={{ alignItems: "center" }}>
<div className="form-row">
<input
type="time"
value={newStartTime}
onChange={(event) => setNewStartTime(event.target.value)}
aria-label="New departure time"
aria-label="Nieuwe vertrektijd"
className="no-grow"
style={{ minWidth: 130 }}
/>
<button type="button" className="secondary" onClick={addStartTime}>
Add departure
<button type="button" className="secondary no-grow" onClick={addStartTime}>
Vertrektijd toevoegen
</button>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
flex: 1,
}}
>
<div className="flex-1 minw-0" style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{startTimes.map((time) => (
<span
key={time}
style={{
display: "inline-flex",
gap: "0.4rem",
alignItems: "center",
padding: "0.4rem 0.6rem",
border: "1px solid #c7dcff",
borderRadius: "999px",
background: "#eaf2ff",
}}
>
<span key={time} className="pill-chip">
{time}
<button
type="button"
onClick={() => removeStartTime(time)}
aria-label={`Remove departure ${time}`}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
}}
>
x
</button>
<button type="button" onClick={() => removeStartTime(time)} aria-label={`Verwijder ${time}`}>×</button>
</span>
))}
</div>
<select
value={driverId}
onChange={(event) => setDriverId(event.target.value)}
>
<option value="">Unassigned</option>
</div>
<div className="form-row">
<select value={driverId} onChange={(event) => setDriverId(event.target.value)}>
<option value="">Niet toegewezen</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
<option key={driver.id} value={driver.id}>{driver.name}</option>
))}
</select>
<button type="submit" disabled={saving}>
{saving ? "Saving..." : `Create ${startTimes.length} departures`}
<button type="submit" className="no-grow" disabled={saving}>
{saving ? "Opslaan…" : `Aanmaken (${startTimes.length} vertrekken)`}
</button>
</div>
</form>
{message && <div className="message">{message}</div>}
{message && <div className={`message ${messageType}`}>{message}</div>}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: "1rem",
marginTop: "1.5rem",
alignItems: "start",
}}
>
<div className="weekly-panels">
{DAY_GROUPS.map((group) => (
<section
key={group.value}
style={{
minWidth: 0,
border: "1px solid #dbe3ef",
borderRadius: "8px",
background: "#f8fafc",
padding: "0.75rem",
}}
>
<h4 style={{ marginTop: 0 }}>{group.label}</h4>
<section key={group.value} className="weekly-panel">
<h4>{group.label}</h4>
{(groupedSchedules[group.value] || []).length === 0 ? (
<p>No schedules configured.</p>
<p className="muted">Nog geen schema geconfigureerd.</p>
) : (
<div style={{ display: "grid", gap: "0.75rem" }}>
<div style={{ display: "grid", gap: "10px" }}>
{groupedSchedules[group.value].map((schedule) => (
<article key={schedule.id} className="schedule-card">
<div className="schedule-header">
<strong>Line {schedule.lineNumber}</strong>
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">
{String(schedule.startTime).slice(0, 5)} -{" "}
{String(schedule.endTime).slice(0, 5)}
{String(schedule.startTime).slice(0, 5)} – {String(schedule.endTime).slice(0, 5)}
</span>
</div>
<div className="schedule-details">
{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "}
{schedule.endStop}
<div>{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} → {schedule.endStop}</div>
</div>
<select
value={schedule.driverId || ""}
onChange={(event) =>
assignDriver(schedule.id, event.target.value)
}
style={{ width: "100%", marginTop: "0.75rem" }}
onChange={(event) => assignDriver(schedule.id, event.target.value)}
style={{ width: "100%", marginTop: "10px" }}
>
<option value="">Unassigned</option>
<option value="">Niet toegewezen</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
<option key={driver.id} value={driver.id}>{driver.name}</option>
))}
</select>
<button
type="button"
className="danger"
className="danger block"
style={{ marginTop: "8px" }}
onClick={() => removeSchedule(schedule.id)}
style={{ width: "100%", marginTop: "0.5rem" }}
>
Delete
Verwijderen
</button>
</article>
))}
@@ -284,4 +211,4 @@ export default function WeeklyScheduleEditor({
</div>
</section>
);
}
}
+104
View File
@@ -0,0 +1,104 @@
import React from 'react'
const Svg = ({ size = 18, children, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
)
export const IconGrid = (p) => (
<Svg {...p}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</Svg>
)
export const IconMapPin = (p) => (
<Svg {...p}>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</Svg>
)
export const IconRoute = (p) => (
<Svg {...p}>
<circle cx="6" cy="19" r="3" />
<circle cx="18" cy="5" r="3" />
<path d="M9 19h3a4 4 0 0 0 0-8H9a4 4 0 0 1 0-8h6" />
</Svg>
)
export const IconLink = (p) => (
<Svg {...p}>
<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7" />
<path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" />
</Svg>
)
export const IconBus = (p) => (
<Svg {...p}>
<path d="M4 6a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
<path d="M4 10h16" />
<path d="M8 20v2M16 20v2" />
<circle cx="8" cy="14" r="1" />
<circle cx="16" cy="14" r="1" />
</Svg>
)
export const IconCalendar = (p) => (
<Svg {...p}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</Svg>
)
export const IconAlert = (p) => (
<Svg {...p}>
<path d="M10.3 4.2 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0Z" />
<path d="M12 9v4M12 17h.01" />
</Svg>
)
export const IconLogout = (p) => (
<Svg {...p}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5M21 12H9" />
</Svg>
)
export const IconBusFront = (p) => (
<Svg {...p}>
<path d="M4 8V7a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v1" />
<path d="M4 8h16v6H4zM8 17v3M16 17v3" />
<rect x="4" y="14" width="16" height="3" rx="1" />
<circle cx="8" cy="11" r="0.6" />
<circle cx="16" cy="11" r="0.6" />
</Svg>
)
export const IconClock = (p) => (
<Svg {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</Svg>
)
export const IconTerminal = (p) => (
<Svg {...p}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="m7 9 3 3-3 3M13 15h4" />
</Svg>
)
+1087 -173
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -15,7 +15,26 @@ pool.query('SELECT 1')
.catch(err => {
console.error('PostgreSQL connection FAILED');
console.error(err);
process.exit(1);
const url = process.env.ERROR_LOG_API_URL || 'https://error.xtransit.testinstance.nl/error_log';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-api-key': process.env.INTERNAL_API_KEY || '',
},
body: JSON.stringify({
service: 'auth_api',
partofservice: 'startup: db connection',
error: err.stack || err.message || String(err),
}),
signal: controller.signal,
}).catch(() => {})
.finally(() => {
clearTimeout(timer);
process.exit(1);
});
});
module.exports = {
+72 -21
View File
@@ -4,6 +4,11 @@ const jwt = require("jsonwebtoken");
const db = require("./db");
require("dotenv").config();
function log(level, message, meta = {}) {
const timestamp = new Date().toISOString();
console.log(JSON.stringify({ timestamp, level, service: "auth_api", message, ...meta }));
}
const app = express();
app.use(express.json());
@@ -32,28 +37,54 @@ function friendlyErrorMessage(statusCode) {
async function reportError({ partofservice, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
const response = await fetch(ERROR_LOG_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"x-internal-api-key": process.env.INTERNAL_API_KEY || "",
},
body: JSON.stringify({
service: "auth_api",
partofservice,
error,
}),
});
} catch (_) {}
if (!response.ok) {
log("warn", "Error log forward failed", { status: response.status, partofservice });
}
} catch (err) {
log("warn", "Error log forward failed", { error: err.message, partofservice });
}
}
function reportUnhandled(context, err) {
const message = err?.message || String(err);
const stack = err?.stack || message;
log("error", context, { error: message, stack });
void reportError({ partofservice: context, error: stack });
}
process.on("uncaughtException", (err) => reportUnhandled("uncaughtException", err));
process.on("unhandledRejection", (reason) => reportUnhandled("unhandledRejection", reason));
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === "object") {
const rawError =
body.error || body.message || body.details || `HTTP ${res.statusCode}`;
[body.error, body.message, body.details]
.filter((v) => v !== undefined && v !== null && v !== "")
.join(" - ") || `HTTP ${res.statusCode}`;
log("warn", `HTTP ${res.statusCode}`, { path: req.originalUrl, method: req.method, error: rawError });
void reportError({
partofservice: `${req.method} ${req.originalUrl}`,
error: rawError,
});
// /login errors are client-actionable: pass the specific message through
// instead of masking it with the generic session-expired text.
if (req.method === "POST" && req.path === "/login") {
return originalJson(body);
}
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
@@ -74,49 +105,62 @@ app.post("/login", async (req, res) => {
try {
const { userCode, password } = req.body;
if (!userCode || !password)
return res.status(400).json({ error: "userCode and password required" });
return res.status(400).json({ error: "Vul gebruikerscode en wachtwoord in." });
const result = await db.query(
"SELECT id, usercode, password FROM workers.users WHERE usercode = $1",
[userCode],
);
const user = result.rows[0];
if (!user) return res.status(401).json({ error: "invalid_credentials" });
if (!user) {
log("warn", "Login attempt for non-existent user", { userCode });
return res.status(401).json({ error: "Ongeldige gebruikerscode of wachtwoord." });
}
const ok = await bcrypt.compare(password, user.password);
if (!ok) return res.status(401).json({ error: "invalid_credentials" });
if (!ok) {
log("warn", "Invalid password for user", { userCode });
return res.status(401).json({ error: "Ongeldige gebruikerscode of wachtwoord." });
}
const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
const payload = { userId: user.id, userCode: user.usercode, isAdmin };
const payload = {
userId: user.id,
userCode: user.usercode,
isAdmin: Boolean(isAdmin),
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "1h" });
log("info", "User logged in", { userCode: user.usercode, isAdmin });
res.json({
token,
user: { id: user.id, userCode: user.usercode, job: user.job, isAdmin },
user: {
id: user.id,
userCode: user.usercode,
job: user.job,
isAdmin: Boolean(isAdmin),
},
});
} catch (err) {
await reportError({
partofservice: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
console.error(err);
log("error", "Login error", { error: err.message, stack: err.stack });
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
res.status(500).json({ error: friendlyErrorMessage(500) });
}
});
app.get("/me", async (req, res) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith("Bearer "))
if (!auth || !auth.startsWith("Bearer ")) {
log("warn", "Missing or invalid Authorization header", { path: req.originalUrl });
return res.status(401).json({ error: "missing_token" });
}
const token = auth.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET);
res.json({ user: payload });
} catch (err) {
void reportError({
partofservice: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
log("error", "JWT verification failed", { error: err.message, stack: err.stack });
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
res.status(401).json({ error: friendlyErrorMessage(401) });
}
});
@@ -129,8 +173,15 @@ app.get("/me", async (req, res) => {
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
app.get("/health", (_req, res) => {
res.json({ status: "ok", service: "auth_api" });
app.get("/health", async (_req, res) => {
try {
await db.query("SELECT 1");
res.json({ status: "ok", service: "auth_api", database: "ok" });
} catch (err) {
log("error", "Health check DB query failed", { error: err.message, stack: err.stack });
reportError({ partofservice: "GET /health", error: err.stack || err.message });
res.status(503).json({ status: "unavailable", service: "auth_api" });
}
});
module.exports = app;
-44
View File
@@ -1,44 +0,0 @@
# Flutter / Dart
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
**/doc/api/
**/ios/Flutter/.last_build_id
# IDE / editor
.idea/
.vscode/
*.iml
*.ipr
*.iws
# OS files
.DS_Store
*.swp
# Environment and secrets
.env
.env.*
!.env.example
# Generated and platform artifacts
android/key.properties
android/app/debug
android/app/profile
android/app/release
# Logs and temp files
*.log
*.tmp
# Symbolication / obfuscation
*.symbols
*.map.json
# Legacy migration note
legacy_windows_xp_plan.md
-45
View File
@@ -1,45 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: android
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: ios
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: linux
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: macos
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: web
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: windows
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
-5
View File
@@ -1,5 +0,0 @@
analyzer:
errors:
deprecated_member_use: ignore
use_build_context_synchronously: ignore
include: package:flutter_lints/flutter.yaml
-14
View File
@@ -1,14 +0,0 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
-44
View File
@@ -1,44 +0,0 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.bus_terminal"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.bus_terminal"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -1,48 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application
android:label="bus_terminal"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -1,5 +0,0 @@
package com.example.bus_terminal
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-24
View File
@@ -1,24 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
-2
View File
@@ -1,2 +0,0 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
-26
View File
@@ -1,26 +0,0 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
-3
View File
@@ -1,3 +0,0 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
-34
View File
@@ -1,34 +0,0 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
@@ -1,620 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
-16
View File
@@ -1,16 +0,0 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -1,122 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

@@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
-74
View File
@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Bus Terminal</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>bus_terminal</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We need your location to show the bus route on the map and navigate you through your scheduled stops.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We need your location to show the bus route on the map and navigate you through your scheduled stops.</string>
</dict>
</plist>
@@ -1 +0,0 @@
#import "GeneratedPluginRegistrant.h"
@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
@@ -1,12 +0,0 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
-94
View File
@@ -1,94 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'screens/login_screen.dart';
import 'screens/schedule_overview_screen.dart';
import 'services/auth_service.dart';
import 'theme/app_theme.dart';
import 'services/app_navigator.dart';
import 'services/error_log_service.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) {
FlutterError.presentError(details);
void report() => ErrorLogService.report(
service: 'deprecated',
partOfService: 'FlutterError.onError',
error: details.exceptionAsString(),
);
// ignore: unawaited_futures
report();
};
PlatformDispatcher.instance.onError = (error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'deprecated',
partOfService: 'PlatformDispatcher.onError',
error: '$error\n$stack',
);
return true;
};
runZonedGuarded(
() {
runApp(const MainApp());
},
(error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'deprecated',
partOfService: 'runZonedGuarded',
error: '$error\n$stack',
);
},
);
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Bus Terminal',
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
theme: AppTheme.lightTheme(),
darkTheme: AppTheme.darkTheme(),
themeMode: ThemeMode.system,
routes: {
'/login': (context) => const LoginScreen(),
'/schedule': (context) => const ScheduleOverviewScreen(),
},
home: const _InitialRoute(),
);
}
}
/// Initial route checks if user is logged in
class _InitialRoute extends StatelessWidget {
const _InitialRoute();
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: AuthService().isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
// If logged in, go to schedule; otherwise go to login
return snapshot.data == true
? const ScheduleOverviewScreen()
: const LoginScreen();
},
);
}
}
View File
-211
View File
@@ -1,211 +0,0 @@
import 'package:latlong2/latlong.dart';
/// Navigation route point
class NavigationPoint {
final double latitude;
final double longitude;
final String? name;
NavigationPoint({
required this.latitude,
required this.longitude,
this.name,
});
LatLng toLatLng() => LatLng(latitude, longitude);
factory NavigationPoint.fromLatLng(LatLng latlng, {String? name}) {
return NavigationPoint(
latitude: latlng.latitude,
longitude: latlng.longitude,
name: name,
);
}
Map<String, dynamic> toJson() {
return {
'latitude': latitude,
'longitude': longitude,
'name': name,
};
}
factory NavigationPoint.fromJson(Map<String, dynamic> json) {
return NavigationPoint(
latitude: json['latitude'] as double,
longitude: json['longitude'] as double,
name: json['name'] as String?,
);
}
}
/// Route leg with distance and duration
class RouteLeg {
final List<NavigationPoint> points;
final double distance; // in meters
final int duration; // in seconds
final String summary;
RouteLeg({
required this.points,
required this.distance,
required this.duration,
required this.summary,
});
String getDistanceKm() => (distance / 1000).toStringAsFixed(1);
String getDurationMinutes() => (duration ~/ 60).toString();
factory RouteLeg.fromCoordinates(
List<dynamic> coordinates, {
double distance = 0.0,
int duration = 0,
String summary = 'Route',
}) {
return RouteLeg(
points: coordinates
.map((coord) {
if (coord is List && coord.length >= 2) {
return NavigationPoint(
latitude: (coord[1] as num).toDouble(),
longitude: (coord[0] as num).toDouble(),
);
}
return null;
})
.whereType<NavigationPoint>()
.toList(),
distance: distance,
duration: duration,
summary: summary,
);
}
factory RouteLeg.fromPoints({
required List<NavigationPoint> points,
required double distance,
required int duration,
String summary = 'Route',
}) {
return RouteLeg(
points: points,
distance: distance,
duration: duration,
summary: summary,
);
}
factory RouteLeg.fromJson(Map<String, dynamic> json) {
final geometry = json['geometry'] as Map<String, dynamic>?;
final coordinates = geometry?['coordinates'] as List? ?? [];
return RouteLeg(
points: coordinates
.map((coord) {
if (coord is List && coord.length >= 2) {
return NavigationPoint(
latitude: (coord[1] as num).toDouble(),
longitude: (coord[0] as num).toDouble(),
);
}
return null;
})
.whereType<NavigationPoint>()
.toList(),
distance: (json['distance'] as num?)?.toDouble() ?? 0.0,
duration: (json['duration'] as num?)?.toInt() ?? 0,
summary: (json['summary'] as String?) ?? 'Route',
);
}
}
/// Complete navigation route from start to end
class NavigationRoute {
final NavigationPoint start;
final NavigationPoint end;
final List<RouteLeg> legs;
final double totalDistance; // in meters
final int totalDuration; // in seconds
NavigationRoute({
required this.start,
required this.end,
required this.legs,
required this.totalDistance,
required this.totalDuration,
});
String getTotalDistanceKm() => (totalDistance / 1000).toStringAsFixed(1);
String getTotalDurationMinutes() => (totalDuration ~/ 60).toString();
String getTotalDurationHM() {
final hours = totalDuration ~/ 3600;
final minutes = (totalDuration % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
List<NavigationPoint> getAllPoints() {
final allPoints = <NavigationPoint>[];
for (final leg in legs) {
allPoints.addAll(leg.points);
}
return allPoints;
}
factory NavigationRoute.fromJson(Map<String, dynamic> json) {
final routes = json['routes'] as List?;
if (routes == null || routes.isEmpty) {
throw Exception('No route found in response');
}
final routeData = routes[0] as Map<String, dynamic>?;
if (routeData == null) {
throw Exception('Invalid route data');
}
return NavigationRoute.fromOsrmRouteData(
routeData,
start: NavigationPoint(latitude: 0, longitude: 0),
end: NavigationPoint(latitude: 0, longitude: 0),
);
}
factory NavigationRoute.fromOsrmRouteData(
Map<String, dynamic> routeData, {
required NavigationPoint start,
required NavigationPoint end,
}) {
final geometry = routeData['geometry'] as Map<String, dynamic>?;
final coordinates = geometry?['coordinates'] as List? ?? [];
final distance = (routeData['distance'] as num?)?.toDouble() ?? 0.0;
final duration = (routeData['duration'] as num?)?.toInt() ?? 0;
final leg = RouteLeg.fromCoordinates(
coordinates,
distance: distance,
duration: duration,
summary: (routeData['summary'] as String?) ?? 'Route',
);
return NavigationRoute(
start: start,
end: end,
legs: [leg],
totalDistance: distance,
totalDuration: duration,
);
}
}
/// Result of an optimized trip calculation with reordered stops.
class OptimizedRouteResult {
final NavigationRoute route;
final List<NavigationPoint> orderedStops;
OptimizedRouteResult({
required this.route,
required this.orderedStops,
});
}
-222
View File
@@ -1,222 +0,0 @@
import 'dart:convert';
/// Bus Line model
class BusLine {
final int id;
final int lineNumber;
final String startStop;
final String endStop;
final int estimatedDuration; // in minutes
final String? description;
BusLine({
required this.id,
required this.lineNumber,
required this.startStop,
required this.endStop,
required this.estimatedDuration,
this.description,
});
factory BusLine.fromJson(Map<String, dynamic> json) {
return BusLine(
id: json['id'] as int,
lineNumber: json['lineNumber'] as int,
startStop: json['startStop'] as String,
endStop: json['endStop'] as String,
estimatedDuration: json['estimatedDuration'] as int,
description: json['description'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'lineNumber': lineNumber,
'startStop': startStop,
'endStop': endStop,
'estimatedDuration': estimatedDuration,
'description': description,
};
}
}
/// Bus type model
class BusType {
final int id;
final String name;
final int seatCapacity;
final String licensePlate;
BusType({
required this.id,
required this.name,
required this.seatCapacity,
required this.licensePlate,
});
factory BusType.fromJson(Map<String, dynamic> json) {
return BusType(
id: json['id'] as int,
name: json['name'] as String,
seatCapacity: json['seatCapacity'] as int,
licensePlate: json['licensePlate'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'seatCapacity': seatCapacity,
'licensePlate': licensePlate,
};
}
}
/// Route model for daily schedule
class Route {
final int id;
final BusLine busLine;
final BusType busType;
final DateTime startTime;
final DateTime? endTime;
final String status; // scheduled, in_progress, completed, cancelled
Route({
required this.id,
required this.busLine,
required this.busType,
required this.startTime,
this.endTime,
required this.status,
});
factory Route.fromJson(Map<String, dynamic> json) {
return Route(
id: json['id'] as int,
busLine: BusLine.fromJson(json['busLine'] as Map<String, dynamic>),
busType: BusType.fromJson(json['busType'] as Map<String, dynamic>),
startTime: DateTime.parse(json['startTime'] as String),
endTime:
json['endTime'] != null ? DateTime.parse(json['endTime'] as String) : null,
status: json['status'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'busLine': busLine.toJson(),
'busType': busType.toJson(),
'startTime': startTime.toIso8601String(),
'endTime': endTime?.toIso8601String(),
'status': status,
};
}
}
/// Daily schedule model
class DailySchedule {
final List<Route> routes;
final DateTime date;
DailySchedule({
required this.routes,
required this.date,
});
factory DailySchedule.fromJson(Map<String, dynamic> json) {
return DailySchedule(
routes: (json['routes'] as List)
.map((r) => Route.fromJson(r as Map<String, dynamic>))
.toList(),
date: DateTime.parse(json['date'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'routes': routes.map((r) => r.toJson()).toList(),
'date': date.toIso8601String(),
};
}
}
/// Stop model for the new schedule system.
class ScheduleStop {
final int id;
final int order;
final String name;
final double latitude;
final double longitude;
final int estimatedArrivalMinutes;
ScheduleStop({
required this.id,
required this.order,
required this.name,
required this.latitude,
required this.longitude,
required this.estimatedArrivalMinutes,
});
factory ScheduleStop.fromJson(Map<String, dynamic> json) {
return ScheduleStop(
id: (json['id'] as num).toInt(),
order: (json['order'] as num).toInt(),
name: (json['name'] ?? json['stopName'] ?? '') as String,
latitude: (json['latitude'] as num).toDouble(),
longitude: (json['longitude'] as num).toDouble(),
estimatedArrivalMinutes: (json['estimatedArrivalMinutes'] as num?)?.toInt() ?? 0,
);
}
}
/// Schedule model for the new public schedule API.
class ServiceSchedule {
final int id;
final int busLineId;
final int busId;
final DateTime startTime;
final DateTime endTime;
final String status;
final int lineNumber;
final String busName;
final List<int> weekdays;
ServiceSchedule({
required this.id,
required this.busLineId,
required this.busId,
required this.startTime,
required this.endTime,
required this.status,
required this.lineNumber,
required this.busName,
required this.weekdays,
});
factory ServiceSchedule.fromJson(Map<String, dynamic> json) {
final rawWeekdays = json['weekdays'];
final weekdays = rawWeekdays is List
? rawWeekdays.map((day) => (day as num).toInt()).toList()
: rawWeekdays is String
? (jsonDecode(rawWeekdays) as List).map((day) => (day as num).toInt()).toList()
: <int>[];
return ServiceSchedule(
id: (json['id'] as num).toInt(),
busLineId: (json['busLineId'] as num).toInt(),
busId: (json['busId'] as num).toInt(),
startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String),
status: (json['status'] ?? 'planned') as String,
lineNumber: (json['lineNumber'] as num).toInt(),
busName: (json['busName'] ?? '') as String,
weekdays: weekdays,
);
}
bool get isRecurring => weekdays.isNotEmpty;
}
-85
View File
@@ -1,85 +0,0 @@
/// User model
class User {
final int id;
final int userCode;
final String? job;
User({
required this.id,
required this.userCode,
this.job,
});
factory User.fromJson(Map<String, dynamic> json) {
// userCode may be provided as 'userCode' or 'usercode' and may be int or string
final dynamic rawUserCode = json['userCode'] ?? json['usercode'] ?? json['user_code'];
int parsedUserCode;
if (rawUserCode is int) {
parsedUserCode = rawUserCode;
} else if (rawUserCode is String) {
parsedUserCode = int.tryParse(rawUserCode) ?? (throw FormatException('Invalid userCode'));
} else {
// If the backend doesn't return a userCode, treat as unknown (-1)
parsedUserCode = -1;
}
return User(
id: json['id'] as int,
userCode: parsedUserCode,
job: json['job'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userCode': userCode,
'job': job,
};
}
}
/// Login response model
class AuthResponse {
final String token;
final User? user;
AuthResponse({
required this.token,
this.user,
});
factory AuthResponse.fromJson(Map<String, dynamic> json) {
final dynamic rawToken = json['token'] ?? json['Token'] ?? json['authToken'];
final token = rawToken?.toString();
if (token == null || token.isEmpty) throw FormatException('Missing token in auth response');
// Determine if a user object exists and appears valid
Map<String, dynamic>? userMap;
if (json.containsKey('user') && json['user'] is Map<String, dynamic>) {
userMap = json['user'] as Map<String, dynamic>;
} else {
// maybe user fields are top-level (id, userCode, job)
final hasUserFields = json.containsKey('id') || json.containsKey('userCode') || json.containsKey('usercode') || json.containsKey('user_code');
if (hasUserFields) {
// treat whole json as user map
userMap = json.cast<String, dynamic>();
}
}
User? parsedUser;
if (userMap != null) {
try {
parsedUser = User.fromJson(userMap);
} catch (e) {
// If parsing user fails, ignore user but keep token
parsedUser = null;
}
}
return AuthResponse(
token: token,
user: parsedUser,
);
}
}
View File
@@ -1,371 +0,0 @@
import 'package:flutter/material.dart';
import '../models/schedule.dart' as schedule;
import '../services/schedule_service.dart';
import '../services/auth_service.dart';
import '../services/exceptions.dart';
import '../widgets/route_card.dart';
class DailyScheduleScreen extends StatefulWidget {
const DailyScheduleScreen({super.key});
@override
State<DailyScheduleScreen> createState() => _DailyScheduleScreenState();
}
class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<schedule.DailySchedule> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_scheduleFuture = _scheduleService.getTodaySchedule();
}
Future<void> _handleLogout() async {
await _authService.logout();
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
Future<void> _refreshSchedule() async {
setState(() {
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
});
}
Future<void> _selectDate() async {
final colorScheme = Theme.of(context).colorScheme;
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime.now().subtract(const Duration(days: 7)),
lastDate: DateTime.now().add(const Duration(days: 30)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
dialogBackgroundColor: colorScheme.surface,
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
});
_refreshSchedule();
}
}
// Group routes by hour for better visual organization
Map<int, List<schedule.Route>> _groupByHour(List<schedule.Route> routes) {
final grouped = <int, List<schedule.Route>>{};
for (final route in routes) {
final hour = route.startTime.hour;
grouped.putIfAbsent(hour, () => []).add(route);
}
return grouped;
}
void _moveDay(int deltaDays) {
setState(() {
_selectedDate = _selectedDate.add(Duration(days: deltaDays));
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
});
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final dateStr =
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
final today = DateTime.now();
final isToday = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day) ==
DateTime(today.year, today.month, today.day);
return Scaffold(
appBar: AppBar(
title: const Text('Today on the Road'),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Logout',
onPressed: _handleLogout,
),
],
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark
? const [Color(0xFF0B1220), Color(0xFF111827)]
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.16),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isToday ? 'Vandaag' : 'Geplande datum',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white.withOpacity(0.78),
),
),
const SizedBox(height: 6),
Text(
dateStr,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Routes, stops en status in één overzicht',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.84),
),
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: () => _moveDay(-1),
icon: const Icon(Icons.chevron_left, size: 18),
label: const Text('Vorige dag'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today, size: 18),
label: const Text('Kies datum'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: () => _moveDay(1),
icon: const Icon(Icons.chevron_right, size: 18),
label: const Text('Volgende dag'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
],
),
),
],
),
),
Expanded(
child: FutureBuilder<schedule.DailySchedule>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
// Auto-logout on session expiry
if (snapshot.error is UnauthorizedException) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLogout();
});
}
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
snapshot.error is UnauthorizedException
? 'Session Expired'
: 'Failed to load schedule',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
snapshot.error is UnauthorizedException
? 'Redirecting to login...'
: snapshot.error.toString(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
if (snapshot.error is! UnauthorizedException) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _refreshSchedule,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
],
),
),
);
}
if (!snapshot.hasData || snapshot.data!.routes.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_today,
size: 64,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
'Geen actieve ritten',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Je hebt geen geplande ritten voor deze dag',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
final routes = [...snapshot.data!.routes]
..sort((a, b) => a.startTime.compareTo(b.startTime));
final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList();
final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList();
// Merge and group by hour
final allActiveRoutes = [...inProgressRoutes, ...scheduledRoutes]
..sort((a, b) => a.startTime.compareTo(b.startTime));
final hourGroups = _groupByHour(allActiveRoutes);
final sortedHours = hourGroups.keys.toList()..sort();
return RefreshIndicator(
onRefresh: _refreshSchedule,
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
if (allActiveRoutes.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(
'Geen actieve ritten voor deze dag',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
...sortedHours.map((hour) {
final routesForHour = hourGroups[hour] ?? [];
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8),
child: Row(
children: [
Text(
'${hour.toString().padLeft(2, '0')}:00 uur',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant,
),
),
],
),
),
...routesForHour.map((route) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: RouteCard(route: route, onTap: () {}),
)),
const SizedBox(height: 4),
],
);
}),
const SizedBox(height: 8),
],
),
);
},
),
),
],
),
),
);
}
}
-314
View File
@@ -1,314 +0,0 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../services/error_log_service.dart';
import '../widgets/custom_input_field.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _userCodeController = TextEditingController();
final _passwordController = TextEditingController();
final _authService = AuthService();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_userCodeController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final input = _userCodeController.text.trim();
final parsedUserCode = int.tryParse(input);
if (parsedUserCode == null) {
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin',
error: 'User code must be a number',
);
if (mounted) {
setState(() {
_errorMessage = 'User code must be a number';
});
}
return;
}
await _authService.login(
userCode: parsedUserCode,
password: _passwordController.text,
);
if (mounted) {
// Navigate to daily schedule screen
Navigator.of(context).pushReplacementNamed('/schedule');
}
} catch (e) {
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin.catch',
error: e.toString(),
);
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
String? _validateUserCode(String? value) {
final v = value?.trim();
if (v?.isEmpty ?? true) {
return 'User code is required';
}
if (int.tryParse(v!) == null) {
return 'User code must be a number';
}
return null;
}
String? _validatePassword(String? value) {
if (value?.isEmpty ?? true) {
return 'Password is required';
}
if ((value?.length ?? 0) < 3) {
return 'Password must be at least 3 characters';
}
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark
? const [Color(0xFF0B1220), Color(0xFF111827), Color(0xFF1D4ED8)]
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0), Color(0xFFBAE6FD)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.18),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.16),
shape: BoxShape.circle,
),
child: const Icon(
Icons.directions_bus,
color: Colors.white,
size: 30,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bus Terminal',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Driver access dashboard',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.82),
),
),
],
),
),
],
),
),
const SizedBox(height: 18),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.92),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outline.withOpacity(0.14)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Driver Login',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
'Sign in to view your route, stops and live navigation.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 28),
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.error.withOpacity(0.12),
border: Border.all(color: colorScheme.error.withOpacity(0.35)),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
),
),
const SizedBox(height: 20),
],
CustomInputField(
label: 'User Code',
hint: 'Enter your user code',
controller: _userCodeController,
validator: _validateUserCode,
),
const SizedBox(height: 20),
CustomInputField(
label: 'Password',
hint: 'Enter your password',
controller: _passwordController,
obscureText: true,
validator: _validatePassword,
),
const SizedBox(height: 28),
FilledButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
),
)
: const Text('Login'),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_statusPill(Icons.shield_outlined, 'Secure access'),
const SizedBox(width: 10),
_statusPill(Icons.route_outlined, 'Live route data'),
],
),
],
),
),
],
),
),
),
),
),
),
);
}
Widget _statusPill(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.72),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
);
}
}
@@ -1,913 +0,0 @@
// ignore_for_file: deprecated_member_use
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../models/navigation.dart';
import '../models/schedule.dart' as schedule;
import '../services/navigation_service.dart';
import '../services/schedule_service.dart';
class NavigationScreen extends StatefulWidget {
final schedule.Route route;
final List<NavigationPoint>? initialStops;
const NavigationScreen({
super.key,
required this.route,
this.initialStops,
});
@override
State<NavigationScreen> createState() => _NavigationScreenState();
}
class _NavigationScreenState extends State<NavigationScreen> {
final _navigationService = NavigationService();
final _scheduleService = ScheduleService();
late MapController _mapController;
NavigationRoute? _currentRoute;
NavigationPoint? _currentLocation;
bool _isLoading = false;
String? _errorMessage;
List<NavigationRoute>? _alternativeRoutes;
List<NavigationPoint>? _stops;
String? _stopsFetchError;
int _currentStopIndex = 0;
final Set<int> _completedStops = {};
@override
void initState() {
super.initState();
_mapController = MapController();
_initializeNavigation();
}
Future<void> _initializeNavigation() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Get current location
final currentLoc = await _navigationService.getCurrentLocation();
setState(() => _currentLocation = currentLoc);
// Use provided initial stops when available, otherwise fetch from API
if (widget.initialStops != null) {
_stops = widget.initialStops;
_stopsFetchError = null;
} else {
try {
_stops = await _scheduleService.getStops(widget.route.busLine.id);
_stopsFetchError = null;
} catch (e) {
_stops = [];
_stopsFetchError = e.toString().replaceAll('Exception: ', '');
}
}
// Defensive: filter out invalid stops (missing/NaN coordinates) and deduplicate
if (_stops != null) {
_stops = _stops!
.where((s) => s.latitude.isFinite && s.longitude.isFinite)
.toList();
// Remove exact duplicates by lat/lon
final seen = <String>{};
_stops = _stops!.where((s) {
final key = '${s.latitude.toStringAsFixed(6)}|${s.longitude.toStringAsFixed(6)}';
if (seen.contains(key)) return false;
seen.add(key);
return true;
}).toList();
}
// Determine start and end points: prefer stop locations if available.
// If no stops are returned from the API, geocode the stop names.
late NavigationPoint startPoint;
late NavigationPoint endPoint;
if (_stops != null && _stops!.length >= 2) {
final hasScheduleStops = widget.initialStops != null;
if (!hasScheduleStops) {
final optimized = await _navigationService.getOptimizedRouteThroughStops(stops: _stops!);
_stops = optimized.orderedStops;
if (mounted) {
setState(() {
_currentRoute = optimized.route;
_alternativeRoutes = null;
_isLoading = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(optimized.route));
}
return;
}
final navRoute = await _navigationService.getRouteThroughStops(stops: _stops!);
startPoint = _stops!.first;
endPoint = _stops!.last;
if (mounted) {
setState(() {
_currentRoute = navRoute;
_alternativeRoutes = null;
_isLoading = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
}
return;
} else if (_stops != null && _stops!.length == 1) {
// With exactly one linked stop, force that stop into the route as start
// so the marker is guaranteed to be in-view.
startPoint = _stops!.first;
try {
endPoint = await _navigationService.geocodePlace(widget.route.busLine.endStop);
} catch (_) {
endPoint = NavigationPoint(
latitude: startPoint.latitude + 0.01,
longitude: startPoint.longitude + 0.01,
name: widget.route.busLine.endStop,
);
}
} else {
// Try geocoding names; fall back to conservative defaults on failure
try {
startPoint = await _navigationService.geocodePlace(widget.route.busLine.startStop);
} catch (_) {
startPoint = NavigationPoint(
latitude: 52.5200,
longitude: 13.4050,
name: widget.route.busLine.startStop,
);
}
try {
endPoint = await _navigationService.geocodePlace(widget.route.busLine.endStop);
} catch (_) {
endPoint = NavigationPoint(
latitude: 52.5300,
longitude: 13.4150,
name: widget.route.busLine.endStop,
);
}
}
// Get route without waypoints
final navRoute = await _navigationService.getRoute(start: startPoint, end: endPoint);
// Get alternative routes (but cap to a small number)
final alternatives = await _navigationService.getMultipleRoutes(start: startPoint, end: endPoint);
if (mounted) {
setState(() {
_currentRoute = navRoute;
_alternativeRoutes = alternatives.take(2).toList();
_isLoading = false;
});
// Fit map to route bounds after first frame so MapController is attached
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
_isLoading = false;
});
}
}
}
void _fitMapToRoute(NavigationRoute route) {
// Build list of points to include in bounds, filter invalids
final allPoints = <LatLng>[];
allPoints.addAll(route.getAllPoints().where((p) => p.latitude.isFinite && p.longitude.isFinite).map((point) => LatLng(point.latitude, point.longitude)));
allPoints.addAll((_stops ?? []).where((s) => s.latitude.isFinite && s.longitude.isFinite).map((stop) => LatLng(stop.latitude, stop.longitude)));
if (_currentLocation != null && _currentLocation!.latitude.isFinite && _currentLocation!.longitude.isFinite) {
allPoints.add(LatLng(_currentLocation!.latitude, _currentLocation!.longitude));
}
if (allPoints.isEmpty) return;
double minLat = allPoints.first.latitude;
double maxLat = allPoints.first.latitude;
double minLon = allPoints.first.longitude;
double maxLon = allPoints.first.longitude;
for (final point in allPoints) {
minLat = min(minLat, point.latitude);
maxLat = max(maxLat, point.latitude);
minLon = min(minLon, point.longitude);
maxLon = max(maxLon, point.longitude);
}
// Clamp bounds to a reasonable maximum span to avoid huge map tiles requests
const double maxSpanDegrees = 2.0; // ~222km lat span per degree ~111km; 2 degrees ~222km
final latSpan = maxLat - minLat;
final lonSpan = maxLon - minLon;
double centerLat = (minLat + maxLat) / 2.0;
double centerLon = (minLon + maxLon) / 2.0;
double halfLat = latSpan / 2.0;
double halfLon = lonSpan / 2.0;
if (latSpan > maxSpanDegrees) halfLat = maxSpanDegrees / 2.0;
if (lonSpan > maxSpanDegrees) halfLon = maxSpanDegrees / 2.0;
final clampedMinLat = centerLat - halfLat;
final clampedMaxLat = centerLat + halfLat;
final clampedMinLon = centerLon - halfLon;
final clampedMaxLon = centerLon + halfLon;
final bounds = LatLngBounds(
LatLng(clampedMinLat, clampedMinLon),
LatLng(clampedMaxLat, clampedMaxLon),
);
_mapController.fitBounds(
bounds,
options: const FitBoundsOptions(padding: EdgeInsets.all(100)),
);
}
int _nextUncompletedStopIndex([int startIndex = 0]) {
if (_stops == null || _stops!.isEmpty) return 0;
for (var index = startIndex; index < _stops!.length; index++) {
if (!_completedStops.contains(index)) {
return index;
}
}
return _stops!.length - 1;
}
Future<void> _refreshRouteFromIndex(int index) async {
if (_stops == null || _stops!.isEmpty) return;
if (index < 0 || index >= _stops!.length) return;
setState(() {
_isLoading = true;
_errorMessage = null;
_currentStopIndex = index;
});
try {
final remainingStops = _stops!.sublist(index);
final navRoute = remainingStops.length >= 2
? await _navigationService.getRouteThroughStops(stops: remainingStops)
: _currentRoute;
if (mounted) {
setState(() {
if (navRoute != null) {
_currentRoute = navRoute;
}
_alternativeRoutes = null;
_isLoading = false;
});
if (navRoute != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
_isLoading = false;
});
}
}
}
Future<void> _focusStop(int index) async {
if (_stops == null || _stops!.isEmpty) return;
if (index < 0 || index >= _stops!.length) return;
await _refreshRouteFromIndex(index);
}
Future<void> _markStopCompleted(int index) async {
if (_stops == null || _stops!.isEmpty) return;
if (index < 0 || index >= _stops!.length) return;
// When this screen is opened from schedule overview, route.id maps to scheduleId.
if (widget.initialStops != null) {
try {
await _scheduleService.markScheduleStopPassed(
scheduleId: widget.route.id,
stopOrder: index + 1,
actualPassedAt: DateTime.now(),
);
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Stop update mislukt: ${e.toString().replaceAll('Exception: ', '')}';
});
}
}
}
setState(() {
_completedStops.add(index);
});
final nextIndex = _nextUncompletedStopIndex(index + 1);
await _refreshRouteFromIndex(nextIndex);
}
double get _routeProgress {
if (_stops == null || _stops!.isEmpty) return 0;
if (_stops!.length == 1) return 1;
return _currentStopIndex / (_stops!.length - 1);
}
Color get _routeColor => Color.lerp(Colors.blue, Colors.orange, _routeProgress) ?? Colors.blue;
Color _stopColor(int index) {
if (_completedStops.contains(index) || index < _currentStopIndex) return Colors.green;
if (index == _currentStopIndex) return Colors.orange;
return Colors.blueGrey;
}
IconData _stopIcon(int index) {
if (_completedStops.contains(index) || index < _currentStopIndex) return Icons.check_circle;
if (index == _currentStopIndex) return Icons.radio_button_checked;
return Icons.location_on;
}
bool _isStopCompleted(int index) => _completedStops.contains(index) || index < _currentStopIndex;
bool _hasStopNearPoint(NavigationPoint point, {double threshold = 0.00005}) {
if (_stops == null || _stops!.isEmpty) return false;
for (final stop in _stops!) {
final latDiff = (stop.latitude - point.latitude).abs();
final lonDiff = (stop.longitude - point.longitude).abs();
if (latDiff <= threshold && lonDiff <= threshold) {
return true;
}
}
return false;
}
NavigationPoint? get _currentStopPoint {
if (_stops == null || _stops!.isEmpty) return null;
return _stops![_currentStopIndex.clamp(0, _stops!.length - 1)];
}
NavigationPoint? get _nextStopPoint {
if (_stops == null || _stops!.isEmpty) return null;
final nextIndex = _nextUncompletedStopIndex(_currentStopIndex + 1);
return _stops![nextIndex.clamp(0, _stops!.length - 1)];
}
double get _completionRatio {
if (_stops == null || _stops!.isEmpty) return 0;
return _completedStops.length / _stops!.length;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Route ${widget.route.busLine.lineNumber} Navigation'),
elevation: 0,
),
body: Row(
children: [
// Map area (left pane)
Expanded(
flex: 6,
child: _currentRoute != null
? FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: LatLng(
_currentRoute!.start.latitude,
_currentRoute!.start.longitude,
),
initialZoom: 13.0,
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.bus_terminal',
),
MarkerLayer(
markers: [
...(_stops ?? []).asMap().entries.map((entry) {
final index = entry.key;
final stop = entry.value;
final color = _stopColor(index);
return Marker(
point: LatLng(stop.latitude, stop.longitude),
width: 46,
height: 46,
child: Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 3,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.22),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
Icon(
_stopIcon(index),
color: Colors.white,
size: 20,
),
Positioned(
bottom: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.35),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
);
}),
// Start marker
if (!_hasStopNearPoint(_currentRoute!.start))
Marker(
point: LatLng(
_currentRoute!.start.latitude,
_currentRoute!.start.longitude,
),
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: const Icon(
Icons.location_on,
color: Colors.white,
size: 20,
),
),
),
// End marker
if (!_hasStopNearPoint(_currentRoute!.end))
Marker(
point: LatLng(
_currentRoute!.end.latitude,
_currentRoute!.end.longitude,
),
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: const Icon(
Icons.location_on,
color: Colors.white,
size: 20,
),
),
),
// Current location marker
if (_currentLocation != null)
Marker(
point: LatLng(
_currentLocation!.latitude,
_currentLocation!.longitude,
),
width: 30,
height: 30,
child: Container(
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: const Icon(
Icons.directions_bus,
color: Colors.white,
size: 16,
),
),
),
],
),
PolylineLayer(
polylines: [
// Main route
Polyline(
points: _currentRoute!
.getAllPoints()
.map((p) => LatLng(p.latitude, p.longitude))
.toList(),
color: _routeColor,
strokeWidth: 5,
),
// Alternative routes (lighter)
if (_alternativeRoutes != null)
...List.generate(
_alternativeRoutes!.length,
(i) => Polyline(
points: _alternativeRoutes![i]
.getAllPoints()
.map((p) => LatLng(p.latitude, p.longitude))
.toList(),
color: Colors.grey.withOpacity(0.5),
strokeWidth: 2,
),
),
],
),
],
)
: (_isLoading
? const Center(child: CircularProgressIndicator())
: Center(
child: _errorMessage != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Failed to load route',
style:
Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
)
: const Text('No route'))),
),
// Vertical divider
Container(width: 1, color: Colors.grey[200]),
// Stops / Info panel (right pane)
Expanded(
flex: 4,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(16),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(-2, 0),
),
],
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.18),
shape: BoxShape.circle,
),
child: const Icon(
Icons.route,
color: Colors.white,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Route ${widget.route.busLine.lineNumber}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'${widget.route.busLine.startStop} → ${widget.route.busLine.endStop}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white.withOpacity(0.85),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 10,
value: _completionRatio,
backgroundColor: Colors.white.withOpacity(0.12),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.amberAccent),
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${(_completionRatio * 100).round()}% afgerond',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
Text(
'${_completedStops.length}/${_stops?.length ?? 0} stops',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white.withOpacity(0.85),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSummaryPill(
'Huidig',
_currentStopPoint?.name ?? 'Geen stop',
Icons.radio_button_checked,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildSummaryPill(
'Volgend',
_nextStopPoint?.name ?? 'Einde route',
Icons.skip_next,
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF0F172A),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: _stops == null || _stops!.isEmpty || _isStopCompleted(_currentStopIndex)
? null
: () => _markStopCompleted(_currentStopIndex),
icon: const Icon(Icons.check_circle_outline),
label: const Text('Markeer huidige stop afgerond'),
),
),
],
),
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_legendDot(Colors.green, 'Afgerond'),
const SizedBox(width: 12),
_legendDot(Colors.orange, 'Actief'),
const SizedBox(width: 12),
_legendDot(Colors.blueGrey, 'Te doen'),
],
),
),
if (_stopsFetchError != null) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.withOpacity(0.25)),
),
child: Text(
'Stops konden niet geladen worden voor buslijn ${widget.route.busLine.id}: $_stopsFetchError',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
const SizedBox(height: 12),
Text(
'Stops',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Expanded(
child: _stops == null || _stops!.isEmpty
? Center(
child: Text(
'No stops available for this line',
style: Theme.of(context).textTheme.bodySmall,
),
)
: ListView.separated(
itemCount: _stops!.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final s = _stops![index];
final color = _stopColor(index);
final completed = _isStopCompleted(index);
final isCurrent = index == _currentStopIndex;
return ListTile(
leading: CircleAvatar(
radius: 14,
backgroundColor: color,
child: Icon(
_stopIcon(index),
color: Colors.white,
size: 16,
),
),
title: Text(s.name ?? 'Stop ${index + 1}'),
subtitle: Text('${s.latitude.toStringAsFixed(5)}, ${s.longitude.toStringAsFixed(5)}'),
trailing: completed
? const Chip(
label: Text('Afgerond'),
visualDensity: VisualDensity.compact,
)
: TextButton.icon(
onPressed: () => _markStopCompleted(index),
icon: const Icon(Icons.check, size: 18),
label: Text(isCurrent ? 'Afronden' : 'Klaarzetten'),
),
onTap: () => _focusStop(index),
);
},
),
),
],
),
),
),
),
],
),
);
}
Widget _legendDot(Color color, String label) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildSummaryPill(String title, String value, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.12),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withOpacity(0.16)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.white, size: 18),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white.withOpacity(0.75),
letterSpacing: 0.2,
),
),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
@@ -1,332 +0,0 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import '../models/schedule.dart' as sched;
import '../services/schedule_service.dart';
class RecurringSchedulesScreen extends StatefulWidget {
const RecurringSchedulesScreen({super.key});
@override
State<RecurringSchedulesScreen> createState() => _RecurringSchedulesScreenState();
}
class _RecurringSchedulesScreenState extends State<RecurringSchedulesScreen> {
final _scheduleService = ScheduleService();
late Future<List<sched.ServiceSchedule>> _schedulesFuture;
String _selectedSortBy = 'line'; // 'line', 'time', 'weekday'
final Set<int> _expandedLineIds = {};
@override
void initState() {
super.initState();
_loadRecurringSchedules();
}
void _loadRecurringSchedules() {
_schedulesFuture = _scheduleService.getRecurringSchedules();
}
String _formatWeekdays(List<int> weekdays) {
const dayLabels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
if (weekdays.isEmpty) return 'Eenmalig';
if (weekdays.length == 7) return 'Dagelijks';
final sorted = weekdays.toSet().toList()..sort((a, b) => a.compareTo(b));
return sorted.map((d) => dayLabels[d]).join(', ');
}
String _formatTime(DateTime dt) {
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
List<sched.ServiceSchedule> _sortSchedules(List<sched.ServiceSchedule> schedules) {
final sorted = List<sched.ServiceSchedule>.from(schedules);
switch (_selectedSortBy) {
case 'line':
sorted.sort((a, b) => a.lineNumber.compareTo(b.lineNumber));
break;
case 'time':
sorted.sort((a, b) => a.startTime.compareTo(b.startTime));
break;
case 'weekday':
sorted.sort((a, b) {
final aFirst = a.weekdays.isEmpty ? 7 : a.weekdays.first;
final bFirst = b.weekdays.isEmpty ? 7 : b.weekdays.first;
return aFirst.compareTo(bFirst);
});
break;
}
return sorted;
}
Map<int, List<sched.ServiceSchedule>> _groupByLine(List<sched.ServiceSchedule> schedules) {
final grouped = <int, List<sched.ServiceSchedule>>{};
for (final schedule in schedules) {
grouped.putIfAbsent(schedule.lineNumber, () => []).add(schedule);
}
return grouped;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Vaste Diensten'),
centerTitle: true,
actions: [
PopupMenuButton<String>(
onSelected: (value) {
setState(() => _selectedSortBy = value);
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'line',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.sort_by_alpha, color: _selectedSortBy == 'line' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Lijn'),
],
),
),
PopupMenuItem(
value: 'time',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, color: _selectedSortBy == 'time' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Tijd'),
],
),
),
PopupMenuItem(
value: 'weekday',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, color: _selectedSortBy == 'weekday' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Weekdag'),
],
),
),
],
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Icon(Icons.filter_list),
),
),
],
),
body: FutureBuilder<List<sched.ServiceSchedule>>(
future: _schedulesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'Fout bij laden: ${snapshot.error}',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() => _loadRecurringSchedules());
},
child: const Text('Opnieuw proberen'),
),
],
),
);
}
final allSchedules = snapshot.data ?? [];
// Filter to show only truly recurring schedules (with weekdays > 0)
final schedules = allSchedules.where((s) => s.weekdays.isNotEmpty).toList();
if (schedules.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.event_note_outlined, size: 48, color: colorScheme.outline),
const SizedBox(height: 16),
Text(
'Geen herhaalde diensten gevonden',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Herhaalde diensten worden hier weergegeven',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
final sortedSchedules = _sortSchedules(schedules);
final groupedByLine = _groupByLine(sortedSchedules);
return ListView.builder(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
itemCount: groupedByLine.length,
itemBuilder: (context, lineIndex) {
final lineNumber = groupedByLine.keys.toList()[lineIndex];
final lineSchedules = groupedByLine[lineNumber] ?? [];
final isExpanded = _expandedLineIds.contains(lineNumber);
return Column(
children: [
Card(
child: InkWell(
onTap: () {
setState(() {
if (isExpanded) {
_expandedLineIds.remove(lineNumber);
} else {
_expandedLineIds.add(lineNumber);
}
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lijn $lineNumber',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${lineSchedules.length} diensten',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: colorScheme.primary,
),
],
),
),
),
),
if (isExpanded)
Padding(
padding: const EdgeInsets.only(top: 8),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: lineSchedules.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, scheduleIndex) {
final schedule = lineSchedules[scheduleIndex];
return _ScheduleCard(schedule: schedule, formatTime: _formatTime, formatWeekdays: _formatWeekdays);
},
),
),
const SizedBox(height: 8),
],
);
},
);
},
),
);
}
}
class _ScheduleCard extends StatelessWidget {
final sched.ServiceSchedule schedule;
final String Function(DateTime) formatTime;
final String Function(List<int>) formatWeekdays;
const _ScheduleCard({
required this.schedule,
required this.formatTime,
required this.formatWeekdays,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${formatTime(schedule.startTime)} - ${formatTime(schedule.endTime)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
schedule.busName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(
label: Text(
formatWeekdays(schedule.weekdays),
style: Theme.of(context).textTheme.labelSmall,
),
avatar: const Icon(Icons.calendar_today, size: 16),
visualDensity: VisualDensity.compact,
),
],
),
],
),
),
);
}
}
@@ -1,674 +0,0 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import '../models/schedule.dart' as sched;
import '../models/navigation.dart';
import '../services/auth_service.dart';
import '../services/schedule_service.dart';
import '../screens/navigation_screen.dart';
import '../screens/recurring_schedules_screen.dart';
class ScheduleOverviewScreen extends StatefulWidget {
const ScheduleOverviewScreen({super.key});
@override
State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState();
}
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with TickerProviderStateMixin {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<List<sched.ServiceSchedule>> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
late TabController _tabController;
@override
void initState() {
super.initState();
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _handleLogout() async {
await _authService.logout();
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
Future<void> _refreshSchedules() async {
setState(() {
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
});
}
Future<void> _selectDate() async {
final colorScheme = Theme.of(context).colorScheme;
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime.now().subtract(const Duration(days: 7)),
lastDate: DateTime.now().add(const Duration(days: 30)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
dialogBackgroundColor: colorScheme.surface,
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
});
_refreshSchedules();
}
}
Future<void> _showScheduleDetails(sched.ServiceSchedule svc) async {
try {
final stops = await _scheduleService.getScheduleStops(svc.id);
if (!mounted) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
minChildSize: 0.45,
maxChildSize: 0.95,
builder: (context, scrollController) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
Text(
'Lijn ${svc.lineNumber}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
svc.busName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${_formatTime(svc.startTime)} - ${_formatTime(svc.endTime)}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_InfoChip(label: svc.status),
_InfoChip(label: svc.isRecurring ? _weekdayLabel(svc.weekdays) : 'Eenmalig'),
],
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
try {
await _scheduleService.updateScheduleStatus(
svc.id,
'in_progress',
date: svc.startTime,
);
if (!mounted) return;
Navigator.of(sheetContext).pop();
final routeObj = sched.Route(
id: svc.id,
busLine: sched.BusLine(
id: svc.busLineId,
lineNumber: svc.lineNumber,
startStop: '',
endStop: '',
estimatedDuration: 0,
),
busType: sched.BusType(
id: svc.busId,
name: svc.busName,
seatCapacity: 0,
licensePlate: '',
),
startTime: svc.startTime,
endTime: svc.endTime,
status: 'in_progress',
);
final navStops = stops
.map((s) => NavigationPoint(
latitude: s.latitude,
longitude: s.longitude,
name: s.name,
))
.toList();
try {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops),
),
);
} catch (navErr) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Navigatiefout: ${navErr.toString()}')),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Status bijwerken mislukt: $e')),
);
}
},
icon: const Icon(Icons.navigation),
label: const Text('Start route'),
),
),
Text(
'Haltes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
if (stops.isEmpty)
Text(
'Geen haltes gekoppeld aan deze dienst.',
style: Theme.of(context).textTheme.bodyMedium,
)
else
...stops.map((stop) {
final departureTime = svc.startTime.add(
Duration(minutes: stop.estimatedArrivalMinutes),
);
return Card(
child: ListTile(
dense: true,
title: Text('${stop.order}. ${stop.name}'),
subtitle: Text('${stop.estimatedArrivalMinutes} min na vertrek'),
trailing: Text(_formatTime(departureTime)),
),
);
}),
],
);
},
);
},
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Kon dienstdetails niet laden: $e')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Diensten'),
centerTitle: true,
elevation: 0,
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.calendar_today), text: 'Dagschema'),
Tab(icon: Icon(Icons.repeat), text: 'Vaste diensten'),
Tab(icon: Icon(Icons.timeline), text: 'Voortgang'),
],
),
actions: [
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Logout',
onPressed: _handleLogout,
),
],
),
body: TabBarView(
controller: _tabController,
children: [
_DailyScheduleTab(
scheduleService: _scheduleService,
selectedDate: _selectedDate,
onSelectDate: _selectDate,
onRefresh: _refreshSchedules,
onShowDetails: _showScheduleDetails,
scheduleFuture: _scheduleFuture,
),
const RecurringSchedulesScreen(),
_ProgressTab(
scheduleService: _scheduleService,
selectedDate: _selectedDate,
scheduleFuture: _scheduleFuture,
),
],
),
);
}
}
class _ProgressTab extends StatelessWidget {
final ScheduleService scheduleService;
final DateTime selectedDate;
final Future<List<sched.ServiceSchedule>> scheduleFuture;
const _ProgressTab({
required this.scheduleService,
required this.selectedDate,
required this.scheduleFuture,
});
@override
Widget build(BuildContext context) {
final dateStr = '${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}';
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Voortgang voor $dateStr', style: Theme.of(context).textTheme.titleLarge),
IconButton(
onPressed: () async {
// refresh handled by parent via reselecting date; show a simple feedback
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Ververs de datum in Dagschema-tab om andere datum te laden')));
},
icon: const Icon(Icons.info_outline),
),
],
),
),
Expanded(
child: FutureBuilder<List<sched.ServiceSchedule>>(
future: scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (snapshot.hasError) return Center(child: Text('Fout bij laden: ${snapshot.error}'));
final schedules = snapshot.data ?? [];
if (schedules.isEmpty) return Center(child: Text('Geen diensten op $dateStr'));
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: schedules.length,
itemBuilder: (context, index) {
final svc = schedules[index];
return Card(
child: ListTile(
title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'),
subtitle: Text(_formatTime(svc.startTime)),
trailing: TextButton(
onPressed: () async {
try {
final progress = await scheduleService.getScheduleProgress(svc.id, selectedDate);
if (!context.mounted) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final stops = (progress['stops'] as List<dynamic>?) ?? [];
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Voortgang — Lijn ${svc.lineNumber}', style: Theme.of(ctx).textTheme.headlineSmall),
const SizedBox(height: 8),
Text('Status: ${progress['schedule']?['status'] ?? 'onbekend'}'),
Text('Driver state: ${progress['driverState'] ?? 'onbekend'}'),
Text('Passed: ${progress['passedStopCount'] ?? 0} / ${progress['totalStopCount'] ?? 0}'),
const SizedBox(height: 12),
const Text('Haltes:'),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
shrinkWrap: true,
itemCount: stops.length,
itemBuilder: (c, i) {
final s = stops[i] as Map<String, dynamic>;
return ListTile(
dense: true,
title: Text('${s['stopOrder']}. ${s['stopName']}'),
subtitle: Text('Scheduled: ${s['scheduledPassedAt'] ?? '-'}\nActual: ${s['actualPassedAt'] ?? '-'}'),
);
},
),
),
],
),
);
},
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Kon voortgang niet laden: $e')));
}
},
child: const Text('Bekijk voortgang'),
),
),
);
},
);
},
),
),
],
);
}
}
class _DailyScheduleTab extends StatelessWidget {
final ScheduleService scheduleService;
final DateTime selectedDate;
final VoidCallback onSelectDate;
final VoidCallback onRefresh;
final Function(sched.ServiceSchedule) onShowDetails;
final Future<List<sched.ServiceSchedule>> scheduleFuture;
const _DailyScheduleTab({
required this.scheduleService,
required this.selectedDate,
required this.onSelectDate,
required this.onRefresh,
required this.onShowDetails,
required this.scheduleFuture,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final dateStr =
'${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}';
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark
? const [Color(0xFF0B1220), Color(0xFF111827)]
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.16),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Geplande diensten',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white.withOpacity(0.78),
),
),
const SizedBox(height: 6),
Text(
dateStr,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Automatisch berekende eindtijden, haltes en herhalende weekdagen',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.84),
),
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: onSelectDate,
icon: const Icon(Icons.calendar_today, size: 18),
label: const Text('Wijzig datum'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
),
],
),
),
Expanded(
child: FutureBuilder<List<sched.ServiceSchedule>>(
future: scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text('Fout bij laden: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: onRefresh,
child: const Text('Opnieuw proberen'),
),
],
),
);
}
final schedules = snapshot.data ?? [];
if (schedules.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.event_available_outlined, size: 48, color: colorScheme.outline),
const SizedBox(height: 16),
Text('Geen diensten op $dateStr', style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => onRefresh(),
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return _ScheduleSummaryCard(
schedule: schedule,
onTap: () => onShowDetails(schedule),
);
},
),
);
},
),
),
],
),
);
}
}
class _ScheduleSummaryCard extends StatelessWidget {
final sched.ServiceSchedule schedule;
final VoidCallback onTap;
const _ScheduleSummaryCard({
required this.schedule,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
elevation: 0,
color: colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [colorScheme.primary, colorScheme.tertiary],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Line ${schedule.lineNumber}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
// Only show status for one-time schedules; recurring schedules have no fixed status
if (!schedule.isRecurring)
_InfoChip(label: schedule.status),
],
),
const SizedBox(height: 16),
Text(
schedule.busName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Text(
schedule.isRecurring ? _weekdayLabel(schedule.weekdays) : 'Eenmalige dienst',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.list_alt, size: 18),
label: const Text('Haltes'),
),
),
],
),
),
),
);
}
}
class _InfoChip extends StatelessWidget {
final String label;
const _InfoChip({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
);
}
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
String _weekdayLabel(List<int> weekdays) {
const labels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
if (weekdays.isEmpty) {
return 'Eenmalige dienst';
}
if (weekdays.length == 7) {
return 'Dagelijks';
}
return weekdays.where((day) => day >= 0 && day < labels.length).map((day) => labels[day]).join(', ');
}
View File
@@ -1,29 +0,0 @@
import 'package:flutter/material.dart';
import 'auth_service.dart';
/// Global navigator and messenger keys so services can navigate/logout
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<ScaffoldMessengerState> messengerKey = GlobalKey<ScaffoldMessengerState>();
/// Handle expired authentication: clear stored token and navigate to login.
Future<void> handleAuthExpired({String? message}) async {
try {
await AuthService().logout();
} catch (_) {}
// Show a message if possible
try {
final messenger = messengerKey.currentState;
if (messenger != null && message != null) {
messenger.showSnackBar(SnackBar(content: Text(message)));
}
} catch (_) {}
// Navigate to login, removing all previous routes
try {
final nav = navigatorKey.currentState;
if (nav != null) {
nav.pushNamedAndRemoveUntil('/login', (r) => false);
}
} catch (_) {}
}
-95
View File
@@ -1,95 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
import 'error_log_service.dart';
/// Authentication Service
class AuthService {
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
static const String _tokenKey = 'auth_token';
late SharedPreferences _prefs;
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
Future<void> _initPrefs() async {
_prefs = await SharedPreferences.getInstance();
}
/// Login with userCode and password
Future<AuthResponse> login({
required int userCode,
required String password,
}) async {
await _initPrefs();
try {
final response = await http.post(
Uri.parse('$_apiBaseUrl/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'userCode': userCode,
'password': password,
}),
);
if (response.statusCode == 200) {
final body = response.body;
try {
final data = jsonDecode(body) as Map<String, dynamic>;
final authResponse = AuthResponse.fromJson(data);
// Store token
await _prefs.setString(_tokenKey, authResponse.token);
return authResponse;
} catch (e) {
await _reportError('AuthService.login.parse', e);
// Surface the response body to help debug backend field names / formats
throw Exception('Login parse error: $e -- response body: $body');
}
} else if (response.statusCode == 401) {
throw Exception('Invalid credentials');
} else {
throw Exception('Login failed: ${response.statusCode}');
}
} catch (e) {
await _reportError('AuthService.login', e);
throw Exception('Login error: $e');
}
}
/// Get stored token
Future<String?> getToken() async {
await _initPrefs();
return _prefs.getString(_tokenKey);
}
/// Check if user is logged in
Future<bool> isLoggedIn() async {
final token = await getToken();
return token != null;
}
/// Logout
Future<void> logout() async {
await _initPrefs();
await _prefs.remove(_tokenKey);
}
/// Get Authorization header
Future<Map<String, String>> getAuthHeaders() async {
final token = await getToken();
return {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
}
}
@@ -1,26 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class ErrorLogService {
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
static Future<void> report({
required String service,
required String partOfService,
required String error,
}) async {
try {
await http.post(
Uri.parse('$_apiBaseUrl/error_log'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'service': service,
'partOfService': partOfService,
'error': error,
}),
);
} catch (_) {
// Intentionally ignore logging failures.
}
}
}
-16
View File
@@ -1,16 +0,0 @@
// Custom exception for authentication errors
import 'app_navigator.dart';
class UnauthorizedException implements Exception {
final String message;
UnauthorizedException(this.message) {
// Trigger centralized logout/navigation asynchronously
try {
handleAuthExpired(message: message);
} catch (_) {}
}
@override
String toString() => message;
}

Some files were not shown because too many files have changed in this diff Show More