Add version files and update imports for trip model; enhance error handling

This commit is contained in:
ImBenji
2026-03-27 21:17:56 +00:00
parent e41e14e252
commit 427bcadc77
89 changed files with 9455 additions and 395 deletions
+12
View File
@@ -0,0 +1,12 @@
/*
* SUPABASE
*/
/// Supabase project endpoint (for example: https://project-ref.supabase.co).
/// Leave empty to disable Supabase initialization.
const String kSupabaseEndpoint = "https://fbgvisimvgeksfxpemuk.supabase.co";
/// Supabase publishable/anon key.
/// Leave empty to disable Supabase initialization.
const String kSupabasePublishableKey =
"sb_publishable_tqhag58YEhNrIHy264JsKg_T1VCIZib";
+57 -26
View File
@@ -3,7 +3,7 @@ import "dart:typed_data";
import "package:archive/archive.dart";
import "package:excel/excel.dart";
import "package:flutter/services.dart";
import "../models/trip.dart";
import "../models/operations/trip.dart";
import "../models/brr_metadata.dart";
import "brr_exporter.dart";
@@ -20,7 +20,10 @@ class ArrivaBRRExporter implements BRRExporter {
// strip the whole numFmts block — Numbers export puts built-in IDs
// in there which the excel package rejects
xml = xml.replaceAll(RegExp(r'<numFmts[^>]*>.*?</numFmts>', dotAll: true), "");
xml = xml.replaceAll(
RegExp(r'<numFmts[^>]*>.*?</numFmts>', dotAll: true),
"",
);
// reset all numFmtId refs in xf elements to 0 (General)
// so nothing tries to look up the stripped formats
@@ -36,10 +39,9 @@ class ArrivaBRRExporter implements BRRExporter {
return ZipEncoder().encode(output)!;
}
static const int _dataStartRow = 8; // row 9 (0-indexed)
static const int _dataStartRow = 8; // row 9 (0-indexed)
static const int _templateDataRows = 15; // rows 923
@override
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata) async {
final templateBytes = await rootBundle.load("assets/arriva_brr.xlsx");
@@ -62,7 +64,8 @@ class ArrivaBRRExporter implements BRRExporter {
// Shifts all rows from (_dataStartRow + _templateDataRows) onwards down by extraRows
void _shiftRowsDown(Sheet sheet, int extraRows) {
final firstRowToShift = _dataStartRow + _templateDataRows; // row 24 (index 23)
final firstRowToShift =
_dataStartRow + _templateDataRows; // row 24 (index 23)
// figure out how many rows exist beyond the data block
final maxRow = sheet.rows.length;
@@ -77,10 +80,14 @@ class ArrivaBRRExporter implements BRRExporter {
final cell = srcRow[c];
if (cell == null) continue;
sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow)).value =
cell.value;
sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow)).cellStyle =
cell.cellStyle;
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow))
.value = cell
.value;
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow))
.cellStyle = cell
.cellStyle;
}
}
@@ -88,7 +95,10 @@ class ArrivaBRRExporter implements BRRExporter {
for (var r = firstRowToShift; r < firstRowToShift + extraRows; r++) {
if (r >= sheet.rows.length) break;
for (var c = 0; c < 18; c++) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: r)).value = null;
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: r))
.value =
null;
}
}
}
@@ -98,30 +108,51 @@ class ArrivaBRRExporter implements BRRExporter {
final trip = trips[i];
final row = _dataStartRow + i;
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row)).value =
TextCellValue(trip.scheduledTime);
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row)).value =
TextCellValue(trip.tripNumber);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row))
.value = TextCellValue(
trip.scheduledTime,
);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row))
.value = TextCellValue(
trip.tripNumber,
);
if (trip.actualDepartureTime != null) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row)).value =
TextCellValue(trip.actualDepartureTime!);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row))
.value = TextCellValue(
trip.actualDepartureTime!,
);
}
if (trip.actualFleetNumber != null) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row)).value =
TextCellValue(trip.actualFleetNumber!);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row))
.value = TextCellValue(
trip.actualFleetNumber!,
);
}
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row)).value =
TextCellValue(trip.dutyNumber);
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row)).value =
TextCellValue(trip.runningNumber);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row))
.value = TextCellValue(
trip.dutyNumber,
);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row))
.value = TextCellValue(
trip.busWorkNumber,
);
final didOperate = trip.actualDepartureTime != null && trip.actualFleetNumber != null;
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 6, rowIndex: row)).value =
TextCellValue(didOperate ? "Y" : "N");
final didOperate =
trip.actualDepartureTime != null && trip.actualFleetNumber != null;
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 6, rowIndex: row))
.value = TextCellValue(
didOperate ? "Y" : "N",
);
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import "dart:typed_data";
import "../models/trip.dart";
import "../models/operations/trip.dart";
import "../models/brr_metadata.dart";
abstract class BRRExporter {
+34 -16
View File
@@ -1,11 +1,10 @@
import "dart:typed_data";
import "package:excel/excel.dart";
import "../models/trip.dart";
import "../models/operations/trip.dart";
import "../models/brr_metadata.dart";
import "brr_exporter.dart";
class StagecoachBRRExporter implements BRRExporter {
@override
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata) async {
final excel = Excel.createExcel();
@@ -26,24 +25,31 @@ class StagecoachBRRExporter implements BRRExporter {
final bold = CellStyle(bold: true);
for (var c = 0; c < headers.length; c++) {
final cell = sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: 0));
final cell = sheet.cell(
CellIndex.indexByColumnRow(columnIndex: c, rowIndex: 0),
);
cell.value = TextCellValue(headers[c]);
cell.cellStyle = bold;
}
for (var i = 0; i < trips.length; i++) {
final trip = trips[i];
final row = i + 1;
// Dep Time (HHMM no colon)
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row)).value =
TextCellValue(trip.scheduledTime.replaceAll(":", ""));
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row))
.value = TextCellValue(
trip.scheduledTime.replaceAll(":", ""),
);
// (+/-) No. - user fills in
if (trip.actualDepartureTime != null) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row)).value =
TextCellValue(trip.actualDepartureTime!);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row))
.value = TextCellValue(
trip.actualDepartureTime!,
);
}
// Ser. — outbound shows route name, inbound shows "PARK"
@@ -51,22 +57,34 @@ class StagecoachBRRExporter implements BRRExporter {
final ser = trip.direction == "outbound"
? (metadata.route != "Unknown" ? metadata.route : "OUT")
: "PARK";
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row)).value =
TextCellValue(ser);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row))
.value = TextCellValue(
ser,
);
// Bus Wk No
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row)).value =
TextCellValue(trip.dutyNumber);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row))
.value = TextCellValue(
trip.busWorkNumber,
);
// Fleet No. — actual fleet number entered by user
if (trip.actualFleetNumber != null) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row)).value =
TextCellValue(trip.actualFleetNumber!);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row))
.value = TextCellValue(
trip.actualFleetNumber!,
);
}
// Crew Duty
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row)).value =
TextCellValue(trip.tripNumber);
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row))
.value = TextCellValue(
trip.dutyNumber,
);
}
final bytes = excel.encode();
+87 -80
View File
@@ -1,100 +1,107 @@
import "package:flutter/material.dart";
import "package:go_router/go_router.dart";
import "package:hive_flutter/hive_flutter.dart";
import "pages/home_page.dart";
import "pages/station_selection_page.dart";
import "pages/trip_list_page.dart";
import 'package:bus_running_record/pages/home/page.dart' as home_v2;
import 'package:bus_running_record/pages/home_page.dart' as legacy_home;
import "package:bus_running_record/pages/operations_upload/page.dart";
import "package:bus_running_record/pages/invite/page.dart";
import "package:bus_running_record/pages/org_settings/page.dart";
import "package:bus_running_record/pages/auth/page.dart";
import "package:bus_running_record/pages/auth/verify_email_page.dart";
import "package:bus_running_record/pages/station_selection_page.dart";
import "package:bus_running_record/pages/trip_list_page.dart";
import "package:bus_running_record/provider/collaboration_state.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:bus_running_record/constants.dart";
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
runApp(MyApp());
await Supabase.initialize(
url: kSupabaseEndpoint,
anonKey: kSupabasePublishableKey,
);
final supabaseProvider = SupabaseProvider();
runApp(RoadboundApp(supabaseProvider: supabaseProvider));
}
class MyApp extends StatelessWidget {
MyApp({super.key});
class RoadboundApp extends StatefulWidget {
const RoadboundApp({required this.supabaseProvider, super.key});
final _router = GoRouter(
final SupabaseProvider supabaseProvider;
@override
State<RoadboundApp> createState() => _RoadboundAppState();
}
class _RoadboundAppState extends State<RoadboundApp> {
late final GoRouter _routerConfig = GoRouter(
refreshListenable: widget.supabaseProvider,
routes: [
HomePage.route,
LoginPage.route,
VerifyEmailPage.route,
home_v2.HomePage.rootRoute,
home_v2.HomePage.channelRoute,
legacy_home.HomePage.route,
StationSelectionPage.route,
TripListPage.route,
OperationsUploadPage.route,
InvitePage.route,
OrganizationSettingsPage.route,
],
redirect: (context, state) {
final isLoggedIn = widget.supabaseProvider.isAuthenticated;
final isValidatingSession = widget.supabaseProvider.isValidatingSession;
final hasSession = widget.supabaseProvider.session != null;
final requestedPath = state.uri.path;
final onLogin = requestedPath == LoginPage.routePath;
final onVerify = requestedPath == VerifyEmailPage.routePath;
final onInvite = requestedPath.startsWith("/invite/");
final onLegacyFlow =
requestedPath == legacy_home.HomePage.routePath ||
requestedPath == StationSelectionPage.routePath ||
requestedPath == TripListPage.routePath;
if (onVerify) return null;
if (onInvite) return null;
if (onLegacyFlow) return null;
// Avoid login flash on cold start: keep current route until
// persisted session validation completes.
if (hasSession && isValidatingSession) return null;
if (!isLoggedIn && !onLogin) return LoginPage.routePath;
if (isLoggedIn && onLogin) {
final next = state.uri.queryParameters["next"];
return (next != null && next.isNotEmpty) ? next : "/";
}
return null;
},
);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: "Bus Running Record",
theme: ThemeData(
colorScheme: const ColorScheme.dark(
primary: Color(0xFF00A9CE),
onPrimary: Colors.black,
surface: Color(0xFF1E1E1E),
onSurface: Color(0xFFEEEEEE),
surfaceContainerHighest: Color(0xFF2A2A2A),
error: Color(0xFFCF6679),
AdaptiveScaling? adaptiveScaling;
if (defaultTargetPlatform == TargetPlatform.iOS) {
adaptiveScaling = AdaptiveScaling(1.15);
}
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: widget.supabaseProvider),
ChangeNotifierProvider(
create: (context) =>
CollaborationProvider(context.read<SupabaseProvider>())
..initialize(),
),
scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: const CardThemeData(
color: Color(0xFF1E1E1E),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(3)),
side: BorderSide(color: Color(0xFF2E2E2E)),
),
],
child: ShadcnApp.router(
routerConfig: _routerConfig,
scaling: adaptiveScaling,
theme: ThemeData(
colorScheme: ColorSchemes.darkNeutral,
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1A1A1A),
foregroundColor: Color(0xFFEEEEEE),
elevation: 0,
titleTextStyle: TextStyle(
color: Color(0xFFEEEEEE),
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(3)),
borderSide: BorderSide(color: Color(0xFF3A3A3A)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(3)),
borderSide: BorderSide(color: Color(0xFF3A3A3A)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(3)),
borderSide: BorderSide(color: Color(0xFF00A9CE), width: 1.5),
),
filled: true,
fillColor: Color(0xFF252525),
labelStyle: TextStyle(color: Color(0xFF999999)),
hintStyle: TextStyle(color: Color(0xFF555555)),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00A9CE),
foregroundColor: Colors.black,
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(3)),
),
textStyle: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
letterSpacing: 0.5,
),
),
),
dividerTheme: const DividerThemeData(
color: Color(0xFF2E2E2E),
thickness: 1,
),
useMaterial3: true,
),
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import "trip.dart";
import "operations/trip.dart";
class BRRState {
final List<Trip> trips;
+67
View File
@@ -0,0 +1,67 @@
import "package:supabase_flutter/supabase_flutter.dart";
enum ChannelKind { text, operations, voice, announcement, unknown }
abstract class BaseChannel {
BaseChannel({
required this.client,
required this.id,
required this.organizationId,
required this.name,
required this.description,
required this.slug,
required this.kind,
required this.isPrivate,
required this.position,
});
final SupabaseClient client;
final String id;
final String organizationId;
final String name;
final String description;
final String slug;
final ChannelKind kind;
final bool isPrivate;
final int position;
Map<String, dynamic> toJson() {
return {
"id": id,
"organization_id": organizationId,
"name": name,
"description": description,
"slug": slug,
"type": kind.name,
"is_private": isPrivate,
"position": position,
};
}
static ChannelKind channelKindFromApi(Object? rawType) {
final value = (rawType ?? "").toString().trim().toLowerCase();
switch (value) {
case "text":
return ChannelKind.text;
case "operations":
return ChannelKind.operations;
case "voice":
return ChannelKind.voice;
case "announcement":
return ChannelKind.announcement;
default:
return ChannelKind.unknown;
}
}
static Map<String, dynamic> asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
return <String, dynamic>{};
}
static List<dynamic> asList(Object? value) {
if (value is List) return value;
return const <dynamic>[];
}
}
+436
View File
@@ -0,0 +1,436 @@
import "package:bus_running_record/models/channels/base_channel.dart";
import "package:bus_running_record/models/operations/stop.dart";
import "package:supabase_flutter/supabase_flutter.dart";
class OperationsSchedule {
const OperationsSchedule({
required this.id,
required this.version,
required this.sourceFileName,
required this.uploadedAt,
});
final String id;
final int version;
final String sourceFileName;
final DateTime? uploadedAt;
factory OperationsSchedule.fromMap(Map<String, dynamic> map) {
return OperationsSchedule(
id: (map["id"] ?? "").toString(),
version: (map["version"] as num?)?.toInt() ?? 1,
sourceFileName: (map["source_file_name"] ?? "").toString(),
uploadedAt: DateTime.tryParse((map["uploaded_at"] ?? "").toString()),
);
}
}
class OperationsTrip {
const OperationsTrip({
required this.id,
required this.scheduleId,
required this.tripNumber,
required this.dutyNumber,
required this.busWorkNumber,
required this.direction,
required this.sortOrder,
});
final String id;
final String scheduleId;
final String tripNumber;
final String dutyNumber;
final String busWorkNumber;
final String direction;
final int sortOrder;
String get dutyKey => "$dutyNumber::$busWorkNumber";
factory OperationsTrip.fromMap(Map<String, dynamic> map) {
return OperationsTrip(
id: (map["id"] ?? "").toString(),
scheduleId: (map["schedule_id"] ?? "").toString(),
tripNumber: (map["trip_number"] ?? "").toString(),
dutyNumber: (map["duty_number"] ?? "").toString(),
busWorkNumber: (map["bus_work_number"] ?? "").toString(),
direction: (map["direction"] ?? "").toString(),
sortOrder: (map["sort_order"] as num?)?.toInt() ?? 0,
);
}
}
class OperationsScheduledStop extends Stop {
const OperationsScheduledStop({
required this.id,
required this.tripId,
required this.sequence,
required super.name,
required this.scheduledTime,
super.alias,
super.aliasSource,
});
final String id;
final String tripId;
final int sequence;
final String? scheduledTime;
}
class OperationsStopUpdate {
const OperationsStopUpdate({
required this.status,
required this.actualTime,
required this.vehicleId,
required this.notes,
});
final String? status;
final String? actualTime;
final String? vehicleId;
final String? notes;
}
class OperationsStop {
const OperationsStop({required this.scheduledStop, this.update});
final OperationsScheduledStop scheduledStop;
final OperationsStopUpdate? update;
String get id => scheduledStop.id;
String get tripId => scheduledStop.tripId;
int get sequence => scheduledStop.sequence;
String get name => scheduledStop.name;
String get displayName => scheduledStop.displayName;
String? get aliasSource => scheduledStop.aliasSource;
String? get scheduledTime => scheduledStop.scheduledTime;
String? get status => update?.status;
String? get actualTime => update?.actualTime;
String? get vehicleId => update?.vehicleId;
String? get notes => update?.notes;
}
class OperationsTripSnapshot {
const OperationsTripSnapshot({
required this.trip,
required this.stops,
required this.scheduledStops,
});
final OperationsTrip trip;
final List<OperationsStop> stops;
final List<OperationsScheduledStop> scheduledStops;
}
class OperationsDutySnapshot {
const OperationsDutySnapshot({
required this.dutyNumber,
required this.busWorkNumber,
required this.trips,
});
final String dutyNumber;
final String busWorkNumber;
final List<OperationsTripSnapshot> trips;
}
class OperationsStopAlias {
const OperationsStopAlias({
required this.rawStopName,
required this.aliasStopName,
required this.source,
});
final String rawStopName;
final String aliasStopName;
final String source;
}
class OperationsSnapshot {
const OperationsSnapshot({
required this.schedule,
required this.trips,
required this.duties,
required this.scheduledStops,
required this.stopAliasesByRawName,
required this.stopAliases,
});
final OperationsSchedule? schedule;
final List<OperationsTripSnapshot> trips;
final List<OperationsDutySnapshot> duties;
final List<OperationsScheduledStop> scheduledStops;
final Map<String, String> stopAliasesByRawName;
final List<OperationsStopAlias> stopAliases;
}
class OperationsChannel extends BaseChannel {
OperationsChannel({
required super.client,
required super.id,
required super.organizationId,
required super.name,
required super.description,
required super.slug,
required super.isPrivate,
required super.position,
}) : super(kind: ChannelKind.operations);
factory OperationsChannel.fromApi({
required SupabaseClient client,
required Map<String, dynamic> map,
}) {
return OperationsChannel(
client: client,
id: (map["id"] ?? "").toString(),
organizationId: (map["organization_id"] ?? "").toString(),
name: (map["name"] ?? "").toString(),
description: (map["description"] ?? map["topic"] ?? "").toString(),
slug: (map["slug"] ?? "").toString(),
isPrivate: map["is_private"] == true,
position: (map["position"] as num?)?.toInt() ?? 0,
);
}
Future<OperationsSnapshot> loadSnapshot() async {
final scheduleRow = await client
.from("operations_schedules")
.select("id, version, source_file_name, uploaded_at")
.eq("channel_id", id)
.eq("is_active", true)
.order("uploaded_at", ascending: false)
.limit(1)
.maybeSingle();
if (scheduleRow == null) {
return const OperationsSnapshot(
schedule: null,
trips: <OperationsTripSnapshot>[],
duties: <OperationsDutySnapshot>[],
scheduledStops: <OperationsScheduledStop>[],
stopAliasesByRawName: <String, String>{},
stopAliases: <OperationsStopAlias>[],
);
}
final stopAliases = await listStopAliases();
final stopAliasesByRawName = _aliasesByRawStopName(stopAliases);
final aliasesByNormalizedName = <String, String>{};
final aliasSourceByNormalizedName = <String, String>{};
for (final entry in stopAliasesByRawName.entries) {
aliasesByNormalizedName[Stop.normalizeName(entry.key)] = entry.value;
}
for (final alias in stopAliases) {
aliasSourceByNormalizedName[Stop.normalizeName(alias.rawStopName)] =
alias.source;
}
final schedule = OperationsSchedule.fromMap(BaseChannel.asMap(scheduleRow));
final tripRows = await client
.from("operations_trips")
.select(
"id, schedule_id, trip_number, duty_number, bus_work_number, direction, sort_order",
)
.eq("schedule_id", schedule.id)
.order("sort_order", ascending: true);
final trips = BaseChannel.asList(
tripRows,
).map((row) => OperationsTrip.fromMap(BaseChannel.asMap(row))).toList();
if (trips.isEmpty) {
return OperationsSnapshot(
schedule: schedule,
trips: const <OperationsTripSnapshot>[],
duties: const <OperationsDutySnapshot>[],
scheduledStops: const <OperationsScheduledStop>[],
stopAliasesByRawName: stopAliasesByRawName,
stopAliases: stopAliases,
);
}
final tripIds = trips.map((trip) => trip.id).toList();
final stopRows = await client
.from("operations_trip_stops")
.select("id, trip_id, stop_sequence, stop_name, scheduled_time")
.filter("trip_id", "in", "(${tripIds.join(",")})")
.order("trip_id", ascending: true)
.order("stop_sequence", ascending: true);
final stopIds = BaseChannel.asList(stopRows)
.map((row) => (BaseChannel.asMap(row)["id"] ?? "").toString())
.where((value) => value.isNotEmpty)
.toList();
List<dynamic> updateRows = const <dynamic>[];
if (stopIds.isNotEmpty) {
updateRows = await client
.from("operations_stop_updates")
.select("trip_stop_id, status, actual_time, vehicle_id, notes")
.filter("trip_stop_id", "in", "(${stopIds.join(",")})");
}
final updatesByStopId = <String, Map<String, dynamic>>{};
for (final row in BaseChannel.asList(updateRows)) {
final map = BaseChannel.asMap(row);
final tripStopId = (map["trip_stop_id"] ?? "").toString();
if (tripStopId.isEmpty) continue;
updatesByStopId[tripStopId] = map;
}
final scheduledStopsByTripId = <String, List<OperationsScheduledStop>>{};
final allScheduledStops = <OperationsScheduledStop>[];
for (final row in BaseChannel.asList(stopRows)) {
final map = BaseChannel.asMap(row);
final stopId = (map["id"] ?? "").toString();
final tripId = (map["trip_id"] ?? "").toString();
final scheduledStop = OperationsScheduledStop(
id: stopId,
tripId: tripId,
sequence: (map["stop_sequence"] as num?)?.toInt() ?? 0,
name: (map["stop_name"] ?? "").toString().trim(),
alias: aliasesByNormalizedName[
Stop.normalizeName((map["stop_name"] ?? "").toString())],
aliasSource: aliasSourceByNormalizedName[
Stop.normalizeName((map["stop_name"] ?? "").toString())],
scheduledTime: (map["scheduled_time"] ?? "").toString().trim().isEmpty
? null
: (map["scheduled_time"] ?? "").toString(),
);
allScheduledStops.add(scheduledStop);
scheduledStopsByTripId
.putIfAbsent(tripId, () => <OperationsScheduledStop>[])
.add(scheduledStop);
}
final snapshots = trips.map((trip) {
final scheduledStops =
scheduledStopsByTripId[trip.id] ?? const <OperationsScheduledStop>[];
final stops = scheduledStops
.map((scheduledStop) {
final update = updatesByStopId[scheduledStop.id];
final hasUpdate = update != null;
return OperationsStop(
scheduledStop: scheduledStop,
update: !hasUpdate
? null
: OperationsStopUpdate(
status: (update["status"] ?? "").toString().trim().isEmpty
? null
: (update["status"] ?? "").toString(),
actualTime:
(update["actual_time"] ?? "")
.toString()
.trim()
.isEmpty
? null
: (update["actual_time"] ?? "").toString(),
vehicleId:
(update["vehicle_id"] ?? "").toString().trim().isEmpty
? null
: (update["vehicle_id"] ?? "").toString(),
notes: (update["notes"] ?? "").toString().trim().isEmpty
? null
: (update["notes"] ?? "").toString(),
),
);
})
.toList(growable: false);
return OperationsTripSnapshot(
trip: trip,
stops: stops,
scheduledStops: scheduledStops,
);
}).toList();
final groupedByDuty = <String, List<OperationsTripSnapshot>>{};
for (final snapshot in snapshots) {
groupedByDuty
.putIfAbsent(snapshot.trip.dutyKey, () => <OperationsTripSnapshot>[])
.add(snapshot);
}
final duties = groupedByDuty.entries
.map((entry) {
final dutyTrips = entry.value;
final firstTrip = dutyTrips.first.trip;
return OperationsDutySnapshot(
dutyNumber: firstTrip.dutyNumber,
busWorkNumber: firstTrip.busWorkNumber,
trips: dutyTrips,
);
})
.toList(growable: false);
return OperationsSnapshot(
schedule: schedule,
trips: snapshots,
duties: duties,
scheduledStops: allScheduledStops,
stopAliasesByRawName: stopAliasesByRawName,
stopAliases: stopAliases,
);
}
Future<List<OperationsStopAlias>> listStopAliases() async {
final rows = await client
.from("operations_stop_aliases")
.select("raw_stop_name, alias_stop_name, source")
.eq("channel_id", id);
final aliases = <OperationsStopAlias>[];
for (final row in BaseChannel.asList(rows)) {
final map = BaseChannel.asMap(row);
final raw = (map["raw_stop_name"] ?? "").toString().trim();
final alias = (map["alias_stop_name"] ?? "").toString().trim();
final source = (map["source"] ?? "").toString().trim();
if (raw.isEmpty || alias.isEmpty) continue;
aliases.add(
OperationsStopAlias(
rawStopName: raw,
aliasStopName: alias,
source: source.isEmpty ? "user" : source,
),
);
}
return aliases;
}
Map<String, String> _aliasesByRawStopName(List<OperationsStopAlias> aliases) {
final byRawStopName = <String, String>{};
for (final alias in aliases) {
byRawStopName[alias.rawStopName] = alias.aliasStopName;
}
return byRawStopName;
}
Future<void> markStopCompleted({
required String tripStopId,
required bool completed,
String? actualTime,
String? vehicleId,
String? notes,
}) async {
final userId = client.auth.currentUser?.id;
if (userId == null || userId.isEmpty) {
throw StateError(
"Cannot update stop status without an authenticated user.",
);
}
String? persistedActualTime = actualTime;
if (completed &&
(persistedActualTime == null || persistedActualTime.isEmpty)) {
final now = DateTime.now();
final hour = now.hour.toString().padLeft(2, "0");
final minute = now.minute.toString().padLeft(2, "0");
persistedActualTime = "$hour:$minute";
}
await client.from("operations_stop_updates").upsert({
"trip_stop_id": tripStopId,
"status": completed ? "completed" : null,
"actual_time": completed ? persistedActualTime : null,
"vehicle_id": vehicleId,
"notes": notes,
"updated_by": userId,
}, onConflict: "trip_stop_id");
}
}
+129
View File
@@ -0,0 +1,129 @@
import "package:bus_running_record/models/channels/base_channel.dart";
import "package:supabase_flutter/supabase_flutter.dart";
class TextChannelMessage {
const TextChannelMessage({
required this.id,
required this.channelId,
required this.authorUserId,
required this.content,
required this.createdAt,
});
final String id;
final String channelId;
final String authorUserId;
final String content;
final DateTime? createdAt;
factory TextChannelMessage.fromMap(Map<String, dynamic> map) {
return TextChannelMessage(
id: (map["id"] ?? "").toString(),
channelId: (map["channel_id"] ?? "").toString(),
authorUserId: (map["author_user_id"] ?? "").toString(),
content: (map["content"] ?? "").toString(),
createdAt: DateTime.tryParse((map["created_at"] ?? "").toString()),
);
}
Map<String, dynamic> toJson() {
return {
"id": id,
"channel_id": channelId,
"author_user_id": authorUserId,
"content": content,
"created_at": createdAt?.toIso8601String(),
};
}
}
class TextChannel extends BaseChannel {
TextChannel({
required super.client,
required super.id,
required super.organizationId,
required super.name,
required super.description,
required super.slug,
required super.isPrivate,
required super.position,
}) : super(kind: ChannelKind.text);
factory TextChannel.fromApi({
required SupabaseClient client,
required Map<String, dynamic> map,
}) {
return TextChannel(
client: client,
id: (map["id"] ?? "").toString(),
organizationId: (map["organization_id"] ?? "").toString(),
name: (map["name"] ?? "").toString(),
description: (map["description"] ?? map["topic"] ?? "").toString(),
slug: (map["slug"] ?? "").toString(),
isPrivate: map["is_private"] == true,
position: (map["position"] as num?)?.toInt() ?? 0,
);
}
Future<List<TextChannelMessage>> listMessages({int limit = 50}) async {
final rows = await client
.from("messages")
.select("id, channel_id, author_user_id, content, created_at")
.eq("channel_id", id)
.order("created_at", ascending: true)
.limit(limit);
return BaseChannel.asList(rows)
.map((row) => TextChannelMessage.fromMap(BaseChannel.asMap(row)))
.where((message) => message.id.isNotEmpty)
.toList();
}
Future<TextChannelMessage?> sendMessage(String content) async {
final trimmed = content.trim();
if (trimmed.isEmpty) return null;
final authorUserId = client.auth.currentUser?.id;
if (authorUserId == null || authorUserId.isEmpty) {
throw StateError("Cannot send message without an authenticated user.");
}
final inserted = await client
.from("messages")
.insert({
"channel_id": id,
"author_user_id": authorUserId,
"content": trimmed,
})
.select("id, channel_id, author_user_id, content, created_at")
.single();
return TextChannelMessage.fromMap(BaseChannel.asMap(inserted));
}
RealtimeChannel subscribeToMessages({
required void Function() onMessageChanged,
void Function(RealtimeSubscribeStatus status, Object? error)? onStatus,
}) {
final topic = "messages:$id";
return client.channel(topic)
..onPostgresChanges(
event: PostgresChangeEvent.all,
schema: "public",
table: "messages",
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: "channel_id",
value: id,
),
callback: (_) => onMessageChanged(),
)
..subscribe((status, [error]) {
onStatus?.call(status, error);
});
}
Future<void> unsubscribe(RealtimeChannel channel) async {
await client.removeChannel(channel);
}
}
+24
View File
@@ -0,0 +1,24 @@
class Duty {
const Duty({required this.dutyNumber, required this.busWorkNumber});
final String dutyNumber;
final String busWorkNumber;
Duty copyWith({String? dutyNumber, String? busWorkNumber}) {
return Duty(
dutyNumber: dutyNumber ?? this.dutyNumber,
busWorkNumber: busWorkNumber ?? this.busWorkNumber,
);
}
Map<String, dynamic> toJson() {
return {"dutyNumber": dutyNumber, "busWorkNumber": busWorkNumber};
}
factory Duty.fromJson(Map<String, dynamic> json) {
return Duty(
dutyNumber: (json["dutyNumber"] ?? "").toString(),
busWorkNumber: (json["busWorkNumber"] ?? "").toString(),
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import "package:bus_running_record/models/operations/stop.dart";
class ScheduledStop extends Stop {
const ScheduledStop({
required super.name,
required this.sequence,
this.scheduledTime,
super.alias,
super.aliasSource,
});
final int sequence;
final String? scheduledTime;
ScheduledStop copyWith({
String? name,
String? alias,
String? aliasSource,
int? sequence,
String? scheduledTime,
}) {
return ScheduledStop(
name: name ?? this.name,
alias: alias ?? this.alias,
aliasSource: aliasSource ?? this.aliasSource,
sequence: sequence ?? this.sequence,
scheduledTime: scheduledTime ?? this.scheduledTime,
);
}
Map<String, dynamic> toJson() {
return {
"name": name,
"alias": alias,
"aliasSource": aliasSource,
"sequence": sequence,
"scheduledTime": scheduledTime,
};
}
factory ScheduledStop.fromJson(Map<String, dynamic> json) {
final raw = (json["scheduledTime"] ?? "").toString().trim();
return ScheduledStop(
name: (json["name"] ?? "").toString(),
alias: (json["alias"] ?? "").toString().trim().isEmpty
? null
: (json["alias"] ?? "").toString(),
aliasSource: (json["aliasSource"] ?? json["source"] ?? "")
.toString()
.trim()
.isEmpty
? null
: (json["aliasSource"] ?? json["source"] ?? "").toString(),
sequence: (json["sequence"] as num?)?.toInt() ?? 0,
scheduledTime: raw.isEmpty ? null : raw,
);
}
}
+15
View File
@@ -0,0 +1,15 @@
class Stop {
const Stop({required this.name, this.alias, this.aliasSource});
final String name;
final String? alias;
final String? aliasSource;
String get displayName {
final value = (alias ?? "").trim();
if (value.isEmpty) return name;
return value;
}
static String normalizeName(String name) => name.trim().toLowerCase();
}
+206
View File
@@ -0,0 +1,206 @@
import "package:bus_running_record/models/operations/duty.dart";
import "package:bus_running_record/models/operations/scheduled_stop.dart";
import "package:bus_running_record/models/operations/stop.dart";
class Trip {
final String scheduledTime; // "15:31" - default departure time
final String tripNumber; // "112"
final Duty duty;
final String tripType; // "", "N", "R", "F"
final bool isFinishing;
final List<ScheduledStop> scheduledStops;
final String direction; // "outbound" or "inbound"
String? actualDepartureTime; // "15:33" (nullable - user input)
String? actualFleetNumber; // "33523" (nullable - user input)
Trip({
required this.scheduledTime,
required this.tripNumber,
String? dutyNumber,
String? busWorkNumber,
Duty? duty,
this.tripType = "",
this.isFinishing = false,
Map<String, String> stationTimes = const {},
List<String> stationOrder = const [],
List<ScheduledStop>? scheduledStops,
this.direction = "outbound",
this.actualDepartureTime,
this.actualFleetNumber,
}) : duty =
duty ??
Duty(
dutyNumber: dutyNumber ?? "",
busWorkNumber: busWorkNumber ?? "",
),
scheduledStops =
scheduledStops ?? _buildScheduledStops(stationTimes, stationOrder);
bool get isComplete =>
actualDepartureTime != null && actualFleetNumber != null;
String get dutyNumber => duty.dutyNumber;
String get busWorkNumber => duty.busWorkNumber;
List<String> get stationOrder =>
scheduledStops.map((stop) => stop.name).toList(growable: false);
Map<String, String> get stationTimes {
final result = <String, String>{};
for (final stop in scheduledStops) {
final time = (stop.scheduledTime ?? "").trim();
if (time.isEmpty) continue;
result[stop.name] = time;
}
return result;
}
Trip copyWith({
String? scheduledTime,
String? tripNumber,
String? dutyNumber,
String? busWorkNumber,
Duty? duty,
String? tripType,
bool? isFinishing,
Map<String, String>? stationTimes,
List<String>? stationOrder,
List<ScheduledStop>? scheduledStops,
String? direction,
String? actualDepartureTime,
String? actualFleetNumber,
}) {
return Trip(
scheduledTime: scheduledTime ?? this.scheduledTime,
tripNumber: tripNumber ?? this.tripNumber,
duty:
duty ??
this.duty.copyWith(
dutyNumber: dutyNumber,
busWorkNumber: busWorkNumber,
),
tripType: tripType ?? this.tripType,
isFinishing: isFinishing ?? this.isFinishing,
scheduledStops:
scheduledStops ??
(stationTimes != null || stationOrder != null
? _buildScheduledStops(
stationTimes ?? this.stationTimes,
stationOrder ?? this.stationOrder,
)
: this.scheduledStops),
direction: direction ?? this.direction,
actualDepartureTime: actualDepartureTime ?? this.actualDepartureTime,
actualFleetNumber: actualFleetNumber ?? this.actualFleetNumber,
);
}
Trip withStopAliases(
Map<String, String> aliasesByRawName, {
String aliasSource = "ai",
}) {
if (aliasesByRawName.isEmpty) return this;
final aliasesByNormalizedName = <String, String>{};
for (final entry in aliasesByRawName.entries) {
aliasesByNormalizedName[Stop.normalizeName(entry.key)] = entry.value;
}
return copyWith(
scheduledStops: scheduledStops
.map(
(stop) => stop.copyWith(
alias: aliasesByNormalizedName[Stop.normalizeName(stop.name)],
aliasSource:
aliasesByNormalizedName[Stop.normalizeName(stop.name)] == null
? stop.aliasSource
: aliasSource,
),
)
.toList(growable: false),
);
}
Map<String, dynamic> toJson() {
return {
"scheduledTime": scheduledTime,
"tripNumber": tripNumber,
"duty": duty.toJson(),
"dutyNumber": dutyNumber,
"busWorkNumber": busWorkNumber,
"tripType": tripType,
"isFinishing": isFinishing,
"scheduledStops": scheduledStops.map((stop) => stop.toJson()).toList(),
"stationTimes": stationTimes,
"stationOrder": stationOrder,
"direction": direction,
"actualDepartureTime": actualDepartureTime,
"actualFleetNumber": actualFleetNumber,
};
}
factory Trip.fromJson(Map<String, dynamic> json) {
final dutyJson = json["duty"];
final duty = dutyJson is Map<String, dynamic>
? Duty.fromJson(dutyJson)
: Duty(
dutyNumber: (json["dutyNumber"] ?? "").toString(),
busWorkNumber:
(json["busWorkNumber"] ?? json["runningNumber"] ?? "")
.toString(),
);
final scheduledStopsJson = json["scheduledStops"];
final scheduledStops = scheduledStopsJson is List
? scheduledStopsJson
.whereType<Map>()
.map(
(stopJson) =>
ScheduledStop.fromJson(Map<String, dynamic>.from(stopJson)),
)
.toList()
: null;
return Trip(
scheduledTime: json["scheduledTime"] as String,
tripNumber: json["tripNumber"] as String,
duty: duty,
tripType: json["tripType"] as String? ?? "",
isFinishing: json["isFinishing"] as bool? ?? false,
scheduledStops: scheduledStops,
stationTimes: Map<String, String>.from(
json["stationTimes"] as Map? ?? {},
),
stationOrder: List<String>.from(json["stationOrder"] as List? ?? []),
direction: json["direction"] as String? ?? "outbound",
actualDepartureTime: json["actualDepartureTime"] as String?,
actualFleetNumber: json["actualFleetNumber"] as String?,
);
}
static List<ScheduledStop> _buildScheduledStops(
Map<String, String> stationTimes,
List<String> stationOrder,
) {
if (stationOrder.isEmpty) {
var index = 0;
return stationTimes.entries
.map(
(entry) => ScheduledStop(
name: entry.key,
sequence: ++index,
scheduledTime: entry.value.trim().isEmpty ? null : entry.value,
),
)
.toList(growable: false);
}
return List<ScheduledStop>.generate(stationOrder.length, (i) {
final stopName = stationOrder[i];
final time = (stationTimes[stopName] ?? "").trim();
return ScheduledStop(
name: stopName,
sequence: i + 1,
scheduledTime: time.isEmpty ? null : time,
);
}, growable: false);
}
}
-91
View File
@@ -1,91 +0,0 @@
class Trip {
final String scheduledTime; // "15:31" - default departure time (first non-empty time)
final String tripNumber; // "112"
final String dutyNumber; // "518"
final String runningNumber; // "518"
final String tripType; // "", "N", "R", "F"
final bool isFinishing;
final Map<String, String> stationTimes; // {"UXBG": "15:31", "HILL": "15:42", ...}
final List<String> stationOrder; // ["UXBG", "UXBG", "HILL", "ICKE", ...]
final String direction; // "outbound" or "inbound"
String? actualDepartureTime; // "15:33" (nullable - user input)
String? actualFleetNumber; // "33523" (nullable - user input)
Trip({
required this.scheduledTime,
required this.tripNumber,
required this.dutyNumber,
required this.runningNumber,
this.tripType = "",
this.isFinishing = false,
this.stationTimes = const {},
this.stationOrder = const [],
this.direction = "outbound",
this.actualDepartureTime,
this.actualFleetNumber,
});
bool get isComplete =>
actualDepartureTime != null && actualFleetNumber != null;
Trip copyWith({
String? scheduledTime,
String? tripNumber,
String? dutyNumber,
String? runningNumber,
String? tripType,
bool? isFinishing,
Map<String, String>? stationTimes,
List<String>? stationOrder,
String? direction,
String? actualDepartureTime,
String? actualFleetNumber,
}) {
return Trip(
scheduledTime: scheduledTime ?? this.scheduledTime,
tripNumber: tripNumber ?? this.tripNumber,
dutyNumber: dutyNumber ?? this.dutyNumber,
runningNumber: runningNumber ?? this.runningNumber,
tripType: tripType ?? this.tripType,
isFinishing: isFinishing ?? this.isFinishing,
stationTimes: stationTimes ?? this.stationTimes,
stationOrder: stationOrder ?? this.stationOrder,
direction: direction ?? this.direction,
actualDepartureTime: actualDepartureTime ?? this.actualDepartureTime,
actualFleetNumber: actualFleetNumber ?? this.actualFleetNumber,
);
}
Map<String, dynamic> toJson() {
return {
"scheduledTime": scheduledTime,
"tripNumber": tripNumber,
"dutyNumber": dutyNumber,
"runningNumber": runningNumber,
"tripType": tripType,
"isFinishing": isFinishing,
"stationTimes": stationTimes,
"stationOrder": stationOrder,
"direction": direction,
"actualDepartureTime": actualDepartureTime,
"actualFleetNumber": actualFleetNumber,
};
}
factory Trip.fromJson(Map<String, dynamic> json) {
return Trip(
scheduledTime: json["scheduledTime"] as String,
tripNumber: json["tripNumber"] as String,
dutyNumber: json["dutyNumber"] as String,
runningNumber: json["runningNumber"] as String,
tripType: json["tripType"] as String? ?? "",
isFinishing: json["isFinishing"] as bool? ?? false,
stationTimes: Map<String, String>.from(json["stationTimes"] as Map? ?? {}),
stationOrder: List<String>.from(json["stationOrder"] as List? ?? []),
direction: json["direction"] as String? ?? "outbound",
actualDepartureTime: json["actualDepartureTime"] as String?,
actualFleetNumber: json["actualFleetNumber"] as String?,
);
}
}
+963
View File
@@ -0,0 +1,963 @@
import "dart:async";
import "dart:math" as math;
import "package:bus_running_record/pages/auth/verify_email_page.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:flutter/material.dart" as material;
import "package:flutter/services.dart"
as flutter_services
show TextInput, TextInputAction;
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class LoginPage extends StatefulWidget {
static const String routePath = "/login";
static final GoRoute route = GoRoute(
path: routePath,
builder: (context, state) => const LoginPage(),
);
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
static const _loginEmailKey = TextFieldKey("login_email");
static const _loginPasswordKey = TextFieldKey("login_password");
static const _signUpEmailKey = TextFieldKey("signup_email");
static const _signUpPasswordKey = TextFieldKey("signup_password");
static const _confirmPasswordKey = TextFieldKey("signup_confirm_password");
bool _isSubmitting = false;
bool _isExitingScreen = false;
bool _isSignUpMode = false;
bool _isSignUpPasswordStep = false;
bool _desktopChecklistEntered = false;
String? _error;
int _signInShakeNonce = 0;
String _loginEmailDraft = "";
String _loginPasswordDraft = "";
String _signUpPasswordDraft = "";
String _signUpEmailDraft = "";
String _signUpConfirmPasswordDraft = "";
final FocusNode _loginEmailFocusNode = FocusNode();
final FocusNode _loginPasswordFocusNode = FocusNode();
final FocusNode _signUpEmailFocusNode = FocusNode();
final FocusNode _signUpPasswordFocusNode = FocusNode();
final FocusNode _signUpConfirmPasswordFocusNode = FocusNode();
@override
void initState() {
super.initState();
_signUpPasswordFocusNode.addListener(_onSignUpPasswordFocusChanged);
}
@override
void dispose() {
_loginEmailFocusNode.dispose();
_loginPasswordFocusNode.dispose();
_signUpEmailFocusNode.dispose();
_signUpPasswordFocusNode.removeListener(_onSignUpPasswordFocusChanged);
_signUpPasswordFocusNode.dispose();
_signUpConfirmPasswordFocusNode.dispose();
super.dispose();
}
void _onSignUpPasswordFocusChanged() {
if (!mounted) return;
setState(() {});
}
Future<bool> _submitAuth({
required String email,
required String password,
}) async {
final supabase = context.read<SupabaseProvider>();
setState(() {
_isSubmitting = true;
_error = null;
});
try {
if (_isSignUpMode) {
await supabase.signUpWithPassword(email: email, password: password);
if (mounted) {
flutter_services.TextInput.finishAutofillContext(shouldSave: true);
// Force token-based verification flow after sign-up.
await supabase.signOut();
if (!mounted) return false;
final encodedEmail = Uri.encodeQueryComponent(email);
await _animateOutAndGo(
"${VerifyEmailPage.routePath}?email=$encodedEmail",
);
}
return true;
} else {
await supabase.signInWithPassword(email: email, password: password);
}
if (mounted) {
flutter_services.TextInput.finishAutofillContext(shouldSave: true);
}
return true;
} catch (error, stackTrace) {
_logAuthError("submitAuth", error, stackTrace);
if (!_isSignUpMode && _isEmailNotConfirmedError(error)) {
try {
await supabase.signOut();
await supabase.resendSignUpOtp(email: email);
if (mounted) {
final encodedEmail = Uri.encodeQueryComponent(email);
await _animateOutAndGo(
"${VerifyEmailPage.routePath}?email=$encodedEmail",
);
}
return true;
} catch (resendError, resendStackTrace) {
_logAuthError(
"submitAuth/signInEmailNotConfirmedResend",
resendError,
resendStackTrace,
);
if (mounted) {
setState(
() => _error =
"Email is not confirmed yet. We couldn't resend a token right now.",
);
}
return false;
}
}
if (_isSignUpMode && _isExistingAccountError(error)) {
if (mounted) {
await supabase.signOut();
if (!mounted) return false;
final encodedEmail = Uri.encodeQueryComponent(email);
await _animateOutAndGo(
"${VerifyEmailPage.routePath}?email=$encodedEmail",
);
}
return true;
}
if (mounted) {
if (!_isSignUpMode) {
_triggerSignInShake();
}
setState(() => _error = _formatAuthError(error));
}
return false;
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
void _logAuthError(String operation, Object error, StackTrace stackTrace) {
debugPrint("[AuthPage] $operation failed (${error.runtimeType}): $error");
debugPrintStack(stackTrace: stackTrace);
}
void _triggerSignInShake() {
if (!mounted) return;
setState(() {
_signInShakeNonce += 1;
});
}
Future<void> _animateOutAndGo(String location) async {
if (!mounted) return;
if (_isExitingScreen) return;
FocusScope.of(context).unfocus();
setState(() {
_isExitingScreen = true;
});
await Future.delayed(const Duration(milliseconds: 260));
if (mounted) {
context.go(location);
}
}
bool _isExistingAccountError(Object error) {
final message = error.toString().toLowerCase();
return message.contains("already registered") ||
message.contains("already exists") ||
message.contains("user already exists") ||
message.contains("database error saving new user") ||
(message.contains("unexpected_failure") &&
message.contains("saving new user")) ||
message.contains("duplicate key value violates unique constraint");
}
bool _isEmailNotConfirmedError(Object error) {
final message = error.toString().toLowerCase();
return message.contains("email not confirmed") ||
message.contains("email_not_confirmed");
}
String _formatAuthError(Object error) {
final raw = error.toString().trim();
final wrapped = RegExp(
r'^[A-Za-z_]\w*\(message:\s*(.*?)(?:,\s*[A-Za-z_]\w*:\s*.*)?\)$',
dotAll: true,
).firstMatch(raw);
final message = wrapped?.group(1) ?? raw;
return message.trim();
}
void _submitLoginFromKeyboard(BuildContext fieldContext) {
try {
fieldContext.submitForm();
} catch (error, stackTrace) {
debugPrint("[AuthPage] submitLoginFromKeyboard fallback: $error");
debugPrintStack(stackTrace: stackTrace);
final email = _loginEmailDraft.trim();
final password = _loginPasswordDraft;
if (email.isNotEmpty && password.isNotEmpty) {
unawaited(_submitAuth(email: email, password: password));
}
} finally {
FocusScope.of(fieldContext).unfocus();
}
}
void _submitSignUpEmailFromKeyboard(BuildContext fieldContext) {
try {
fieldContext.submitForm();
} catch (error, stackTrace) {
debugPrint("[AuthPage] submitSignUpEmailFromKeyboard fallback: $error");
debugPrintStack(stackTrace: stackTrace);
final email = _signUpEmailDraft.trim();
if (email.isNotEmpty) {
_handleSignUpEmailContinue(email);
}
} finally {
FocusScope.of(fieldContext).unfocus();
}
}
void _submitSignUpPasswordFromKeyboard(BuildContext fieldContext) {
try {
fieldContext.submitForm();
} catch (error, stackTrace) {
debugPrint("[AuthPage] submitSignUpPasswordFromKeyboard fallback: $error");
debugPrintStack(stackTrace: stackTrace);
final email = _signUpEmailDraft.trim();
final password = _signUpPasswordDraft;
if (email.isNotEmpty && password.isNotEmpty) {
unawaited(_submitAuth(email: email, password: password));
}
} finally {
FocusScope.of(fieldContext).unfocus();
}
}
Future<ValidationResult?> _validateLoginSubmission(String? value) async {
final email = _loginEmailDraft.trim();
final password = value ?? "";
final success = await _submitAuth(email: email, password: password);
if (success) {
return null;
}
return InvalidResult(
_error ?? "Unable to sign in. Please try again.",
state: FormValidationMode.submitted,
);
}
Future<ValidationResult?> _validateSignUpSubmission(String? value) async {
final email = _signUpEmailDraft.trim();
final password = value ?? "";
final success = await _submitAuth(email: email, password: password);
if (success) {
return null;
}
return InvalidResult(
_error ?? "Unable to create account. Please try again.",
state: FormValidationMode.submitted,
);
}
void _toggleMode() {
if (_isSubmitting || _isExitingScreen) return;
setState(() {
_isSignUpMode = !_isSignUpMode;
_error = null;
});
}
void _continueToPasswordStep(String email) {
setState(() {
_signUpEmailDraft = email;
_isSignUpPasswordStep = true;
_desktopChecklistEntered = false;
_error = null;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_signUpPasswordFocusNode.requestFocus();
setState(() {
_desktopChecklistEntered = true;
});
});
}
void _handleSignUpEmailContinue(String email) {
_continueToPasswordStep(email);
}
Future<void> _backToEmailStep() async {
final isDesktop = MediaQuery.of(context).size.width >= 600;
if (isDesktop && _desktopChecklistEntered) {
setState(() {
_desktopChecklistEntered = false;
});
await Future.delayed(const Duration(milliseconds: 50));
if (!mounted) return;
}
setState(() {
_isSignUpPasswordStep = false;
_desktopChecklistEntered = false;
_error = null;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_signUpEmailFocusNode.requestFocus();
}
});
}
Widget _buildErrorBlock(String? initializationError) {
final messages = <String>[
if (initializationError != null)
"Supabase init failed: $initializationError",
if (_error != null) _error!,
];
return AnimatedCrossFade(
duration: const Duration(milliseconds: 260),
sizeCurve: Curves.easeInOutCubic,
firstCurve: Curves.easeOutCubic,
secondCurve: Curves.easeInCubic,
crossFadeState: messages.isEmpty
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: const SizedBox(key: ValueKey("no-error"), height: 24),
secondChild: Column(
key: const ValueKey("with-error"),
children: [
const Gap(24),
for (var i = 0; i < messages.length; i++) ...[
Text(
messages[i],
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).small.semiBold,
if (i < messages.length - 1) const Gap(8),
],
const Gap(24),
],
),
);
}
Widget _buildPasswordRequirement({required bool met, required String label}) {
final color = met
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.mutedForeground;
return Row(
children: [
Icon(
met ? LucideIcons.circleCheck : LucideIcons.circle,
color: color,
size: 14,
),
const Gap(8),
Text(label, style: TextStyle(color: color)).small,
],
);
}
Widget _buildLoginForm(String? initializationError) {
return AutofillGroup(
child: Form(
key: const ValueKey("login-form"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text("Welcome back").x3Large.black,
Text("Sign in with your account.").xSmall.muted,
const Gap(26),
SizedBox(
width: double.infinity,
child: FormField(
key: _loginEmailKey,
label: const Text("Email"),
validator:
const NotEmptyValidator(message: "Email is required") &
const EmailValidator(message: "Enter a valid email"),
showErrors: const {
FormValidationMode.changed,
FormValidationMode.submitted,
},
child: TextField(
initialValue: _loginEmailDraft,
placeholder: const Text("Enter your email"),
focusNode: _loginEmailFocusNode,
keyboardType: TextInputType.emailAddress,
textInputAction: flutter_services.TextInputAction.next,
autofillHints: const [AutofillHints.email],
features: const [
InputFeature.leading(Icon(LucideIcons.mail)),
],
onChanged: (value) {
_loginEmailDraft = value;
},
onSubmitted: (_) => _loginPasswordFocusNode.requestFocus(),
),
),
),
const Gap(16),
SizedBox(
width: double.infinity,
child: FormField(
key: _loginPasswordKey,
label: const Text("Password"),
validator:
const NotEmptyValidator(message: "Password is required") &
const LengthValidator(
min: 8,
message: "Minimum 8 characters",
) &
ValidationMode(
ValidatorBuilder<String>(_validateLoginSubmission),
mode: {FormValidationMode.submitted},
),
showErrors: const {
FormValidationMode.changed,
FormValidationMode.submitted,
},
child: TextField(
initialValue: _loginPasswordDraft,
placeholder: const Text("Your password"),
focusNode: _loginPasswordFocusNode,
obscureText: true,
textInputAction: flutter_services.TextInputAction.done,
features: const [
InputFeature.leading(Icon(LucideIcons.lock)),
],
autofillHints: const [AutofillHints.password],
onChanged: (value) {
_loginPasswordDraft = value;
},
onEditingComplete: () => _submitLoginFromKeyboard(context),
onSubmitted: (_) => _submitLoginFromKeyboard(context),
),
),
),
_buildErrorBlock(initializationError),
SizedBox(
width: double.infinity,
child: SubmitButton(
loading: const Text("Signing in..."),
loadingTrailing: AspectRatio(
aspectRatio: 1,
child: CircularProgressIndicator(
onSurface: true,
strokeWidth: 2,
),
),
alignment: Alignment.center,
child: const Text("Sign in"),
),
),
const Gap(8),
Button.text(
onPressed: _isSubmitting ? null : _toggleMode,
child: const Text("Need an account? Create one"),
),
const Gap(24),
material.ElevatedButton(
onPressed: () {
context.go("/deprecated");
},
child: const Text("Use Legacy System"),
)
],
),
),
);
}
Widget _buildSignUpForm(String? initializationError) {
final isDesktop = MediaQuery.of(context).size.width >= 600;
final showPasswordChecklist = isDesktop
? _desktopChecklistEntered
: _signUpPasswordFocusNode.hasFocus;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 260),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
transitionBuilder: (child, animation) {
final slide =
Tween<Offset>(
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: slide, child: child),
);
},
child: _isSignUpPasswordStep
? AutofillGroup(
child: Form(
key: const ValueKey("signup-password-step"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text("Create account").x3Large.black,
Text(
"Set a password for\n$_signUpEmailDraft.",
).xSmall.muted.textCenter,
const Gap(26),
SizedBox(
width: double.infinity,
child: FormField(
key: _signUpPasswordKey,
label: const Text("Password"),
validator:
const NotEmptyValidator(
message: "Password is required",
) &
const LengthValidator(
min: 8,
message: "Minimum 8 characters",
) &
const SafePasswordValidator(
requireDigit: true,
requireLowercase: true,
requireUppercase: true,
requireSpecialChar: true,
message:
"Password must include lowercase, uppercase, number, and symbol.",
),
showErrors: const {
FormValidationMode.changed,
FormValidationMode.submitted,
},
child: TextField(
initialValue: _signUpPasswordDraft,
placeholder: const Text("Create a password"),
obscureText: true,
features: const [
InputFeature.leading(Icon(LucideIcons.lock)),
],
autofillHints: const [AutofillHints.newPassword],
focusNode: _signUpPasswordFocusNode,
textInputAction:
flutter_services.TextInputAction.next,
onSubmitted: (_) =>
_signUpConfirmPasswordFocusNode.requestFocus(),
onChanged: (value) {
setState(() {
_signUpPasswordDraft = value;
});
},
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
child: showPasswordChecklist
? Column(
children: [
const Gap(12),
Align(
alignment: Alignment.centerLeft,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: 1,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
_buildPasswordRequirement(
met: RegExp(
r"[A-Z]",
).hasMatch(_signUpPasswordDraft),
label: "Uppercase letter",
),
const Gap(6),
_buildPasswordRequirement(
met: RegExp(
r"[a-z]",
).hasMatch(_signUpPasswordDraft),
label: "Lowercase letter",
),
const Gap(6),
_buildPasswordRequirement(
met: RegExp(
r"\d",
).hasMatch(_signUpPasswordDraft),
label: "Number",
),
const Gap(6),
_buildPasswordRequirement(
met: RegExp(
r"[\W_]",
).hasMatch(_signUpPasswordDraft),
label:
"Special character (e.g. !?<>@#\$%)",
),
const Gap(6),
_buildPasswordRequirement(
met: _signUpPasswordDraft.length >= 8,
label: "8 characters or more",
),
],
),
),
),
],
)
: const SizedBox.shrink(),
),
const Gap(16),
SizedBox(
width: double.infinity,
child: FormField(
key: _confirmPasswordKey,
label: const Text("Confirm Password"),
validator:
CompareWith.equal(
_signUpPasswordKey,
message: "Passwords do not match",
) &
ValidationMode(
ValidatorBuilder<String>(
_validateSignUpSubmission,
),
mode: {FormValidationMode.submitted},
),
showErrors: const {
FormValidationMode.changed,
FormValidationMode.submitted,
},
child: TextField(
initialValue: _signUpConfirmPasswordDraft,
placeholder: const Text("Confirm password"),
focusNode: _signUpConfirmPasswordFocusNode,
obscureText: true,
textInputAction:
flutter_services.TextInputAction.done,
features: const [
InputFeature.leading(Icon(LucideIcons.lockKeyhole)),
],
autofillHints: const [AutofillHints.newPassword],
onChanged: (value) {
_signUpConfirmPasswordDraft = value;
},
onEditingComplete: () =>
_submitSignUpPasswordFromKeyboard(context),
onSubmitted: (_) =>
_submitSignUpPasswordFromKeyboard(context),
),
),
),
_buildErrorBlock(initializationError),
SizedBox(
width: double.infinity,
child: SubmitButton(
loading: const Text("Creating account..."),
loadingTrailing: AspectRatio(
aspectRatio: 1,
child: CircularProgressIndicator(
onSurface: true,
strokeWidth: 2,
),
),
alignment: Alignment.center,
child: const Text("Create account"),
),
),
const Gap(8),
Button.text(
onPressed: _isSubmitting
? null
: () => unawaited(_backToEmailStep()),
child: const Text("Back"),
),
],
),
),
)
: AutofillGroup(
child: Form(
key: const ValueKey("signup-email-step"),
onSubmit: (context, values) {
final email = (_signUpEmailKey[values] ?? "").trim();
_handleSignUpEmailContinue(email);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text("Create account").x3Large.black,
Text(
"Start with your email address",
).xSmall.muted.textCenter,
const Gap(26),
SizedBox(
width: double.infinity,
child: FormField(
key: _signUpEmailKey,
label: const Text("Email"),
validator:
const NotEmptyValidator(
message: "Email is required",
) &
const EmailValidator(
message: "Enter a valid email",
),
showErrors: const {
FormValidationMode.changed,
FormValidationMode.submitted,
},
child: TextField(
initialValue: _signUpEmailDraft,
placeholder: const Text("Enter your email"),
focusNode: _signUpEmailFocusNode,
keyboardType: TextInputType.emailAddress,
textInputAction:
flutter_services.TextInputAction.done,
autofillHints: const [AutofillHints.email],
features: const [
InputFeature.leading(Icon(LucideIcons.mail)),
],
onChanged: (value) {
_signUpEmailDraft = value;
},
onEditingComplete: () =>
_submitSignUpEmailFromKeyboard(context),
onSubmitted: (_) =>
_submitSignUpEmailFromKeyboard(context),
),
),
),
_buildErrorBlock(initializationError),
SizedBox(
width: double.infinity,
child: SubmitButton(
loading: const Text("Continuing..."),
loadingTrailing: AspectRatio(
aspectRatio: 1,
child: CircularProgressIndicator(
onSurface: true,
strokeWidth: 2,
),
),
alignment: Alignment.center,
child: const Text("Continue"),
),
),
const Gap(8),
Button.text(
onPressed: _isSubmitting ? null : _toggleMode,
child: const Text("Already have an account? Sign in"),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final supabase = context.watch<SupabaseProvider>();
const String? initializationError = null;
final topPadding = MediaQuery.of(context).padding.top;
final bottomPadding = MediaQuery.of(context).padding.bottom;
bool isMobile = MediaQuery.of(context).size.width < 600;
final header = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("ROADBOUND", style: TextStyle(height: 0.9, fontSize: 24)).black,
Text(
"by IMBENJI.NET LTD",
style: TextStyle(height: 0.9, fontSize: 12),
).bold.muted,
],
);
final showValidatingState = !_isSignUpMode && supabase.isValidatingSession;
final shouldHideForExit = _isExitingScreen && !showValidatingState;
final content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
IgnorePointer(
ignoring: _isSignUpMode || shouldHideForExit,
child: ExcludeFocus(
excluding: _isSignUpMode,
child: AnimatedSlide(
duration: const Duration(milliseconds: 320),
curve: Curves.easeInOutCubic,
offset: _isSignUpMode ? const Offset(-1.0, 0) : Offset.zero,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
opacity: _isSignUpMode ? 0 : 1,
child: _signInShakeNonce == 0
? _buildLoginForm(initializationError)
: TweenAnimationBuilder<double>(
key: ValueKey("login-shake-$_signInShakeNonce"),
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
builder: (context, value, child) {
final amplitude = 12 * (1 - value);
final dx =
math.sin(value * math.pi * 6) * amplitude;
return Transform.translate(
offset: Offset(dx, 0),
child: child,
);
},
child: _buildLoginForm(initializationError),
),
),
),
),
),
IgnorePointer(
ignoring: !_isSignUpMode || shouldHideForExit,
child: ExcludeFocus(
excluding: !_isSignUpMode,
child: AnimatedSlide(
duration: const Duration(milliseconds: 320),
curve: Curves.easeInOutCubic,
offset: _isSignUpMode ? Offset.zero : const Offset(1.0, 0),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
opacity: _isSignUpMode ? 1 : 0,
child: _buildSignUpForm(initializationError),
),
),
),
),
if (showValidatingState)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: Theme.of(
context,
).colorScheme.background.withValues(alpha: 0.85),
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
Gap(12),
Text("Validating session..."),
],
),
),
),
),
),
],
),
),
],
);
bool isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0;
return Scaffold(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Gap(topPadding + 16),
header,
if (false) ...[
content,
const Spacer(),
] else ...[
Expanded(
child: Center(
child: AnimatedSlide(
duration: const Duration(milliseconds: 260),
curve: Curves.easeInCubic,
offset: _isExitingScreen
? const Offset(0, -0.08)
: Offset.zero,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInCubic,
opacity: shouldHideForExit ? 0 : 1,
child: content,
),
),
),
),
],
Visibility(
visible: !isKeyboardOpen,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
child: Column(
children: [
Text(
"IMBENJI.NET LTD is a private limited company registered in England & Wales. Company number: 16955294.",
).xSmall.muted.textCenter,
Gap(bottomPadding + 16),
],
),
),
],
),
),
),
),
);
}
}
+143
View File
@@ -0,0 +1,143 @@
import "dart:async";
import "package:bus_running_record/pages/auth/page.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class VerifyEmailPage extends StatefulWidget {
static const String routePath = "/verify-email";
static final GoRoute route = GoRoute(
path: routePath,
builder: (context, state) {
final email = state.uri.queryParameters["email"] ?? "";
return VerifyEmailPage(email: email);
},
);
const VerifyEmailPage({required this.email, super.key});
final String email;
@override
State<VerifyEmailPage> createState() => _VerifyEmailPageState();
}
class _VerifyEmailPageState extends State<VerifyEmailPage> {
static const int _tokenLength = 6;
String _token = "";
bool _isSubmitting = false;
String? _error;
Future<void> _verifyToken(String token) async {
if (widget.email.isEmpty) {
setState(() => _error = "Missing email address for verification.");
return;
}
if (token.length != _tokenLength) {
setState(() => _error = "Enter the $_tokenLength-digit token from your email.");
return;
}
setState(() {
_isSubmitting = true;
_error = null;
});
try {
await context.read<SupabaseProvider>().verifySignUpOtp(
email: widget.email,
token: token,
);
if (mounted) context.go("/");
} catch (error, stackTrace) {
debugPrint(
"[VerifyEmailPage] verifyToken failed (${error.runtimeType}): $error",
);
debugPrintStack(stackTrace: stackTrace);
if (mounted) {
setState(() {
_error = error.toString();
});
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Gap(topPadding + 16),
Text("You're almost there!").x2Large.semiBold,
const Gap(8),
Text(
"We sent a $_tokenLength-digit token to your email.\n(${widget.email})",
).xSmall.muted.textCenter,
const Gap(14),
InputOTP(
onChanged: (value) {
setState(() {
_token = value.otpToString();
_error = null;
});
},
onSubmitted: (value) {
FocusScope.of(context).unfocus();
unawaited(_verifyToken(value.otpToString()));
},
children: [
InputOTPChild.character(allowDigit: true),
InputOTPChild.character(allowDigit: true),
InputOTPChild.character(allowDigit: true),
InputOTPChild.character(allowDigit: true),
InputOTPChild.character(allowDigit: true),
InputOTPChild.character(allowDigit: true),
],
),
const Gap(20),
Button.primary(
enabled: !_isSubmitting && _token.length == _tokenLength,
onPressed: () => unawaited(_verifyToken(_token)),
alignment: Alignment.center,
child: Text(
_isSubmitting ? "Verifying..." : "Verify token",
),
),
const Gap(8),
Button.text(
onPressed: _isSubmitting
? null
: () => context.go(LoginPage.routePath),
child: const Text("Back to sign in"),
),
if (_error != null) ...[
const Gap(8),
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).xSmall.textCenter,
],
],
),
),
),
),
);
}
}
@@ -0,0 +1,42 @@
import "package:bus_running_record/models/channels/operations_channel.dart";
import "package:go_router/go_router.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class OperationsChannelView extends StatelessWidget {
const OperationsChannelView({required this.channel, super.key});
final OperationsChannel channel;
@override
Widget build(BuildContext context) {
final isScheduleUploaded = channel.id.isEmpty;
if (!isScheduleUploaded) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"No schedule uploaded yet.",
).h4,
Gap(8),
Button.secondary(
child: Text(
"Upload Schedule",
),
onPressed: () {
context.go(
"/channel/${channel.organizationId}/${channel.id}/upload",
);
},
)
],
)
);
}
return const SizedBox.expand();
}
}
@@ -0,0 +1,299 @@
import "dart:async";
import "package:bus_running_record/models/channels/text_channel.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "package:supabase_flutter/supabase_flutter.dart";
class TextChannelView extends StatefulWidget {
const TextChannelView({required this.channel, super.key});
final TextChannel channel;
@override
State<TextChannelView> createState() => _TextChannelViewState();
}
class _TextChannelViewState extends State<TextChannelView> {
RealtimeChannel? _messagesRealtimeChannel;
bool _loadingMessages = false;
bool _sendingMessage = false;
String? _messageError;
String _draftMessage = "";
int _composerNonce = 0;
List<TextChannelMessage> _messages = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_initializeChannel());
});
}
@override
void didUpdateWidget(covariant TextChannelView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.channel.id == widget.channel.id) return;
unawaited(_initializeChannel());
}
Future<void> _initializeChannel() async {
await _unsubscribeFromRealtimeMessages();
if (!mounted) return;
setState(() {
_messages = const [];
_messageError = null;
_draftMessage = "";
_composerNonce += 1;
});
await _subscribeToRealtimeMessages();
await _loadMessages();
}
Future<void> _loadMessages() async {
if (_loadingMessages) return;
setState(() {
_loadingMessages = true;
_messageError = null;
});
try {
final messages = await widget.channel.listMessages();
if (!mounted) return;
setState(() {
_messages = messages;
});
} catch (error, stackTrace) {
debugPrint("[TextChannelView] loadMessages failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_messageError = error.toString();
});
} finally {
if (mounted) {
setState(() {
_loadingMessages = false;
});
}
}
}
Future<void> _subscribeToRealtimeMessages() async {
if (_messagesRealtimeChannel != null) return;
final realtime = widget.channel.subscribeToMessages(
onMessageChanged: () {
if (!mounted) return;
unawaited(_loadMessages());
},
onStatus: (status, error) {
if (status == RealtimeSubscribeStatus.subscribed) return;
if (status == RealtimeSubscribeStatus.channelError ||
status == RealtimeSubscribeStatus.timedOut) {
debugPrint(
"[TextChannelView] realtime subscribe issue ($status) for channel ${widget.channel.id}: $error",
);
}
},
);
_messagesRealtimeChannel = realtime;
}
Future<void> _unsubscribeFromRealtimeMessages() async {
final realtime = _messagesRealtimeChannel;
_messagesRealtimeChannel = null;
if (realtime == null) return;
try {
await widget.channel.unsubscribe(realtime);
debugPrint("[TextChannelView] realtime unsubscribed");
} catch (error, stackTrace) {
debugPrint("[TextChannelView] realtime unsubscribe failed: $error");
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> _sendMessage() async {
final content = _draftMessage.trim();
if (content.isEmpty || _sendingMessage) return;
setState(() {
_sendingMessage = true;
_messageError = null;
});
try {
await widget.channel.sendMessage(content);
if (!mounted) return;
setState(() {
_draftMessage = "";
_composerNonce += 1;
});
await _loadMessages();
} catch (error, stackTrace) {
debugPrint("[TextChannelView] sendMessage failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_messageError = error.toString();
});
} finally {
if (mounted) {
setState(() {
_sendingMessage = false;
});
}
}
}
Widget _buildMessageList() {
if (_loadingMessages) {
return const Center(child: CircularProgressIndicator());
}
if (_messages.isEmpty) {
return Center(child: Text("No messages yet. Say hi.").small.muted);
}
final currentUserId = widget.channel.client.auth.currentUser?.id ?? "";
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
for (var i = 0; i < _messages.length; i++) ...[
MessageBubble(message: _messages[i], currentUserId: currentUserId),
if (i != _messages.length - 1) const Gap(2),
],
],
),
);
}
@override
void dispose() {
unawaited(_unsubscribeFromRealtimeMessages());
super.dispose();
}
@override
Widget build(BuildContext context) {
double bottomPadding = MediaQuery.of(context).padding.bottom;
return Column(
children: [
Expanded(child: _buildMessageList()),
if (_messageError != null)
Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: Text(
_messageError!,
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).small,
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
clipBehavior: Clip.none,
height: 60,
child: TextField(
key: ValueKey("composer-$_composerNonce"),
initialValue: "",
placeholder: Text("Message #${widget.channel.slug}"),
enabled: !_sendingMessage,
onChanged: (value) {
_draftMessage = value;
},
onSubmitted: (_) => unawaited(_sendMessage()),
features: [
InputFeature.leading(
IconButton.ghost(
icon: const Icon(LucideIcons.plus).iconSmall,
onPressed: () {},
),
),
],
),
),
Gap(bottomPadding + 12),
],
);
}
}
class MessageBubble extends StatelessWidget {
const MessageBubble({
required this.message,
required this.currentUserId,
super.key,
});
final TextChannelMessage message;
final String currentUserId;
String _formatTime() {
final createdAt = message.createdAt?.toLocal();
if (createdAt == null) {
return "";
}
final paddedHour = createdAt.hour.toString().padLeft(2, "0");
final paddedMinute = createdAt.minute.toString().padLeft(2, "0");
return "$paddedHour:$paddedMinute";
}
String _senderLabel() {
final authorUserId = message.authorUserId;
final isCurrentUser =
currentUserId.isNotEmpty && authorUserId == currentUserId;
if (isCurrentUser) {
return "You";
}
if (authorUserId.length >= 8) {
return "User ${authorUserId.substring(0, 8)}";
}
return "User $authorUserId";
}
@override
Widget build(BuildContext context) {
final timeText = _formatTime();
final senderLabel = _senderLabel();
final shouldShowTime = timeText.isNotEmpty;
final senderInitials = Avatar.getInitials(senderLabel);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(initials: senderInitials),
const Gap(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(senderLabel).small.semiBold,
if (shouldShowTime) ...[
const Gap(8),
Text(timeText).xSmall.muted,
],
],
),
const Gap(2),
Text(message.content).small,
],
),
),
],
),
);
}
}
+192
View File
@@ -0,0 +1,192 @@
import "dart:async";
import "package:bus_running_record/models/channels/base_channel.dart";
import "package:bus_running_record/models/channels/operations_channel.dart";
import "package:bus_running_record/models/channels/text_channel.dart";
import "package:bus_running_record/pages/home/channels/operations_channel_view.dart";
import "package:bus_running_record/pages/home/channels/text_channel_view.dart";
import "package:bus_running_record/pages/home/widgets/swiper.dart";
import "package:bus_running_record/pages/home/widgets/channel_header.dart";
import "package:bus_running_record/pages/home/widgets/home_left_sidebar.dart";
import "package:bus_running_record/provider/collaboration_state.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class HomePage extends StatefulWidget {
static GoRoute rootRoute = GoRoute(
path: "/",
builder: (context, state) => const HomePage(),
);
static GoRoute channelRoute = GoRoute(
path: "/channel/:orgId/:channelId",
builder: (context, state) => HomePage(
organizationId: state.pathParameters["orgId"],
channelId: state.pathParameters["channelId"],
),
);
const HomePage({this.organizationId, this.channelId, super.key});
final String? organizationId;
final String? channelId;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
String? _lastSyncedRouteKey;
Future<void> _syncRouteSelection() async {
final orgId = widget.organizationId;
final channelId = widget.channelId;
if (orgId == null || channelId == null) return;
final routeKey = "$orgId/$channelId";
if (_lastSyncedRouteKey == routeKey) return;
_lastSyncedRouteKey = routeKey;
final collab = context.read<CollaborationProvider>();
if (collab.selectedOrganizationId != orgId) {
await collab.selectOrganization(orgId);
}
final channels = collab.channelsForOrganization(orgId);
if (channels.any((channel) => channel.id == channelId)) {
collab.selectChannel(channelId);
}
}
@override
Widget build(BuildContext context) {
if (widget.organizationId != null && widget.channelId != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_syncRouteSelection());
});
} else {
_lastSyncedRouteKey = null;
}
final isMobile = MediaQuery.of(context).size.width < 600;
if (isMobile) {
return const SidebarSwiper(
sidebar: HomeLeftSidebar(),
child: Scaffold(
child: Row(children: [Expanded(child: _HomeChannelPane())]),
),
);
}
return const Scaffold(
child: Row(
children: [
HomeLeftSidebar(),
VerticalDivider(),
Expanded(child: _HomeChannelPane()),
],
),
);
}
}
class _HomeChannelPane extends StatelessWidget {
const _HomeChannelPane();
ChannelSummary? _findChannel(
CollaborationProvider collab,
String? organizationId,
String? channelId,
) {
if (organizationId == null || channelId == null) return null;
final channels = collab.channelsForOrganization(organizationId);
for (final channel in channels) {
if (channel.id == channelId) {
return channel;
}
}
return null;
}
BaseChannel? _buildChannelModel(
BuildContext context,
ChannelSummary? selectedChannel,
) {
if (selectedChannel == null) return null;
final client = context.read<SupabaseProvider>().client;
if (selectedChannel.type == "operations") {
return OperationsChannel(
client: client,
id: selectedChannel.id,
organizationId: selectedChannel.organizationId,
name: selectedChannel.name,
description: selectedChannel.description,
slug: selectedChannel.slug,
isPrivate: selectedChannel.isPrivate,
position: selectedChannel.position,
);
}
return TextChannel(
client: client,
id: selectedChannel.id,
organizationId: selectedChannel.organizationId,
name: selectedChannel.name,
description: selectedChannel.description,
slug: selectedChannel.slug,
isPrivate: selectedChannel.isPrivate,
position: selectedChannel.position,
);
}
Widget _buildChannelContent({
required BaseChannel? channel,
}) {
if (channel == null) {
return Center(
child: Text("Pick a channel to start chatting.").small.muted,
);
}
if (channel is OperationsChannel) {
return OperationsChannelView(channel: channel);
}
final textChannel = channel as TextChannel;
return TextChannelView(
key: ValueKey("text-channel-${textChannel.id}"),
channel: textChannel,
);
}
@override
Widget build(BuildContext context) {
final collab = context.watch<CollaborationProvider>();
final selectedOrg = collab.selectedOrganizationId;
final selectedChannelId = collab.selectedChannelId;
final selectedChannel = _findChannel(
collab,
selectedOrg,
selectedChannelId,
);
final channel = _buildChannelModel(context, selectedChannel);
final channelBody = _buildChannelContent(
channel: channel,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (channel != null) ...[
ChannelHeader(channel: channel),
],
Expanded(child: channelBody),
],
);
}
}
@@ -0,0 +1,52 @@
import "package:bus_running_record/models/channels/base_channel.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class ChannelHeader extends StatelessWidget {
const ChannelHeader({required this.channel, super.key});
final BaseChannel channel;
@override
Widget build(BuildContext context) {
double topPadding = MediaQuery.of(context).padding.top;
return Column(
children: [
Gap(topPadding),
SizedBox(
height: 40,
child: Column(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.hash,
).iconSmall,
Gap(4),
Text(channel.slug).textSmall,
Icon(LucideIcons.dot).iconMutedForeground,
Text(channel.description),
],
),
),
),
),
const Divider(),
],
),
),
],
);
}
}
+376
View File
@@ -0,0 +1,376 @@
import "dart:convert";
import "package:bus_running_record/provider/collaboration_state.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
Future<void> showCreateOrganizationDialog(BuildContext context) async {
final result = await showDialog<String>(
context: context,
builder: (dialogContext) {
String name = "";
return AlertDialog(
title: const Text("Create Organization"),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Name your new organization."),
const Gap(12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: TextField(
autofocus: true,
placeholder: const Text("Enter organization name"),
onChanged: (value) {
name = value;
},
onSubmitted: (_) {
Navigator.of(dialogContext).pop(name);
},
),
),
],
),
actions: [
Button.text(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("Cancel"),
),
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(name),
child: const Text("Create"),
),
],
);
},
);
final name = (result ?? "").trim();
if (name.isEmpty) {
return;
}
if (!context.mounted) return;
await context.read<CollaborationProvider>().createOrganization(name);
}
Future<void> showCreateChannelDialog(
BuildContext context, {
required String organizationId,
}) async {
final result = await showDialog<Map<String, String>>(
context: context,
builder: (dialogContext) => const _CreateChannelDialog(),
);
final channelName = (result?["name"] ?? "").trim();
final channelDescription = (result?["description"] ?? "").trim();
final channelType = (result?["type"] ?? "text").trim();
if (channelName.isEmpty) return;
if (!context.mounted) return;
try {
await context.read<CollaborationProvider>().createChannel(
organizationId: organizationId,
name: channelName,
description: channelDescription,
type: channelType,
);
} catch (error, stackTrace) {
debugPrint("[HomePage] createChannel dialog failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Create Channel Failed"),
content: Text(error.toString()),
actions: [
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("Close"),
),
],
),
);
}
}
String extractInviteToken(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return "";
final invitePathMatch = RegExp(r"/invite/([0-9a-fA-F]+)").firstMatch(trimmed);
if (invitePathMatch != null) {
return (invitePathMatch.group(1) ?? "").toLowerCase();
}
final hashInviteMatch = RegExp(
r"#/invite/([0-9a-fA-F]+)",
).firstMatch(trimmed);
if (hashInviteMatch != null) {
return (hashInviteMatch.group(1) ?? "").toLowerCase();
}
return trimmed.toLowerCase();
}
Future<void> showJoinOrganizationDialog(BuildContext context) async {
final result = await showDialog<String>(
context: context,
builder: (dialogContext) {
String inviteInput = "";
return AlertDialog(
title: const Text("Join Organization"),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Paste an invite link or invite token."),
const Gap(12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: TextField(
autofocus: true,
placeholder: const Text("https://.../#/invite/<token>"),
onChanged: (value) {
inviteInput = value;
},
onSubmitted: (_) {
Navigator.of(dialogContext).pop(inviteInput);
},
),
),
],
),
actions: [
Button.text(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("Cancel"),
),
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(inviteInput),
child: const Text("Join"),
),
],
);
},
);
final token = extractInviteToken(result ?? "");
if (token.isEmpty) return;
if (!context.mounted) return;
try {
await context.read<CollaborationProvider>().acceptInviteToken(token);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Joined"),
content: const Text("You have joined the organization."),
actions: [
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("Close"),
),
],
),
);
} catch (error, stackTrace) {
debugPrint("[HomePage] join organization failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Join Failed"),
content: Text(error.toString()),
actions: [
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("Close"),
),
],
),
);
}
}
Future<void> runAuthDebug(BuildContext context) async {
final client = context.read<SupabaseProvider>().client;
final encoder = const JsonEncoder.withIndent(" ");
try {
final response = await client.functions.invoke("auth-debug", body: {});
final payload = response.data;
final formatted = payload is Map || payload is List
? encoder.convert(payload)
: payload.toString();
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Auth Debug Response"),
content: SizedBox(
width: 600,
child: SingleChildScrollView(child: Text(formatted).small),
),
actions: [
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("Close"),
),
],
),
);
} catch (error, stackTrace) {
debugPrint("[HomePage] auth-debug failed (${error.runtimeType}): $error");
debugPrintStack(stackTrace: stackTrace);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Auth Debug Failed"),
content: Text(error.toString()),
actions: [
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("Close"),
),
],
),
);
}
}
class _CreateChannelDialog extends StatelessWidget {
const _CreateChannelDialog();
@override
Widget build(BuildContext context) {
final selectedType = ValueNotifier<int>(1);
final channelName = ValueNotifier<String>("");
final channelDescription = ValueNotifier<String>("");
return AlertDialog(
title: const Text("Create Channel"),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder(
valueListenable: selectedType,
builder: (context, value, child) {
return RadioGroup<int>(
value: value,
onChanged: (v) {
selectedType.value = v;
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
RadioItem(
value: 1,
trailing: Basic(
title: Text("Text Channel").large,
subtitle: Text(
"Standard chat channel for communications and discussions.",
),
),
),
RadioItem(
value: 2,
trailing: Basic(
title: Text("Operations Channel").large,
subtitle: Text(
"Upload a schedule document and interact with it in a way that matters.",
),
),
),
],
),
);
},
),
const Gap(12),
const Text("Channel Name").medium.semiBold,
const Gap(8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: TextField(
autofocus: true,
features: [
InputFeature.leading(
Icon(
LucideIcons.hash,
color: Theme.of(context).colorScheme.mutedForeground,
).iconSmall,
),
],
placeholder: const Text("general"),
onChanged: (value) {
channelName.value = value;
},
onSubmitted: (_) {
Navigator.of(context).pop({
"name": channelName.value,
"type": _channelTypeFromValue(selectedType.value),
});
},
),
),
const Gap(12),
const Text("Channel Description").medium.semiBold,
const Gap(8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: TextField(
placeholder: const Text("What this channel is for"),
onChanged: (value) {
channelDescription.value = value;
},
onSubmitted: (_) {
Navigator.of(context).pop({
"name": channelName.value,
"description": channelDescription.value,
"type": _channelTypeFromValue(selectedType.value),
});
},
),
),
],
),
actions: [
Button.text(
onPressed: () => Navigator.of(context).pop(),
child: const Text("Cancel"),
),
Button.primary(
onPressed: () => Navigator.of(context).pop({
"name": channelName.value,
"description": channelDescription.value,
"type": _channelTypeFromValue(selectedType.value),
}),
child: const Text("Create"),
),
],
);
}
}
String _channelTypeFromValue(int value) {
switch (value) {
case 2:
return "operations";
default:
return "text";
}
}
@@ -0,0 +1,613 @@
import "dart:async";
import "package:bus_running_record/pages/home/widgets/home_dialogs.dart";
import "package:bus_running_record/provider/collaboration_state.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class HomeLeftSidebar extends StatelessWidget {
const HomeLeftSidebar({super.key});
String _buildInitials(String displayName) {
if (displayName.isEmpty) {
return "U";
}
return displayName.substring(0, 1).toUpperCase();
}
@override
Widget build(BuildContext context) {
final collab = context.watch<CollaborationProvider>();
final supabase = context.watch<SupabaseProvider>();
final organizations = collab.organizations;
final user = supabase.session?.user;
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,
),
),
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 VerticalDivider(),
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: [
Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
width: double.infinity,
child: Button.ghost(
alignment: Alignment.center,
leading: const Icon(Icons.add),
child: const Text("Create Organization"),
onPressed: () {
unawaited(showCreateOrganizationDialog(context));
},
),
),
),
Padding(
padding: const EdgeInsets.only(
left: 8,
right: 8,
bottom: 8,
),
child: SizedBox(
width: double.infinity,
child: Button.ghost(
alignment: Alignment.center,
leading: const Icon(LucideIcons.userRoundPlus),
child: const Text("Join Organization"),
onPressed: () {
unawaited(showJoinOrganizationDialog(context));
},
),
),
),
Padding(
padding: const EdgeInsets.only(
left: 8,
right: 8,
bottom: 8,
),
child: SizedBox(
width: double.infinity,
child: Button.secondary(
alignment: Alignment.center,
leading: const Icon(LucideIcons.bug),
child: const Text("Auth Debug"),
onPressed: () {
unawaited(runAuthDebug(context));
},
),
),
),
const Divider(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
top: 8,
left: 8,
right: 16,
bottom: 8,
),
child: _OrganizationList(
organizations: organizations,
isLoading: collab.isLoadingOrganizations,
errorMessage: collab.errorMessage,
),
),
),
const Divider(),
Padding(
padding: const EdgeInsets.all(8),
child: Button.ghost(
child: Row(
children: [
Avatar(initials: initials),
const Gap(8),
Expanded(child: Basic(title: Text(displayName))),
const Icon(LucideIcons.logOut).iconSmall,
],
),
onPressed: () {
unawaited(context.read<SupabaseProvider>().signOut());
},
),
),
],
),
),
),
],
),
);
}
}
class _OrganizationList extends StatelessWidget {
const _OrganizationList({
required this.organizations,
required this.isLoading,
required this.errorMessage,
});
final List<OrganizationSummary> organizations;
final bool isLoading;
final String? errorMessage;
@override
Widget build(BuildContext context) {
if (isLoading && organizations.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (errorMessage != null && organizations.isEmpty) {
return Center(
child: Text(errorMessage!, textAlign: TextAlign.center).small,
);
}
if (organizations.isEmpty) {
return Center(
child: Text("No organizations yet. Create one to get started.").small,
);
}
final collab = context.watch<CollaborationProvider>();
return ListView.separated(
itemBuilder: (context, index) {
final org = organizations[index];
return _OrganizationGroup(
organization: org,
channels: collab.channelsForOrganization(org.id),
selectedOrganizationId: collab.selectedOrganizationId,
selectedChannelId: collab.selectedChannelId,
);
},
separatorBuilder: (context, index) => const Gap(4),
itemCount: organizations.length,
);
}
}
class _ServerRail extends StatelessWidget {
const _ServerRail({
required this.organizations,
required this.selectedOrganizationId,
});
final List<OrganizationSummary> organizations;
final String? selectedOrganizationId;
Future<void> _openOrganization(
BuildContext context,
String organizationId,
) async {
final collab = context.read<CollaborationProvider>();
await collab.selectOrganization(organizationId);
if (!context.mounted) return;
final channelId = collab.selectedChannelId;
if (channelId == null || channelId.isEmpty) {
context.go("/");
return;
}
context.go("/channel/$organizationId/$channelId");
}
void _openOrganizationSettings(BuildContext context, String organizationId) {
context.go("/org/$organizationId/settings");
}
String _fallbackInitial(String organizationName) {
final trimmedName = organizationName.trim();
if (trimmedName.isEmpty) {
return "O";
}
return trimmedName.substring(0, 1).toUpperCase();
}
Widget _buildOrganizationAvatar({
required OrganizationSummary organization,
required String fallbackInitial,
}) {
final iconUrl = organization.iconUrl;
if (iconUrl == null || iconUrl.isEmpty) {
return Center(child: Text(fallbackInitial).semiBold.small);
}
return AspectRatio(
aspectRatio: 1,
child: Image.network(
iconUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Center(child: Text(fallbackInitial).semiBold.small);
},
),
);
}
ButtonStyle _organizationButtonStyle(bool isSelected) {
if (isSelected) {
return ButtonStyle.ghost(density: ButtonDensity.compact);
}
return ButtonStyle.ghost();
}
@override
Widget build(BuildContext context) {
if (organizations.isEmpty) {
return const SizedBox.shrink();
}
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);
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(),
),
);
}
}
class _OrganizationGroup extends StatefulWidget {
const _OrganizationGroup({
required this.organization,
required this.channels,
required this.selectedOrganizationId,
required this.selectedChannelId,
});
final OrganizationSummary organization;
final List<ChannelSummary> channels;
final String? selectedOrganizationId;
final String? selectedChannelId;
@override
State<_OrganizationGroup> createState() => _OrganizationGroupState();
}
class _OrganizationGroupState extends State<_OrganizationGroup> {
bool _headerHover = false;
late bool _isExpanded;
bool get _isSelected =>
widget.selectedOrganizationId == widget.organization.id;
IconData _expansionIcon(bool expanded) {
if (expanded) {
return LucideIcons.chevronDown;
}
return LucideIcons.chevronRight;
}
void _toggleOrSelectOrganization() {
if (_isSelected) {
setState(() {
_isExpanded = !_isExpanded;
});
return;
}
setState(() {
_isExpanded = true;
});
unawaited(
context.read<CollaborationProvider>().selectOrganization(
widget.organization.id,
),
);
}
@override
void initState() {
super.initState();
_isExpanded = _isSelected;
}
@override
void didUpdateWidget(covariant _OrganizationGroup oldWidget) {
super.didUpdateWidget(oldWidget);
final wasSelected =
oldWidget.selectedOrganizationId == oldWidget.organization.id;
if (!wasSelected && _isSelected) {
_isExpanded = true;
}
}
@override
Widget build(BuildContext context) {
final expanded = _isExpanded;
final isMobile = MediaQuery.of(context).size.width < 600;
final header = Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: isMobile ? () => _showOrganizationMenu(context) : null,
child: MouseRegion(
onEnter: (_) => setState(() => _headerHover = true),
onExit: (_) => setState(() => _headerHover = false),
child: Row(
children: [
Expanded(
child: Button.text(
alignment: Alignment.centerLeft,
marginAlignment: Alignment.centerLeft,
style: ButtonStyle.text(density: ButtonDensity.dense),
leading: SizedBox(
width: 16,
child: Icon(_expansionIcon(expanded)).iconSmall,
),
onPressed: _toggleOrSelectOrganization,
child: SizedBox(
width: double.infinity,
child: Text(widget.organization.name).normal,
),
),
),
Visibility.maintain(
visible: _headerHover,
child: Builder(
builder: (buttonContext) => Button.text(
style: ButtonStyle.text(density: ButtonDensity.dense),
onPressed: () {
_showOrganizationMenu(buttonContext);
},
child: Icon(LucideIcons.ellipsis).iconSmall,
),
),
),
const Gap(6),
],
),
),
),
);
if (!expanded) return header;
final channels = widget.channels;
if (channels.isEmpty) {
return Column(
children: [
header,
Padding(
padding: EdgeInsets.only(left: 28, top: 4, bottom: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text("No channels yet").small.muted,
),
),
],
);
}
return Column(
children: [
header,
const Gap(2),
...channels.map(
(channel) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: _ChannelButton(
channel: channel,
active: widget.selectedChannelId == channel.id,
onPressed: () {
unawaited(
context.read<CollaborationProvider>().selectOrganization(
widget.organization.id,
),
);
context.read<CollaborationProvider>().selectChannel(channel.id);
context.go("/channel/${widget.organization.id}/${channel.id}");
},
),
),
),
const Gap(4),
],
);
}
void _showOrganizationMenu(BuildContext context) {
showDropdown<void>(
context: context,
anchorAlignment: Alignment.bottomRight,
alignment: Alignment.topLeft,
builder: (menuContext) {
return DropdownMenu(
children: [
MenuLabel(child: Text(widget.organization.name).small.semiBold),
const MenuDivider(),
MenuButton(
leading: const Icon(LucideIcons.plus).iconSmall,
onPressed: (_) {
unawaited(
showCreateChannelDialog(
context,
organizationId: widget.organization.id,
),
);
},
child: const Text("Add Channel"),
),
const MenuDivider(),
MenuButton(
leading: const Icon(LucideIcons.settings2).iconSmall,
onPressed: (_) {
context.go("/org/${widget.organization.id}/settings");
},
child: const Text("Settings"),
),
],
);
},
);
}
}
class _ChannelButton extends StatelessWidget {
const _ChannelButton({
required this.channel,
required this.active,
required this.onPressed,
});
final ChannelSummary channel;
final bool active;
final VoidCallback onPressed;
IconData _iconForType() {
switch (channel.type) {
case "voice":
return LucideIcons.mic;
case "operations":
return LucideIcons.clipboardList;
default:
return LucideIcons.notebookPen;
}
}
Color _labelColor(BuildContext context) {
if (active) {
return Theme.of(context).colorScheme.primary;
}
return Theme.of(context).colorScheme.mutedForeground;
}
@override
Widget build(BuildContext context) {
final color = _labelColor(context);
return Button.ghost(
marginAlignment: Alignment.centerLeft,
style: ButtonStyle.ghost(density: ButtonDensity.dense),
onPressed: onPressed,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
width: double.infinity,
child: Row(
children: [
Icon(_iconForType(), color: color).iconSmall,
const Gap(8),
Expanded(
child: Text(channel.name, style: TextStyle(color: color)).normal,
),
],
),
),
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import "dart:math" as math;
import "package:flutter/material.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;
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
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 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,
),
),
],
);
}
}
+6 -4
View File
@@ -1,7 +1,7 @@
import "package:flutter/material.dart";
import "package:file_picker/file_picker.dart";
import "package:go_router/go_router.dart";
import "../models/trip.dart";
import "../models/operations/trip.dart";
import "../parsers/arriva_schedule_parser.dart";
import "../parsers/stagecoach_schedule_parser.dart";
import "../services/brr_export_service.dart";
@@ -9,9 +9,10 @@ import "../services/storage_service.dart";
class HomePage extends StatefulWidget {
const HomePage({super.key});
static const routePath = "/deprecated";
static GoRoute route = GoRoute(
path: "/",
path: routePath,
builder: (context, state) => const HomePage(),
);
@@ -102,8 +103,9 @@ class _HomePageState extends State<HomePage> {
if (routeName != null) "routeName": routeName,
});
}
} catch (e) {
} catch (e, stackTrace) {
print("Error: $e");
print(stackTrace);
setState(() {
_errorMessage = e.toString();
});
@@ -284,4 +286,4 @@ class _OperatorChip extends StatelessWidget {
),
);
}
}
}
+132
View File
@@ -0,0 +1,132 @@
import "dart:async";
import "package:bus_running_record/provider/collaboration_state.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:flutter/foundation.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class InvitePage extends StatefulWidget {
const InvitePage({required this.token, super.key});
final String token;
static final GoRoute route = GoRoute(
path: "/invite/:token",
builder: (context, state) {
final token = state.pathParameters["token"] ?? "";
return InvitePage(token: token);
},
);
@override
State<InvitePage> createState() => _InvitePageState();
}
class _InvitePageState extends State<InvitePage> {
bool _accepting = false;
bool _accepted = false;
String? _error;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final isLoggedIn = context.read<SupabaseProvider>().isAuthenticated;
if (isLoggedIn) {
unawaited(_acceptInvite());
}
});
}
@override
Widget build(BuildContext context) {
final supabase = context.watch<SupabaseProvider>();
final isLoggedIn = supabase.isAuthenticated;
return Scaffold(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Organization Invite").x2Large.semiBold,
const Gap(8),
Text("Token: ${widget.token}").xSmall.muted,
const Gap(16),
if (!isLoggedIn) ...[
Text("Sign in to accept this invite.").small,
const Gap(12),
Button.primary(
onPressed: () {
final next = Uri.encodeComponent("/invite/${widget.token}");
context.go("/login?next=$next");
},
child: const Text("Sign in"),
),
] else if (_accepting) ...[
Row(
children: [
const CircularProgressIndicator(),
const Gap(10),
Text("Accepting invite...").small,
],
),
] else if (_accepted) ...[
Text("Invite accepted.").small,
const Gap(12),
Button.primary(
onPressed: () => context.go("/"),
child: const Text("Open workspace"),
),
] else ...[
if (_error != null) Text(_error!).small,
const Gap(12),
Button.primary(
onPressed: () => unawaited(_acceptInvite()),
child: const Text("Accept invite"),
),
],
],
),
),
),
),
);
}
Future<void> _acceptInvite() async {
if (_accepting) return;
setState(() {
_accepting = true;
_error = null;
});
try {
await context.read<CollaborationProvider>().acceptInviteToken(widget.token);
if (!mounted) return;
setState(() {
_accepted = true;
});
} catch (error, stackTrace) {
debugPrint("[InvitePage] acceptInvite failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_error = error.toString();
});
} finally {
if (mounted) {
setState(() {
_accepting = false;
});
}
}
}
}
+761
View File
@@ -0,0 +1,761 @@
import "dart:async";
import "dart:math";
import "package:bus_running_record/models/operations/scheduled_stop.dart";
import "package:bus_running_record/models/operations/trip.dart";
import "package:bus_running_record/parsers/arriva_schedule_parser.dart";
import "package:bus_running_record/parsers/stagecoach_schedule_parser.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:bus_running_record/widgets/trip_diagram.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/foundation.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class OperationsUploadPage extends StatefulWidget {
const OperationsUploadPage({
required this.organizationId,
required this.channelId,
super.key,
});
final String organizationId;
final String channelId;
static final GoRoute route = GoRoute(
path: "/channel/:orgId/:channelId/upload",
builder: (context, state) => OperationsUploadPage(
organizationId: state.pathParameters["orgId"] ?? "",
channelId: state.pathParameters["channelId"] ?? "",
),
);
@override
State<OperationsUploadPage> createState() => _OperationsUploadPageState();
}
class _OperationsUploadPageState extends State<OperationsUploadPage> {
bool _parsing = false;
bool _saving = false;
bool _enhancing = false;
bool _enhanced = false;
String? _error;
List<Trip> _parsedTrips = const [];
String? _fileName;
String? _parserType;
String? _sourceMime;
Future<void> _pickAndParse() async {
if (_parsing) return;
setState(() {
_parsing = true;
_error = null;
});
try {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
withData: true,
type: FileType.custom,
allowedExtensions: const ["docx", "pdf"],
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.bytes == null) {
throw StateError("Could not read schedule file bytes.");
}
final ext = (file.extension ?? "").toLowerCase();
final parserType = ext == "pdf" ? "stagecoach" : "arriva";
final sourceMime = ext == "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
final trips = await _parseTrips(file.bytes!, ext);
if (trips.isEmpty) {
throw StateError("No trips parsed from schedule.");
}
if (!mounted) return;
setState(() {
_parsedTrips = trips;
_fileName = file.name;
_parserType = parserType;
_sourceMime = sourceMime;
_enhanced = false;
});
} catch (error, stackTrace) {
debugPrint("[OperationsUploadPage] parse failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_error = error.toString();
});
} finally {
if (mounted) {
setState(() {
_parsing = false;
});
}
}
}
Future<List<Trip>> _parseTrips(Uint8List bytes, String extension) async {
if (extension == "pdf") return StagecoachScheduleParser().parseBytes(bytes);
if (extension == "docx") return ArrivaScheduleParser().parseBytes(bytes);
throw UnsupportedError("Unsupported schedule extension: $extension");
}
List<Trip> _sortedTrips() {
final sorted = List<Trip>.from(_parsedTrips);
sorted.sort((a, b) {
final aNum = int.tryParse(a.tripNumber);
final bNum = int.tryParse(b.tripNumber);
if (aNum != null && bNum != null) {
final byNumber = aNum.compareTo(bNum);
if (byNumber != 0) return byNumber;
} else {
final byText = a.tripNumber.compareTo(b.tripNumber);
if (byText != 0) return byText;
}
return a.scheduledTime.compareTo(b.scheduledTime);
});
return sorted;
}
Future<void> _saveToChannel() async {
if (_saving || _parsedTrips.isEmpty) return;
final parserType = _parserType;
final fileName = _fileName;
final sourceMime = _sourceMime;
if (parserType == null || fileName == null || sourceMime == null) return;
setState(() {
_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();
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;
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,
"stop_sequence": s + 1,
"stop_name": stopName,
"scheduled_time": scheduled.isEmpty ? null : scheduled,
});
}
if (stopRows.isNotEmpty) {
await client.from("operations_trip_stops").insert(stopRows);
}
}
if (!mounted) return;
context.go("/channel/${widget.organizationId}/${widget.channelId}");
} catch (error, stackTrace) {
debugPrint("[OperationsUploadPage] save failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_error = error.toString();
});
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
Future<void> _enhanceStops() async {
if (_enhancing || _parsedTrips.isEmpty) return;
setState(() {
_enhancing = true;
_error = null;
});
try {
final stopNames = _parsedTrips
.expand((trip) => trip.scheduledStops.map((stop) => stop.name.trim()))
.where((name) => name.isNotEmpty)
.toSet()
.toList(growable: false);
if (stopNames.isEmpty) {
if (!mounted) return;
setState(() {
_enhancing = false;
});
return;
}
final response = await _invokeAuthedFunction(
"operations-stop-alias-enhance",
body: {
"channel_id": widget.channelId,
"stop_names": stopNames,
},
);
final data = response.data;
if (data is! Map) {
throw StateError("Enhance function returned unexpected response.");
}
final aliasesRaw = data["aliases"];
if (aliasesRaw is! List) {
throw StateError("Enhance function returned invalid aliases.");
}
final aliasesByRawStopName = <String, String>{};
for (final row in aliasesRaw) {
if (row is! Map) continue;
final rawStopName = (row["raw_stop_name"] ?? "").toString().trim();
final aliasStopName = (row["alias_stop_name"] ?? "").toString().trim();
if (rawStopName.isEmpty || aliasStopName.isEmpty) continue;
aliasesByRawStopName[rawStopName] = aliasStopName;
}
final enhancedTrips = _parsedTrips
.map((trip) => trip.withStopAliases(aliasesByRawStopName))
.toList(growable: false);
if (!mounted) return;
setState(() {
_parsedTrips = enhancedTrips;
_enhanced = true;
});
} catch (error, stackTrace) {
debugPrint("[OperationsUploadPage] enhance failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_error = error.toString();
});
} finally {
if (mounted) {
setState(() {
_enhancing = false;
});
}
}
}
Future<dynamic> _invokeAuthedFunction(
String functionName, {
Object? body,
}) async {
final client = context.read<SupabaseProvider>().client;
var token = await _getFreshAccessToken();
if (token == null || token.isEmpty) {
throw StateError("No valid access token available for edge function call.");
}
Future<dynamic> invokeOnce(String accessToken) {
client.functions.setAuth(accessToken);
return client.functions.invoke(
functionName,
body: body,
headers: <String, String>{"Authorization": "Bearer $accessToken"},
);
}
try {
return await invokeOnce(token);
} catch (error, stackTrace) {
debugPrint(
"[OperationsUploadPage] invokeAuthedFunction/$functionName initial attempt 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<String?> _getFreshAccessToken() async {
final client = context.read<SupabaseProvider>().client;
var session = client.auth.currentSession;
final nowUnix = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expiresAt = session?.expiresAt;
final shouldRefresh =
session != null && expiresAt != null && expiresAt <= nowUnix + 30;
if (shouldRefresh) {
try {
final refreshed = await client.auth.refreshSession();
session = refreshed.session ?? client.auth.currentSession;
} catch (error, stackTrace) {
debugPrint(
"[OperationsUploadPage] getFreshAccessToken/refreshSession failed: $error",
);
debugPrintStack(stackTrace: stackTrace);
session = client.auth.currentSession;
}
}
return session?.accessToken;
}
bool _isUnauthorizedFunctionError(Object error) {
final text = error.toString();
return text.contains("status: 401") || text.contains("code: 401");
}
@override
Widget build(BuildContext context) {
double topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(topPadding),
SizedBox(
height: 40,
child: Column(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
IconButton.ghost(
density: ButtonDensity.iconDense,
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.go(
"/channel/${widget.organizationId}/${widget.channelId}",
),
),
Gap(8),
Text(
"Operations Schedule Upload",
).textSmall,
],
),
),
),
),
const Divider(),
],
),
),
Expanded(
child: _parsedTrips.isEmpty
? _buildBeforeUpload(context)
: _buildParsedPreview(context),
)
],
),
);
}
Widget _buildBeforeUpload(BuildContext context){
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Upload Operations Schedule").h4,
const Gap(10),
Button.primary(
onPressed: _parsing ? null : () => unawaited(_pickAndParse()),
child: _parsing
? const Text("Parsing...")
: const Text("Choose File"),
),
if (_error != null) ...[
const Gap(8),
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).small,
],
],
),
),
);
}
String? filterBy;
Map<String, Object?> filterValue = {};
Trip? _selectedTripForFilter() {
if (filterBy != "By Trip") return null;
final selectedTripNumber = (filterValue["By Trip"] ?? "").toString();
if (selectedTripNumber.isEmpty) return null;
for (final trip in _parsedTrips) {
if (trip.tripNumber == selectedTripNumber) {
return trip;
}
}
return null;
}
List<TripDiagramEntry> _tripDiagramEntries(Trip trip) {
final orderedStops = List<ScheduledStop>.from(trip.scheduledStops)
..sort((a, b) => a.sequence.compareTo(b.sequence));
return orderedStops
.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);
}
Widget _buildParsedPreview(BuildContext context) {
double bottomPadding = MediaQuery.of(context).padding.bottom;
bool isMobile = defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android;
return Column(
children: [
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;
}
return 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 ${selectedTrip.tripNumber} • Duty ${selectedTrip.dutyNumber}",
).semiBold.textSmall,
],
),
),
Divider(),
TripDiagram(
lineColor: Colors.red,
entries: _tripDiagramEntries(selectedTrip),
leftOffset: 12,
rowHeight: 48,
),
],
);
},
),
),
),
Divider(),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: Select<String>(
itemBuilder: (context, item) {
return Text("Filter by: $item");
},
popupConstraints: BoxConstraints(
maxHeight: 300,
maxWidth: !isMobile ? 200 : double.infinity,
),
onChanged: (value) {
setState(() {
filterBy = value;
});
},
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,
child: Select<Object>(
itemBuilder: (context, item) {
if (filterBy == "By Duty") {
return Text(
"Duty $item"
);
} else if (filterBy == "By Trip") {
final tripNumber = item.toString();
final matchingTrip = _parsedTrips.where(
(trip) => trip.tripNumber == tripNumber,
);
if (matchingTrip.isEmpty) return Text("Trip $tripNumber");
final trip = matchingTrip.first;
return Text("Trip ${trip.tripNumber} • Duty ${trip.dutyNumber}");
} else if (filterBy == "By Stop") {
ScheduledStop stop = item as ScheduledStop;
return Text(stop.name);
}
return Text("Undefined");
},
popupConstraints: BoxConstraints(
maxHeight: 300,
maxWidth: !isMobile ? 200 : double.infinity,
),
onChanged: (value) {
setState(() {
if (filterBy == null) return;
filterValue[filterBy!] = value;
});
},
value: filterValue[filterBy],
popup: SelectPopup.builder(
searchPlaceholder: Text(
"Search ${filterBy?.toLowerCase()}"
),
builder: (context, searchQuery) {
List<SelectItemButton> items = [];
if (filterBy == "By Duty") {
final duties = _parsedTrips
.map((t) => t.dutyNumber)
.toSet()
.toList()
..sort();
items = duties.map((d) => SelectItemButton(
value: d,
child: Text(d)
)).toList();
} else if (filterBy == "By Trip") {
final trips = _parsedTrips
.map((t) => t.tripNumber)
.toSet()
.toList()
..sort();
// Sort trips by number if possible, otherwise by text
trips.sort((a, b) {
final aNum = int.tryParse(a);
final bNum = int.tryParse(b);
if (aNum != null && bNum != null) {
return aNum.compareTo(bNum);
}
return a.compareTo(b);
});
items = trips.map((t) => SelectItemButton(
value: t,
child: Text("Trip $t")
)).toList();
} else if (filterBy == "By Stop") {
final stops = _parsedTrips
.expand((t) => t.stationTimes.keys)
.toSet()
.toList()
..sort();
items = stops.map((s) => SelectItemButton(
value: s,
child: Text(s)
)).toList();
}
return SelectItemList(
children: items,
);
},
),
),
)
],
)
),
Divider(),
Gap(8),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Row(
children: [
Button.secondary(
trailing: Icon(
LucideIcons.sparkle
).iconSmall,
child: Text(
_enhanced
? "Enhanced"
: _enhancing
? "Enhancing..."
: "Enhance"
),
onPressed: _enhancing || _enhanced
? null
: () => unawaited(_enhanceStops()),
),
Spacer(),
Button.secondary(
trailing: Icon(
LucideIcons.upload
).iconSmall,
child: Text(
"Upload"
),
onPressed: () {
},
)
],
),
),
Gap(max(bottomPadding, 16)),
],
);
}
}
+566
View File
@@ -0,0 +1,566 @@
import "dart:async";
import "package:bus_running_record/provider/collaboration_state.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/services.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class OrganizationSettingsPage extends StatefulWidget {
const OrganizationSettingsPage({required this.organizationId, super.key});
final String organizationId;
static final GoRoute route = GoRoute(
path: "/org/:orgId/settings",
builder: (context, state) {
final organizationId = state.pathParameters["orgId"] ?? "";
return OrganizationSettingsPage(organizationId: organizationId);
},
);
@override
State<OrganizationSettingsPage> createState() =>
_OrganizationSettingsPageState();
}
class _OrganizationSettingsPageState extends State<OrganizationSettingsPage> {
String _organizationName = "";
String _newChannelName = "";
String _newChannelDescription = "";
bool _savingOrganization = false;
bool _uploadingIcon = false;
bool _creatingChannel = false;
String? _deletingChannelId;
bool _creatingInvite = false;
String? _message;
String? _inviteLink;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(
context.read<CollaborationProvider>().selectOrganization(
widget.organizationId,
),
);
});
}
@override
Widget build(BuildContext context) {
final collab = context.watch<CollaborationProvider>();
OrganizationSummary? organization;
for (final org in collab.organizations) {
if (org.id == widget.organizationId) {
organization = org;
break;
}
}
final channels = collab.channelsForOrganization(widget.organizationId);
if (_organizationName.isEmpty && organization != null) {
_organizationName = organization.name;
}
final organizationId = organization?.id;
return Scaffold(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Button.text(
leading: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.go("/"),
child: const Text("Back to workspace"),
),
const Gap(12),
Text("Organization Settings").x2Large.semiBold,
Text(organization?.name ?? "Loading organization...").muted,
const Gap(20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.border,
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Organization name").semiBold,
const Gap(10),
TextField(
initialValue: _organizationName,
placeholder: const Text("Enter organization name"),
enabled: !_savingOrganization,
onChanged: (value) {
_organizationName = value;
},
),
const Gap(10),
Button.primary(
onPressed:
(_savingOrganization || organizationId == null)
? null
: () => unawaited(
_saveOrganizationName(context, organizationId),
),
child: _savingOrganization
? const Text("Saving...")
: const Text("Save name"),
),
const Gap(16),
Text("Organization icon").semiBold,
const Gap(10),
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.muted,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Theme.of(context).colorScheme.border,
),
),
clipBehavior: Clip.antiAlias,
child:
(organization?.iconUrl != null &&
organization!.iconUrl!.isNotEmpty)
? Image.network(
organization.iconUrl!,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Icon(
LucideIcons.imageOff,
color: Theme.of(
context,
).colorScheme.mutedForeground,
).iconSmall,
)
: Icon(
LucideIcons.image,
color: Theme.of(
context,
).colorScheme.mutedForeground,
).iconSmall,
),
const Gap(10),
Button.secondary(
onPressed:
(_uploadingIcon || organizationId == null)
? null
: () => unawaited(
_uploadOrganizationIcon(organizationId),
),
child: _uploadingIcon
? const Text("Uploading...")
: const Text("Upload icon"),
),
],
),
],
),
),
const Gap(16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.border,
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Create channel").semiBold,
const Gap(10),
TextField(
initialValue: _newChannelName,
placeholder: const Text("Channel name"),
enabled: !_creatingChannel,
onChanged: (value) {
_newChannelName = value;
},
),
const Gap(10),
TextField(
initialValue: _newChannelDescription,
placeholder: const Text("Channel description"),
enabled: !_creatingChannel,
onChanged: (value) {
_newChannelDescription = value;
},
),
const Gap(10),
Button.secondary(
onPressed: (_creatingChannel || organizationId == null)
? null
: () => unawaited(
_createChannel(context, organizationId),
),
child: _creatingChannel
? const Text("Creating...")
: const Text("Add channel"),
),
if (channels.isNotEmpty) ...[
const Gap(14),
Text("Existing channels").small.muted,
const Gap(8),
...channels.map(
(channel) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(
LucideIcons.hash,
size: 14,
color: Theme.of(
context,
).colorScheme.mutedForeground,
),
const Gap(6),
Expanded(child: Text(channel.name).small),
Button.text(
style: ButtonStyle.text(
density: ButtonDensity.dense,
),
onPressed:
(_deletingChannelId != null ||
organizationId == null)
? null
: () => unawaited(
_confirmDeleteChannel(
organizationId,
channel,
),
),
child: _deletingChannelId == channel.id
? const Text("Deleting...")
: Text(
"Delete",
style: TextStyle(
color: Theme.of(
context,
).colorScheme.destructive,
),
).small,
),
],
),
),
),
],
],
),
),
const Gap(16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.border,
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Invite links").semiBold,
const Gap(10),
Text(
"Generate a one-time invite link to join this organization.",
).small.muted,
const Gap(10),
Row(
children: [
Button.secondary(
onPressed:
(_creatingInvite || organizationId == null)
? null
: () => unawaited(
_createInviteLink(context, organizationId),
),
child: _creatingInvite
? const Text("Generating...")
: const Text("Generate invite link"),
),
if (_inviteLink != null) ...[
const Gap(8),
Button.outline(
onPressed: () => unawaited(_copyInviteLink()),
child: const Text("Copy"),
),
],
],
),
if (_inviteLink != null) ...[
const Gap(12),
SelectableText(_inviteLink!),
],
],
),
),
if (_message != null) ...[const Gap(12), Text(_message!).small],
if (collab.errorMessage != null) ...[
const Gap(8),
Text(
collab.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).small,
],
],
),
),
),
),
);
}
Future<void> _saveOrganizationName(
BuildContext context,
String organizationId,
) async {
final name = _organizationName.trim();
if (name.isEmpty) return;
setState(() {
_savingOrganization = true;
_message = null;
});
try {
await context.read<CollaborationProvider>().updateOrganization(
organizationId: organizationId,
name: name,
);
if (!mounted) return;
setState(() {
_message = "Organization name updated.";
});
} catch (error, stackTrace) {
debugPrint(
"[OrganizationSettingsPage] saveOrganizationName failed: $error",
);
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_message = "Could not update organization name.";
});
} finally {
if (mounted) {
setState(() => _savingOrganization = false);
}
}
}
Future<void> _uploadOrganizationIcon(String organizationId) async {
setState(() {
_uploadingIcon = true;
_message = null;
});
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
final bytes = file.bytes;
if (bytes == null) {
throw StateError("Could not read image bytes.");
}
final extension = (file.extension ?? "png").toLowerCase();
final contentType = _mimeTypeForExtension(extension);
if (!mounted) return;
await context.read<CollaborationProvider>().uploadOrganizationIcon(
organizationId: organizationId,
bytes: bytes,
contentType: contentType,
extension: extension,
);
if (!mounted) return;
setState(() {
_message = "Organization icon updated.";
});
} catch (error, stackTrace) {
debugPrint(
"[OrganizationSettingsPage] uploadOrganizationIcon failed: $error",
);
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_message = "Could not upload organization icon.";
});
} finally {
if (mounted) {
setState(() => _uploadingIcon = false);
}
}
}
String _mimeTypeForExtension(String extension) {
switch (extension.toLowerCase()) {
case "jpg":
case "jpeg":
return "image/jpeg";
case "webp":
return "image/webp";
case "gif":
return "image/gif";
case "png":
default:
return "image/png";
}
}
Future<void> _createChannel(
BuildContext context,
String organizationId,
) async {
final name = _newChannelName.trim();
final description = _newChannelDescription.trim();
if (name.isEmpty) return;
setState(() {
_creatingChannel = true;
_message = null;
});
try {
await context.read<CollaborationProvider>().createChannel(
organizationId: organizationId,
name: name,
description: description,
);
if (!mounted) return;
setState(() {
_newChannelName = "";
_newChannelDescription = "";
_message = "Channel created.";
});
} catch (error, stackTrace) {
debugPrint("[OrganizationSettingsPage] createChannel failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_message = "Could not create channel.";
});
} finally {
if (mounted) {
setState(() => _creatingChannel = false);
}
}
}
Future<void> _confirmDeleteChannel(
String organizationId,
ChannelSummary channel,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Delete Channel"),
content: Text(
"Delete #${channel.name}? This removes messages and operations data for this channel.",
),
actions: [
Button.text(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text("Cancel"),
),
Button.destructive(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text("Delete"),
),
],
),
);
if (confirmed != true) return;
if (!mounted) return;
setState(() {
_deletingChannelId = channel.id;
_message = null;
});
try {
await context.read<CollaborationProvider>().deleteChannel(
organizationId: organizationId,
channelId: channel.id,
);
if (!mounted) return;
setState(() {
_message = "Channel deleted.";
});
} catch (error, stackTrace) {
debugPrint("[OrganizationSettingsPage] deleteChannel failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_message = "Could not delete channel.";
});
} finally {
if (mounted) {
setState(() => _deletingChannelId = null);
}
}
}
Future<void> _createInviteLink(
BuildContext context,
String organizationId,
) async {
setState(() {
_creatingInvite = true;
_message = null;
});
try {
final token = await context.read<CollaborationProvider>().createInvite(
organizationId: organizationId,
);
if (!mounted) return;
final base = Uri.base;
final origin = base.hasAuthority
? "${base.scheme}://${base.authority}"
: "";
final inviteLink = "$origin/#/invite/$token";
setState(() {
_inviteLink = inviteLink;
_message = "Invite link generated.";
});
} catch (error, stackTrace) {
debugPrint("[OrganizationSettingsPage] createInviteLink failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_message = "Could not generate invite link.";
});
} finally {
if (mounted) {
setState(() => _creatingInvite = false);
}
}
}
Future<void> _copyInviteLink() async {
final link = _inviteLink;
if (link == null || link.isEmpty) return;
await Clipboard.setData(ClipboardData(text: link));
if (!mounted) return;
setState(() {
_message = "Invite link copied.";
});
}
}
+6 -4
View File
@@ -1,9 +1,11 @@
import "package:flutter/material.dart";
import "package:go_router/go_router.dart";
import "../models/trip.dart";
import "../models/operations/trip.dart";
import "../services/brr_export_service.dart";
class StationSelectionPage extends StatefulWidget {
static const routePath = "/station-selection";
static const legacyHomePath = "/deprecated";
final List<Trip> trips;
final String fileName;
final BRROperator operator;
@@ -18,12 +20,12 @@ class StationSelectionPage extends StatefulWidget {
});
static GoRoute route = GoRoute(
path: "/station-selection",
path: routePath,
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
if (extra == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go("/");
context.go(legacyHomePath);
});
return const SizedBox.shrink();
}
@@ -166,7 +168,7 @@ class _StationSelectionPageState extends State<StationSelectionPage> {
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back, size: 20),
onPressed: () => context.go("/"),
onPressed: () => context.go(StationSelectionPage.legacyHomePath),
),
title: const Text("SELECT STATION"),
),
+4 -3
View File
@@ -6,13 +6,14 @@ import "package:file_saver/file_saver.dart";
import "package:go_router/go_router.dart";
import "package:path_provider/path_provider.dart";
import "package:share_plus/share_plus.dart";
import "../models/trip.dart";
import "../models/operations/trip.dart";
import "../models/brr_metadata.dart";
import "../models/brr_state.dart";
import "../services/brr_export_service.dart";
import "../services/storage_service.dart";
class TripListPage extends StatefulWidget {
static const routePath = "/trips";
final List<Trip> trips;
final String fileName;
final bool fromStationSelection;
@@ -29,7 +30,7 @@ class TripListPage extends StatefulWidget {
});
static GoRoute route = GoRoute(
path: "/trips",
path: routePath,
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
if (extra == null) {
@@ -478,7 +479,7 @@ class _TripCardState extends State<TripCard> {
children: [
_InfoCell("DUTY", widget.trip.dutyNumber),
const SizedBox(width: 12),
_InfoCell("RUNNING", widget.trip.runningNumber),
_InfoCell("RUNNING", widget.trip.busWorkNumber),
],
),
+106 -44
View File
@@ -1,6 +1,6 @@
import "dart:typed_data";
import "package:docx_to_text/docx_to_text.dart";
import "../models/trip.dart";
import "../models/operations/trip.dart";
import "../exceptions/schedule_parse_exception.dart";
import "schedule_parser.dart";
@@ -50,7 +50,9 @@ class ArrivaScheduleParser implements ScheduleParser {
print("=== FOUND ${documentSections.length} SECTIONS ===");
for (var section in documentSections) {
print("Section: ${section.direction}, ${section.stations.length} stations, ${section.tripLines.length} trips");
print(
"Section: ${section.direction}, ${section.stations.length} stations, ${section.tripLines.length} trips",
);
}
if (documentSections.isEmpty) {
@@ -62,7 +64,9 @@ class ArrivaScheduleParser implements ScheduleParser {
for (var section in documentSections) {
final sectionTrips = _parseSectionTrips(section);
trips.addAll(sectionTrips);
print("✓ Parsed ${sectionTrips.length} trips from ${section.direction} section");
print(
"✓ Parsed ${sectionTrips.length} trips from ${section.direction} section",
);
}
// Step 4: Sort by scheduled time
@@ -74,12 +78,13 @@ class ArrivaScheduleParser implements ScheduleParser {
String _extractTextFromDocx(Uint8List bytes) {
try {
return docxToText(bytes);
} catch (e) {
} catch (e, stackTrace) {
print("Arriva parser document read failed: $e");
print(stackTrace);
throw ScheduleParseException("Failed to read document: $e");
}
}
String _formatTime(String rawTime) {
if (rawTime.length != 4) {
throw FormatException("Invalid time format: $rawTime");
@@ -109,14 +114,17 @@ class ArrivaScheduleParser implements ScheduleParser {
tripLines: [],
);
print("Found station header at line $i with ${stations.length} stations");
print(" Stations: ${stations.take(3).join(", ")} ... ${stations.skip(stations.length - 2).join(", ")}");
print(
"Found station header at line $i with ${stations.length} stations",
);
print(
" Stations: ${stations.take(3).join(", ")} ... ${stations.skip(stations.length - 2).join(", ")}",
);
continue;
}
// Check if this is a trip line
if (currentSection != null && _isTripLine(line)) {
// Infer direction from first trip line if not yet set
if (currentSection.direction == "unknown") {
currentSection.direction = _inferDirectionFromTripLine(line);
@@ -147,22 +155,68 @@ class ArrivaScheduleParser implements ScheduleParser {
// Split by whitespace and filter for potential station codes (3-8 uppercase letters)
final parts = line.split(RegExp(r"\s+"));
final potentialStations = parts
.where((part) => part.length >= 3 &&
part.length <= 8 &&
RegExp(r"^[A-Z]+$").hasMatch(part))
.where(
(part) =>
part.length >= 3 &&
part.length <= 8 &&
RegExp(r"^[A-Z]+$").hasMatch(part),
)
.toList();
if (potentialStations.length < 8) return null;
// Filter out common metadata words that appear in headers
const nonStationWords = {
"TRP", "DUTY", "BUS", "START", "END", "GAR", "DEP", "ARR",
"DENOTES", "FINISHES", "RELIEF", "TRIP", "NEXT", "NO", "AT",
"SPELL", "HOURS", "TOTAL", "LAYOVER", "MILES", "LIVE", "DEAD",
"MILEAGE", "TIME", "SIGN", "FORM", "NXT", "THIS", "HAS", "OR",
"FOR", "CHANGE", "SERVICE", "POINT", "LSN", "MAN", "RUI", "SN",
"ROUTE", "RUNNING", "PREV", "FIN", "ENTOD", "SOALL", "USHRS",
"ADDTL", "CASH", "TODAYS", "REL", "IEF",
"TRP",
"DUTY",
"BUS",
"START",
"END",
"GAR",
"DEP",
"ARR",
"DENOTES",
"FINISHES",
"RELIEF",
"TRIP",
"NEXT",
"NO",
"AT",
"SPELL",
"HOURS",
"TOTAL",
"LAYOVER",
"MILES",
"LIVE",
"DEAD",
"MILEAGE",
"TIME",
"SIGN",
"FORM",
"NXT",
"THIS",
"HAS",
"OR",
"FOR",
"CHANGE",
"SERVICE",
"POINT",
"LSN",
"MAN",
"RUI",
"SN",
"ROUTE",
"RUNNING",
"PREV",
"FIN",
"ENTOD",
"SOALL",
"USHRS",
"ADDTL",
"CASH",
"TODAYS",
"REL",
"IEF",
};
final stations = potentialStations
@@ -199,7 +253,9 @@ class ArrivaScheduleParser implements ScheduleParser {
// Note: running number might be "N503 EC" (with spaces) or "N 503 EC" or just "503 EC"
// Outbound: starts with HHMM_HHMM
final isOutboundFormat = RegExp(r"^\d{4}_\d{4}").hasMatch(line);
final isInboundFormat = RegExp(r"^\d+\s+\d+\s+(?:[NRF]\d+\s+|[NRF]\s+\d+\s+|F\s+|\d+\s+)EC").hasMatch(line);
final isInboundFormat = RegExp(
r"^\d+\s+\d+\s+(?:[NRF]\d+\s+|[NRF]\s+\d+\s+|F\s+|\d+\s+)EC",
).hasMatch(line);
if (isOutboundFormat) {
trip = _parseOutboundTrip(line, section.stations);
@@ -210,25 +266,28 @@ class ArrivaScheduleParser implements ScheduleParser {
if (trip != null) {
trips.add(trip);
} else {
final format = isOutboundFormat ? "outbound" : isInboundFormat ? "inbound" : "unknown";
print("Failed to parse $format line: ${line.substring(0, line.length > 80 ? 80 : line.length)}...");
final format = isOutboundFormat
? "outbound"
: isInboundFormat
? "inbound"
: "unknown";
print(
"Failed to parse $format line: ${line.substring(0, line.length > 80 ? 80 : line.length)}...",
);
}
}
return trips;
}
Trip? _parseInboundTrip(
String line,
List<String> stations,
) {
Trip? _parseInboundTrip(String line, List<String> stations) {
// INBOUND: trip duty running EC### ... HHMM_HHMM times...
var match = _inboundPattern.firstMatch(line);
if (match != null) {
final tripNumber = match.group(1)!;
final dutyNumber = match.group(2)!;
final tripType = match.group(3) ?? "";
final runningNumber = match.group(4)!;
final busWorkNumber = match.group(4)!;
final firstTime = match.group(6)!;
final secondTime = match.group(7)!;
final timesString = match.group(8) ?? "";
@@ -244,13 +303,15 @@ class ArrivaScheduleParser implements ScheduleParser {
return Trip(
tripNumber: tripNumber,
dutyNumber: dutyNumber,
runningNumber: runningNumber,
busWorkNumber: busWorkNumber,
scheduledTime: scheduledTime,
tripType: tripType,
isFinishing: false,
stationTimes: stationTimes,
stationOrder: stations,
direction: (int.tryParse(tripNumber) ?? 0).isOdd ? "inbound" : "outbound",
direction: (int.tryParse(tripNumber) ?? 0).isOdd
? "inbound"
: "outbound",
);
}
@@ -273,23 +334,22 @@ class ArrivaScheduleParser implements ScheduleParser {
return Trip(
tripNumber: tripNumber,
dutyNumber: dutyNumber,
runningNumber: dutyNumber,
busWorkNumber: dutyNumber,
scheduledTime: scheduledTime,
tripType: "F",
isFinishing: true,
stationTimes: stationTimes,
stationOrder: stations,
direction: (int.tryParse(tripNumber) ?? 0).isOdd ? "inbound" : "outbound",
direction: (int.tryParse(tripNumber) ?? 0).isOdd
? "inbound"
: "outbound",
);
}
return null;
}
Trip? _parseOutboundTrip(
String line,
List<String> stations,
) {
Trip? _parseOutboundTrip(String line, List<String> stations) {
// OUTBOUND: HHMM_HHMM times... EC### duty running trip
var match = _outboundPattern.firstMatch(line);
if (match != null) {
@@ -298,7 +358,7 @@ class ArrivaScheduleParser implements ScheduleParser {
final timesString = match.group(3) ?? "";
final dutyNumber = match.group(5)!;
final tripType = match.group(6) ?? "";
final runningNumber = match.group(7)!;
final busWorkNumber = match.group(7)!;
final tripNumber = match.group(8)!;
// Build complete time array: first_time, second_time, then remaining times
@@ -311,13 +371,15 @@ class ArrivaScheduleParser implements ScheduleParser {
return Trip(
tripNumber: tripNumber,
dutyNumber: dutyNumber,
runningNumber: runningNumber,
busWorkNumber: busWorkNumber,
scheduledTime: scheduledTime,
tripType: tripType,
isFinishing: false,
stationTimes: stationTimes,
stationOrder: stations,
direction: (int.tryParse(tripNumber) ?? 0).isOdd ? "inbound" : "outbound",
direction: (int.tryParse(tripNumber) ?? 0).isOdd
? "inbound"
: "outbound",
);
}
@@ -336,15 +398,18 @@ class ArrivaScheduleParser implements ScheduleParser {
final scheduledTime = _formatTime(firstTime);
return Trip(
tripNumber: dutyNumber, // Finishing trips may not have separate trip number
tripNumber:
dutyNumber, // Finishing trips may not have separate trip number
dutyNumber: dutyNumber,
runningNumber: dutyNumber,
busWorkNumber: dutyNumber,
scheduledTime: scheduledTime,
tripType: "F",
isFinishing: true,
stationTimes: stationTimes,
stationOrder: stations,
direction: (int.tryParse(dutyNumber) ?? 0).isOdd ? "inbound" : "outbound",
direction: (int.tryParse(dutyNumber) ?? 0).isOdd
? "inbound"
: "outbound",
);
}
@@ -354,10 +419,7 @@ class ArrivaScheduleParser implements ScheduleParser {
List<String> _extractTimesFromString(String timesString) {
// Extract all 4-digit times from the string
final pattern = RegExp(r"\b(\d{4})\b");
return pattern
.allMatches(timesString)
.map((m) => m.group(1)!)
.toList();
return pattern.allMatches(timesString).map((m) => m.group(1)!).toList();
}
List<String> _extractAllTimes(String line) {
+1 -1
View File
@@ -1,5 +1,5 @@
import "dart:typed_data";
import "../models/trip.dart";
import "../models/operations/trip.dart";
abstract class ScheduleParser {
Future<List<Trip>> parseBytes(Uint8List bytes);
+104 -34
View File
@@ -1,6 +1,6 @@
import "dart:typed_data";
import "package:syncfusion_flutter_pdf/pdf.dart";
import "../models/trip.dart";
import "../models/operations/trip.dart";
import "../exceptions/schedule_parse_exception.dart";
import "schedule_parser.dart";
@@ -20,14 +20,24 @@ class StagecoachScheduleParser implements ScheduleParser {
// Syncfusion splits text with newlines everywhere.
// Collapse each page into single line, normalize all whitespace runs to single space
final pages = rawPages.map((p) =>
p.replaceAll("\n", " ").replaceAll(RegExp(r"\s+"), " ").trim()
).toList();
final pages = rawPages
.map(
(p) => p.replaceAll("\n", " ").replaceAll(RegExp(r"\s+"), " ").trim(),
)
.toList();
print("=== STAGECOACH: ${pages.length} pages ===");
for (var i = 0; i < rawPages.length; i++) {
print("=== RAW PDF PAGE ${i + 1} TEXT START ===");
print(rawPages[i]);
print("=== RAW PDF PAGE ${i + 1} TEXT END ===");
}
// Extract route name — "ROUTE 099CSATURDAY..." or "ROUTE CRL SATURDAY..."
final routeMatch = RegExp(r"ROUTE\s+(\w+?)(?:SATURDAY|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FRIDAY)", caseSensitive: false).firstMatch(pages.first);
final routeMatch = RegExp(
r"ROUTE\s+(\w+?)(?:SATURDAY|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FRIDAY)",
caseSensitive: false,
).firstMatch(pages.first);
if (routeMatch != null) {
parsedRouteName = routeMatch.group(1);
} else {
@@ -57,7 +67,9 @@ class StagecoachScheduleParser implements ScheduleParser {
if (seenKeys.contains(key)) continue;
seenKeys.add(key);
allTrips.add(t);
print(" TRIP: ${t.tripNumber} | ${t.scheduledTime} | ${t.direction} | duty=${t.dutyNumber} run=${t.runningNumber} | stations=${t.stationTimes}");
print(
" TRIP: ${t.tripNumber} | ${t.scheduledTime} | ${t.direction} | duty=${t.dutyNumber} busWork=${t.busWorkNumber} | stations=${t.stationTimes}",
);
}
}
@@ -75,13 +87,15 @@ class StagecoachScheduleParser implements ScheduleParser {
final doc = PdfDocument(inputBytes: bytes);
final pages = <String>[];
for (int i = 0; i < doc.pages.count; i++) {
pages.add(PdfTextExtractor(doc).extractText(
startPageIndex: i, endPageIndex: i,
));
pages.add(
PdfTextExtractor(doc).extractText(startPageIndex: i, endPageIndex: i),
);
}
doc.dispose();
return pages;
} catch (e) {
} catch (e, stackTrace) {
print("Stagecoach parser PDF read failed: $e");
print(stackTrace);
throw ScheduleParseException("Failed to read PDF: $e");
}
}
@@ -99,7 +113,9 @@ class StagecoachScheduleParser implements ScheduleParser {
if (isOutbound) {
// Between "DEPT GAR" and "RLF DEPT TIME" (or just before the first duty ID)
final match = RegExp(r"DEPT GAR (.+?) RLF DEPT TIME").firstMatch(pageText);
final match = RegExp(
r"DEPT GAR (.+?) RLF DEPT TIME",
).firstMatch(pageText);
if (match != null) stationBlock = match.group(1);
} else {
// Between "RLF STS:" and "ARR GAR"
@@ -122,8 +138,16 @@ class StagecoachScheduleParser implements ScheduleParser {
var block = match.group(1)!;
// Remove known non-station words
for (final word in ["DEPT GAR", "ARR GAR", "RLF DEPT TIME", "DUTY END",
"NEXT BUS", "NEXT TRIP", "DRV SPELL", "RLF"]) {
for (final word in [
"DEPT GAR",
"ARR GAR",
"RLF DEPT TIME",
"DUTY END",
"NEXT BUS",
"NEXT TRIP",
"DRV SPELL",
"RLF",
]) {
block = block.replaceAll(word, " ");
}
return _pairStationTokens(block.trim());
@@ -163,13 +187,18 @@ class StagecoachScheduleParser implements ScheduleParser {
return stations;
}
List<Trip> _parseTripsFromPage(String pageText, String direction, List<String> stations) {
List<Trip> _parseTripsFromPage(
String pageText,
String direction,
List<String> stations,
) {
final trips = <Trip>[];
// Find all trip-start positions: DUTY_ID followed by RUN(digits) and BUS(3 digits)
// This pattern marks the beginning of a real trip entry
final tripStartPattern = RegExp(r"\b((\d/)?\d[A-Z]\d{3})\s+(\d+)\s+(\d{3})\b");
final tripStartPattern = RegExp(
r"\b((\d/)?\d[A-Z]\d{3})\s+(\d+)\s+(\d{3})\b",
);
final starts = tripStartPattern.allMatches(pageText).toList();
print(" Trip-start matches: ${starts.length}");
@@ -180,20 +209,31 @@ class StagecoachScheduleParser implements ScheduleParser {
for (var i = 0; i < starts.length; i++) {
final segStart = starts[i].start;
// segment ends where the next trip starts, or at end of text
final segEnd = (i + 1 < starts.length) ? starts[i + 1].start : pageText.length;
final segEnd = (i + 1 < starts.length)
? starts[i + 1].start
: pageText.length;
final segment = pageText.substring(segStart, segEnd).trim();
final dutyId = starts[i].group(1)!;
final runNumber = starts[i].group(3)!;
final busWorkingNo = starts[i].group(4)!;
final trip = _parseTripFromSegment(segment, dutyId, runNumber, busWorkingNo, direction, stations);
final trip = _parseTripFromSegment(
segment,
dutyId,
runNumber,
busWorkingNo,
direction,
stations,
);
if (trip != null) {
trips.add(trip);
parsed++;
} else {
failed++;
final preview = segment.length > 120 ? segment.substring(0, 120) : segment;
final preview = segment.length > 120
? segment.substring(0, 120)
: segment;
print(" Skip: $preview");
}
}
@@ -204,20 +244,32 @@ class StagecoachScheduleParser implements ScheduleParser {
}
Trip? _parseTripFromSegment(
String segment, String dutyId, String runNumber, String busWorkingNo,
String direction, List<String> stations,
String segment,
String dutyId,
String runNumber,
String busWorkingNo,
String direction,
List<String> stations,
) {
// Skip header junk
if (segment.contains("TRIP NO") ||
segment.contains("DUTY START") || segment.contains("SCHEDULE DATE")) {
segment.contains("DUTY START") ||
segment.contains("SCHEDULE DATE")) {
return null;
}
// Skip deadhead/light runs — but only if LIGHT appears early in the segment
// (before station times), not in trailing text from the next concatenated entry
final lightMatch = RegExp(r"LIGHT", caseSensitive: false).firstMatch(segment);
final lightMatch = RegExp(
r"LIGHT",
caseSensitive: false,
).firstMatch(segment);
if (lightMatch != null) {
final headerEnd = RegExp(RegExp.escape(dutyId) + r"\s+\d+\s+\d{3}").firstMatch(segment)?.end ?? 0;
final headerEnd =
RegExp(
RegExp.escape(dutyId) + r"\s+\d+\s+\d{3}",
).firstMatch(segment)?.end ??
0;
// if LIGHT is within 40 chars of the header, its a genuine deadhead
if (lightMatch.start - headerEnd < 40) {
return null;
@@ -225,14 +277,21 @@ class StagecoachScheduleParser implements ScheduleParser {
}
// Strip the leading "DUTY RUN BUS" we already extracted
final afterHeader = segment.substring(
RegExp(RegExp.escape(dutyId) + r"\s+\d+\s+\d{3}").firstMatch(segment)!.end
).trim();
final afterHeader = segment
.substring(
RegExp(
RegExp.escape(dutyId) + r"\s+\d+\s+\d{3}",
).firstMatch(segment)!.end,
)
.trim();
// Strip relief/garage prefix to get to the times
var timesSection = afterHeader;
// remove leading relief markers and garage name
timesSection = timesSection.replaceFirst(RegExp(r"^(RR|FR|FRX|FX|R)\s*"), "");
timesSection = timesSection.replaceFirst(
RegExp(r"^(RR|FR|FRX|FX|R)\s*"),
"",
);
timesSection = timesSection.replaceFirst(RegExp(r"^HomeGara\s*"), "");
// Also remove leading text from inbound like just "R " or "F "
timesSection = timesSection.replaceFirst(RegExp(r"^(F|R)\s+"), "");
@@ -249,8 +308,18 @@ class StagecoachScheduleParser implements ScheduleParser {
if (times.isEmpty) return null;
// For garage trips, first time is the garage departure — skip it
final isFromGarage = afterHeader.contains("HomeGara");
// For garage-prefixed trips, first time is often a non-route garage departure.
// Stagecoach extracts vary ("HomeGara", "DEPT GAR", etc), so use multiple hints.
var isFromGarage = RegExp(
r"\b(HomeGara|DEPT\s*GAR|ARR\s*GAR)\b",
caseSensitive: false,
).hasMatch(afterHeader);
// Practical fallback: outbound rows commonly include one leading garage time.
if (!isFromGarage &&
direction == "outbound" &&
times.length > stations.length) {
isFromGarage = true;
}
int firstStationIdx = 0;
if (isFromGarage && times.length > 1) {
firstStationIdx = 1;
@@ -271,7 +340,8 @@ class StagecoachScheduleParser implements ScheduleParser {
final scheduledTime = _formatTime(times[firstStationIdx]);
String tripType = "";
if (afterHeader.startsWith("RR") || afterHeader.startsWith("R")) tripType = "R";
if (afterHeader.startsWith("RR") || afterHeader.startsWith("R"))
tripType = "R";
final actualDirection = dutyId.startsWith("2/") ? "inbound" : direction;
@@ -283,9 +353,9 @@ class StagecoachScheduleParser implements ScheduleParser {
return Trip(
scheduledTime: scheduledTime,
tripNumber: dutyId,
dutyNumber: busWorkingNo,
runningNumber: runNumber,
tripNumber: runNumber,
dutyNumber: dutyId,
busWorkNumber: busWorkingNo,
tripType: tripType,
isFinishing: segment.contains("BUS FIN"),
stationTimes: stationTimes,
+536
View File
@@ -0,0 +1,536 @@
import "package:bus_running_record/provider/supabase_state.dart";
import "package:bus_running_record/constants.dart";
import "package:flutter/foundation.dart";
import "package:supabase_flutter/supabase_flutter.dart";
class OrganizationSummary {
const OrganizationSummary({
required this.id,
required this.name,
required this.slug,
required this.iconUrl,
required this.role,
});
final String id;
final String name;
final String slug;
final String? iconUrl;
final String role;
factory OrganizationSummary.fromApi(Map<String, dynamic> map) {
final org =
(map["organization"] as Map?)?.cast<String, dynamic>() ?? const {};
final iconUrlRaw = (org["icon_url"] ?? "").toString().trim();
return OrganizationSummary(
id: (org["id"] ?? "").toString(),
name: (org["name"] ?? "").toString(),
slug: (org["slug"] ?? "").toString(),
iconUrl: iconUrlRaw.isEmpty ? null : iconUrlRaw,
role: (map["role"] ?? "member").toString(),
);
}
}
class ChannelSummary {
const ChannelSummary({
required this.id,
required this.organizationId,
required this.name,
required this.description,
required this.slug,
required this.type,
required this.isPrivate,
required this.position,
});
final String id;
final String organizationId;
final String name;
final String description;
final String slug;
final String type;
final bool isPrivate;
final int position;
factory ChannelSummary.fromApi(Map<String, dynamic> map) {
final description = (map["description"] ?? map["topic"] ?? "").toString();
return ChannelSummary(
id: (map["id"] ?? "").toString(),
organizationId: (map["organization_id"] ?? "").toString(),
name: (map["name"] ?? "").toString(),
description: description,
slug: (map["slug"] ?? "").toString(),
type: (map["type"] ?? "text").toString(),
isPrivate: map["is_private"] == true,
position: (map["position"] as num?)?.toInt() ?? 0,
);
}
}
class MessageSummary {
const MessageSummary({
required this.id,
required this.channelId,
required this.authorUserId,
required this.content,
required this.createdAt,
});
final String id;
final String channelId;
final String authorUserId;
final String content;
final DateTime? createdAt;
factory MessageSummary.fromApi(Map<String, dynamic> map) {
final createdAtRaw = (map["created_at"] ?? "").toString();
return MessageSummary(
id: (map["id"] ?? "").toString(),
channelId: (map["channel_id"] ?? "").toString(),
authorUserId: (map["author_user_id"] ?? "").toString(),
content: (map["content"] ?? "").toString(),
createdAt: DateTime.tryParse(createdAtRaw),
);
}
}
class CollaborationProvider extends ChangeNotifier {
CollaborationProvider(this._supabaseProvider) {
_supabaseProvider.addListener(_handleSupabaseStateChange);
}
final SupabaseProvider _supabaseProvider;
bool _initialized = false;
bool _isLoadingOrganizations = false;
String? _errorMessage;
List<OrganizationSummary> _organizations = const [];
final Map<String, List<ChannelSummary>> _channelsByOrganization = {};
String? _selectedOrganizationId;
String? _selectedChannelId;
bool get isLoadingOrganizations => _isLoadingOrganizations;
String? get errorMessage => _errorMessage;
List<OrganizationSummary> get organizations => _organizations;
String? get selectedOrganizationId => _selectedOrganizationId;
String? get selectedChannelId => _selectedChannelId;
List<ChannelSummary> channelsForOrganization(String orgId) =>
_channelsByOrganization[orgId] ?? const [];
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;
if (!_supabaseProvider.isAuthenticated) return;
await refreshOrganizations();
}
Future<void> refreshOrganizations() async {
_isLoadingOrganizations = true;
_errorMessage = null;
notifyListeners();
final previousOrgId = _selectedOrganizationId;
final previousChannelId = _selectedChannelId;
try {
final response = await _invokeAuthedFunction("org-list", body: {});
final payload = _asMap(response.data);
final orgRows = _asList(payload["organizations"]);
_organizations = orgRows
.map((row) => OrganizationSummary.fromApi(_asMap(row)))
.where((org) => org.id.isNotEmpty)
.toList();
if (_organizations.isEmpty) {
_selectedOrganizationId = null;
_selectedChannelId = null;
_channelsByOrganization.clear();
} else {
final orgStillExists = _organizations.any((o) => o.id == previousOrgId);
_selectedOrganizationId = orgStillExists
? previousOrgId
: _organizations.first.id;
await _loadChannelsForOrganization(
_selectedOrganizationId!,
preferredChannelId: previousChannelId,
);
}
} catch (error, stackTrace) {
_logError("refreshOrganizations/org-list", error, stackTrace);
_errorMessage = error.toString();
} finally {
_isLoadingOrganizations = false;
notifyListeners();
}
}
void _handleSupabaseStateChange() {
if (!_initialized) return;
if (!_supabaseProvider.isAuthenticated) {
_organizations = const [];
_channelsByOrganization.clear();
_selectedOrganizationId = null;
_selectedChannelId = null;
_errorMessage = null;
notifyListeners();
return;
}
refreshOrganizations();
}
Future<void> createOrganization(String name) async {
try {
final response = await _invokeAuthedFunction(
"org-create",
body: {"name": name},
);
final createdOrg = _asMap(_asMap(response.data)["organization"]);
final orgId = (createdOrg["id"] ?? "").toString();
if (orgId.isNotEmpty) {
try {
await _invokeAuthedFunction(
"channel-create",
body: {
"organization_id": orgId,
"name": "general",
"slug": "general",
"type": "text",
"position": 0,
"is_private": false,
},
);
} catch (error, stackTrace) {
_logError("createOrganization/channel-create", error, stackTrace);
// Best effort; org creation is the critical action.
}
}
await refreshOrganizations();
if (orgId.isNotEmpty) {
_selectedOrganizationId = orgId;
notifyListeners();
}
} catch (error, stackTrace) {
_logError("createOrganization/org-create", error, stackTrace);
_errorMessage = error.toString();
notifyListeners();
}
}
Future<void> updateOrganization({
required String organizationId,
String? name,
String? iconUrl,
}) async {
final trimmedName = name?.trim();
try {
final body = <String, Object?>{"organization_id": organizationId};
if (trimmedName != null && trimmedName.isNotEmpty) {
body["name"] = trimmedName;
}
if (iconUrl != null) {
body["icon_url"] = iconUrl;
}
await _invokeAuthedFunction("org-update", body: body);
await refreshOrganizations();
_selectedOrganizationId = organizationId;
notifyListeners();
} catch (error, stackTrace) {
_logError("updateOrganization/org-update", error, stackTrace);
_errorMessage = error.toString();
notifyListeners();
rethrow;
}
}
Future<void> uploadOrganizationIcon({
required String organizationId,
required Uint8List bytes,
required String contentType,
String extension = "png",
}) async {
final ext = extension.toLowerCase();
final safeExtension = RegExp(r"^[a-z0-9]+$").hasMatch(ext) ? ext : "png";
final path =
"$organizationId/${DateTime.now().millisecondsSinceEpoch}.$safeExtension";
final client = _supabaseProvider.client;
try {
await client.storage
.from("organization-icons")
.uploadBinary(
path,
bytes,
fileOptions: FileOptions(upsert: true, contentType: contentType),
);
var publicUrl = client.storage
.from("organization-icons")
.getPublicUrl(path);
if (Uri.tryParse(publicUrl)?.hasScheme != true) {
publicUrl =
"$kSupabaseEndpoint/storage/v1/object/public/organization-icons/$path";
}
await updateOrganization(
organizationId: organizationId,
iconUrl: publicUrl,
);
} catch (error, stackTrace) {
_logError("uploadOrganizationIcon/storage-upload", error, stackTrace);
_errorMessage = error.toString();
notifyListeners();
rethrow;
}
}
Future<void> createChannel({
required String organizationId,
required String name,
String description = "",
String type = "text",
bool isPrivate = false,
}) async {
try {
await _invokeAuthedFunction(
"channel-create",
body: {
"organization_id": organizationId,
"name": name,
"description": description,
"type": type,
"is_private": isPrivate,
},
);
await _loadChannelsForOrganization(organizationId);
notifyListeners();
} catch (error, stackTrace) {
_logError("createChannel/channel-create", error, stackTrace);
_errorMessage = error.toString();
notifyListeners();
rethrow;
}
}
Future<void> deleteChannel({
required String organizationId,
required String channelId,
}) async {
try {
await _invokeAuthedFunction(
"channel-delete",
body: {"channel_id": channelId},
);
await _loadChannelsForOrganization(organizationId);
notifyListeners();
} catch (error, stackTrace) {
_logError("deleteChannel/channel-delete", error, stackTrace);
_errorMessage = error.toString();
notifyListeners();
rethrow;
}
}
Future<List<MessageSummary>> listMessages({
required String channelId,
int limit = 50,
}) async {
final response = await _invokeAuthedFunction(
"message-list",
body: {"channel_id": channelId, "limit": limit},
);
final payload = _asMap(response.data);
final rows = _asList(payload["messages"]);
return rows
.map((row) => MessageSummary.fromApi(_asMap(row)))
.where((message) => message.id.isNotEmpty)
.toList();
}
Future<void> sendMessage({
required String channelId,
required String content,
}) async {
await _invokeAuthedFunction(
"message-send",
body: {"channel_id": channelId, "content": content},
);
}
Future<String> createInvite({
required String organizationId,
int maxUses = 1,
int expiresInDays = 7,
}) async {
final response = await _invokeAuthedFunction(
"org-invite-create",
body: {
"organization_id": organizationId,
"max_uses": maxUses,
"expires_in_days": expiresInDays,
},
);
final payload = _asMap(response.data);
final invite = _asMap(payload["invite"]);
final token = (invite["token"] ?? "").toString();
if (token.isEmpty) {
throw StateError("Invite token missing from response.");
}
return token;
}
Future<String> acceptInviteToken(String token) async {
final response = await _invokeAuthedFunction(
"org-invite-accept",
body: {"token": token},
);
final payload = _asMap(response.data);
final organizationId = (payload["organization_id"] ?? "").toString();
if (organizationId.isEmpty) {
throw StateError("Invite did not return organization_id.");
}
await refreshOrganizations();
_selectedOrganizationId = organizationId;
await _loadChannelsForOrganization(organizationId);
notifyListeners();
return organizationId;
}
Future<void> selectOrganization(String organizationId) async {
if (_selectedOrganizationId == organizationId) return;
_selectedOrganizationId = organizationId;
_selectedChannelId = null;
notifyListeners();
await _loadChannelsForOrganization(organizationId);
notifyListeners();
}
void selectChannel(String channelId) {
_selectedChannelId = channelId;
notifyListeners();
}
Future<void> _loadChannelsForOrganization(
String organizationId, {
String? preferredChannelId,
}) async {
try {
final response = await _invokeAuthedFunction(
"channel-list",
body: {"organization_id": organizationId},
);
final payload = _asMap(response.data);
final channelRows = _asList(payload["channels"]);
final channels = channelRows
.map((row) => ChannelSummary.fromApi(_asMap(row)))
.where((channel) => channel.id.isNotEmpty)
.toList();
_channelsByOrganization[organizationId] = channels;
if (channels.isEmpty) {
_selectedChannelId = null;
} else if (preferredChannelId != null &&
channels.any((c) => c.id == preferredChannelId)) {
_selectedChannelId = preferredChannelId;
} else {
_selectedChannelId = channels.first.id;
}
} catch (error, stackTrace) {
_logError("loadChannels/channel-list", error, stackTrace);
_errorMessage = error.toString();
}
}
void _logError(String operation, Object error, StackTrace stackTrace) {
debugPrint(
"[CollaborationProvider] $operation failed (${error.runtimeType}): $error",
);
debugPrintStack(stackTrace: stackTrace);
}
Future<dynamic> _invokeAuthedFunction(
String functionName, {
Object? body,
}) async {
final client = _supabaseProvider.client;
var token = await _getFreshAccessToken();
if (token == null || token.isEmpty) {
throw StateError(
"No valid access token available for edge function call.",
);
}
Future<dynamic> invokeOnce(String accessToken) {
client.functions.setAuth(accessToken);
return client.functions.invoke(
functionName,
body: body,
headers: <String, String>{"Authorization": "Bearer $accessToken"},
);
}
try {
return await invokeOnce(token);
} catch (error, stackTrace) {
debugPrint(
"[CollaborationProvider] invokeAuthedFunction/$functionName initial attempt failed: $error",
);
debugPrintStack(stackTrace: stackTrace);
if (!_isUnauthorizedFunctionError(error)) rethrow;
// One forced refresh + retry for token rotation races.
final refreshed = await client.auth.refreshSession();
token =
refreshed.session?.accessToken ??
client.auth.currentSession?.accessToken;
if (token == null || token.isEmpty) rethrow;
return invokeOnce(token);
}
}
Future<String?> _getFreshAccessToken() async {
var session = _supabaseProvider.session;
final nowUnix = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expiresAt = session?.expiresAt;
final shouldRefresh =
session != null && expiresAt != null && expiresAt <= nowUnix + 30;
if (shouldRefresh) {
try {
final refreshed = await _supabaseProvider.client.auth.refreshSession();
session =
refreshed.session ?? _supabaseProvider.client.auth.currentSession;
} catch (error, stackTrace) {
debugPrint(
"[CollaborationProvider] getFreshAccessToken/refreshSession failed: $error",
);
debugPrintStack(stackTrace: stackTrace);
session = _supabaseProvider.client.auth.currentSession;
}
}
return session?.accessToken;
}
bool _isUnauthorizedFunctionError(Object error) {
final text = error.toString();
return text.contains("status: 401") || text.contains("code: 401");
}
static Map<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
return {};
}
static List<dynamic> _asList(Object? value) {
if (value is List) return value;
return const [];
}
@override
void dispose() {
_supabaseProvider.removeListener(_handleSupabaseStateChange);
super.dispose();
}
}
+131
View File
@@ -0,0 +1,131 @@
import "dart:async";
import "package:flutter/foundation.dart";
import "package:supabase_flutter/supabase_flutter.dart";
SupabaseClient get supabase => Supabase.instance.client;
class SupabaseProvider extends ChangeNotifier {
Session? _session;
StreamSubscription<AuthState>? _authSub;
bool _isSessionValidated = false;
bool _isValidatingSession = false;
SupabaseProvider() {
_session = supabase.auth.currentSession;
unawaited(_validateSession(_session));
_authSub = supabase.auth.onAuthStateChange.listen((data) {
unawaited(_validateSession(data.session));
});
}
Session? get session => _session;
bool get isAuthenticated => _session != null && _isSessionValidated;
bool get isValidatingSession => _isValidatingSession;
SupabaseClient get client => supabase;
Future<void> signInWithPassword({
required String email,
required String password,
}) async {
await supabase.auth.signInWithPassword(email: email, password: password);
}
Future<bool> signUpWithPassword({
required String email,
required String password,
}) async {
final res = await supabase.auth.signUp(email: email, password: password);
// returns true if email confirmation is needed
return res.session == null;
}
Future<void> verifySignUpOtp({
required String email,
required String token,
}) async {
await supabase.auth.verifyOTP(
email: email,
token: token,
type: OtpType.signup,
);
}
Future<void> resendSignUpOtp({required String email}) async {
await supabase.auth.resend(type: OtpType.signup, email: email);
}
Future<void> signOut() async {
try {
await supabase.auth.signOut(scope: SignOutScope.local);
} catch (error, stackTrace) {
debugPrint("[SupabaseProvider] signOut failed: $error");
debugPrintStack(stackTrace: stackTrace);
}
_session = null;
_isSessionValidated = false;
notifyListeners();
}
Future<void> _validateSession(Session? incomingSession) async {
_session = incomingSession ?? supabase.auth.currentSession;
if (_session == null) {
_isSessionValidated = false;
_isValidatingSession = false;
notifyListeners();
return;
}
_isValidatingSession = true;
notifyListeners();
try {
final userResponse = await supabase.auth.getUser();
if (userResponse.user != null) {
_session = supabase.auth.currentSession ?? _session;
_isSessionValidated = true;
_isValidatingSession = false;
notifyListeners();
return;
}
} catch (error, stackTrace) {
debugPrint("[SupabaseProvider] validateSession/getUser failed: $error");
debugPrintStack(stackTrace: stackTrace);
}
try {
final refreshed = await supabase.auth.refreshSession();
_session = refreshed.session ?? supabase.auth.currentSession;
final refreshedUser = await supabase.auth.getUser();
_isSessionValidated = refreshedUser.user != null;
} catch (error, stackTrace) {
debugPrint("[SupabaseProvider] validateSession/refresh failed: $error");
debugPrintStack(stackTrace: stackTrace);
_isSessionValidated = false;
}
if (!_isSessionValidated) {
try {
await supabase.auth.signOut(scope: SignOutScope.local);
} catch (error, stackTrace) {
debugPrint("[SupabaseProvider] validateSession/signOut failed: $error");
debugPrintStack(stackTrace: stackTrace);
}
_session = null;
}
_isValidatingSession = false;
notifyListeners();
}
@override
void dispose() {
_authSub?.cancel();
super.dispose();
}
}
+6 -3
View File
@@ -1,5 +1,6 @@
import "dart:typed_data";
import "../models/trip.dart";
import "package:flutter/foundation.dart";
import "../models/operations/trip.dart";
import "../models/brr_metadata.dart";
import "../exporters/brr_exporter.dart";
import "../exporters/arriva_brr_exporter.dart";
@@ -37,8 +38,10 @@ class BRRExportService {
try {
final bytes = await _exporter.export(trips, metadata);
return ExportResult.success(bytes);
} catch (e) {
} catch (e, stackTrace) {
debugPrint("[BRRExportService] exportBRR failed: $e");
debugPrintStack(stackTrace: stackTrace);
return ExportResult.error(["Export failed: $e"]);
}
}
}
}
+4 -1
View File
@@ -1,4 +1,5 @@
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:hive_flutter/hive_flutter.dart";
import "../models/brr_state.dart";
@@ -21,7 +22,9 @@ class StorageService {
try {
return BRRState.fromJson(jsonDecode(jsonString) as Map<String, dynamic>);
} catch (e) {
} catch (e, stackTrace) {
debugPrint("[StorageService] loadState failed: $e");
debugPrintStack(stackTrace: stackTrace);
return null;
}
}
+7 -5
View File
@@ -1,4 +1,4 @@
import "../models/trip.dart";
import "../models/operations/trip.dart";
class TripValidator {
static List<String> validate(Trip trip) {
@@ -14,15 +14,17 @@ class TripValidator {
errors.add("Missing trip number");
}
// Validate duty/running numbers
if (trip.dutyNumber.isEmpty || trip.runningNumber.isEmpty) {
errors.add("Missing duty or running number");
// Validate duty/bus-work numbers
if (trip.dutyNumber.isEmpty || trip.busWorkNumber.isEmpty) {
errors.add("Missing duty or bus work number");
}
// Validate actual departure time if provided
if (trip.actualDepartureTime != null &&
!RegExp(r"^\d{2}:\d{2}$").hasMatch(trip.actualDepartureTime!)) {
errors.add("Invalid actual departure time format: ${trip.actualDepartureTime}");
errors.add(
"Invalid actual departure time format: ${trip.actualDepartureTime}",
);
}
return errors;
+280
View File
@@ -0,0 +1,280 @@
import "package:flutter/material.dart";
class TripDiagramEntry {
const TripDiagramEntry({required this.label, this.labelIcon, this.subtitle, this.time});
final String label;
final IconData? labelIcon;
final String? subtitle;
final String? time;
}
class TripDiagram extends StatelessWidget {
const TripDiagram({
required this.entries,
super.key,
this.leftOffset = 0,
this.rowHeight = 32,
this.lineWidth = 7,
this.lineColor,
this.highlightLastEntry = false,
this.labelOffset = 26,
this.rightPadding = 16,
this.oddRowColor,
this.terminalRowColor,
this.stationTextStyle,
this.timeTextStyle,
this.missingTimeTextStyle,
this.emptyMessage = "No stations found.",
this.emptyTextStyle,
});
final List<TripDiagramEntry> entries;
final double leftOffset;
final double rowHeight;
final double lineWidth;
final Color? lineColor;
final bool highlightLastEntry;
final double labelOffset;
final double rightPadding;
final Color? oddRowColor;
final Color? terminalRowColor;
final TextStyle? stationTextStyle;
final TextStyle? timeTextStyle;
final TextStyle? missingTimeTextStyle;
final String emptyMessage;
final TextStyle? emptyTextStyle;
@override
Widget build(BuildContext context) {
final resolvedLineColor = lineColor ?? Theme.of(context).colorScheme.primary;
final deduped = entries;
if (deduped.isEmpty) {
return Text(
emptyMessage,
style:
emptyTextStyle ??
TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
);
}
final totalHeight = deduped.length * rowHeight;
return Stack(
children: [
SizedBox(
height: totalHeight,
width: double.infinity,
child: CustomPaint(
painter: _TripDiagramPainter(
count: deduped.length,
rowHeight: rowHeight,
lineWidth: lineWidth,
lineColor: resolvedLineColor,
leftOffset: leftOffset,
),
),
),
Positioned.fill(
child: Column(
children: List.generate(deduped.length, (index) {
final entry = deduped[index];
final time = entry.time;
final isTerminalRow = highlightLastEntry && index == deduped.length - 1;
return Container(
height: rowHeight,
color: isTerminalRow
? (terminalRowColor ?? resolvedLineColor.withValues(alpha: 0.12))
: index.isOdd
? (oddRowColor ?? Colors.white.withValues(alpha: 0.03))
: null,
child: Padding(
padding: EdgeInsets.only(
left: leftOffset + labelOffset,
right: rightPadding,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Text(
entry.label,
style:
stationTextStyle ??
const TextStyle(
fontSize: 15,
color: Color(0xFFDDDDDD),
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
overflow: TextOverflow.ellipsis,
),
if (entry.labelIcon != null) ...[
const SizedBox(width: 4),
Icon(
entry.labelIcon,
size: 12,
color: const Color(0xFFBBBBBB),
),
]
],
),
if (entry.subtitle != null)
Text(
entry.subtitle!,
style:
stationTextStyle ??
const TextStyle(
fontSize: 11,
color: Color(0xFFDDDDDD),
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
Text(
time ?? "--:--",
style: time != null
? (timeTextStyle ??
const TextStyle(
fontSize: 15,
fontFamily: "monospace",
fontWeight: FontWeight.w700,
color: Color(0xFFEEEEEE),
))
: (missingTimeTextStyle ??
const TextStyle(
fontSize: 15,
fontFamily: "monospace",
fontWeight: FontWeight.w700,
color: Color(0xFF555555),
)),
),
],
),
),
);
}),
),
),
],
);
}
}
class _TripDiagramPainter extends CustomPainter {
const _TripDiagramPainter({
required this.count,
required this.rowHeight,
required this.lineWidth,
required this.lineColor,
this.leftOffset = 0,
});
final int count;
final double rowHeight;
final double lineWidth;
final Color lineColor;
final double leftOffset;
static const double _linePad = 4.0;
static const double _blobPad = 4.0;
static const double _centerSize = 4.0;
static const double _cornerFactor = 0.3;
@override
void paint(Canvas canvas, Size size) {
if (count <= 0) return;
final strokeWidth = lineWidth / 2.0;
final ringSize = _centerSize + strokeWidth;
final paddingWidth = strokeWidth + _blobPad;
final centerX = leftOffset + ringSize / 2 + paddingWidth / 2 + 2;
final firstY = rowHeight / 2;
final lastY = (count - 1) * rowHeight + rowHeight / 2;
final nodeRects = List.generate(count, (index) {
final centerY = index * rowHeight + rowHeight / 2;
final rect = Rect.fromCenter(
center: Offset(centerX, centerY),
width: ringSize,
height: ringSize,
);
return RRect.fromRectAndRadius(
rect,
Radius.circular(ringSize * _cornerFactor),
);
});
for (final rect in nodeRects) {
canvas.drawRRect(
rect,
Paint()
..color = Colors.white
..strokeWidth = paddingWidth
..style = PaintingStyle.stroke,
);
}
canvas.drawLine(
Offset(centerX, firstY),
Offset(centerX, lastY),
Paint()
..color = Colors.white
..strokeWidth = lineWidth + _linePad
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke,
);
canvas.drawLine(
Offset(centerX, firstY),
Offset(centerX, lastY),
Paint()
..color = lineColor
..strokeWidth = lineWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke,
);
for (final rect in nodeRects) {
final centerRect = rect.deflate(strokeWidth / 2);
canvas.drawRRect(
centerRect,
Paint()
..color = Colors.white
..style = PaintingStyle.fill,
);
canvas.drawRRect(
rect,
Paint()
..color = lineColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke,
);
}
}
@override
bool shouldRepaint(covariant _TripDiagramPainter oldDelegate) {
return oldDelegate.count != count ||
oldDelegate.rowHeight != rowHeight ||
oldDelegate.lineWidth != lineWidth ||
oldDelegate.lineColor != lineColor ||
oldDelegate.leftOffset != leftOffset;
}
}