Compare commits
20 Commits
d7100e6f2a
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c35999a84 | |||
| 30f4ca6701 | |||
| 46fb9b9d6b | |||
| e169e37b5e | |||
| 7d55580d87 | |||
| 2ae1720383 | |||
| b766725b3b | |||
| 2707bff6fd | |||
| b36975b680 | |||
| 50634a3ed7 | |||
| 18c330854e | |||
| 0da6d07283 | |||
| 04bb0e3347 | |||
| a5924086f4 | |||
| 89e4f90fc3 | |||
| 39b0b07a77 | |||
| 48c710bfbf | |||
| 1c055d84e8 | |||
| ebcc485f51 | |||
| c09b3f3f66 |
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}` },
|
||||
});
|
||||
|
||||
|
||||
@@ -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)',
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)',
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)',
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -1,5 +0,0 @@
|
||||
analyzer:
|
||||
errors:
|
||||
deprecated_member_use: ignore
|
||||
use_build_context_synchronously: ignore
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
@@ -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 +0,0 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
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>
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||