Enhance operations configuration and UI; add new functions for schedule upload and duty filtering
This commit is contained in:
parent
427bcadc77
commit
7049e58049
8 changed files with 1435 additions and 314 deletions
|
|
@ -1,42 +1,850 @@
|
|||
import "dart:async";
|
||||
import "dart:math";
|
||||
|
||||
import "package:bus_running_record/models/channels/operations_channel.dart";
|
||||
import "package:bus_running_record/models/operations/stop.dart";
|
||||
import "package:bus_running_record/widgets/trip_diagram.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class OperationsChannelView extends StatelessWidget {
|
||||
|
||||
class OperationsChannelView extends StatefulWidget {
|
||||
const OperationsChannelView({required this.channel, super.key});
|
||||
|
||||
final OperationsChannel channel;
|
||||
|
||||
@override
|
||||
State<OperationsChannelView> createState() => _OperationsChannelViewState();
|
||||
}
|
||||
|
||||
class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||
|
||||
bool _loadingMeta = true;
|
||||
bool _loadingDetail = false;
|
||||
|
||||
String? _scheduleId;
|
||||
List<String> _duties = const [];
|
||||
List<({String tripNumber, String dutyNumber})> _tripMeta = const [];
|
||||
List<String> _stopNames = const [];
|
||||
Map<String, ({String alias, String source})> _aliases = const {};
|
||||
|
||||
String filterBy = "By Duty";
|
||||
Map<String, Object?> filterValue = {};
|
||||
|
||||
List<OperationsTripSnapshot>? _dutyTrips;
|
||||
OperationsTripSnapshot? _tripSnapshot;
|
||||
List<({OperationsTrip trip, String? time})>? _stopSchedule;
|
||||
bool _stopHasMore = false;
|
||||
bool _stopLoadingMore = false;
|
||||
int _stopOffset = 0;
|
||||
static const int _stopPageSize = 20;
|
||||
List<GlobalKey> _stopRowKeys = [];
|
||||
|
||||
final ScrollController _stopScrollController = ScrollController();
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stopScrollController.addListener(_onStopScroll);
|
||||
unawaited(_loadMeta());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onStopScroll() {
|
||||
if (!_stopHasMore || _stopLoadingMore || _loadingDetail) return;
|
||||
final pos = _stopScrollController.position;
|
||||
if (pos.pixels >= pos.maxScrollExtent - 100) {
|
||||
unawaited(_loadMoreStop());
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _invoke(String functionName, Map<String, dynamic> body) async {
|
||||
final client = widget.channel.client;
|
||||
client.functions.setAuth(
|
||||
client.auth.currentSession?.accessToken ?? "",
|
||||
);
|
||||
return client.functions.invoke(
|
||||
functionName,
|
||||
body: body,
|
||||
headers: {
|
||||
"Authorization": "Bearer ${client.auth.currentSession?.accessToken ?? ""}",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadMeta() async {
|
||||
setState(() {
|
||||
_loadingMeta = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await _invoke("operations-schedule-meta", {
|
||||
"channel_id": widget.channel.id,
|
||||
});
|
||||
|
||||
final data = response.data as Map?;
|
||||
if (data == null) throw StateError("Empty response from meta function.");
|
||||
|
||||
if (data["has_schedule"] != true) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scheduleId = null;
|
||||
_loadingMeta = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final scheduleId = (data["schedule_id"] ?? "").toString();
|
||||
final duties = List<String>.from(data["duties"] as List? ?? []);
|
||||
|
||||
final tripMetaRaw = data["trips"] as List? ?? [];
|
||||
final tripMeta = tripMetaRaw.map((t) {
|
||||
final m = t as Map;
|
||||
return (
|
||||
tripNumber: (m["trip_number"] ?? "").toString(),
|
||||
dutyNumber: (m["duty_number"] ?? "").toString(),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final stopNames = List<String>.from(data["stop_names"] as List? ?? []);
|
||||
|
||||
final aliasesRaw = data["aliases"] as List? ?? [];
|
||||
final aliases = <String, ({String alias, String source})>{};
|
||||
for (final row in aliasesRaw) {
|
||||
final m = row as Map;
|
||||
final raw = (m["raw_stop_name"] ?? "").toString().trim();
|
||||
final alias = (m["alias_stop_name"] ?? "").toString().trim();
|
||||
final source = (m["source"] ?? "user").toString();
|
||||
if (raw.isEmpty || alias.isEmpty) continue;
|
||||
aliases[Stop.normalizeName(raw)] = (alias: alias, source: source);
|
||||
}
|
||||
|
||||
final firstDuty = duties.isNotEmpty ? duties.first : null;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scheduleId = scheduleId;
|
||||
_duties = duties;
|
||||
_tripMeta = tripMeta;
|
||||
_stopNames = stopNames;
|
||||
_aliases = aliases;
|
||||
_loadingMeta = false;
|
||||
|
||||
if (firstDuty != null) {
|
||||
filterBy = "By Duty";
|
||||
filterValue["By Duty"] = firstDuty;
|
||||
}
|
||||
});
|
||||
|
||||
if (firstDuty != null) {
|
||||
unawaited(_loadDutyDetail(firstDuty));
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[OperationsChannelView] loadMeta failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingMeta = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDutyDetail(String dutyNumber) async {
|
||||
final scheduleId = _scheduleId;
|
||||
if (scheduleId == null) return;
|
||||
|
||||
setState(() {
|
||||
_loadingDetail = true;
|
||||
_dutyTrips = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await _invoke("operations-duty-detail", {
|
||||
"channel_id": widget.channel.id,
|
||||
"schedule_id": scheduleId,
|
||||
"duty_number": dutyNumber,
|
||||
});
|
||||
|
||||
final data = response.data as Map?;
|
||||
final tripsRaw = data?["trips"] as List? ?? [];
|
||||
|
||||
final snapshots = <OperationsTripSnapshot>[];
|
||||
for (final tripData in tripsRaw) {
|
||||
final m = tripData as Map;
|
||||
final trip = OperationsTrip(
|
||||
id: (m["id"] ?? "").toString(),
|
||||
scheduleId: scheduleId,
|
||||
tripNumber: (m["trip_number"] ?? "").toString(),
|
||||
dutyNumber: (m["duty_number"] ?? "").toString(),
|
||||
busWorkNumber: (m["bus_work_number"] ?? "").toString(),
|
||||
direction: (m["direction"] ?? "").toString(),
|
||||
sortOrder: (m["sort_order"] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
final stopsRaw = m["stops"] as List? ?? [];
|
||||
final stops = stopsRaw.map((s) {
|
||||
final sm = s as Map;
|
||||
final name = (sm["stop_name"] ?? "").toString().trim();
|
||||
final aliasEntry = _aliases[Stop.normalizeName(name)];
|
||||
return OperationsScheduledStop(
|
||||
id: (sm["id"] ?? "").toString(),
|
||||
tripId: trip.id,
|
||||
sequence: (sm["stop_sequence"] as num?)?.toInt() ?? 0,
|
||||
name: name,
|
||||
alias: aliasEntry?.alias,
|
||||
aliasSource: aliasEntry?.source,
|
||||
scheduledTime: (sm["scheduled_time"] ?? "").toString().trim().isEmpty
|
||||
? null
|
||||
: sm["scheduled_time"].toString(),
|
||||
);
|
||||
}).toList()
|
||||
..sort((a, b) => a.sequence.compareTo(b.sequence));
|
||||
|
||||
snapshots.add(OperationsTripSnapshot(
|
||||
trip: trip,
|
||||
stops: stops.map((s) => OperationsStop(scheduledStop: s)).toList(),
|
||||
scheduledStops: stops,
|
||||
));
|
||||
}
|
||||
|
||||
snapshots.sort((a, b) {
|
||||
final aTime = a.scheduledStops.firstOrNull?.scheduledTime ?? "";
|
||||
final bTime = b.scheduledStops.firstOrNull?.scheduledTime ?? "";
|
||||
return aTime.compareTo(bTime);
|
||||
});
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dutyTrips = snapshots;
|
||||
_loadingDetail = false;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[OperationsChannelView] loadDutyDetail failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingDetail = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTripDetail(String tripNumber) async {
|
||||
final scheduleId = _scheduleId;
|
||||
if (scheduleId == null) return;
|
||||
|
||||
setState(() {
|
||||
_loadingDetail = true;
|
||||
_tripSnapshot = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await _invoke("operations-trip-detail", {
|
||||
"channel_id": widget.channel.id,
|
||||
"schedule_id": scheduleId,
|
||||
"trip_number": tripNumber,
|
||||
});
|
||||
|
||||
final data = response.data as Map?;
|
||||
final tripData = data?["trip"] as Map?;
|
||||
if (tripData == null) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingDetail = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final trip = OperationsTrip(
|
||||
id: (tripData["id"] ?? "").toString(),
|
||||
scheduleId: scheduleId,
|
||||
tripNumber: (tripData["trip_number"] ?? "").toString(),
|
||||
dutyNumber: (tripData["duty_number"] ?? "").toString(),
|
||||
busWorkNumber: (tripData["bus_work_number"] ?? "").toString(),
|
||||
direction: (tripData["direction"] ?? "").toString(),
|
||||
sortOrder: (tripData["sort_order"] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
final stopsRaw = data?["stops"] as List? ?? [];
|
||||
final stops = stopsRaw.map((s) {
|
||||
final sm = s as Map;
|
||||
final name = (sm["stop_name"] ?? "").toString().trim();
|
||||
final aliasEntry = _aliases[Stop.normalizeName(name)];
|
||||
return OperationsScheduledStop(
|
||||
id: (sm["id"] ?? "").toString(),
|
||||
tripId: trip.id,
|
||||
sequence: (sm["stop_sequence"] as num?)?.toInt() ?? 0,
|
||||
name: name,
|
||||
alias: aliasEntry?.alias,
|
||||
aliasSource: aliasEntry?.source,
|
||||
scheduledTime: (sm["scheduled_time"] ?? "").toString().trim().isEmpty
|
||||
? null
|
||||
: sm["scheduled_time"].toString(),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tripSnapshot = OperationsTripSnapshot(
|
||||
trip: trip,
|
||||
stops: stops.map((s) => OperationsStop(scheduledStop: s)).toList(),
|
||||
scheduledStops: stops,
|
||||
);
|
||||
_loadingDetail = false;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[OperationsChannelView] loadTripDetail failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingDetail = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStopDetail(String stopName) async {
|
||||
final scheduleId = _scheduleId;
|
||||
if (scheduleId == null) return;
|
||||
|
||||
setState(() {
|
||||
_loadingDetail = true;
|
||||
_stopSchedule = null;
|
||||
_stopOffset = 0;
|
||||
_stopHasMore = false;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await _invoke("operations-stop-detail", {
|
||||
"channel_id": widget.channel.id,
|
||||
"schedule_id": scheduleId,
|
||||
"stop_name": stopName,
|
||||
"offset": 0,
|
||||
"limit": _stopPageSize,
|
||||
});
|
||||
|
||||
final data = response.data as Map?;
|
||||
final page = _parseStopSchedule(scheduleId, data?["schedule"] as List? ?? []);
|
||||
final hasMore = data?["has_more"] == true;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stopSchedule = page;
|
||||
_stopOffset = page.length;
|
||||
_stopHasMore = hasMore;
|
||||
_stopRowKeys = List.generate(page.length, (_) => GlobalKey());
|
||||
_loadingDetail = false;
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToClosestTime(page));
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[OperationsChannelView] loadStopDetail failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingDetail = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreStop() async {
|
||||
final scheduleId = _scheduleId;
|
||||
if (scheduleId == null || !_stopHasMore || _stopLoadingMore) return;
|
||||
final stopName = (filterValue["By Stop"] ?? "").toString().trim();
|
||||
if (stopName.isEmpty) return;
|
||||
|
||||
setState(() => _stopLoadingMore = true);
|
||||
|
||||
try {
|
||||
final response = await _invoke("operations-stop-detail", {
|
||||
"channel_id": widget.channel.id,
|
||||
"schedule_id": scheduleId,
|
||||
"stop_name": stopName,
|
||||
"offset": _stopOffset,
|
||||
"limit": _stopPageSize,
|
||||
});
|
||||
|
||||
final data = response.data as Map?;
|
||||
final page = _parseStopSchedule(scheduleId, data?["schedule"] as List? ?? []);
|
||||
final hasMore = data?["has_more"] == true;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stopSchedule = [...?_stopSchedule, ...page];
|
||||
_stopOffset += page.length;
|
||||
_stopHasMore = hasMore;
|
||||
_stopLoadingMore = false;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[OperationsChannelView] loadMoreStop failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!mounted) return;
|
||||
setState(() => _stopLoadingMore = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToClosestTime(List<({OperationsTrip trip, String? time})> schedule) {
|
||||
if (!_stopScrollController.hasClients) return;
|
||||
if (schedule.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final nowMins = now.hour * 60 + now.minute;
|
||||
|
||||
int bestIndex = 0;
|
||||
int bestDiff = 9999;
|
||||
|
||||
for (var i = 0; i < schedule.length; i++) {
|
||||
final time = schedule[i].time;
|
||||
if (time == null) continue;
|
||||
final parts = time.split(":");
|
||||
if (parts.length < 2) continue;
|
||||
final h = int.tryParse(parts[0]);
|
||||
final m = int.tryParse(parts[1]);
|
||||
if (h == null || m == null) continue;
|
||||
final diff = ((h * 60 + m) - nowMins).abs();
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// each row is roughly 61px tall (10 vertical padding top+bottom + ~41 content)
|
||||
const rowHeight = 61.0;
|
||||
final offset = (bestIndex * rowHeight).clamp(
|
||||
0.0,
|
||||
_stopScrollController.position.maxScrollExtent,
|
||||
);
|
||||
|
||||
_stopScrollController.animateTo(
|
||||
offset,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
List<({OperationsTrip trip, String? time})> _parseStopSchedule(
|
||||
String scheduleId,
|
||||
List scheduleRaw,
|
||||
) {
|
||||
return scheduleRaw.map((row) {
|
||||
final m = row as Map;
|
||||
final tripData = m["trip"] as Map;
|
||||
final trip = OperationsTrip(
|
||||
id: (tripData["id"] ?? "").toString(),
|
||||
scheduleId: scheduleId,
|
||||
tripNumber: (tripData["trip_number"] ?? "").toString(),
|
||||
dutyNumber: (tripData["duty_number"] ?? "").toString(),
|
||||
busWorkNumber: (tripData["bus_work_number"] ?? "").toString(),
|
||||
direction: (tripData["direction"] ?? "").toString(),
|
||||
sortOrder: 0,
|
||||
);
|
||||
final time = (m["scheduled_time"] ?? "").toString().trim();
|
||||
return (trip: trip, time: time.isEmpty ? null : time);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String? _aliasForStop(String rawName) =>
|
||||
_aliases[Stop.normalizeName(rawName)]?.alias;
|
||||
|
||||
Widget _stopLabel(String rawName) {
|
||||
final alias = _aliasForStop(rawName);
|
||||
if (alias == null || alias.isEmpty) return Text(rawName);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(alias).semiBold,
|
||||
if (alias != rawName)
|
||||
Text(
|
||||
rawName,
|
||||
style: const TextStyle(fontSize: 11, height: 1),
|
||||
).xSmall.muted.semiBold,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<TripDiagramEntry> _diagramEntries(List<OperationsScheduledStop> stops) {
|
||||
return stops.map((stop) => TripDiagramEntry(
|
||||
label: stop.displayName,
|
||||
labelIcon: stop.aliasSource == "ai" ? LucideIcons.sparkles : null,
|
||||
subtitle: stop.displayName == stop.name ? null : stop.name,
|
||||
time: stop.scheduledTime,
|
||||
)).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
bool isMobile = defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
final isScheduleUploaded = channel.id.isEmpty;
|
||||
if (_loadingMeta) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (!isScheduleUploaded) {
|
||||
if (_scheduleId == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"No schedule uploaded yet.",
|
||||
).h4,
|
||||
|
||||
Text("No schedule uploaded yet.").h4,
|
||||
Gap(8),
|
||||
|
||||
Button.secondary(
|
||||
child: Text(
|
||||
"Upload Schedule",
|
||||
onPressed: () => context.go(
|
||||
"/channel/${widget.channel.organizationId}/${widget.channel.id}/upload",
|
||||
),
|
||||
onPressed: () {
|
||||
context.go(
|
||||
"/channel/${channel.organizationId}/${channel.id}/upload",
|
||||
);
|
||||
},
|
||||
)
|
||||
child: const Text("Upload Schedule"),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.expand();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
|
||||
if (_loadingDetail) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// By Stop
|
||||
if (filterBy == "By Stop") {
|
||||
final selectedStop = (filterValue["By Stop"] ?? "").toString().trim();
|
||||
if (selectedStop.isEmpty) {
|
||||
return Center(child: Text("Select a stop to preview its schedule.").small.muted);
|
||||
}
|
||||
|
||||
final schedules = _stopSchedule ?? const [];
|
||||
return Column(
|
||||
children: [
|
||||
Gap(8),
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.pin),
|
||||
Gap(8),
|
||||
_stopLabel(selectedStop),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _stopScrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (schedules.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text("No scheduled times found for this stop.").small.muted,
|
||||
)
|
||||
else
|
||||
...schedules.map(
|
||||
(row) => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.07),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Gap(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text("${row.trip.dutyNumber} • ").extraBold,
|
||||
Text(
|
||||
"Trip ${row.trip.tripNumber} • Bus ${row.trip.busWorkNumber}",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).muted,
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(row.trip.direction).muted.textSmall,
|
||||
if (row.trip.direction.toLowerCase().contains("out")) ...[
|
||||
Gap(4),
|
||||
Icon(LucideIcons.arrowUpRight).iconSmall,
|
||||
] else if (row.trip.direction.toLowerCase().contains("in")) ...[
|
||||
Gap(4),
|
||||
Icon(LucideIcons.arrowDownLeft).iconSmall,
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(row.time ?? "--:--").extraBold.mono,
|
||||
Gap(2),
|
||||
Icon(LucideIcons.chevronRight),
|
||||
Gap(2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_stopLoadingMore)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// By Duty
|
||||
if (filterBy == "By Duty") {
|
||||
final selectedDuty = (filterValue["By Duty"] ?? "").toString();
|
||||
if (selectedDuty.isEmpty) {
|
||||
return Center(child: Text("Select a duty to preview.").small.muted);
|
||||
}
|
||||
|
||||
final snapshots = _dutyTrips ?? const [];
|
||||
if (snapshots.isEmpty) {
|
||||
return Center(child: Text("No trips found for this duty.").small.muted);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Gap(8),
|
||||
Divider(),
|
||||
Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.busFront),
|
||||
Gap(10),
|
||||
Text("Duty $selectedDuty".toUpperCase()).extraBold.textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < snapshots.length; i++) ...[
|
||||
|
||||
if (i != 0) ...[
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
],
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.bus),
|
||||
Gap(8),
|
||||
Text(
|
||||
"Trip ${snapshots[i].trip.tripNumber} • Duty ${snapshots[i].trip.dutyNumber}",
|
||||
).semiBold.textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
TripDiagram(
|
||||
lineColor: Colors.red,
|
||||
entries: _diagramEntries(snapshots[i].scheduledStops),
|
||||
leftOffset: 12,
|
||||
rowHeight: 48,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// By Trip
|
||||
if (filterBy == "By Trip") {
|
||||
final selectedTripNumber = (filterValue["By Trip"] ?? "").toString();
|
||||
if (selectedTripNumber.isEmpty) {
|
||||
return Center(child: Text("Select a trip to preview.").small.muted);
|
||||
}
|
||||
|
||||
final snapshot = _tripSnapshot;
|
||||
if (snapshot == null) {
|
||||
return Center(child: Text("No data for this trip.").small.muted);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(8),
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.bus),
|
||||
Gap(8),
|
||||
Text(
|
||||
"Trip ${snapshot.trip.tripNumber} • Duty ${snapshot.trip.dutyNumber}",
|
||||
).semiBold.textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
TripDiagram(
|
||||
lineColor: Colors.red,
|
||||
entries: _diagramEntries(snapshot.scheduledStops),
|
||||
leftOffset: 12,
|
||||
rowHeight: 48,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Text("Select a filter to preview the schedule.").small.muted,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: Select<String>(
|
||||
itemBuilder: (context, item) => Text("Filter by: $item"),
|
||||
popupConstraints: BoxConstraints(
|
||||
maxHeight: 300,
|
||||
maxWidth: !isMobile ? 200 : double.infinity,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
filterBy = value ?? filterBy;
|
||||
});
|
||||
},
|
||||
value: filterBy,
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
SelectItemButton(value: "By Duty", child: Text("By Duty")),
|
||||
SelectItemButton(value: "By Trip", child: Text("By Trip")),
|
||||
SelectItemButton(value: "By Stop", child: Text("By Stop")),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Gap(16),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: Select<Object>(
|
||||
itemBuilder: (context, item) {
|
||||
if (filterBy == "By Duty") return Text("Duty $item");
|
||||
|
||||
if (filterBy == "By Trip") {
|
||||
final tripNumber = item.toString();
|
||||
final match = _tripMeta.where((t) => t.tripNumber == tripNumber).firstOrNull;
|
||||
if (match == null) return Text("Trip $tripNumber");
|
||||
return Text("Trip ${match.tripNumber} • Duty ${match.dutyNumber}");
|
||||
}
|
||||
|
||||
if (filterBy == "By Stop") return _stopLabel(item.toString());
|
||||
|
||||
return Text("Undefined");
|
||||
},
|
||||
popupConstraints: BoxConstraints(
|
||||
maxHeight: 300,
|
||||
maxWidth: !isMobile ? 200 : double.infinity,
|
||||
),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() {
|
||||
filterValue[filterBy] = value;
|
||||
_dutyTrips = null;
|
||||
_tripSnapshot = null;
|
||||
_stopSchedule = null;
|
||||
});
|
||||
if (filterBy == "By Duty") {
|
||||
unawaited(_loadDutyDetail(value.toString()));
|
||||
} else if (filterBy == "By Trip") {
|
||||
unawaited(_loadTripDetail(value.toString()));
|
||||
} else if (filterBy == "By Stop") {
|
||||
unawaited(_loadStopDetail(value.toString()));
|
||||
}
|
||||
},
|
||||
value: filterValue[filterBy],
|
||||
popup: SelectPopup.builder(
|
||||
searchPlaceholder: Text("Search ${filterBy.toLowerCase()}"),
|
||||
builder: (context, searchQuery) {
|
||||
List<SelectItemButton> items = [];
|
||||
|
||||
if (filterBy == "By Duty") {
|
||||
items = _duties.map((d) => SelectItemButton(
|
||||
value: d,
|
||||
child: Text(d),
|
||||
)).toList();
|
||||
} else if (filterBy == "By Trip") {
|
||||
final trips = List.from(_tripMeta);
|
||||
trips.sort((a, b) {
|
||||
final aNum = int.tryParse(a.tripNumber);
|
||||
final bNum = int.tryParse(b.tripNumber);
|
||||
if (aNum != null && bNum != null) return aNum.compareTo(bNum);
|
||||
return a.tripNumber.compareTo(b.tripNumber);
|
||||
});
|
||||
items = trips.map((t) => SelectItemButton(
|
||||
value: t.tripNumber,
|
||||
child: Text("Trip ${t.tripNumber}"),
|
||||
)).toList();
|
||||
} else if (filterBy == "By Stop") {
|
||||
items = _stopNames.map((s) => SelectItemButton(
|
||||
value: s,
|
||||
child: _stopLabel(s),
|
||||
)).toList();
|
||||
}
|
||||
|
||||
return SelectItemList(children: items);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Gap(max(bottomPadding, 16)),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,71 +26,71 @@ class HomeLeftSidebar extends StatelessWidget {
|
|||
final displayName = user?.email ?? "Signed in user";
|
||||
final initials = _buildInitials(displayName);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ServerRail(
|
||||
organizations: organizations,
|
||||
selectedOrganizationId: collab.selectedOrganizationId,
|
||||
double topPadding = MediaQuery.of(context).padding.top;
|
||||
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ServerRail(
|
||||
organizations: organizations,
|
||||
selectedOrganizationId: collab.selectedOrganizationId,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
width: 100,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: IconButton.outline(
|
||||
onPressed: () {
|
||||
unawaited(showCreateOrganizationDialog(context));
|
||||
},
|
||||
shape: ButtonShape.circle,
|
||||
size: ButtonSize.normal,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
width: 100,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: IconButton.outline(
|
||||
onPressed: () {
|
||||
unawaited(showCreateOrganizationDialog(context));
|
||||
},
|
||||
shape: ButtonShape.circle,
|
||||
size: ButtonSize.normal,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
RotatedBox(
|
||||
quarterTurns: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"ROADBOUND",
|
||||
style: const TextStyle(height: 1),
|
||||
).extraBold.x3Large,
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: const TextStyle(height: 1),
|
||||
).small.muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
RotatedBox(
|
||||
quarterTurns: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"ROADBOUND",
|
||||
style: const TextStyle(height: 1),
|
||||
).extraBold.x3Large,
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: const TextStyle(height: 1),
|
||||
).small.muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Gap(bottomPadding)
|
||||
],
|
||||
),
|
||||
const VerticalDivider(),
|
||||
Container(
|
||||
),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
width: 300,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width - 50,
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(topPadding),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SizedBox(
|
||||
|
|
@ -178,8 +178,8 @@ class HomeLeftSidebar extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -301,62 +301,68 @@ class _ServerRail extends StatelessWidget {
|
|||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
double topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Column(
|
||||
children: organizations.map((organization) {
|
||||
final isSelected = selectedOrganizationId == organization.id;
|
||||
final fallbackInitial = _fallbackInitial(organization.name);
|
||||
final buttonStyle = _organizationButtonStyle(isSelected);
|
||||
children: [
|
||||
Gap(topPadding),
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: HoverCard(
|
||||
hoverBuilder: (context) {
|
||||
return SurfaceCard(
|
||||
child: Basic(title: Text(organization.name).medium),
|
||||
);
|
||||
},
|
||||
anchorAlignment: Alignment.centerLeft,
|
||||
popoverAlignment: Alignment.centerRight,
|
||||
popoverOffset: const Offset(-4, 0),
|
||||
child: ContextMenu(
|
||||
items: [
|
||||
MenuLabel(child: Text(organization.name)),
|
||||
const MenuDivider(),
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.settings2).iconSmall,
|
||||
onPressed: (_) {
|
||||
_openOrganizationSettings(context, organization.id);
|
||||
},
|
||||
child: const Text("Server Settings"),
|
||||
),
|
||||
],
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildOrganizationAvatar(
|
||||
organization: organization,
|
||||
fallbackInitial: fallbackInitial,
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
height: 54,
|
||||
child: Button(
|
||||
style: buttonStyle,
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
_openOrganization(context, organization.id),
|
||||
);
|
||||
},
|
||||
child: Container(),
|
||||
),
|
||||
...organizations.map((organization) {
|
||||
final isSelected = selectedOrganizationId == organization.id;
|
||||
final fallbackInitial = _fallbackInitial(organization.name);
|
||||
final buttonStyle = _organizationButtonStyle(isSelected);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: HoverCard(
|
||||
hoverBuilder: (context) {
|
||||
return SurfaceCard(
|
||||
child: Basic(title: Text(organization.name).medium),
|
||||
);
|
||||
},
|
||||
anchorAlignment: Alignment.centerLeft,
|
||||
popoverAlignment: Alignment.centerRight,
|
||||
popoverOffset: const Offset(-4, 0),
|
||||
child: ContextMenu(
|
||||
items: [
|
||||
MenuLabel(child: Text(organization.name)),
|
||||
const MenuDivider(),
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.settings2).iconSmall,
|
||||
onPressed: (_) {
|
||||
_openOrganizationSettings(context, organization.id);
|
||||
},
|
||||
child: const Text("Server Settings"),
|
||||
),
|
||||
],
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildOrganizationAvatar(
|
||||
organization: organization,
|
||||
fallbackInitial: fallbackInitial,
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
height: 54,
|
||||
child: Button(
|
||||
style: buttonStyle,
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
_openOrganization(context, organization.id),
|
||||
);
|
||||
},
|
||||
child: Container(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
})
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,126 +1,69 @@
|
|||
import "dart:math" as math;
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class SidebarSwiper extends StatefulWidget {
|
||||
const SidebarSwiper({
|
||||
required this.sidebar,
|
||||
required this.child,
|
||||
this.maxSidebarWidth = 360,
|
||||
this.sidebarWidthFactor = 0.92,
|
||||
this.edgeDragWidth = 44,
|
||||
this.animationDuration = const Duration(milliseconds: 220),
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget sidebar;
|
||||
final Widget child;
|
||||
final double maxSidebarWidth;
|
||||
final double sidebarWidthFactor;
|
||||
final double edgeDragWidth;
|
||||
final Duration animationDuration;
|
||||
|
||||
@override
|
||||
State<SidebarSwiper> createState() => _SidebarSwiperState();
|
||||
}
|
||||
|
||||
class _SidebarSwiperState extends State<SidebarSwiper> {
|
||||
static const double _closedExtraOffset = 12;
|
||||
final _controller = PageController(initialPage: 1);
|
||||
|
||||
double _progress = 0; // 0 = closed, 1 = fully open
|
||||
bool _isDragging = false;
|
||||
bool _canDrag = false;
|
||||
double _dragStartGlobalX = 0;
|
||||
double _dragStartProgress = 0;
|
||||
|
||||
void _setOpen(bool value) {
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
_progress = value ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details, double sidebarWidth) {
|
||||
final isOpen = _progress > 0.001;
|
||||
final fromEdge = details.globalPosition.dx <= widget.edgeDragWidth;
|
||||
final fromSidebarZone = details.globalPosition.dx <= sidebarWidth;
|
||||
_canDrag = fromEdge || (isOpen && fromSidebarZone);
|
||||
if (!_canDrag) return;
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragStartGlobalX = details.globalPosition.dx;
|
||||
_dragStartProgress = _progress;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details, double sidebarWidth) {
|
||||
if (!_canDrag || !_isDragging) return;
|
||||
if (sidebarWidth <= 0) return;
|
||||
final movedX = details.globalPosition.dx - _dragStartGlobalX;
|
||||
setState(() {
|
||||
_progress = (_dragStartProgress + (movedX / sidebarWidth)).clamp(0, 1);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
if (!_canDrag) return;
|
||||
_canDrag = false;
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
final shouldOpen = velocity > 250
|
||||
? true
|
||||
: (velocity < -250 ? false : _progress >= 0.35);
|
||||
_setOpen(shouldOpen);
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final sidebarWidth = math.min(
|
||||
widget.maxSidebarWidth,
|
||||
screenWidth * widget.sidebarWidthFactor,
|
||||
);
|
||||
final leftOffset =
|
||||
-(sidebarWidth + _closedExtraOffset) +
|
||||
((sidebarWidth + _closedExtraOffset) * _progress);
|
||||
final showScrim = _progress > 0;
|
||||
return Scaffold(
|
||||
child: PageView(
|
||||
controller: _controller,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
// page 0 - sidebar
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _KeepAlive(child: Scaffold(child: widget.sidebar))),
|
||||
const VerticalDivider(width: 1),
|
||||
],
|
||||
),
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (details) =>
|
||||
_handleDragStart(details, sidebarWidth),
|
||||
onHorizontalDragUpdate: (details) =>
|
||||
_handleDragUpdate(details, sidebarWidth),
|
||||
onHorizontalDragEnd: _handleDragEnd,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
if (showScrim)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _setOpen(false),
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.45 * _progress),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: _isDragging ? Duration.zero : widget.animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
left: leftOffset,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: sidebarWidth,
|
||||
child: Material(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: widget.sidebar,
|
||||
),
|
||||
),
|
||||
],
|
||||
// page 1 - main content
|
||||
_KeepAlive(child: widget.child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeepAlive extends StatefulWidget {
|
||||
const _KeepAlive({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_KeepAlive> createState() => _KeepAliveState();
|
||||
}
|
||||
|
||||
class _KeepAliveState extends State<_KeepAlive>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return SizedBox.expand(child: widget.child);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
import "dart:math";
|
||||
|
||||
import "package:bus_running_record/models/operations/scheduled_stop.dart";
|
||||
|
|
@ -74,12 +76,17 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
throw StateError("No trips parsed from schedule.");
|
||||
}
|
||||
if (!mounted) return;
|
||||
final duties = trips.map((t) => t.dutyNumber).toSet().toList()..sort();
|
||||
final firstDuty = duties.isNotEmpty ? duties.first : null;
|
||||
|
||||
setState(() {
|
||||
_parsedTrips = trips;
|
||||
_fileName = file.name;
|
||||
_parserType = parserType;
|
||||
_sourceMime = sourceMime;
|
||||
_enhanced = false;
|
||||
filterBy = "By Duty";
|
||||
if (firstDuty != null) filterValue["By Duty"] = firstDuty;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[OperationsUploadPage] parse failed: $error");
|
||||
|
|
@ -131,87 +138,64 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.read<SupabaseProvider>().client;
|
||||
final userId = client.auth.currentUser?.id;
|
||||
if (userId == null || userId.isEmpty) {
|
||||
throw StateError("No authenticated user.");
|
||||
}
|
||||
|
||||
final current = await client
|
||||
.from("operations_schedules")
|
||||
.select("version")
|
||||
.eq("channel_id", widget.channelId)
|
||||
.order("version", ascending: false)
|
||||
.limit(1);
|
||||
final latestVersion = (current as List).isEmpty
|
||||
? 0
|
||||
: (((current.first as Map)["version"] as num?)?.toInt() ?? 0);
|
||||
final nextVersion = latestVersion + 1;
|
||||
|
||||
await client
|
||||
.from("operations_schedules")
|
||||
.update({"is_active": false})
|
||||
.eq("channel_id", widget.channelId)
|
||||
.eq("is_active", true);
|
||||
|
||||
final scheduleRow = await client
|
||||
.from("operations_schedules")
|
||||
.insert({
|
||||
"channel_id": widget.channelId,
|
||||
"version": nextVersion,
|
||||
"source_file_name": fileName,
|
||||
"source_mime": sourceMime,
|
||||
"parser": parserType,
|
||||
"parse_status": "parsed",
|
||||
"uploaded_by": userId,
|
||||
"is_active": true,
|
||||
"parsed_at": DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
final scheduleId = (scheduleRow["id"] ?? "").toString();
|
||||
if (scheduleId.isEmpty) throw StateError("Created schedule missing id.");
|
||||
|
||||
final sortedTrips = _sortedTrips();
|
||||
|
||||
final tripPayloads = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < sortedTrips.length; i++) {
|
||||
final trip = sortedTrips[i];
|
||||
final tripRow = await client
|
||||
.from("operations_trips")
|
||||
.insert({
|
||||
"schedule_id": scheduleId,
|
||||
"trip_number": trip.tripNumber,
|
||||
"duty_number": trip.dutyNumber,
|
||||
"bus_work_number": trip.busWorkNumber,
|
||||
"direction": trip.direction,
|
||||
"service_code": trip.tripType,
|
||||
"sort_order": i,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
final tripId = (tripRow["id"] ?? "").toString();
|
||||
if (tripId.isEmpty) continue;
|
||||
|
||||
final stopRows = <Map<String, dynamic>>[];
|
||||
final stationOrder = trip.stationOrder.isEmpty
|
||||
? trip.stationTimes.keys.toList()
|
||||
: trip.stationOrder;
|
||||
|
||||
final stops = <Map<String, dynamic>>[];
|
||||
for (var s = 0; s < stationOrder.length; s++) {
|
||||
final stopName = stationOrder[s].trim();
|
||||
if (stopName.isEmpty) continue;
|
||||
final scheduled = (trip.stationTimes[stopName] ?? "").trim();
|
||||
stopRows.add({
|
||||
"trip_id": tripId,
|
||||
stops.add({
|
||||
"stop_sequence": s + 1,
|
||||
"stop_name": stopName,
|
||||
"scheduled_time": scheduled.isEmpty ? null : scheduled,
|
||||
});
|
||||
}
|
||||
if (stopRows.isNotEmpty) {
|
||||
await client.from("operations_trip_stops").insert(stopRows);
|
||||
}
|
||||
|
||||
tripPayloads.add({
|
||||
"trip_number": trip.tripNumber,
|
||||
"duty_number": trip.dutyNumber,
|
||||
"bus_work_number": trip.busWorkNumber,
|
||||
"direction": trip.direction,
|
||||
"service_code": trip.tripType,
|
||||
"sort_order": i,
|
||||
"stops": stops,
|
||||
});
|
||||
}
|
||||
|
||||
final bodyJson = {
|
||||
"channel_id": widget.channelId,
|
||||
"file_name": fileName,
|
||||
"source_mime": sourceMime,
|
||||
"parser": parserType,
|
||||
"trips": tripPayloads,
|
||||
};
|
||||
|
||||
final jsonBytes = utf8.encode(jsonEncode(bodyJson));
|
||||
final compressed = GZipCodec().encode(jsonBytes);
|
||||
|
||||
final response = await _invokeAuthedFunctionRaw(
|
||||
"operations-schedule-upload",
|
||||
body: Uint8List.fromList(compressed),
|
||||
extraHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
},
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data is Map && data["error"] != null) {
|
||||
throw StateError(data["error"].toString());
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
@ -304,6 +288,46 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _invokeAuthedFunctionRaw(
|
||||
String functionName, {
|
||||
required Uint8List body,
|
||||
Map<String, String> extraHeaders = const {},
|
||||
}) async {
|
||||
var token = await _getFreshAccessToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw StateError("No valid access token available for edge function call.");
|
||||
}
|
||||
|
||||
final client = context.read<SupabaseProvider>().client;
|
||||
|
||||
Future<dynamic> invokeOnce(String accessToken) {
|
||||
client.functions.setAuth(accessToken);
|
||||
return client.functions.invoke(
|
||||
functionName,
|
||||
body: body,
|
||||
headers: {
|
||||
...extraHeaders,
|
||||
"Authorization": "Bearer $accessToken",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await invokeOnce(token);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
"[OperationsUploadPage] invokeAuthedFunctionRaw/$functionName failed: $error",
|
||||
);
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!_isUnauthorizedFunctionError(error)) rethrow;
|
||||
|
||||
final refreshed = await client.auth.refreshSession();
|
||||
token = refreshed.session?.accessToken ?? client.auth.currentSession?.accessToken;
|
||||
if (token == null || token.isEmpty) rethrow;
|
||||
return invokeOnce(token);
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _invokeAuthedFunction(
|
||||
String functionName, {
|
||||
Object? body,
|
||||
|
|
@ -458,7 +482,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
);
|
||||
}
|
||||
|
||||
String? filterBy;
|
||||
String? filterBy = "By Duty";
|
||||
Map<String, Object?> filterValue = {};
|
||||
|
||||
Trip? _selectedTripForFilter() {
|
||||
|
|
@ -473,6 +497,99 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
return null;
|
||||
}
|
||||
|
||||
List<Trip> _selectedDutyTripsForFilter() {
|
||||
if (filterBy != "By Duty") return const <Trip>[];
|
||||
final selectedDuty = (filterValue["By Duty"] ?? "").toString();
|
||||
if (selectedDuty.isEmpty) return const <Trip>[];
|
||||
final trips = _parsedTrips
|
||||
.where((trip) => trip.dutyNumber == selectedDuty)
|
||||
.toList(growable: false);
|
||||
final sorted = List<Trip>.from(trips);
|
||||
sorted.sort((a, b) {
|
||||
final byTime = a.scheduledTime.compareTo(b.scheduledTime);
|
||||
if (byTime != 0) return byTime;
|
||||
final aNum = int.tryParse(a.tripNumber);
|
||||
final bNum = int.tryParse(b.tripNumber);
|
||||
if (aNum != null && bNum != null) return aNum.compareTo(bNum);
|
||||
return a.tripNumber.compareTo(b.tripNumber);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
String? _selectedStopForFilter() {
|
||||
if (filterBy != "By Stop") return null;
|
||||
final selectedStop = (filterValue["By Stop"] ?? "").toString().trim();
|
||||
if (selectedStop.isEmpty) return null;
|
||||
return selectedStop;
|
||||
}
|
||||
|
||||
List<({Trip trip, String? time})> _selectedStopSchedules(String stopName) {
|
||||
final rows = <({Trip trip, String? time})>[];
|
||||
for (final trip in _parsedTrips) {
|
||||
String? scheduledTime;
|
||||
for (final stop in trip.scheduledStops) {
|
||||
if (stop.name != stopName) continue;
|
||||
scheduledTime = stop.scheduledTime;
|
||||
break;
|
||||
}
|
||||
if (scheduledTime == null || scheduledTime.trim().isEmpty) continue;
|
||||
rows.add((trip: trip, time: scheduledTime));
|
||||
}
|
||||
|
||||
rows.sort((a, b) {
|
||||
final aNum = int.tryParse(a.trip.tripNumber);
|
||||
final bNum = int.tryParse(b.trip.tripNumber);
|
||||
if (aNum != null && bNum != null) {
|
||||
final byNum = aNum.compareTo(bNum);
|
||||
if (byNum != 0) return byNum;
|
||||
} else {
|
||||
final byText = a.trip.tripNumber.compareTo(b.trip.tripNumber);
|
||||
if (byText != 0) return byText;
|
||||
}
|
||||
final byDuty = a.trip.dutyNumber.compareTo(b.trip.dutyNumber);
|
||||
if (byDuty != 0) return byDuty;
|
||||
return (a.time ?? "").compareTo(b.time ?? "");
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
String? _aliasForStopName(String rawStopName) {
|
||||
final normalizedRaw = rawStopName.trim();
|
||||
if (normalizedRaw.isEmpty) return null;
|
||||
for (final trip in _parsedTrips) {
|
||||
for (final stop in trip.scheduledStops) {
|
||||
if (stop.name != normalizedRaw) continue;
|
||||
final alias = (stop.alias ?? "").trim();
|
||||
if (alias.isNotEmpty) return alias;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _stopDisplayLabel(String rawStopName) {
|
||||
final alias = _aliasForStopName(rawStopName);
|
||||
if (alias == null || alias.isEmpty) return Text(rawStopName);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
alias,
|
||||
).semiBold,
|
||||
|
||||
if (alias != rawStopName)
|
||||
Text(
|
||||
rawStopName,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
height: 1
|
||||
),
|
||||
).xSmall.muted.semiBold,
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<TripDiagramEntry> _tripDiagramEntries(Trip trip) {
|
||||
final orderedStops = List<ScheduledStop>.from(trip.scheduledStops)
|
||||
..sort((a, b) => a.sequence.compareTo(b.sequence));
|
||||
|
|
@ -497,16 +614,211 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final selectedTrip = _selectedTripForFilter();
|
||||
if (selectedTrip == null) {
|
||||
return Text(
|
||||
"Select 'By Trip' and choose a trip to preview it.",
|
||||
).small.muted;
|
||||
}
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final selectedStop = _selectedStopForFilter();
|
||||
if (filterBy == "By Stop" && selectedStop != null) {
|
||||
final schedules = _selectedStopSchedules(selectedStop);
|
||||
final selectedStopAlias = _aliasForStopName(selectedStop);
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
Gap(8),
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.pin),
|
||||
Gap(8),
|
||||
_stopDisplayLabel(selectedStop),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
if (schedules.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
"No scheduled times found for this stop.",
|
||||
).small.muted,
|
||||
)
|
||||
else
|
||||
...schedules.map(
|
||||
(row) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.07),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Gap(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Text(
|
||||
// "${row.trip.dutyNumber} • Trip ${row.trip.tripNumber} • Work ${row.trip.busWorkNumber}",
|
||||
// ).extraBold,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"${row.trip.dutyNumber} • ",
|
||||
).extraBold,
|
||||
// Gap(),
|
||||
Text(
|
||||
"Trip ${row.trip.tripNumber} • Bus ${row.trip.busWorkNumber}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).muted,
|
||||
],
|
||||
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"${row.trip.scheduledStops.last.displayName}",
|
||||
).muted.textSmall,
|
||||
if (row.trip.direction.toLowerCase().contains("out"))...[
|
||||
Gap(4),
|
||||
Icon(
|
||||
LucideIcons.arrowUpRight,
|
||||
).iconSmall
|
||||
]
|
||||
else if (row.trip.direction.toLowerCase().contains("in"))...[
|
||||
Gap(4),
|
||||
Icon(
|
||||
LucideIcons.arrowDownLeft,
|
||||
).iconSmall
|
||||
]
|
||||
],
|
||||
),
|
||||
|
||||
// Text(
|
||||
// "Trip ${row.trip.tripNumber}",
|
||||
// ).textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
row.time ?? "--:--",
|
||||
).extraBold.mono,
|
||||
|
||||
Gap(2),
|
||||
|
||||
Icon(
|
||||
LucideIcons.chevronRight
|
||||
),
|
||||
|
||||
Gap(2)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final selectedDutyTrips = _selectedDutyTripsForFilter();
|
||||
if (filterBy == "By Duty" && selectedDutyTrips.isNotEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
Gap(8),
|
||||
Divider(),
|
||||
Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.busFront),
|
||||
Gap(10),
|
||||
Text(
|
||||
"Duty ${selectedDutyTrips.first.dutyNumber}".toUpperCase(),
|
||||
).extraBold.textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < selectedDutyTrips.length; i++) ...[
|
||||
|
||||
if (i != 0)
|
||||
...[
|
||||
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
],
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.bus),
|
||||
Gap(8),
|
||||
Text(
|
||||
"Trip ${selectedDutyTrips[i].tripNumber} • Duty ${selectedDutyTrips[i].dutyNumber}",
|
||||
).semiBold.textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
TripDiagram(
|
||||
lineColor: Colors.red,
|
||||
entries: _tripDiagramEntries(selectedDutyTrips[i]),
|
||||
leftOffset: 12,
|
||||
rowHeight: 48,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final selectedTrip = _selectedTripForFilter();
|
||||
if (selectedTrip == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Select 'By Trip' or 'By Duty' and choose a value to preview it.",
|
||||
).small.muted,
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
|
|
@ -542,9 +854,9 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
rowHeight: 48,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -557,6 +869,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: Select<String>(
|
||||
itemBuilder: (context, item) {
|
||||
return Text("Filter by: $item");
|
||||
|
|
@ -602,6 +915,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: Select<Object>(
|
||||
itemBuilder: (context, item) {
|
||||
|
||||
|
|
@ -621,10 +935,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
return Text("Trip ${trip.tripNumber} • Duty ${trip.dutyNumber}");
|
||||
|
||||
} else if (filterBy == "By Stop") {
|
||||
|
||||
ScheduledStop stop = item as ScheduledStop;
|
||||
|
||||
return Text(stop.name);
|
||||
return _stopDisplayLabel(item.toString());
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -691,7 +1002,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
..sort();
|
||||
items = stops.map((s) => SelectItemButton(
|
||||
value: s,
|
||||
child: Text(s)
|
||||
child: _stopDisplayLabel(s)
|
||||
)).toList();
|
||||
}
|
||||
|
||||
|
|
@ -742,11 +1053,9 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|||
LucideIcons.upload
|
||||
).iconSmall,
|
||||
child: Text(
|
||||
"Upload"
|
||||
_saving ? "Uploading..." : "Upload"
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
},
|
||||
onPressed: _saving ? null : () => unawaited(_saveToChannel()),
|
||||
)
|
||||
|
||||
],
|
||||
|
|
|
|||
|
|
@ -67,15 +67,18 @@ class _OrganizationSettingsPageState extends State<OrganizationSettingsPage> {
|
|||
}
|
||||
final organizationId = organization?.id;
|
||||
|
||||
double topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 820),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 820),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(topPadding),
|
||||
Button.text(
|
||||
leading: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => context.go("/"),
|
||||
|
|
|
|||
|
|
@ -351,10 +351,12 @@ class StagecoachScheduleParser implements ScheduleParser {
|
|||
? stations.reversed.toList()
|
||||
: List<String>.from(stations);
|
||||
|
||||
final normalizedDuty = dutyId.startsWith("2/") ? dutyId.substring(2) : dutyId;
|
||||
|
||||
return Trip(
|
||||
scheduledTime: scheduledTime,
|
||||
tripNumber: runNumber,
|
||||
dutyNumber: dutyId,
|
||||
dutyNumber: normalizedDuty,
|
||||
busWorkNumber: busWorkingNo,
|
||||
tripType: tripType,
|
||||
isFinishing: segment.contains("BUS FIN"),
|
||||
|
|
|
|||
|
|
@ -33,3 +33,18 @@ verify_jwt = false
|
|||
|
||||
[functions.operations-stop-alias-enhance]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.operations-schedule-upload]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.operations-schedule-meta]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.operations-duty-detail]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.operations-trip-detail]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.operations-stop-detail]
|
||||
verify_jwt = false
|
||||
|
|
|
|||
|
|
@ -43,6 +43,22 @@ function extractCode(rawStopName: string): string {
|
|||
return normalized.slice(0, 8);
|
||||
}
|
||||
|
||||
function hasStandaloneTerminusToken(rawStopName: string): boolean {
|
||||
const tokens = rawStopName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((token) => token.toUpperCase())
|
||||
.filter((token) => token.length > 0);
|
||||
if (tokens.length === 0) return false;
|
||||
return tokens[tokens.length - 1] === "T";
|
||||
}
|
||||
|
||||
function applyStandRule(rawStopName: string, estimatedName: string): string {
|
||||
if (!hasStandaloneTerminusToken(rawStopName)) return estimatedName;
|
||||
if (estimatedName.toLowerCase().endsWith(" stand")) return estimatedName;
|
||||
return `${estimatedName} Stand`;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
|
@ -143,13 +159,32 @@ Deno.serve(async (req) => {
|
|||
const estimated = codeToEstimated.get(code);
|
||||
if (!estimated) continue;
|
||||
for (const raw of rawStops) {
|
||||
const aliasWithStandRule = applyStandRule(raw, estimated);
|
||||
aliases.push({
|
||||
raw_stop_name: raw,
|
||||
alias_stop_name: estimated,
|
||||
alias_stop_name: aliasWithStandRule,
|
||||
source: "ai",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (aliases.length > 0) {
|
||||
const upsertRows = aliases.map((a) => ({
|
||||
channel_id: channelId,
|
||||
raw_stop_name: a.raw_stop_name,
|
||||
alias_stop_name: a.alias_stop_name,
|
||||
source: a.source,
|
||||
created_by: user.id,
|
||||
}));
|
||||
|
||||
const { error: upsertError } = await client
|
||||
.from("operations_stop_aliases")
|
||||
.upsert(upsertRows, { onConflict: "channel_id,raw_stop_name_normalized", ignoreDuplicates: false });
|
||||
|
||||
if (upsertError) {
|
||||
console.error("[operations-stop-alias-enhance] failed to persist aliases:", upsertError.message);
|
||||
}
|
||||
}
|
||||
|
||||
return json({ aliases });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue