From 427bcadc77f92c8a4106e372330d46d6c6937f1e Mon Sep 17 00:00:00 2001 From: ImBenji Date: Fri, 27 Mar 2026 21:17:56 +0000 Subject: [PATCH] Add version files and update imports for trip model; enhance error handling --- devtools_options.yaml | 3 + ios/Podfile.lock | 77 +- ios/Runner.xcodeproj/project.pbxproj | 25 + .../xcshareddata/swiftpm/Package.resolved | 59 ++ .../xcshareddata/xcschemes/Runner.xcscheme | 18 + .../xcshareddata/swiftpm/Package.resolved | 59 ++ ios/Runner/Runner.entitlements | 10 + lib/constants.dart | 12 + lib/exporters/arriva_brr_exporter.dart | 83 +- lib/exporters/brr_exporter.dart | 2 +- lib/exporters/stagecoach_brr_exporter.dart | 50 +- lib/main.dart | 167 +-- lib/models/brr_state.dart | 2 +- lib/models/channels/base_channel.dart | 67 ++ lib/models/channels/operations_channel.dart | 436 ++++++++ lib/models/channels/text_channel.dart | 129 +++ lib/models/operations/duty.dart | 24 + lib/models/operations/scheduled_stop.dart | 58 ++ lib/models/operations/stop.dart | 15 + lib/models/operations/trip.dart | 206 ++++ lib/models/trip.dart | 91 -- lib/pages/auth/page.dart | 963 ++++++++++++++++++ lib/pages/auth/verify_email_page.dart | 143 +++ .../channels/operations_channel_view.dart | 42 + .../home/channels/text_channel_view.dart | 299 ++++++ lib/pages/home/page.dart | 192 ++++ lib/pages/home/widgets/channel_header.dart | 52 + lib/pages/home/widgets/home_dialogs.dart | 376 +++++++ lib/pages/home/widgets/home_left_sidebar.dart | 613 +++++++++++ lib/pages/home/widgets/swiper.dart | 126 +++ lib/pages/home_page.dart | 10 +- lib/pages/invite/page.dart | 132 +++ lib/pages/operations_upload/page.dart | 761 ++++++++++++++ lib/pages/org_settings/page.dart | 566 ++++++++++ lib/pages/station_selection_page.dart | 10 +- lib/pages/trip_list_page.dart | 7 +- lib/parsers/arriva_schedule_parser.dart | 150 ++- lib/parsers/schedule_parser.dart | 2 +- lib/parsers/stagecoach_schedule_parser.dart | 138 ++- lib/provider/collaboration_state.dart | 536 ++++++++++ lib/provider/supabase_state.dart | 131 +++ lib/services/brr_export_service.dart | 9 +- lib/services/storage_service.dart | 5 +- lib/validators/trip_validator.dart | 12 +- lib/widgets/trip_diagram.dart | 280 +++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 + macos/Runner/DebugProfile.entitlements | 4 + macos/Runner/Release.entitlements | 4 + pubspec.lock | 389 ++++++- pubspec.yaml | 6 +- supabase/.temp/cli-latest | 1 + supabase/.temp/gotrue-version | 1 + supabase/.temp/pooler-url | 1 + supabase/.temp/postgres-version | 1 + supabase/.temp/project-ref | 1 + supabase/.temp/rest-version | 1 + supabase/.temp/storage-migration | 1 + supabase/.temp/storage-version | 1 + supabase/config.toml | 35 + supabase/functions/_shared/http.ts | 24 + supabase/functions/_shared/supabase.ts | 43 + supabase/functions/auth-debug/index.ts | 114 +++ supabase/functions/channel-create/index.ts | 95 ++ supabase/functions/channel-delete/index.ts | 52 + supabase/functions/channel-list/index.ts | 42 + supabase/functions/message-list/index.ts | 48 + supabase/functions/message-send/index.ts | 43 + .../operations-stop-alias-enhance/index.ts | 155 +++ supabase/functions/org-create/index.ts | 53 + supabase/functions/org-invite-accept/index.ts | 88 ++ supabase/functions/org-invite-create/index.ts | 98 ++ supabase/functions/org-list/index.ts | 32 + supabase/functions/org-update/index.ts | 76 ++ .../20260325120000_collab_schema.sql | 308 ++++++ ...0326150000_collab_policy_bootstrap_fix.sql | 31 + ...20260326162400_channel_type_operations.sql | 30 + ...0260326173000_reset_collab_to_hash_ids.sql | 322 ++++++ .../20260326190000_add_org_invites.sql | 39 + ...4500_enable_realtime_for_collab_tables.sql | 42 + ...26195500_add_operations_channel_tables.sql | 341 +++++++ .../20260326201000_add_organization_icons.sql | 59 ++ ...name_running_number_to_bus_work_number.sql | 2 + ...20260326213000_add_channel_description.sql | 7 + ...0327110000_add_operations_stop_aliases.sql | 84 ++ ..._add_source_to_operations_stop_aliases.sql | 13 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 89 files changed, 9455 insertions(+), 395 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner/Runner.entitlements create mode 100644 lib/constants.dart create mode 100644 lib/models/channels/base_channel.dart create mode 100644 lib/models/channels/operations_channel.dart create mode 100644 lib/models/channels/text_channel.dart create mode 100644 lib/models/operations/duty.dart create mode 100644 lib/models/operations/scheduled_stop.dart create mode 100644 lib/models/operations/stop.dart create mode 100644 lib/models/operations/trip.dart delete mode 100644 lib/models/trip.dart create mode 100644 lib/pages/auth/page.dart create mode 100644 lib/pages/auth/verify_email_page.dart create mode 100644 lib/pages/home/channels/operations_channel_view.dart create mode 100644 lib/pages/home/channels/text_channel_view.dart create mode 100644 lib/pages/home/page.dart create mode 100644 lib/pages/home/widgets/channel_header.dart create mode 100644 lib/pages/home/widgets/home_dialogs.dart create mode 100644 lib/pages/home/widgets/home_left_sidebar.dart create mode 100644 lib/pages/home/widgets/swiper.dart create mode 100644 lib/pages/invite/page.dart create mode 100644 lib/pages/operations_upload/page.dart create mode 100644 lib/pages/org_settings/page.dart create mode 100644 lib/provider/collaboration_state.dart create mode 100644 lib/provider/supabase_state.dart create mode 100644 lib/widgets/trip_diagram.dart create mode 100644 supabase/.temp/cli-latest create mode 100644 supabase/.temp/gotrue-version create mode 100644 supabase/.temp/pooler-url create mode 100644 supabase/.temp/postgres-version create mode 100644 supabase/.temp/project-ref create mode 100644 supabase/.temp/rest-version create mode 100644 supabase/.temp/storage-migration create mode 100644 supabase/.temp/storage-version create mode 100644 supabase/config.toml create mode 100644 supabase/functions/_shared/http.ts create mode 100644 supabase/functions/_shared/supabase.ts create mode 100644 supabase/functions/auth-debug/index.ts create mode 100644 supabase/functions/channel-create/index.ts create mode 100644 supabase/functions/channel-delete/index.ts create mode 100644 supabase/functions/channel-list/index.ts create mode 100644 supabase/functions/message-list/index.ts create mode 100644 supabase/functions/message-send/index.ts create mode 100644 supabase/functions/operations-stop-alias-enhance/index.ts create mode 100644 supabase/functions/org-create/index.ts create mode 100644 supabase/functions/org-invite-accept/index.ts create mode 100644 supabase/functions/org-invite-create/index.ts create mode 100644 supabase/functions/org-list/index.ts create mode 100644 supabase/functions/org-update/index.ts create mode 100644 supabase/migrations/20260325120000_collab_schema.sql create mode 100644 supabase/migrations/20260326150000_collab_policy_bootstrap_fix.sql create mode 100644 supabase/migrations/20260326162400_channel_type_operations.sql create mode 100644 supabase/migrations/20260326173000_reset_collab_to_hash_ids.sql create mode 100644 supabase/migrations/20260326190000_add_org_invites.sql create mode 100644 supabase/migrations/20260326194500_enable_realtime_for_collab_tables.sql create mode 100644 supabase/migrations/20260326195500_add_operations_channel_tables.sql create mode 100644 supabase/migrations/20260326201000_add_organization_icons.sql create mode 100644 supabase/migrations/20260326212000_rename_running_number_to_bus_work_number.sql create mode 100644 supabase/migrations/20260326213000_add_channel_description.sql create mode 100644 supabase/migrations/20260327110000_add_operations_stop_aliases.sql create mode 100644 supabase/migrations/20260327113000_add_source_to_operations_stop_aliases.sql diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5681a92..d361308 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,88 +1,21 @@ PODS: - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery + - file_saver (0.0.1): - Flutter - Flutter (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - SDWebImage (5.21.1): - - SDWebImage/Core (= 5.21.1) - - SDWebImage/Core (5.21.1) - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - SwiftyGif (5.4.5) DEPENDENCIES: - - file_picker (from `.symlinks/plugins/file_picker/ios`) + - file_saver (from `.symlinks/plugins/file_saver/ios`) - Flutter (from `Flutter`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - -SPEC REPOS: - trunk: - - DKImagePickerController - - DKPhotoGallery - - SDWebImage - - SwiftyGif EXTERNAL SOURCES: - file_picker: - :path: ".symlinks/plugins/file_picker/ios" + file_saver: + :path: ".symlinks/plugins/file_saver/ios" Flutter: :path: Flutter - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 + file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba - SDWebImage: f29024626962457f3470184232766516dee8dfea - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7326192..79d34dd 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; C536F9E59C502B71681ADDC5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7850BC6B3D5EF5598AF87AE /* Pods_Runner.framework */; }; CA308A35B71C23EC5BE5FDF6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 426174207BBAFA88D7CFC055 /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +66,7 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A7850BC6B3D5EF5598AF87AE /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,6 +74,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, C536F9E59C502B71681ADDC5 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -121,6 +124,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -188,6 +192,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -213,6 +220,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -470,6 +480,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = A9TMA2CA43; ENABLE_BITCODE = NO; @@ -653,6 +664,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = A9TMA2CA43; ENABLE_BITCODE = NO; @@ -676,6 +688,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = A9TMA2CA43; ENABLE_BITCODE = NO; @@ -726,6 +739,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + + + + + keychain-access-groups + + $(AppIdentifierPrefix)$(CFBundleIdentifier) + + + diff --git a/lib/constants.dart b/lib/constants.dart new file mode 100644 index 0000000..05a72cf --- /dev/null +++ b/lib/constants.dart @@ -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"; diff --git a/lib/exporters/arriva_brr_exporter.dart b/lib/exporters/arriva_brr_exporter.dart index e1440c4..32c49d3 100644 --- a/lib/exporters/arriva_brr_exporter.dart +++ b/lib/exporters/arriva_brr_exporter.dart @@ -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']*>.*?', dotAll: true), ""); + xml = xml.replaceAll( + RegExp(r']*>.*?', 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 9–23 - @override Future export(List 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", + ); } } - } diff --git a/lib/exporters/brr_exporter.dart b/lib/exporters/brr_exporter.dart index bcda8fa..9799851 100644 --- a/lib/exporters/brr_exporter.dart +++ b/lib/exporters/brr_exporter.dart @@ -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 { diff --git a/lib/exporters/stagecoach_brr_exporter.dart b/lib/exporters/stagecoach_brr_exporter.dart index c81ae1c..e87ba02 100644 --- a/lib/exporters/stagecoach_brr_exporter.dart +++ b/lib/exporters/stagecoach_brr_exporter.dart @@ -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 export(List 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(); diff --git a/lib/main.dart b/lib/main.dart index cc4a18e..72d13e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 createState() => _RoadboundAppState(); +} + +class _RoadboundAppState extends State { + 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()) + ..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, ), ); } diff --git a/lib/models/brr_state.dart b/lib/models/brr_state.dart index e2f4fad..d79cc44 100644 --- a/lib/models/brr_state.dart +++ b/lib/models/brr_state.dart @@ -1,4 +1,4 @@ -import "trip.dart"; +import "operations/trip.dart"; class BRRState { final List trips; diff --git a/lib/models/channels/base_channel.dart b/lib/models/channels/base_channel.dart new file mode 100644 index 0000000..17e8827 --- /dev/null +++ b/lib/models/channels/base_channel.dart @@ -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 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 asMap(Object? value) { + if (value is Map) return value; + if (value is Map) return value.cast(); + return {}; + } + + static List asList(Object? value) { + if (value is List) return value; + return const []; + } +} diff --git a/lib/models/channels/operations_channel.dart b/lib/models/channels/operations_channel.dart new file mode 100644 index 0000000..37a9462 --- /dev/null +++ b/lib/models/channels/operations_channel.dart @@ -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 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 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 stops; + final List scheduledStops; +} + +class OperationsDutySnapshot { + const OperationsDutySnapshot({ + required this.dutyNumber, + required this.busWorkNumber, + required this.trips, + }); + + final String dutyNumber; + final String busWorkNumber; + final List 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 trips; + final List duties; + final List scheduledStops; + final Map stopAliasesByRawName; + final List 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 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 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: [], + duties: [], + scheduledStops: [], + stopAliasesByRawName: {}, + stopAliases: [], + ); + } + + final stopAliases = await listStopAliases(); + final stopAliasesByRawName = _aliasesByRawStopName(stopAliases); + final aliasesByNormalizedName = {}; + final aliasSourceByNormalizedName = {}; + 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 [], + duties: const [], + scheduledStops: const [], + 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 updateRows = const []; + 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 = >{}; + 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 = >{}; + final allScheduledStops = []; + 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, () => []) + .add(scheduledStop); + } + + final snapshots = trips.map((trip) { + final scheduledStops = + scheduledStopsByTripId[trip.id] ?? const []; + 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 = >{}; + for (final snapshot in snapshots) { + groupedByDuty + .putIfAbsent(snapshot.trip.dutyKey, () => []) + .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> listStopAliases() async { + final rows = await client + .from("operations_stop_aliases") + .select("raw_stop_name, alias_stop_name, source") + .eq("channel_id", id); + + final aliases = []; + 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 _aliasesByRawStopName(List aliases) { + final byRawStopName = {}; + for (final alias in aliases) { + byRawStopName[alias.rawStopName] = alias.aliasStopName; + } + return byRawStopName; + } + + Future 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"); + } +} diff --git a/lib/models/channels/text_channel.dart b/lib/models/channels/text_channel.dart new file mode 100644 index 0000000..dd090a4 --- /dev/null +++ b/lib/models/channels/text_channel.dart @@ -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 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 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 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> 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 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 unsubscribe(RealtimeChannel channel) async { + await client.removeChannel(channel); + } +} diff --git a/lib/models/operations/duty.dart b/lib/models/operations/duty.dart new file mode 100644 index 0000000..c55e6fc --- /dev/null +++ b/lib/models/operations/duty.dart @@ -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 toJson() { + return {"dutyNumber": dutyNumber, "busWorkNumber": busWorkNumber}; + } + + factory Duty.fromJson(Map json) { + return Duty( + dutyNumber: (json["dutyNumber"] ?? "").toString(), + busWorkNumber: (json["busWorkNumber"] ?? "").toString(), + ); + } +} diff --git a/lib/models/operations/scheduled_stop.dart b/lib/models/operations/scheduled_stop.dart new file mode 100644 index 0000000..61ee36b --- /dev/null +++ b/lib/models/operations/scheduled_stop.dart @@ -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 toJson() { + return { + "name": name, + "alias": alias, + "aliasSource": aliasSource, + "sequence": sequence, + "scheduledTime": scheduledTime, + }; + } + + factory ScheduledStop.fromJson(Map 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, + ); + } +} diff --git a/lib/models/operations/stop.dart b/lib/models/operations/stop.dart new file mode 100644 index 0000000..7ba6a7b --- /dev/null +++ b/lib/models/operations/stop.dart @@ -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(); +} diff --git a/lib/models/operations/trip.dart b/lib/models/operations/trip.dart new file mode 100644 index 0000000..6504410 --- /dev/null +++ b/lib/models/operations/trip.dart @@ -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 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 stationTimes = const {}, + List stationOrder = const [], + List? 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 get stationOrder => + scheduledStops.map((stop) => stop.name).toList(growable: false); + Map get stationTimes { + final result = {}; + 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? stationTimes, + List? stationOrder, + List? 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 aliasesByRawName, { + String aliasSource = "ai", + }) { + if (aliasesByRawName.isEmpty) return this; + + final aliasesByNormalizedName = {}; + 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 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 json) { + final dutyJson = json["duty"]; + final duty = dutyJson is Map + ? 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( + (stopJson) => + ScheduledStop.fromJson(Map.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.from( + json["stationTimes"] as Map? ?? {}, + ), + stationOrder: List.from(json["stationOrder"] as List? ?? []), + direction: json["direction"] as String? ?? "outbound", + actualDepartureTime: json["actualDepartureTime"] as String?, + actualFleetNumber: json["actualFleetNumber"] as String?, + ); + } + + static List _buildScheduledStops( + Map stationTimes, + List 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.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); + } +} diff --git a/lib/models/trip.dart b/lib/models/trip.dart deleted file mode 100644 index 9b7992f..0000000 --- a/lib/models/trip.dart +++ /dev/null @@ -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 stationTimes; // {"UXBG": "15:31", "HILL": "15:42", ...} - final List 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? stationTimes, - List? 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 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 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.from(json["stationTimes"] as Map? ?? {}), - stationOrder: List.from(json["stationOrder"] as List? ?? []), - direction: json["direction"] as String? ?? "outbound", - actualDepartureTime: json["actualDepartureTime"] as String?, - actualFleetNumber: json["actualFleetNumber"] as String?, - ); - } -} diff --git a/lib/pages/auth/page.dart b/lib/pages/auth/page.dart new file mode 100644 index 0000000..4c0cd34 --- /dev/null +++ b/lib/pages/auth/page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + 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 _submitAuth({ + required String email, + required String password, + }) async { + final supabase = context.read(); + 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 _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 _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 _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 _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 = [ + 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(_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( + 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( + _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(); + 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( + 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), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/auth/verify_email_page.dart b/lib/pages/auth/verify_email_page.dart new file mode 100644 index 0000000..1dd8193 --- /dev/null +++ b/lib/pages/auth/verify_email_page.dart @@ -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 createState() => _VerifyEmailPageState(); +} + +class _VerifyEmailPageState extends State { + static const int _tokenLength = 6; + String _token = ""; + bool _isSubmitting = false; + String? _error; + + Future _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().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, + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/channels/operations_channel_view.dart b/lib/pages/home/channels/operations_channel_view.dart new file mode 100644 index 0000000..609507c --- /dev/null +++ b/lib/pages/home/channels/operations_channel_view.dart @@ -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(); + } +} diff --git a/lib/pages/home/channels/text_channel_view.dart b/lib/pages/home/channels/text_channel_view.dart new file mode 100644 index 0000000..9fa66c6 --- /dev/null +++ b/lib/pages/home/channels/text_channel_view.dart @@ -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 createState() => _TextChannelViewState(); +} + +class _TextChannelViewState extends State { + RealtimeChannel? _messagesRealtimeChannel; + bool _loadingMessages = false; + bool _sendingMessage = false; + String? _messageError; + String _draftMessage = ""; + int _composerNonce = 0; + List _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 _initializeChannel() async { + await _unsubscribeFromRealtimeMessages(); + if (!mounted) return; + setState(() { + _messages = const []; + _messageError = null; + _draftMessage = ""; + _composerNonce += 1; + }); + await _subscribeToRealtimeMessages(); + await _loadMessages(); + } + + Future _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 _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 _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 _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, + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/home/page.dart b/lib/pages/home/page.dart new file mode 100644 index 0000000..479d43e --- /dev/null +++ b/lib/pages/home/page.dart @@ -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 createState() => _HomePageState(); +} + +class _HomePageState extends State { + String? _lastSyncedRouteKey; + + Future _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(); + 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().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(); + 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), + ], + ); + } +} diff --git a/lib/pages/home/widgets/channel_header.dart b/lib/pages/home/widgets/channel_header.dart new file mode 100644 index 0000000..6b2239f --- /dev/null +++ b/lib/pages/home/widgets/channel_header.dart @@ -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(), + ], + ), + ), + ], + ); + } +} diff --git a/lib/pages/home/widgets/home_dialogs.dart b/lib/pages/home/widgets/home_dialogs.dart new file mode 100644 index 0000000..8490e7d --- /dev/null +++ b/lib/pages/home/widgets/home_dialogs.dart @@ -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 showCreateOrganizationDialog(BuildContext context) async { + final result = await showDialog( + 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().createOrganization(name); +} + +Future showCreateChannelDialog( + BuildContext context, { + required String organizationId, +}) async { + final result = await showDialog>( + 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().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( + 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 showJoinOrganizationDialog(BuildContext context) async { + final result = await showDialog( + 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/"), + 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().acceptInviteToken(token); + if (!context.mounted) return; + await showDialog( + 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( + 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 runAuthDebug(BuildContext context) async { + final client = context.read().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( + 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( + 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(1); + final channelName = ValueNotifier(""); + final channelDescription = ValueNotifier(""); + + 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( + 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"; + } +} diff --git a/lib/pages/home/widgets/home_left_sidebar.dart b/lib/pages/home/widgets/home_left_sidebar.dart new file mode 100644 index 0000000..9a05e49 --- /dev/null +++ b/lib/pages/home/widgets/home_left_sidebar.dart @@ -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(); + final supabase = context.watch(); + 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().signOut()); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _OrganizationList extends StatelessWidget { + const _OrganizationList({ + required this.organizations, + required this.isLoading, + required this.errorMessage, + }); + + final List 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(); + + 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 organizations; + final String? selectedOrganizationId; + + Future _openOrganization( + BuildContext context, + String organizationId, + ) async { + final collab = context.read(); + 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 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().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().selectOrganization( + widget.organization.id, + ), + ); + context.read().selectChannel(channel.id); + context.go("/channel/${widget.organization.id}/${channel.id}"); + }, + ), + ), + ), + const Gap(4), + ], + ); + } + + void _showOrganizationMenu(BuildContext context) { + showDropdown( + 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/swiper.dart b/lib/pages/home/widgets/swiper.dart new file mode 100644 index 0000000..1f8acbf --- /dev/null +++ b/lib/pages/home/widgets/swiper.dart @@ -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 createState() => _SidebarSwiperState(); +} + +class _SidebarSwiperState extends State { + 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, + ), + ), + ], + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index e97a3e3..d1bf635 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -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 { 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 { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/invite/page.dart b/lib/pages/invite/page.dart new file mode 100644 index 0000000..656b828 --- /dev/null +++ b/lib/pages/invite/page.dart @@ -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 createState() => _InvitePageState(); +} + +class _InvitePageState extends State { + bool _accepting = false; + bool _accepted = false; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final isLoggedIn = context.read().isAuthenticated; + if (isLoggedIn) { + unawaited(_acceptInvite()); + } + }); + } + + @override + Widget build(BuildContext context) { + final supabase = context.watch(); + 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 _acceptInvite() async { + if (_accepting) return; + setState(() { + _accepting = true; + _error = null; + }); + + try { + await context.read().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; + }); + } + } + } +} diff --git a/lib/pages/operations_upload/page.dart b/lib/pages/operations_upload/page.dart new file mode 100644 index 0000000..3d6d7b7 --- /dev/null +++ b/lib/pages/operations_upload/page.dart @@ -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 createState() => _OperationsUploadPageState(); +} + +class _OperationsUploadPageState extends State { + bool _parsing = false; + bool _saving = false; + bool _enhancing = false; + bool _enhanced = false; + String? _error; + List _parsedTrips = const []; + String? _fileName; + String? _parserType; + String? _sourceMime; + + Future _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> _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 _sortedTrips() { + final sorted = List.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 _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().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 = >[]; + 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 _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 = {}; + 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 _invokeAuthedFunction( + String functionName, { + Object? body, + }) async { + final client = context.read().client; + var token = await _getFreshAccessToken(); + if (token == null || token.isEmpty) { + throw StateError("No valid access token available for edge function call."); + } + + Future invokeOnce(String accessToken) { + client.functions.setAuth(accessToken); + return client.functions.invoke( + functionName, + body: body, + headers: {"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 _getFreshAccessToken() async { + final client = context.read().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 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 _tripDiagramEntries(Trip trip) { + final orderedStops = List.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( + 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( + 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 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)), + + ], + ); + } +} diff --git a/lib/pages/org_settings/page.dart b/lib/pages/org_settings/page.dart new file mode 100644 index 0000000..9d8bf71 --- /dev/null +++ b/lib/pages/org_settings/page.dart @@ -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 createState() => + _OrganizationSettingsPageState(); +} + +class _OrganizationSettingsPageState extends State { + 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().selectOrganization( + widget.organizationId, + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + final collab = context.watch(); + 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 _saveOrganizationName( + BuildContext context, + String organizationId, + ) async { + final name = _organizationName.trim(); + if (name.isEmpty) return; + + setState(() { + _savingOrganization = true; + _message = null; + }); + try { + await context.read().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 _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().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 _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().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 _confirmDeleteChannel( + String organizationId, + ChannelSummary channel, + ) async { + final confirmed = await showDialog( + 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().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 _createInviteLink( + BuildContext context, + String organizationId, + ) async { + setState(() { + _creatingInvite = true; + _message = null; + }); + try { + final token = await context.read().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 _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."; + }); + } +} diff --git a/lib/pages/station_selection_page.dart b/lib/pages/station_selection_page.dart index b9a57ff..e14ea53 100644 --- a/lib/pages/station_selection_page.dart +++ b/lib/pages/station_selection_page.dart @@ -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 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?; if (extra == null) { WidgetsBinding.instance.addPostFrameCallback((_) { - context.go("/"); + context.go(legacyHomePath); }); return const SizedBox.shrink(); } @@ -166,7 +168,7 @@ class _StationSelectionPageState extends State { 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"), ), diff --git a/lib/pages/trip_list_page.dart b/lib/pages/trip_list_page.dart index 2278c00..166fb08 100644 --- a/lib/pages/trip_list_page.dart +++ b/lib/pages/trip_list_page.dart @@ -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 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?; if (extra == null) { @@ -478,7 +479,7 @@ class _TripCardState extends State { children: [ _InfoCell("DUTY", widget.trip.dutyNumber), const SizedBox(width: 12), - _InfoCell("RUNNING", widget.trip.runningNumber), + _InfoCell("RUNNING", widget.trip.busWorkNumber), ], ), diff --git a/lib/parsers/arriva_schedule_parser.dart b/lib/parsers/arriva_schedule_parser.dart index e2ea18b..636458c 100644 --- a/lib/parsers/arriva_schedule_parser.dart +++ b/lib/parsers/arriva_schedule_parser.dart @@ -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 stations, - ) { + Trip? _parseInboundTrip(String line, List 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 stations, - ) { + Trip? _parseOutboundTrip(String line, List 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 _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 _extractAllTimes(String line) { diff --git a/lib/parsers/schedule_parser.dart b/lib/parsers/schedule_parser.dart index 311423b..a38adcf 100644 --- a/lib/parsers/schedule_parser.dart +++ b/lib/parsers/schedule_parser.dart @@ -1,5 +1,5 @@ import "dart:typed_data"; -import "../models/trip.dart"; +import "../models/operations/trip.dart"; abstract class ScheduleParser { Future> parseBytes(Uint8List bytes); diff --git a/lib/parsers/stagecoach_schedule_parser.dart b/lib/parsers/stagecoach_schedule_parser.dart index 37ad17d..dfd92e0 100644 --- a/lib/parsers/stagecoach_schedule_parser.dart +++ b/lib/parsers/stagecoach_schedule_parser.dart @@ -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 = []; 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 _parseTripsFromPage(String pageText, String direction, List stations) { + List _parseTripsFromPage( + String pageText, + String direction, + List stations, + ) { final trips = []; // 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 stations, + String segment, + String dutyId, + String runNumber, + String busWorkingNo, + String direction, + List 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, diff --git a/lib/provider/collaboration_state.dart b/lib/provider/collaboration_state.dart new file mode 100644 index 0000000..f78977e --- /dev/null +++ b/lib/provider/collaboration_state.dart @@ -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 map) { + final org = + (map["organization"] as Map?)?.cast() ?? 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 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 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 _organizations = const []; + final Map> _channelsByOrganization = {}; + String? _selectedOrganizationId; + String? _selectedChannelId; + + bool get isLoadingOrganizations => _isLoadingOrganizations; + String? get errorMessage => _errorMessage; + List get organizations => _organizations; + String? get selectedOrganizationId => _selectedOrganizationId; + String? get selectedChannelId => _selectedChannelId; + + List channelsForOrganization(String orgId) => + _channelsByOrganization[orgId] ?? const []; + + Future initialize() async { + if (_initialized) return; + _initialized = true; + if (!_supabaseProvider.isAuthenticated) return; + await refreshOrganizations(); + } + + Future 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 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 updateOrganization({ + required String organizationId, + String? name, + String? iconUrl, + }) async { + final trimmedName = name?.trim(); + try { + final body = {"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 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 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 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> 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 sendMessage({ + required String channelId, + required String content, + }) async { + await _invokeAuthedFunction( + "message-send", + body: {"channel_id": channelId, "content": content}, + ); + } + + Future 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 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 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 _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 _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 invokeOnce(String accessToken) { + client.functions.setAuth(accessToken); + return client.functions.invoke( + functionName, + body: body, + headers: {"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 _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 _asMap(Object? value) { + if (value is Map) return value; + if (value is Map) return value.cast(); + return {}; + } + + static List _asList(Object? value) { + if (value is List) return value; + return const []; + } + + @override + void dispose() { + _supabaseProvider.removeListener(_handleSupabaseStateChange); + super.dispose(); + } +} diff --git a/lib/provider/supabase_state.dart b/lib/provider/supabase_state.dart new file mode 100644 index 0000000..eb65503 --- /dev/null +++ b/lib/provider/supabase_state.dart @@ -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? _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 signInWithPassword({ + required String email, + required String password, + }) async { + await supabase.auth.signInWithPassword(email: email, password: password); + } + + Future 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 verifySignUpOtp({ + required String email, + required String token, + }) async { + await supabase.auth.verifyOTP( + email: email, + token: token, + type: OtpType.signup, + ); + } + + Future resendSignUpOtp({required String email}) async { + await supabase.auth.resend(type: OtpType.signup, email: email); + } + + Future 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 _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(); + } +} diff --git a/lib/services/brr_export_service.dart b/lib/services/brr_export_service.dart index de4aef6..b3ba66d 100644 --- a/lib/services/brr_export_service.dart +++ b/lib/services/brr_export_service.dart @@ -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"]); } } -} \ No newline at end of file +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ee7574b..d109773 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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); - } catch (e) { + } catch (e, stackTrace) { + debugPrint("[StorageService] loadState failed: $e"); + debugPrintStack(stackTrace: stackTrace); return null; } } diff --git a/lib/validators/trip_validator.dart b/lib/validators/trip_validator.dart index fddd900..9b39317 100644 --- a/lib/validators/trip_validator.dart +++ b/lib/validators/trip_validator.dart @@ -1,4 +1,4 @@ -import "../models/trip.dart"; +import "../models/operations/trip.dart"; class TripValidator { static List 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; diff --git a/lib/widgets/trip_diagram.dart b/lib/widgets/trip_diagram.dart new file mode 100644 index 0000000..4bdaa8d --- /dev/null +++ b/lib/widgets/trip_diagram.dart @@ -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 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; + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 464061c..777d2b9 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_saver_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin"); file_saver_plugin_register_with_registrar(file_saver_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 4c6b412..01e0060 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_saver + gtk url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 59938fc..3404a53 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,20 @@ import FlutterMacOS import Foundation +import app_links import file_picker import file_saver import path_provider_foundation import share_plus +import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index df0cf73..316e3b3 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,8 +8,12 @@ com.apple.security.network.server + com.apple.security.network.client + com.apple.security.files.user-selected.read-write + com.apple.security.files.bookmarks.app-scope + com.apple.security.files.downloads.read-write diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index fdc5c27..a1bba0d 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,8 +4,12 @@ com.apple.security.app-sandbox + com.apple.security.network.client + com.apple.security.files.user-selected.read-write + com.apple.security.files.bookmarks.app-scope + com.apple.security.files.downloads.read-write diff --git a/pubspec.lock b/pubspec.lock index ca34054..503b4d4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,46 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + animation_kit: + dependency: transitive + description: + name: animation_kit + sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + app_links: + dependency: transitive + description: + name: app_links + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: "direct main" description: @@ -9,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -57,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + country_flags: + dependency: transitive + description: + name: country_flags + sha256: f022d18337f3861f1f4e319b936cb53920de9259f38cb09e169eace9942e2b79 + url: "https://pub.dev" + source: hosted + version: "4.1.2" cross_file: dependency: transitive description: @@ -73,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: cb79ed79baa02b4f59a597bf365873cbd83f9bb15273d63f7803802d21717c7d + url: "https://pub.dev" + source: hosted + version: "3.4.0" + data_widget: + dependency: transitive + description: + name: data_widget + sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a" + url: "https://pub.dev" + source: hosted + version: "0.0.3" dio: dependency: transitive description: @@ -97,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + email_validator: + dependency: transitive + description: + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb + url: "https://pub.dev" + source: hosted + version: "3.0.0" equatable: dependency: transitive description: @@ -113,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.6" + expressions: + dependency: transitive + description: + name: expressions + sha256: f3b0e99563a9a1bde1138e728eb722f292cc7d2aec55d28136c49b1a370306c5 + url: "https://pub.dev" + source: hosted + version: "0.2.5+3" fake_async: dependency: transitive description: @@ -174,6 +262,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -192,6 +285,22 @@ packages: description: flutter source: sdk version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + gap: + dependency: transitive + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" go_router: dependency: "direct main" description: @@ -200,6 +309,22 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: ecdf3fa3ef8c5f886390ba0056d00d29138c02c39984e9caa8194dffd8a73ef7 + url: "https://pub.dev" + source: hosted + version: "2.19.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" hive: dependency: "direct main" description: @@ -236,10 +361,34 @@ packages: dependency: transitive description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" + jovial_misc: + dependency: transitive + description: + name: jovial_misc + sha256: "065b5240badae6b13472efdea28fffe8baf914a7831361469a95c6456d9b8dc8" + url: "https://pub.dev" + source: hosted + version: "0.10.0" + jovial_svg: + dependency: transitive + description: + name: jovial_svg + sha256: "99e9c3afcf7371ae38083ad52de23677d6d751f46150c3c6ae842e009e84d9f0" + url: "https://pub.dev" + source: hosted + version: "1.1.29" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" leak_tracker: dependency: transitive description: @@ -312,6 +461,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -376,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + phonecodes: + dependency: transitive + description: + name: phonecodes + sha256: d963c19d35914cd83620e64125689a0c09047e25046639f2a124142ccf5868bb + url: "https://pub.dev" + source: hosted + version: "0.0.4" platform: dependency: transitive description: @@ -392,6 +557,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: ee8e71af7a834e960f5b2f494f398117488036fbdb11f422611f7287fbf40562 + url: "https://pub.dev" + source: hosted + version: "2.7.1" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shadcn_flutter: + dependency: "direct main" + description: + name: shadcn_flutter + sha256: b04e2f790e182007d02b78234c647df393f2ea95b39d8da88d7cbdaed56f7701 + url: "https://pub.dev" + source: hosted + version: "0.0.52" share_plus: dependency: "direct main" description: @@ -408,6 +637,70 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8c04d0a35c2f61414655fbb2b85bb2e1839e2639978d16fac093feaee8ca8bab" + url: "https://pub.dev" + source: hosted + version: "2.4.22" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + skeletonizer: + dependency: transitive + description: + name: skeletonizer + sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122" + url: "https://pub.dev" + source: hosted + version: "2.1.3" sky_engine: dependency: transitive description: flutter @@ -429,6 +722,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "085a08fd67f234d575113957c04a0e8d0a3050129762f939ce831ee2c0df8257" + url: "https://pub.dev" + source: hosted + version: "2.5.1" stream_channel: dependency: transitive description: @@ -445,22 +746,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: "89b190b585f8609fe1537cbf53eae0c9fda9b777591b064d1150c6f26e607a84" + url: "https://pub.dev" + source: hosted + version: "2.10.4" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: c2974cfdfeb5de517652a35f3ef0d1f3159e068de82b50ccaa27908a2b45fb82 + url: "https://pub.dev" + source: hosted + version: "2.12.2" syncfusion_flutter_core: dependency: transitive description: name: syncfusion_flutter_core - sha256: "325f519ce4ad8edd81811c21b853d72018529e353584490824da0555156ba076" + sha256: cdc9f865a2447b75446c6583e68b2800411687cc1a36df83769b66f96ed70df1 url: "https://pub.dev" source: hosted - version: "27.2.5" + version: "33.1.45" syncfusion_flutter_pdf: dependency: "direct main" description: name: syncfusion_flutter_pdf - sha256: da7fb9d156fafdce7099dc711c1a2dc522c48361188bd4be78520b22c99bbafd + sha256: bb8f5fbe35b79ad418f376448c889fb8fdbaf6d2d42b6c6460985657d9008cc0 url: "https://pub.dev" source: hosted - version: "27.2.5" + version: "33.1.45" term_glyph: dependency: transitive description: @@ -485,6 +802,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -493,6 +834,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -549,6 +898,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" win32: dependency: transitive description: @@ -573,6 +938,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: - dart: ">=3.10.0-75.1.beta <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 0c65cd8..2b1126f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,10 @@ dependencies: flutter: sdk: flutter + shadcn_flutter: ^0.0.52 + provider: ^6.1.5 + supabase_flutter: ^2.12.2 + # Navigation go_router: ^14.6.2 @@ -42,7 +46,7 @@ dependencies: file_saver: ^0.2.14 share_plus: ^10.1.3 path_provider: ^2.1.5 - syncfusion_flutter_pdf: ^27.2.5 + syncfusion_flutter_pdf: ^33.1.45 # Local storage hive: ^2.2.3 diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest new file mode 100644 index 0000000..47c148f --- /dev/null +++ b/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.84.2 \ No newline at end of file diff --git a/supabase/.temp/gotrue-version b/supabase/.temp/gotrue-version new file mode 100644 index 0000000..5bbfd4d --- /dev/null +++ b/supabase/.temp/gotrue-version @@ -0,0 +1 @@ +v2.188.1 \ No newline at end of file diff --git a/supabase/.temp/pooler-url b/supabase/.temp/pooler-url new file mode 100644 index 0000000..d4c26e7 --- /dev/null +++ b/supabase/.temp/pooler-url @@ -0,0 +1 @@ +postgresql://postgres.fbgvisimvgeksfxpemuk@aws-1-eu-west-2.pooler.supabase.com:5432/postgres \ No newline at end of file diff --git a/supabase/.temp/postgres-version b/supabase/.temp/postgres-version new file mode 100644 index 0000000..0b3371f --- /dev/null +++ b/supabase/.temp/postgres-version @@ -0,0 +1 @@ +17.6.1.084 \ No newline at end of file diff --git a/supabase/.temp/project-ref b/supabase/.temp/project-ref new file mode 100644 index 0000000..0ead783 --- /dev/null +++ b/supabase/.temp/project-ref @@ -0,0 +1 @@ +fbgvisimvgeksfxpemuk \ No newline at end of file diff --git a/supabase/.temp/rest-version b/supabase/.temp/rest-version new file mode 100644 index 0000000..d748f28 --- /dev/null +++ b/supabase/.temp/rest-version @@ -0,0 +1 @@ +v14.4 \ No newline at end of file diff --git a/supabase/.temp/storage-migration b/supabase/.temp/storage-migration new file mode 100644 index 0000000..b781586 --- /dev/null +++ b/supabase/.temp/storage-migration @@ -0,0 +1 @@ +fix-optimized-search-function \ No newline at end of file diff --git a/supabase/.temp/storage-version b/supabase/.temp/storage-version new file mode 100644 index 0000000..58d7f5e --- /dev/null +++ b/supabase/.temp/storage-version @@ -0,0 +1 @@ +v1.43.3 \ No newline at end of file diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..a56829c --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,35 @@ +[functions.org-create] +verify_jwt = false + +[functions.org-list] +verify_jwt = false + +[functions.channel-create] +verify_jwt = false + +[functions.channel-list] +verify_jwt = false + +[functions.channel-delete] +verify_jwt = false + +[functions.message-send] +verify_jwt = false + +[functions.message-list] +verify_jwt = false + +[functions.auth-debug] +verify_jwt = false + +[functions.org-update] +verify_jwt = false + +[functions.org-invite-create] +verify_jwt = false + +[functions.org-invite-accept] +verify_jwt = false + +[functions.operations-stop-alias-enhance] +verify_jwt = false diff --git a/supabase/functions/_shared/http.ts b/supabase/functions/_shared/http.ts new file mode 100644 index 0000000..fa3438a --- /dev/null +++ b/supabase/functions/_shared/http.ts @@ -0,0 +1,24 @@ +export const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", +}; + +export function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); +} + +export function fail(message: string, status = 400): Response { + return json({ error: message }, status); +} + +export function handleOptions(req: Request): Response | null { + if (req.method !== "OPTIONS") return null; + return new Response("ok", { headers: corsHeaders }); +} diff --git a/supabase/functions/_shared/supabase.ts b/supabase/functions/_shared/supabase.ts new file mode 100644 index 0000000..71978a2 --- /dev/null +++ b/supabase/functions/_shared/supabase.ts @@ -0,0 +1,43 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const supabaseUrl = Deno.env.get("SUPABASE_URL"); +const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY"); +const supabaseServiceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("SUPABASE_URL and SUPABASE_ANON_KEY are required"); +} + +export function createAuthedClient(req: Request) { + const authHeader = req.headers.get("Authorization") ?? ""; + return createClient(supabaseUrl, supabaseAnonKey, { + global: { + headers: { Authorization: authHeader }, + }, + }); +} + +export function createServiceClient() { + if (!supabaseServiceRoleKey) { + throw new Error("SUPABASE_SERVICE_ROLE_KEY is required"); + } + return createClient(supabaseUrl, supabaseServiceRoleKey); +} + +export async function requireUser(req: Request) { + const client = createAuthedClient(req); + const { data, error } = await client.auth.getUser(); + if (error || !data.user) { + return { client, user: null, error: error?.message ?? "Unauthorized" }; + } + return { client, user: data.user, error: null }; +} + +export function slugify(input: string): string { + return input + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); +} diff --git a/supabase/functions/auth-debug/index.ts b/supabase/functions/auth-debug/index.ts new file mode 100644 index 0000000..2ab2d1d --- /dev/null +++ b/supabase/functions/auth-debug/index.ts @@ -0,0 +1,114 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleOptions, json } from "../_shared/http.ts"; + +const supabaseUrl = Deno.env.get("SUPABASE_URL"); +const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY"); + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("SUPABASE_URL and SUPABASE_ANON_KEY are required"); +} + +type JwtClaims = { + sub?: string; + aud?: string | string[]; + role?: string; + email?: string; + exp?: number; + iat?: number; + nbf?: number; + iss?: string; + [key: string]: unknown; +}; + +function parseJwtClaims(token: string): { + claims: JwtClaims | null; + decodeError: string | null; + partsCount: number; +} { + const parts = token.split("."); + if (parts.length !== 3) { + return { + claims: null, + decodeError: `JWT must have 3 parts, got ${parts.length}`, + partsCount: parts.length, + }; + } + + try { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padLen = (4 - (b64.length % 4)) % 4; + const padded = b64 + "=".repeat(padLen); + const decoded = atob(padded); + const claims = JSON.parse(decoded) as JwtClaims; + return { claims, decodeError: null, partsCount: parts.length }; + } catch (error) { + return { + claims: null, + decodeError: error instanceof Error ? error.message : "Unknown decode error", + partsCount: parts.length, + }; + } +} + +function unixNow() { + return Math.floor(Date.now() / 1000); +} + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "GET" && req.method !== "POST") { + return json({ error: "Method not allowed" }, 405); + } + + const authHeader = req.headers.get("Authorization"); + const bearerPrefixOk = authHeader?.startsWith("Bearer ") ?? false; + const token = bearerPrefixOk ? authHeader!.slice(7).trim() : ""; + const tokenPresent = token.length > 0; + + const jwtParse = tokenPresent + ? parseJwtClaims(token) + : { claims: null, decodeError: "Missing bearer token", partsCount: 0 }; + + const claims = jwtParse.claims; + const now = unixNow(); + + const authClient = createClient(supabaseUrl, supabaseAnonKey, { + global: { headers: { Authorization: authHeader ?? "" } }, + }); + const { data: userData, error: userError } = await authClient.auth.getUser(); + + const checks = { + authorizationHeaderPresent: authHeader != null, + bearerPrefixOk, + tokenPresent, + tokenLength: token.length, + tokenPreview: tokenPresent ? `${token.slice(0, 16)}...` : null, + jwtPartsCount: jwtParse.partsCount, + jwtDecodeError: jwtParse.decodeError, + claimSub: claims?.sub ?? null, + claimRole: claims?.role ?? null, + claimAud: claims?.aud ?? null, + claimEmail: claims?.email ?? null, + claimIssuer: claims?.iss ?? null, + claimExp: claims?.exp ?? null, + claimIat: claims?.iat ?? null, + claimNbf: claims?.nbf ?? null, + nowUnix: now, + isExpired: + typeof claims?.exp === "number" ? claims.exp <= now : null, + notYetValid: + typeof claims?.nbf === "number" ? claims.nbf > now : null, + authGetUserOk: !!userData.user && !userError, + authGetUserError: userError?.message ?? null, + authGetUserUserId: userData.user?.id ?? null, + authGetUserEmail: userData.user?.email ?? null, + }; + + return json({ + function: "auth-debug", + verifyJwtDisabled: true, + checks, + }); +}); diff --git a/supabase/functions/channel-create/index.ts b/supabase/functions/channel-create/index.ts new file mode 100644 index 0000000..3b57551 --- /dev/null +++ b/supabase/functions/channel-create/index.ts @@ -0,0 +1,95 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { + createServiceClient, + requireUser, + slugify, +} from "../_shared/supabase.ts"; + +const allowedTypes = new Set(["text", "voice", "operations"]); + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + const serviceClient = createServiceClient(); + + let body: { + organization_id?: string; + name?: string; + description?: string; + slug?: string; + type?: string; + topic?: string; + position?: number; + is_private?: boolean; + }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const organizationId = body.organization_id; + const name = (body.name ?? "").trim(); + const description = (body.description ?? "").trim(); + const type = body.type ?? "text"; + const topic = body.topic?.trim() || null; + const isPrivate = Boolean(body.is_private); + const position = Number.isInteger(body.position) ? Number(body.position) : 0; + + if (!organizationId) return fail("organization_id is required"); + if (!name) return fail("name is required"); + if (!allowedTypes.has(type)) return fail("type is invalid"); + + const { data: member, error: roleError } = await serviceClient + .from("organization_members") + .select("role") + .eq("organization_id", organizationId) + .eq("user_id", user.id) + .maybeSingle(); + + if (roleError) return fail(roleError.message, 400); + if (!member || !["owner", "admin"].includes(member.role)) { + return fail("forbidden", 403); + } + + const slug = slugify(body.slug?.trim() || name); + if (!slug) return fail("slug is invalid"); + + const { data: channel, error: createError } = await serviceClient + .from("channels") + .insert({ + organization_id: organizationId, + name, + description, + slug, + type, + topic, + is_private: isPrivate, + position, + created_by: user.id, + }) + .select( + "id, organization_id, name, description, slug, type, topic, is_private, position, created_by, created_at", + ) + .single(); + + if (createError) { + if (createError.code === "23505") return fail("channel slug already exists", 409); + return fail(createError.message, 400); + } + + if (channel.is_private) { + const { error: cmError } = await serviceClient.from("channel_members").insert({ + channel_id: channel.id, + user_id: user.id, + }); + if (cmError) return fail(cmError.message, 400); + } + + return json({ channel }, 201); +}); diff --git a/supabase/functions/channel-delete/index.ts b/supabase/functions/channel-delete/index.ts new file mode 100644 index 0000000..0cddf0c --- /dev/null +++ b/supabase/functions/channel-delete/index.ts @@ -0,0 +1,52 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { createServiceClient, requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + const serviceClient = createServiceClient(); + + let body: { + channel_id?: string; + }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = (body.channel_id ?? "").trim(); + if (!channelId) return fail("channel_id is required"); + + const { data: channel, error: channelError } = await serviceClient + .from("channels") + .select("id, organization_id") + .eq("id", channelId) + .maybeSingle(); + if (channelError) return fail(channelError.message, 400); + if (!channel) return fail("channel not found", 404); + + const { data: member, error: roleError } = await serviceClient + .from("organization_members") + .select("role") + .eq("organization_id", channel.organization_id) + .eq("user_id", user.id) + .maybeSingle(); + if (roleError) return fail(roleError.message, 400); + if (!member || !["owner", "admin"].includes(member.role)) { + return fail("forbidden", 403); + } + + const { error: deleteError } = await serviceClient + .from("channels") + .delete() + .eq("id", channelId); + if (deleteError) return fail(deleteError.message, 400); + + return json({ ok: true }); +}); diff --git a/supabase/functions/channel-list/index.ts b/supabase/functions/channel-list/index.ts new file mode 100644 index 0000000..0c9b03a --- /dev/null +++ b/supabase/functions/channel-list/index.ts @@ -0,0 +1,42 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { organization_id?: string }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const organizationId = body.organization_id; + if (!organizationId) return fail("organization_id is required"); + + const { data: membership, error: membershipError } = await client + .from("organization_members") + .select("organization_id") + .eq("organization_id", organizationId) + .eq("user_id", user.id) + .maybeSingle(); + + if (membershipError) return fail(membershipError.message, 400); + if (!membership) return fail("forbidden", 403); + + const { data: channels, error } = await client + .from("channels") + .select("id, organization_id, name, description, slug, type, topic, is_private, position, created_at") + .eq("organization_id", organizationId) + .order("position", { ascending: true }); + + if (error) return fail(error.message, 400); + + return json({ channels: channels ?? [] }); +}); diff --git a/supabase/functions/message-list/index.ts b/supabase/functions/message-list/index.ts new file mode 100644 index 0000000..2945659 --- /dev/null +++ b/supabase/functions/message-list/index.ts @@ -0,0 +1,48 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { channel_id?: string; before?: string; limit?: number }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = body.channel_id; + if (!channelId) return fail("channel_id is required"); + + const limit = Math.max(1, Math.min(Number(body.limit) || 50, 100)); + const before = body.before?.trim(); + + let query = client + .from("messages") + .select("id, channel_id, author_user_id, content, created_at, edited_at") + .eq("channel_id", channelId) + .is("deleted_at", null) + .order("created_at", { ascending: false }) + .limit(limit); + + if (before) { + query = query.lt("created_at", before); + } + + const { data: rows, error } = await query; + if (error) { + if (error.code === "42501") return fail("forbidden", 403); + return fail(error.message, 400); + } + + return json({ + messages: (rows ?? []).reverse(), + next_before: rows && rows.length > 0 ? rows[rows.length - 1].created_at : null, + }); +}); diff --git a/supabase/functions/message-send/index.ts b/supabase/functions/message-send/index.ts new file mode 100644 index 0000000..40496a7 --- /dev/null +++ b/supabase/functions/message-send/index.ts @@ -0,0 +1,43 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { channel_id?: string; content?: string }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = body.channel_id; + const content = (body.content ?? "").trim(); + + if (!channelId) return fail("channel_id is required"); + if (!content) return fail("content is required"); + if (content.length > 4000) return fail("content is too long"); + + const { data: message, error } = await client + .from("messages") + .insert({ + channel_id: channelId, + author_user_id: user.id, + content, + }) + .select("id, channel_id, author_user_id, content, created_at, edited_at, deleted_at") + .single(); + + if (error) { + if (error.code === "42501") return fail("forbidden", 403); + return fail(error.message, 400); + } + + return json({ message }, 201); +}); diff --git a/supabase/functions/operations-stop-alias-enhance/index.ts b/supabase/functions/operations-stop-alias-enhance/index.ts new file mode 100644 index 0000000..ed87afe --- /dev/null +++ b/supabase/functions/operations-stop-alias-enhance/index.ts @@ -0,0 +1,155 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY"); +const OPENAI_MODEL = Deno.env.get("OPENAI_MODEL") ?? "gpt-5.2"; + +type RequestBody = { + channel_id?: string; + stop_names?: string[]; +}; + +type OpenAiAlias = { + original?: string; + estimated?: string; +}; + +const prompt = `You are interpreting abbreviated station names from a rail replacement service display. + +Each entry follows this structure: + +* A 4-letter station code (derived from the real station name, often by removing vowels or compressing syllables) +* A 2-letter stop code (ignore this; it is ambiguous and not needed) +* An optional "T" indicating terminus (ignore for naming purposes) + +Your task is to infer the full station names from the 4-letter codes. + +Guidelines: + +* Treat the 4-letter code as a compressed version of a real station name (e.g. consonant-heavy, missing vowels, or merged syllables) +* Use pattern recognition rather than strict decoding +* Prefer real-world plausibility over perfect letter matching +* Assume all stations are on the same rail corridor or geographically connected route +* Use the sequence of stops to inform your guesses (adjacent stations should make sense geographically) +* If a code is slightly irregular, prioritise what fits the route best over what matches the letters exactly + +Output: +Return JSON with shape {"aliases":[{"original":"","estimated":""}]}. +Return only valid JSON with no markdown or explanation.`; + +function extractCode(rawStopName: string): string { + const firstToken = rawStopName.trim().split(/\s+/)[0] ?? ""; + const normalized = firstToken.toUpperCase().replace(/[^A-Z0-9]/g, ""); + return normalized.slice(0, 8); +} + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + if (!OPENAI_API_KEY) return fail("OPENAI_API_KEY is not configured", 500); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: RequestBody; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = (body.channel_id ?? "").trim(); + const stopNames = Array.isArray(body.stop_names) ? body.stop_names : []; + if (!channelId) return fail("channel_id is required"); + if (stopNames.length == 0) return json({ aliases: [] }); + if (stopNames.length > 1000) return fail("stop_names is too large"); + + const { data: channel, error: channelError } = await client + .from("channels") + .select("id, type") + .eq("id", channelId) + .eq("type", "operations") + .maybeSingle(); + if (channelError) return fail(channelError.message, 400); + if (!channel) return fail("forbidden", 403); + + const rawByCode = new Map>(); + for (const stopName of stopNames) { + const raw = `${stopName ?? ""}`.trim(); + if (!raw) continue; + const code = extractCode(raw); + if (!code) continue; + const existing = rawByCode.get(code) ?? new Set(); + existing.add(raw); + rawByCode.set(code, existing); + } + + const codes = [...rawByCode.keys()]; + if (codes.length == 0) return json({ aliases: [] }); + + const openAiResponse = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: OPENAI_MODEL, + temperature: 0, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: prompt }, + { + role: "user", + content: JSON.stringify({ + channel_id: channelId, + codes, + }), + }, + ], + }), + }); + + if (!openAiResponse.ok) { + const details = await openAiResponse.text(); + return fail(`OpenAI request failed: ${details}`, 502); + } + + const completion = await openAiResponse.json(); + const content = completion?.choices?.[0]?.message?.content; + if (typeof content !== "string" || content.trim().length == 0) { + return fail("OpenAI returned no content", 502); + } + + let parsed: { aliases?: OpenAiAlias[] }; + try { + parsed = JSON.parse(content); + } catch { + return fail("OpenAI returned invalid JSON", 502); + } + + const codeToEstimated = new Map(); + for (const row of parsed.aliases ?? []) { + const original = `${row.original ?? ""}`.trim().toUpperCase(); + const estimated = `${row.estimated ?? ""}`.trim(); + if (!original || !estimated) continue; + codeToEstimated.set(original, estimated); + } + + const aliases = []; + for (const [code, rawStops] of rawByCode.entries()) { + const estimated = codeToEstimated.get(code); + if (!estimated) continue; + for (const raw of rawStops) { + aliases.push({ + raw_stop_name: raw, + alias_stop_name: estimated, + source: "ai", + }); + } + } + + return json({ aliases }); +}); diff --git a/supabase/functions/org-create/index.ts b/supabase/functions/org-create/index.ts new file mode 100644 index 0000000..d60d0fc --- /dev/null +++ b/supabase/functions/org-create/index.ts @@ -0,0 +1,53 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { createServiceClient, requireUser, slugify } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + const serviceClient = createServiceClient(); + + let body: { name?: string; slug?: string }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const name = (body.name ?? "").trim(); + if (!name) return fail("name is required"); + + const slug = slugify(body.slug?.trim() || name); + if (!slug) return fail("slug is invalid"); + + const { data: org, error: createError } = await serviceClient + .from("organizations") + .insert({ + name, + slug, + owner_user_id: user.id, + }) + .select("id, name, slug, icon_url, owner_user_id, created_at") + .single(); + + if (createError) { + if (createError.code === "23505") return fail("slug is already taken", 409); + return fail(createError.message, 400); + } + + const { error: membershipError } = await serviceClient + .from("organization_members") + .insert({ + organization_id: org.id, + user_id: user.id, + role: "owner", + }); + + if (membershipError) return fail(membershipError.message, 400); + + return json({ organization: org }, 201); +}); diff --git a/supabase/functions/org-invite-accept/index.ts b/supabase/functions/org-invite-accept/index.ts new file mode 100644 index 0000000..bd2fb53 --- /dev/null +++ b/supabase/functions/org-invite-accept/index.ts @@ -0,0 +1,88 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { createServiceClient, requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { token?: string }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const token = (body.token ?? "").trim().toLowerCase(); + if (!token) return fail("token is required"); + + const service = createServiceClient(); + const { data: invite, error: inviteError } = await service + .from("organization_invites") + .select( + "id, token, organization_id, role, max_uses, uses_count, expires_at, revoked", + ) + .eq("token", token) + .maybeSingle(); + + if (inviteError) return fail(inviteError.message, 400); + if (!invite) return fail("Invite not found", 404); + if (invite.revoked) return fail("Invite revoked", 410); + + if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) { + return fail("Invite expired", 410); + } + + if (invite.uses_count >= invite.max_uses) { + return fail("Invite has reached max uses", 410); + } + + const { data: existingMember, error: existingMemberError } = await service + .from("organization_members") + .select("organization_id") + .eq("organization_id", invite.organization_id) + .eq("user_id", user.id) + .maybeSingle(); + + if (existingMemberError) return fail(existingMemberError.message, 400); + + if (existingMember) { + return json({ + accepted: true, + already_member: true, + organization_id: invite.organization_id, + }); + } + + const { error: insertMemberError } = await service + .from("organization_members") + .insert({ + organization_id: invite.organization_id, + user_id: user.id, + role: invite.role ?? "member", + }); + + if (insertMemberError) return fail(insertMemberError.message, 400); + + const nextUses = invite.uses_count + 1; + const shouldRevoke = nextUses >= invite.max_uses; + const { error: updateInviteError } = await service + .from("organization_invites") + .update({ + uses_count: nextUses, + revoked: shouldRevoke, + }) + .eq("id", invite.id); + + if (updateInviteError) return fail(updateInviteError.message, 400); + + return json({ + accepted: true, + already_member: false, + organization_id: invite.organization_id, + }); +}); diff --git a/supabase/functions/org-invite-create/index.ts b/supabase/functions/org-invite-create/index.ts new file mode 100644 index 0000000..a1faa39 --- /dev/null +++ b/supabase/functions/org-invite-create/index.ts @@ -0,0 +1,98 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { createServiceClient, requireUser } from "../_shared/supabase.ts"; + +function generateInviteToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { organization_id?: string; max_uses?: number; expires_in_days?: number }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const organizationId = (body.organization_id ?? "").trim(); + if (!organizationId) return fail("organization_id is required"); + + const maxUses = Number.isInteger(body.max_uses) ? Number(body.max_uses) : 1; + if (maxUses < 1 || maxUses > 1000) return fail("max_uses must be between 1 and 1000"); + + const expiresInDays = Number.isInteger(body.expires_in_days) + ? Number(body.expires_in_days) + : 7; + if (expiresInDays < 1 || expiresInDays > 365) { + return fail("expires_in_days must be between 1 and 365"); + } + + const service = createServiceClient(); + const { data: member, error: memberError } = await service + .from("organization_members") + .select("role") + .eq("organization_id", organizationId) + .eq("user_id", user.id) + .maybeSingle(); + + if (memberError) return fail(memberError.message, 400); + if (!member || !["owner", "admin"].includes(member.role)) return fail("forbidden", 403); + + const expiresAt = new Date( + Date.now() + expiresInDays * 24 * 60 * 60 * 1000, + ).toISOString(); + + let invite: + | { + id: string; + token: string; + organization_id: string; + max_uses: number; + uses_count: number; + expires_at: string | null; + revoked: boolean; + created_at: string; + } + | null = null; + let lastError: string | null = null; + + for (let i = 0; i < 5; i++) { + const token = generateInviteToken(); + const { data, error } = await service + .from("organization_invites") + .insert({ + token, + organization_id: organizationId, + created_by: user.id, + role: "member", + max_uses: maxUses, + expires_at: expiresAt, + }) + .select("id, token, organization_id, max_uses, uses_count, expires_at, revoked, created_at") + .single(); + + if (!error) { + invite = data; + break; + } + + lastError = error.message; + if (error.code != "23505") break; + } + + if (!invite) return fail(lastError ?? "Could not create invite", 400); + + return json({ + invite, + }); +}); diff --git a/supabase/functions/org-list/index.ts b/supabase/functions/org-list/index.ts new file mode 100644 index 0000000..c33da4f --- /dev/null +++ b/supabase/functions/org-list/index.ts @@ -0,0 +1,32 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "GET" && req.method !== "POST") { + return fail("Method not allowed", 405); + } + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + const { data, error } = await client + .from("organization_members") + .select( + "role, joined_at, organizations(id, name, slug, icon_url, owner_user_id, created_at)", + ) + .eq("user_id", user.id) + .order("joined_at", { ascending: true }); + + if (error) return fail(error.message, 400); + + return json({ + organizations: (data ?? []).map((row) => ({ + role: row.role, + joined_at: row.joined_at, + organization: row.organizations, + })), + }); +}); diff --git a/supabase/functions/org-update/index.ts b/supabase/functions/org-update/index.ts new file mode 100644 index 0000000..18f8689 --- /dev/null +++ b/supabase/functions/org-update/index.ts @@ -0,0 +1,76 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { createServiceClient, requireUser, slugify } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { + organization_id?: string; + name?: string; + slug?: string; + icon_url?: string | null; + }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const organizationId = (body.organization_id ?? "").trim(); + if (!organizationId) return fail("organization_id is required"); + const name = body.name?.trim(); + const iconUrl = body.icon_url === null ? null : body.icon_url?.trim(); + if ((name == null || name.length === 0) && body.icon_url === undefined) { + return fail("name or icon_url is required"); + } + + const serviceClient = createServiceClient(); + const { data: member, error: memberError } = await serviceClient + .from("organization_members") + .select("role") + .eq("organization_id", organizationId) + .eq("user_id", user.id) + .maybeSingle(); + + if (memberError) return fail(memberError.message, 400); + if (!member || !["owner", "admin"].includes(member.role)) { + return fail("forbidden", 403); + } + + const patch: { + name?: string; + slug?: string; + icon_url?: string | null; + } = {}; + + if (name != null && name.length > 0) { + const slug = slugify((body.slug ?? "").trim() || name); + if (!slug) return fail("slug is invalid"); + patch.name = name; + patch.slug = slug; + } + + if (body.icon_url !== undefined) { + patch.icon_url = iconUrl && iconUrl.length > 0 ? iconUrl : null; + } + + const { data: organization, error: updateError } = await serviceClient + .from("organizations") + .update(patch) + .eq("id", organizationId) + .select("id, name, slug, icon_url, owner_user_id, created_at") + .single(); + + if (updateError) { + if (updateError.code === "23505") return fail("slug is already taken", 409); + return fail(updateError.message, 400); + } + + return json({ organization }); +}); diff --git a/supabase/migrations/20260325120000_collab_schema.sql b/supabase/migrations/20260325120000_collab_schema.sql new file mode 100644 index 0000000..31e42cf --- /dev/null +++ b/supabase/migrations/20260325120000_collab_schema.sql @@ -0,0 +1,308 @@ +create extension if not exists pgcrypto; + +do $$ +begin + if not exists (select 1 from pg_type where typname = 'organization_role') then + create type public.organization_role as enum ('owner', 'admin', 'member'); + end if; + if not exists (select 1 from pg_type where typname = 'channel_type') then + create type public.channel_type as enum ('text', 'voice', 'announcement'); + end if; +end $$; + +create table if not exists public.organizations ( + id uuid primary key default gen_random_uuid(), + name text not null, + slug text not null unique, + owner_user_id uuid not null references auth.users(id) on delete restrict, + created_at timestamptz not null default now() +); + +create table if not exists public.organization_members ( + organization_id uuid not null references public.organizations(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role public.organization_role not null default 'member', + joined_at timestamptz not null default now(), + primary key (organization_id, user_id) +); + +create table if not exists public.channels ( + id uuid primary key default gen_random_uuid(), + organization_id uuid not null references public.organizations(id) on delete cascade, + name text not null, + slug text not null, + type public.channel_type not null default 'text', + position integer not null default 0, + topic text, + is_private boolean not null default false, + created_by uuid not null references auth.users(id) on delete restrict, + created_at timestamptz not null default now(), + unique (organization_id, slug) +); + +create table if not exists public.channel_members ( + channel_id uuid not null references public.channels(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + joined_at timestamptz not null default now(), + primary key (channel_id, user_id) +); + +create table if not exists public.messages ( + id uuid primary key default gen_random_uuid(), + channel_id uuid not null references public.channels(id) on delete cascade, + author_user_id uuid not null references auth.users(id) on delete restrict, + content text not null check (char_length(content) <= 4000), + created_at timestamptz not null default now(), + edited_at timestamptz, + deleted_at timestamptz +); + +create index if not exists idx_org_members_user + on public.organization_members(user_id); + +create index if not exists idx_channels_org_position + on public.channels(organization_id, position); + +create index if not exists idx_messages_channel_created_at_desc + on public.messages(channel_id, created_at desc); + +create or replace function public.is_org_member(org_id uuid, uid uuid default auth.uid()) +returns boolean +language sql +stable +security definer +set search_path = public +as $$ + select exists ( + select 1 + from public.organization_members om + where om.organization_id = org_id + and om.user_id = uid + ); +$$; + +create or replace function public.org_role(org_id uuid, uid uuid default auth.uid()) +returns public.organization_role +language sql +stable +security definer +set search_path = public +as $$ + select om.role + from public.organization_members om + where om.organization_id = org_id + and om.user_id = uid + limit 1; +$$; + +create or replace function public.can_access_channel(ch_id uuid, uid uuid default auth.uid()) +returns boolean +language sql +stable +security definer +set search_path = public +as $$ + select exists ( + select 1 + from public.channels c + where c.id = ch_id + and public.is_org_member(c.organization_id, uid) + and ( + c.is_private = false + or exists ( + select 1 + from public.channel_members cm + where cm.channel_id = c.id + and cm.user_id = uid + ) + ) + ); +$$; + +alter table public.organizations enable row level security; +alter table public.organization_members enable row level security; +alter table public.channels enable row level security; +alter table public.channel_members enable row level security; +alter table public.messages enable row level security; + +drop policy if exists "organizations_select_members" on public.organizations; +create policy "organizations_select_members" + on public.organizations + for select + to authenticated + using (public.is_org_member(id)); + +drop policy if exists "organizations_insert_owner" on public.organizations; +create policy "organizations_insert_owner" + on public.organizations + for insert + to authenticated + with check (owner_user_id = auth.uid()); + +drop policy if exists "organizations_update_admins" on public.organizations; +create policy "organizations_update_admins" + on public.organizations + for update + to authenticated + using (public.org_role(id) in ('owner', 'admin')) + with check (public.org_role(id) in ('owner', 'admin')); + +drop policy if exists "organization_members_select_members" on public.organization_members; +create policy "organization_members_select_members" + on public.organization_members + for select + to authenticated + using (public.is_org_member(organization_id)); + +drop policy if exists "organization_members_insert_admins" on public.organization_members; +create policy "organization_members_insert_admins" + on public.organization_members + for insert + to authenticated + with check (public.org_role(organization_id) in ('owner', 'admin')); + +drop policy if exists "organization_members_update_admins" on public.organization_members; +create policy "organization_members_update_admins" + on public.organization_members + for update + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')) + with check (public.org_role(organization_id) in ('owner', 'admin')); + +drop policy if exists "organization_members_delete_admins" on public.organization_members; +create policy "organization_members_delete_admins" + on public.organization_members + for delete + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')); + +drop policy if exists "channels_select_visible" on public.channels; +create policy "channels_select_visible" + on public.channels + for select + to authenticated + using (public.can_access_channel(id)); + +drop policy if exists "channels_insert_admins" on public.channels; +create policy "channels_insert_admins" + on public.channels + for insert + to authenticated + with check ( + public.org_role(organization_id) in ('owner', 'admin') + and created_by = auth.uid() + ); + +drop policy if exists "channels_update_admins" on public.channels; +create policy "channels_update_admins" + on public.channels + for update + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')) + with check (public.org_role(organization_id) in ('owner', 'admin')); + +drop policy if exists "channels_delete_admins" on public.channels; +create policy "channels_delete_admins" + on public.channels + for delete + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')); + +drop policy if exists "channel_members_select_visible" on public.channel_members; +create policy "channel_members_select_visible" + on public.channel_members + for select + to authenticated + using ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.is_org_member(c.organization_id) + ) + ); + +drop policy if exists "channel_members_insert_admins" on public.channel_members; +create policy "channel_members_insert_admins" + on public.channel_members + for insert + to authenticated + with check ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "channel_members_delete_admins" on public.channel_members; +create policy "channel_members_delete_admins" + on public.channel_members + for delete + to authenticated + using ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "messages_select_visible_channel" on public.messages; +create policy "messages_select_visible_channel" + on public.messages + for select + to authenticated + using (public.can_access_channel(channel_id)); + +drop policy if exists "messages_insert_visible_channel" on public.messages; +create policy "messages_insert_visible_channel" + on public.messages + for insert + to authenticated + with check ( + public.can_access_channel(channel_id) + and author_user_id = auth.uid() + and deleted_at is null + ); + +drop policy if exists "messages_update_author_or_admin" on public.messages; +create policy "messages_update_author_or_admin" + on public.messages + for update + to authenticated + using ( + author_user_id = auth.uid() + or exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ) + with check ( + author_user_id = auth.uid() + or exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "messages_delete_author_or_admin" on public.messages; +create policy "messages_delete_author_or_admin" + on public.messages + for delete + to authenticated + using ( + author_user_id = auth.uid() + or exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); diff --git a/supabase/migrations/20260326150000_collab_policy_bootstrap_fix.sql b/supabase/migrations/20260326150000_collab_policy_bootstrap_fix.sql new file mode 100644 index 0000000..80c3186 --- /dev/null +++ b/supabase/migrations/20260326150000_collab_policy_bootstrap_fix.sql @@ -0,0 +1,31 @@ +-- Fix collaboration bootstrap RLS flow: +-- 1) Allow authenticated users to create organizations they own. +-- 2) Allow org owner to insert their initial owner membership row. + +drop policy if exists "organizations_insert_owner" on public.organizations; +create policy "organizations_insert_owner" + on public.organizations + for insert + to authenticated + with check ( + owner_user_id = auth.uid() + and owner_user_id is not null + ); + +drop policy if exists "organization_members_insert_admins" on public.organization_members; +create policy "organization_members_insert_admins" + on public.organization_members + for insert + to authenticated + with check ( + user_id = auth.uid() + and ( + public.org_role(organization_id) in ('owner', 'admin') + or exists ( + select 1 + from public.organizations o + where o.id = organization_id + and o.owner_user_id = auth.uid() + ) + ) + ); diff --git a/supabase/migrations/20260326162400_channel_type_operations.sql b/supabase/migrations/20260326162400_channel_type_operations.sql new file mode 100644 index 0000000..67b7efe --- /dev/null +++ b/supabase/migrations/20260326162400_channel_type_operations.sql @@ -0,0 +1,30 @@ +-- Switch channel types to text|voice|operations. +-- Existing "announcement" rows are mapped to "operations". + +do $$ +begin + if exists (select 1 from pg_type where typname = 'channel_type') then + alter table public.channels + alter column type drop default; + + alter table public.channels + alter column type type text + using type::text; + + drop type public.channel_type; + create type public.channel_type as enum ('text', 'voice', 'operations'); + + alter table public.channels + alter column type type public.channel_type + using ( + case + when type = 'announcement' then 'operations' + when type in ('text', 'voice', 'operations') then type + else 'text' + end + )::public.channel_type; + + alter table public.channels + alter column type set default 'text'::public.channel_type; + end if; +end $$; diff --git a/supabase/migrations/20260326173000_reset_collab_to_hash_ids.sql b/supabase/migrations/20260326173000_reset_collab_to_hash_ids.sql new file mode 100644 index 0000000..a40142c --- /dev/null +++ b/supabase/migrations/20260326173000_reset_collab_to_hash_ids.sql @@ -0,0 +1,322 @@ +-- Fresh reset migration: replace collaboration IDs with lowercase hash-like text IDs. +-- No compatibility shims by design. + +create extension if not exists pgcrypto; + +-- Drop existing collaboration objects. +drop table if exists public.messages cascade; +drop table if exists public.channel_members cascade; +drop table if exists public.channels cascade; +drop table if exists public.organization_members cascade; +drop table if exists public.organizations cascade; + +drop function if exists public.can_access_channel(uuid, uuid); +drop function if exists public.org_role(uuid, uuid); +drop function if exists public.is_org_member(uuid, uuid); +drop function if exists public.can_access_channel(text, uuid); +drop function if exists public.org_role(text, uuid); +drop function if exists public.is_org_member(text, uuid); + +-- Keep enum types if already present. +do $$ +begin + if not exists (select 1 from pg_type where typname = 'organization_role') then + create type public.organization_role as enum ('owner', 'admin', 'member'); + end if; + if not exists (select 1 from pg_type where typname = 'channel_type') then + create type public.channel_type as enum ('text', 'voice', 'announcement'); + end if; +end $$; + +create or replace function public.gen_hash_id() +returns text +language sql +volatile +as $$ + select substring(md5(random()::text || clock_timestamp()::text) from 1 for 16); +$$; + +create table public.organizations ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + name text not null, + slug text not null unique, + owner_user_id uuid not null references auth.users(id) on delete restrict, + created_at timestamptz not null default now() +); + +create table public.organization_members ( + organization_id text not null references public.organizations(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role public.organization_role not null default 'member', + joined_at timestamptz not null default now(), + primary key (organization_id, user_id) +); + +create table public.channels ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + organization_id text not null references public.organizations(id) on delete cascade, + name text not null, + slug text not null, + type public.channel_type not null default 'text', + position integer not null default 0, + topic text, + is_private boolean not null default false, + created_by uuid not null references auth.users(id) on delete restrict, + created_at timestamptz not null default now(), + unique (organization_id, slug) +); + +create table public.channel_members ( + channel_id text not null references public.channels(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + joined_at timestamptz not null default now(), + primary key (channel_id, user_id) +); + +create table public.messages ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + channel_id text not null references public.channels(id) on delete cascade, + author_user_id uuid not null references auth.users(id) on delete restrict, + content text not null check (char_length(content) <= 4000), + created_at timestamptz not null default now(), + edited_at timestamptz, + deleted_at timestamptz +); + +create index idx_org_members_user on public.organization_members(user_id); +create index idx_channels_org_position on public.channels(organization_id, position); +create index idx_messages_channel_created_at_desc on public.messages(channel_id, created_at desc); + +create or replace function public.is_org_member(org_id text, uid uuid default auth.uid()) +returns boolean +language sql +stable +security definer +set search_path = public +as $$ + select exists ( + select 1 + from public.organization_members om + where om.organization_id = org_id + and om.user_id = uid + ); +$$; + +create or replace function public.org_role(org_id text, uid uuid default auth.uid()) +returns public.organization_role +language sql +stable +security definer +set search_path = public +as $$ + select om.role + from public.organization_members om + where om.organization_id = org_id + and om.user_id = uid + limit 1; +$$; + +create or replace function public.can_access_channel(ch_id text, uid uuid default auth.uid()) +returns boolean +language sql +stable +security definer +set search_path = public +as $$ + select exists ( + select 1 + from public.channels c + where c.id = ch_id + and public.is_org_member(c.organization_id, uid) + and ( + c.is_private = false + or exists ( + select 1 + from public.channel_members cm + where cm.channel_id = c.id + and cm.user_id = uid + ) + ) + ); +$$; + +alter table public.organizations enable row level security; +alter table public.organization_members enable row level security; +alter table public.channels enable row level security; +alter table public.channel_members enable row level security; +alter table public.messages enable row level security; + +create policy "organizations_select_members" + on public.organizations + for select + to authenticated + using (public.is_org_member(id)); + +create policy "organizations_insert_owner" + on public.organizations + for insert + to authenticated + with check (owner_user_id = auth.uid()); + +create policy "organizations_update_admins" + on public.organizations + for update + to authenticated + using (public.org_role(id) in ('owner', 'admin')) + with check (public.org_role(id) in ('owner', 'admin')); + +create policy "organization_members_select_members" + on public.organization_members + for select + to authenticated + using (public.is_org_member(organization_id)); + +create policy "organization_members_insert_admins" + on public.organization_members + for insert + to authenticated + with check ( + user_id = auth.uid() + and ( + public.org_role(organization_id) in ('owner', 'admin') + or exists ( + select 1 + from public.organizations o + where o.id = organization_id + and o.owner_user_id = auth.uid() + ) + ) + ); + +create policy "organization_members_update_admins" + on public.organization_members + for update + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')) + with check (public.org_role(organization_id) in ('owner', 'admin')); + +create policy "organization_members_delete_admins" + on public.organization_members + for delete + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')); + +create policy "channels_select_visible" + on public.channels + for select + to authenticated + using (public.can_access_channel(id)); + +create policy "channels_insert_admins" + on public.channels + for insert + to authenticated + with check ( + public.org_role(organization_id) in ('owner', 'admin') + and created_by = auth.uid() + ); + +create policy "channels_update_admins" + on public.channels + for update + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')) + with check (public.org_role(organization_id) in ('owner', 'admin')); + +create policy "channels_delete_admins" + on public.channels + for delete + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')); + +create policy "channel_members_select_visible" + on public.channel_members + for select + to authenticated + using ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.is_org_member(c.organization_id) + ) + ); + +create policy "channel_members_insert_admins" + on public.channel_members + for insert + to authenticated + with check ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +create policy "channel_members_delete_admins" + on public.channel_members + for delete + to authenticated + using ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +create policy "messages_select_visible_channel" + on public.messages + for select + to authenticated + using (public.can_access_channel(channel_id)); + +create policy "messages_insert_visible_channel" + on public.messages + for insert + to authenticated + with check ( + public.can_access_channel(channel_id) + and author_user_id = auth.uid() + and deleted_at is null + ); + +create policy "messages_update_author_or_admin" + on public.messages + for update + to authenticated + using ( + author_user_id = auth.uid() + or exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ) + with check ( + author_user_id = auth.uid() + or exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +create policy "messages_delete_author_or_admin" + on public.messages + for delete + to authenticated + using ( + author_user_id = auth.uid() + or exists ( + select 1 + from public.channels c + where c.id = channel_id + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); diff --git a/supabase/migrations/20260326190000_add_org_invites.sql b/supabase/migrations/20260326190000_add_org_invites.sql new file mode 100644 index 0000000..f3c9bb3 --- /dev/null +++ b/supabase/migrations/20260326190000_add_org_invites.sql @@ -0,0 +1,39 @@ +create table if not exists public.organization_invites ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + token text not null unique check (token ~ '^[0-9a-f]{24}$'), + organization_id text not null references public.organizations(id) on delete cascade, + created_by uuid not null references auth.users(id) on delete restrict, + role public.organization_role not null default 'member', + max_uses integer not null default 1 check (max_uses > 0), + uses_count integer not null default 0 check (uses_count >= 0), + expires_at timestamptz, + revoked boolean not null default false, + created_at timestamptz not null default now() +); + +create index if not exists idx_org_invites_org on public.organization_invites(organization_id); +create index if not exists idx_org_invites_token on public.organization_invites(token); + +alter table public.organization_invites enable row level security; + +drop policy if exists "organization_invites_select_admins" on public.organization_invites; +create policy "organization_invites_select_admins" + on public.organization_invites + for select + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')); + +drop policy if exists "organization_invites_insert_admins" on public.organization_invites; +create policy "organization_invites_insert_admins" + on public.organization_invites + for insert + to authenticated + with check (public.org_role(organization_id) in ('owner', 'admin')); + +drop policy if exists "organization_invites_update_admins" on public.organization_invites; +create policy "organization_invites_update_admins" + on public.organization_invites + for update + to authenticated + using (public.org_role(organization_id) in ('owner', 'admin')) + with check (public.org_role(organization_id) in ('owner', 'admin')); diff --git a/supabase/migrations/20260326194500_enable_realtime_for_collab_tables.sql b/supabase/migrations/20260326194500_enable_realtime_for_collab_tables.sql new file mode 100644 index 0000000..23567b0 --- /dev/null +++ b/supabase/migrations/20260326194500_enable_realtime_for_collab_tables.sql @@ -0,0 +1,42 @@ +-- Ensure collaboration tables are included in Supabase Realtime publication. + +do $$ +begin + begin + alter publication supabase_realtime add table public.organizations; + exception + when duplicate_object then null; + when undefined_object then null; + end; + + begin + alter publication supabase_realtime add table public.organization_members; + exception + when duplicate_object then null; + when undefined_object then null; + end; + + begin + alter publication supabase_realtime add table public.channels; + exception + when duplicate_object then null; + when undefined_object then null; + end; + + begin + alter publication supabase_realtime add table public.channel_members; + exception + when duplicate_object then null; + when undefined_object then null; + end; + + begin + alter publication supabase_realtime add table public.messages; + exception + when duplicate_object then null; + when undefined_object then null; + end; +end $$; + +-- Helpful for update/delete realtime payload completeness. +alter table public.messages replica identity full; diff --git a/supabase/migrations/20260326195500_add_operations_channel_tables.sql b/supabase/migrations/20260326195500_add_operations_channel_tables.sql new file mode 100644 index 0000000..5df7778 --- /dev/null +++ b/supabase/migrations/20260326195500_add_operations_channel_tables.sql @@ -0,0 +1,341 @@ +-- Operations channel data model (no service-days table): +-- schedules -> trips -> trip_stops -> stop_updates + +create table if not exists public.operations_schedules ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + channel_id text not null references public.channels(id) on delete cascade, + version integer not null check (version > 0), + source_file_name text not null, + source_mime text, + storage_path text, + file_sha256 text, + parser text not null default 'unknown', + parse_status text not null default 'pending', + parse_error text, + uploaded_by uuid not null references auth.users(id) on delete restrict, + uploaded_at timestamptz not null default now(), + parsed_at timestamptz, + is_active boolean not null default false, + unique (channel_id, version) +); + +create unique index if not exists idx_operations_schedules_active_per_channel + on public.operations_schedules(channel_id) + where is_active = true; + +create index if not exists idx_operations_schedules_channel_uploaded + on public.operations_schedules(channel_id, uploaded_at desc); + +create table if not exists public.operations_trips ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + schedule_id text not null references public.operations_schedules(id) on delete cascade, + trip_number text not null, + duty_number text, + running_number text, + direction text, + service_code text, + sort_order integer not null default 0, + created_at timestamptz not null default now() +); + +create index if not exists idx_operations_trips_schedule_sort + on public.operations_trips(schedule_id, sort_order, trip_number); + +create table if not exists public.operations_trip_stops ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + trip_id text not null references public.operations_trips(id) on delete cascade, + stop_sequence integer not null check (stop_sequence > 0), + stop_name text not null, + stop_code text, + scheduled_time text, + is_timing_point boolean not null default false, + raw_label text, + created_at timestamptz not null default now(), + unique (trip_id, stop_sequence) +); + +create index if not exists idx_operations_trip_stops_trip_sequence + on public.operations_trip_stops(trip_id, stop_sequence); + +create index if not exists idx_operations_trip_stops_trip_time + on public.operations_trip_stops(trip_id, scheduled_time); + +create table if not exists public.operations_stop_updates ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + trip_stop_id text not null references public.operations_trip_stops(id) on delete cascade, + status text, + actual_time text, + vehicle_id text, + notes text, + updated_by uuid not null references auth.users(id) on delete restrict, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (trip_stop_id) +); + +create index if not exists idx_operations_stop_updates_trip_stop + on public.operations_stop_updates(trip_stop_id); + +alter table public.operations_schedules enable row level security; +alter table public.operations_trips enable row level security; +alter table public.operations_trip_stops enable row level security; +alter table public.operations_stop_updates enable row level security; + +drop policy if exists "operations_schedules_select_visible" on public.operations_schedules; +create policy "operations_schedules_select_visible" + on public.operations_schedules + for select + to authenticated + using (public.can_access_channel(channel_id)); + +drop policy if exists "operations_schedules_insert_admins" on public.operations_schedules; +create policy "operations_schedules_insert_admins" + on public.operations_schedules + for insert + to authenticated + with check ( + uploaded_by = auth.uid() + and exists ( + select 1 + from public.channels c + where c.id = channel_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "operations_schedules_update_admins" on public.operations_schedules; +create policy "operations_schedules_update_admins" + on public.operations_schedules + for update + to authenticated + using ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ) + with check ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "operations_schedules_delete_admins" on public.operations_schedules; +create policy "operations_schedules_delete_admins" + on public.operations_schedules + for delete + to authenticated + using ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "operations_trips_select_visible" on public.operations_trips; +create policy "operations_trips_select_visible" + on public.operations_trips + for select + to authenticated + using ( + exists ( + select 1 + from public.operations_schedules s + where s.id = schedule_id + and public.can_access_channel(s.channel_id) + ) + ); + +drop policy if exists "operations_trips_write_admins" on public.operations_trips; +create policy "operations_trips_write_admins" + on public.operations_trips + for all + to authenticated + using ( + exists ( + select 1 + from public.operations_schedules s + join public.channels c on c.id = s.channel_id + where s.id = schedule_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ) + with check ( + exists ( + select 1 + from public.operations_schedules s + join public.channels c on c.id = s.channel_id + where s.id = schedule_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "operations_trip_stops_select_visible" on public.operations_trip_stops; +create policy "operations_trip_stops_select_visible" + on public.operations_trip_stops + for select + to authenticated + using ( + exists ( + select 1 + from public.operations_trips t + join public.operations_schedules s on s.id = t.schedule_id + where t.id = trip_id + and public.can_access_channel(s.channel_id) + ) + ); + +drop policy if exists "operations_trip_stops_write_admins" on public.operations_trip_stops; +create policy "operations_trip_stops_write_admins" + on public.operations_trip_stops + for all + to authenticated + using ( + exists ( + select 1 + from public.operations_trips t + join public.operations_schedules s on s.id = t.schedule_id + join public.channels c on c.id = s.channel_id + where t.id = trip_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ) + with check ( + exists ( + select 1 + from public.operations_trips t + join public.operations_schedules s on s.id = t.schedule_id + join public.channels c on c.id = s.channel_id + where t.id = trip_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "operations_stop_updates_select_visible" on public.operations_stop_updates; +create policy "operations_stop_updates_select_visible" + on public.operations_stop_updates + for select + to authenticated + using ( + exists ( + select 1 + from public.operations_trip_stops ts + join public.operations_trips t on t.id = ts.trip_id + join public.operations_schedules s on s.id = t.schedule_id + where ts.id = trip_stop_id + and public.can_access_channel(s.channel_id) + ) + ); + +drop policy if exists "operations_stop_updates_insert_members" on public.operations_stop_updates; +create policy "operations_stop_updates_insert_members" + on public.operations_stop_updates + for insert + to authenticated + with check ( + updated_by = auth.uid() + and exists ( + select 1 + from public.operations_trip_stops ts + join public.operations_trips t on t.id = ts.trip_id + join public.operations_schedules s on s.id = t.schedule_id + where ts.id = trip_stop_id + and public.can_access_channel(s.channel_id) + ) + ); + +drop policy if exists "operations_stop_updates_update_members" on public.operations_stop_updates; +create policy "operations_stop_updates_update_members" + on public.operations_stop_updates + for update + to authenticated + using ( + exists ( + select 1 + from public.operations_trip_stops ts + join public.operations_trips t on t.id = ts.trip_id + join public.operations_schedules s on s.id = t.schedule_id + where ts.id = trip_stop_id + and public.can_access_channel(s.channel_id) + ) + ) + with check ( + updated_by = auth.uid() + and exists ( + select 1 + from public.operations_trip_stops ts + join public.operations_trips t on t.id = ts.trip_id + join public.operations_schedules s on s.id = t.schedule_id + where ts.id = trip_stop_id + and public.can_access_channel(s.channel_id) + ) + ); + +drop policy if exists "operations_stop_updates_delete_members" on public.operations_stop_updates; +create policy "operations_stop_updates_delete_members" + on public.operations_stop_updates + for delete + to authenticated + using ( + exists ( + select 1 + from public.operations_trip_stops ts + join public.operations_trips t on t.id = ts.trip_id + join public.operations_schedules s on s.id = t.schedule_id + where ts.id = trip_stop_id + and public.can_access_channel(s.channel_id) + ) + ); + +-- Keep updated_at fresh on edits. +create or replace function public.tg_set_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at := now(); + return new; +end; +$$; + +drop trigger if exists trg_operations_stop_updates_updated_at on public.operations_stop_updates; +create trigger trg_operations_stop_updates_updated_at +before update on public.operations_stop_updates +for each row execute procedure public.tg_set_updated_at(); + +-- Realtime availability for operations collaboration features. +do $$ +begin + begin + alter publication supabase_realtime add table public.operations_schedules; + exception when duplicate_object then null; + end; + begin + alter publication supabase_realtime add table public.operations_trips; + exception when duplicate_object then null; + end; + begin + alter publication supabase_realtime add table public.operations_trip_stops; + exception when duplicate_object then null; + end; + begin + alter publication supabase_realtime add table public.operations_stop_updates; + exception when duplicate_object then null; + end; +end $$; + diff --git a/supabase/migrations/20260326201000_add_organization_icons.sql b/supabase/migrations/20260326201000_add_organization_icons.sql new file mode 100644 index 0000000..c205c3f --- /dev/null +++ b/supabase/migrations/20260326201000_add_organization_icons.sql @@ -0,0 +1,59 @@ +alter table public.organizations + add column if not exists icon_url text; + +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'organization-icons', + 'organization-icons', + true, + 5242880, + array['image/png', 'image/jpeg', 'image/webp', 'image/gif'] +) +on conflict (id) do update +set + public = excluded.public, + file_size_limit = excluded.file_size_limit, + allowed_mime_types = excluded.allowed_mime_types; + +drop policy if exists "org_icons_public_read" on storage.objects; +create policy "org_icons_public_read" + on storage.objects + for select + to public + using (bucket_id = 'organization-icons'); + +drop policy if exists "org_icons_insert_admins" on storage.objects; +create policy "org_icons_insert_admins" + on storage.objects + for insert + to authenticated + with check ( + bucket_id = 'organization-icons' + and owner_id = auth.uid()::text + and public.org_role(split_part(name, '/', 1)) in ('owner', 'admin') + ); + +drop policy if exists "org_icons_update_admins" on storage.objects; +create policy "org_icons_update_admins" + on storage.objects + for update + to authenticated + using ( + bucket_id = 'organization-icons' + and public.org_role(split_part(name, '/', 1)) in ('owner', 'admin') + ) + with check ( + bucket_id = 'organization-icons' + and owner_id = auth.uid()::text + and public.org_role(split_part(name, '/', 1)) in ('owner', 'admin') + ); + +drop policy if exists "org_icons_delete_admins" on storage.objects; +create policy "org_icons_delete_admins" + on storage.objects + for delete + to authenticated + using ( + bucket_id = 'organization-icons' + and public.org_role(split_part(name, '/', 1)) in ('owner', 'admin') + ); diff --git a/supabase/migrations/20260326212000_rename_running_number_to_bus_work_number.sql b/supabase/migrations/20260326212000_rename_running_number_to_bus_work_number.sql new file mode 100644 index 0000000..150ff26 --- /dev/null +++ b/supabase/migrations/20260326212000_rename_running_number_to_bus_work_number.sql @@ -0,0 +1,2 @@ +alter table public.operations_trips + rename column running_number to bus_work_number; diff --git a/supabase/migrations/20260326213000_add_channel_description.sql b/supabase/migrations/20260326213000_add_channel_description.sql new file mode 100644 index 0000000..9ef23e4 --- /dev/null +++ b/supabase/migrations/20260326213000_add_channel_description.sql @@ -0,0 +1,7 @@ +alter table public.channels + add column if not exists description text not null default ''; + +update public.channels +set description = topic +where coalesce(description, '') = '' + and coalesce(topic, '') <> ''; diff --git a/supabase/migrations/20260327110000_add_operations_stop_aliases.sql b/supabase/migrations/20260327110000_add_operations_stop_aliases.sql new file mode 100644 index 0000000..20d936a --- /dev/null +++ b/supabase/migrations/20260327110000_add_operations_stop_aliases.sql @@ -0,0 +1,84 @@ +create table if not exists public.operations_stop_aliases ( + id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'), + channel_id text not null references public.channels(id) on delete cascade, + raw_stop_name text not null, + raw_stop_name_normalized text generated always as (lower(btrim(raw_stop_name))) stored, + alias_stop_name text not null, + created_by uuid not null references auth.users(id) on delete restrict, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint operations_stop_aliases_alias_not_blank check (char_length(btrim(alias_stop_name)) > 0), + unique (channel_id, raw_stop_name_normalized) +); + +create index if not exists idx_operations_stop_aliases_channel + on public.operations_stop_aliases(channel_id); + +alter table public.operations_stop_aliases enable row level security; + +drop policy if exists "operations_stop_aliases_select_visible" on public.operations_stop_aliases; +create policy "operations_stop_aliases_select_visible" + on public.operations_stop_aliases + for select + to authenticated + using (public.can_access_channel(channel_id)); + +drop policy if exists "operations_stop_aliases_insert_admins" on public.operations_stop_aliases; +create policy "operations_stop_aliases_insert_admins" + on public.operations_stop_aliases + for insert + to authenticated + with check ( + created_by = auth.uid() + and exists ( + select 1 + from public.channels c + where c.id = channel_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "operations_stop_aliases_update_admins" on public.operations_stop_aliases; +create policy "operations_stop_aliases_update_admins" + on public.operations_stop_aliases + for update + to authenticated + using ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ) + with check ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop policy if exists "operations_stop_aliases_delete_admins" on public.operations_stop_aliases; +create policy "operations_stop_aliases_delete_admins" + on public.operations_stop_aliases + for delete + to authenticated + using ( + exists ( + select 1 + from public.channels c + where c.id = channel_id + and c.type = 'operations' + and public.org_role(c.organization_id) in ('owner', 'admin') + ) + ); + +drop trigger if exists trg_operations_stop_aliases_updated_at on public.operations_stop_aliases; +create trigger trg_operations_stop_aliases_updated_at +before update on public.operations_stop_aliases +for each row execute procedure public.tg_set_updated_at(); diff --git a/supabase/migrations/20260327113000_add_source_to_operations_stop_aliases.sql b/supabase/migrations/20260327113000_add_source_to_operations_stop_aliases.sql new file mode 100644 index 0000000..342e48a --- /dev/null +++ b/supabase/migrations/20260327113000_add_source_to_operations_stop_aliases.sql @@ -0,0 +1,13 @@ +alter table public.operations_stop_aliases +add column if not exists source text not null default 'user'; + +update public.operations_stop_aliases +set source = 'user' +where source is null or btrim(source) = ''; + +alter table public.operations_stop_aliases +drop constraint if exists operations_stop_aliases_source_check; + +alter table public.operations_stop_aliases +add constraint operations_stop_aliases_source_check +check (source in ('user', 'ai')); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index ffc4d63..7afb630 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,11 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); FileSaverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSaverPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 822d779..b71dea2 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links file_saver share_plus url_launcher_windows