Add version files and update imports for trip model; enhance error handling
This commit is contained in:
parent
e41e14e252
commit
427bcadc77
89 changed files with 9455 additions and 395 deletions
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
|
|
@ -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:
|
||||||
|
|
@ -1,88 +1,21 @@
|
||||||
PODS:
|
PODS:
|
||||||
- DKImagePickerController/Core (4.3.9):
|
- file_saver (0.0.1):
|
||||||
- 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
|
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- 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:
|
DEPENDENCIES:
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_saver (from `.symlinks/plugins/file_saver/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- 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:
|
EXTERNAL SOURCES:
|
||||||
file_picker:
|
file_saver:
|
||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_saver/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: 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:
|
SPEC CHECKSUMS:
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
|
||||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
|
||||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
|
||||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
C536F9E59C502B71681ADDC5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7850BC6B3D5EF5598AF87AE /* Pods_Runner.framework */; };
|
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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
|
@ -65,6 +66,7 @@
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
A7850BC6B3D5EF5598AF87AE /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -72,6 +74,7 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||||
C536F9E59C502B71681ADDC5 /* Pods_Runner.framework in Frameworks */,
|
C536F9E59C502B71681ADDC5 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -121,6 +124,7 @@
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
|
@ -188,6 +192,9 @@
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
};
|
};
|
||||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
|
@ -213,6 +220,9 @@
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
|
@ -470,6 +480,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = A9TMA2CA43;
|
DEVELOPMENT_TEAM = A9TMA2CA43;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
|
|
@ -653,6 +664,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = A9TMA2CA43;
|
DEVELOPMENT_TEAM = A9TMA2CA43;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
|
|
@ -676,6 +688,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = A9TMA2CA43;
|
DEVELOPMENT_TEAM = A9TMA2CA43;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
|
|
@ -726,6 +739,18 @@
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,24 @@
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|
|
||||||
59
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
59
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
10
ios/Runner/Runner.entitlements
Normal file
10
ios/Runner/Runner.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
12
lib/constants.dart
Normal file
12
lib/constants.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* SUPABASE
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// Supabase project endpoint (for example: https://project-ref.supabase.co).
|
||||||
|
/// Leave empty to disable Supabase initialization.
|
||||||
|
const String kSupabaseEndpoint = "https://fbgvisimvgeksfxpemuk.supabase.co";
|
||||||
|
|
||||||
|
/// Supabase publishable/anon key.
|
||||||
|
/// Leave empty to disable Supabase initialization.
|
||||||
|
const String kSupabasePublishableKey =
|
||||||
|
"sb_publishable_tqhag58YEhNrIHy264JsKg_T1VCIZib";
|
||||||
|
|
@ -3,7 +3,7 @@ import "dart:typed_data";
|
||||||
import "package:archive/archive.dart";
|
import "package:archive/archive.dart";
|
||||||
import "package:excel/excel.dart";
|
import "package:excel/excel.dart";
|
||||||
import "package:flutter/services.dart";
|
import "package:flutter/services.dart";
|
||||||
import "../models/trip.dart";
|
import "../models/operations/trip.dart";
|
||||||
import "../models/brr_metadata.dart";
|
import "../models/brr_metadata.dart";
|
||||||
import "brr_exporter.dart";
|
import "brr_exporter.dart";
|
||||||
|
|
||||||
|
|
@ -20,7 +20,10 @@ class ArrivaBRRExporter implements BRRExporter {
|
||||||
|
|
||||||
// strip the whole numFmts block — Numbers export puts built-in IDs
|
// strip the whole numFmts block — Numbers export puts built-in IDs
|
||||||
// in there which the excel package rejects
|
// in there which the excel package rejects
|
||||||
xml = xml.replaceAll(RegExp(r'<numFmts[^>]*>.*?</numFmts>', dotAll: true), "");
|
xml = xml.replaceAll(
|
||||||
|
RegExp(r'<numFmts[^>]*>.*?</numFmts>', dotAll: true),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
// reset all numFmtId refs in xf elements to 0 (General)
|
// reset all numFmtId refs in xf elements to 0 (General)
|
||||||
// so nothing tries to look up the stripped formats
|
// so nothing tries to look up the stripped formats
|
||||||
|
|
@ -39,7 +42,6 @@ class ArrivaBRRExporter implements BRRExporter {
|
||||||
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
|
static const int _templateDataRows = 15; // rows 9–23
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata) async {
|
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata) async {
|
||||||
final templateBytes = await rootBundle.load("assets/arriva_brr.xlsx");
|
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
|
// Shifts all rows from (_dataStartRow + _templateDataRows) onwards down by extraRows
|
||||||
void _shiftRowsDown(Sheet sheet, int 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
|
// figure out how many rows exist beyond the data block
|
||||||
final maxRow = sheet.rows.length;
|
final maxRow = sheet.rows.length;
|
||||||
|
|
@ -77,10 +80,14 @@ class ArrivaBRRExporter implements BRRExporter {
|
||||||
final cell = srcRow[c];
|
final cell = srcRow[c];
|
||||||
if (cell == null) continue;
|
if (cell == null) continue;
|
||||||
|
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow)).value =
|
sheet
|
||||||
cell.value;
|
.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow))
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow)).cellStyle =
|
.value = cell
|
||||||
cell.cellStyle;
|
.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++) {
|
for (var r = firstRowToShift; r < firstRowToShift + extraRows; r++) {
|
||||||
if (r >= sheet.rows.length) break;
|
if (r >= sheet.rows.length) break;
|
||||||
for (var c = 0; c < 18; c++) {
|
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 trip = trips[i];
|
||||||
final row = _dataStartRow + i;
|
final row = _dataStartRow + i;
|
||||||
|
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.scheduledTime);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row))
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row)).value =
|
.value = TextCellValue(
|
||||||
TextCellValue(trip.tripNumber);
|
trip.scheduledTime,
|
||||||
|
);
|
||||||
|
sheet
|
||||||
|
.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.tripNumber,
|
||||||
|
);
|
||||||
|
|
||||||
if (trip.actualDepartureTime != null) {
|
if (trip.actualDepartureTime != null) {
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.actualDepartureTime!);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.actualDepartureTime!,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trip.actualFleetNumber != null) {
|
if (trip.actualFleetNumber != null) {
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.actualFleetNumber!);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.actualFleetNumber!,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.dutyNumber);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row))
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row)).value =
|
.value = TextCellValue(
|
||||||
TextCellValue(trip.runningNumber);
|
trip.dutyNumber,
|
||||||
|
);
|
||||||
|
sheet
|
||||||
|
.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.busWorkNumber,
|
||||||
|
);
|
||||||
|
|
||||||
final didOperate = trip.actualDepartureTime != null && trip.actualFleetNumber != null;
|
final didOperate =
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 6, rowIndex: row)).value =
|
trip.actualDepartureTime != null && trip.actualFleetNumber != null;
|
||||||
TextCellValue(didOperate ? "Y" : "N");
|
sheet
|
||||||
|
.cell(CellIndex.indexByColumnRow(columnIndex: 6, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
didOperate ? "Y" : "N",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
import "../models/trip.dart";
|
import "../models/operations/trip.dart";
|
||||||
import "../models/brr_metadata.dart";
|
import "../models/brr_metadata.dart";
|
||||||
|
|
||||||
abstract class BRRExporter {
|
abstract class BRRExporter {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
import "package:excel/excel.dart";
|
import "package:excel/excel.dart";
|
||||||
import "../models/trip.dart";
|
import "../models/operations/trip.dart";
|
||||||
import "../models/brr_metadata.dart";
|
import "../models/brr_metadata.dart";
|
||||||
import "brr_exporter.dart";
|
import "brr_exporter.dart";
|
||||||
|
|
||||||
class StagecoachBRRExporter implements BRRExporter {
|
class StagecoachBRRExporter implements BRRExporter {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata) async {
|
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata) async {
|
||||||
final excel = Excel.createExcel();
|
final excel = Excel.createExcel();
|
||||||
|
|
@ -26,24 +25,31 @@ class StagecoachBRRExporter implements BRRExporter {
|
||||||
|
|
||||||
final bold = CellStyle(bold: true);
|
final bold = CellStyle(bold: true);
|
||||||
for (var c = 0; c < headers.length; c++) {
|
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.value = TextCellValue(headers[c]);
|
||||||
cell.cellStyle = bold;
|
cell.cellStyle = bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
for (var i = 0; i < trips.length; i++) {
|
for (var i = 0; i < trips.length; i++) {
|
||||||
final trip = trips[i];
|
final trip = trips[i];
|
||||||
final row = i + 1;
|
final row = i + 1;
|
||||||
|
|
||||||
// Dep Time (HHMM no colon)
|
// Dep Time (HHMM no colon)
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.scheduledTime.replaceAll(":", ""));
|
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.scheduledTime.replaceAll(":", ""),
|
||||||
|
);
|
||||||
|
|
||||||
// (+/-) No. - user fills in
|
// (+/-) No. - user fills in
|
||||||
if (trip.actualDepartureTime != null) {
|
if (trip.actualDepartureTime != null) {
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.actualDepartureTime!);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.actualDepartureTime!,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ser. — outbound shows route name, inbound shows "PARK"
|
// Ser. — outbound shows route name, inbound shows "PARK"
|
||||||
|
|
@ -51,22 +57,34 @@ class StagecoachBRRExporter implements BRRExporter {
|
||||||
final ser = trip.direction == "outbound"
|
final ser = trip.direction == "outbound"
|
||||||
? (metadata.route != "Unknown" ? metadata.route : "OUT")
|
? (metadata.route != "Unknown" ? metadata.route : "OUT")
|
||||||
: "PARK";
|
: "PARK";
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(ser);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
ser,
|
||||||
|
);
|
||||||
|
|
||||||
// Bus Wk No
|
// Bus Wk No
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.dutyNumber);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.busWorkNumber,
|
||||||
|
);
|
||||||
|
|
||||||
// Fleet No. — actual fleet number entered by user
|
// Fleet No. — actual fleet number entered by user
|
||||||
if (trip.actualFleetNumber != null) {
|
if (trip.actualFleetNumber != null) {
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.actualFleetNumber!);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.actualFleetNumber!,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crew Duty
|
// Crew Duty
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row)).value =
|
sheet
|
||||||
TextCellValue(trip.tripNumber);
|
.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row))
|
||||||
|
.value = TextCellValue(
|
||||||
|
trip.dutyNumber,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final bytes = excel.encode();
|
final bytes = excel.encode();
|
||||||
|
|
|
||||||
167
lib/main.dart
167
lib/main.dart
|
|
@ -1,100 +1,107 @@
|
||||||
import "package:flutter/material.dart";
|
import 'package:bus_running_record/pages/home/page.dart' as home_v2;
|
||||||
import "package:go_router/go_router.dart";
|
import 'package:bus_running_record/pages/home_page.dart' as legacy_home;
|
||||||
import "package:hive_flutter/hive_flutter.dart";
|
import "package:bus_running_record/pages/operations_upload/page.dart";
|
||||||
import "pages/home_page.dart";
|
import "package:bus_running_record/pages/invite/page.dart";
|
||||||
import "pages/station_selection_page.dart";
|
import "package:bus_running_record/pages/org_settings/page.dart";
|
||||||
import "pages/trip_list_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 {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
runApp(MyApp());
|
await Supabase.initialize(
|
||||||
|
url: kSupabaseEndpoint,
|
||||||
|
anonKey: kSupabasePublishableKey,
|
||||||
|
);
|
||||||
|
final supabaseProvider = SupabaseProvider();
|
||||||
|
runApp(RoadboundApp(supabaseProvider: supabaseProvider));
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class RoadboundApp extends StatefulWidget {
|
||||||
MyApp({super.key});
|
const RoadboundApp({required this.supabaseProvider, super.key});
|
||||||
|
|
||||||
final _router = GoRouter(
|
final SupabaseProvider supabaseProvider;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RoadboundApp> createState() => _RoadboundAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoadboundAppState extends State<RoadboundApp> {
|
||||||
|
late final GoRouter _routerConfig = GoRouter(
|
||||||
|
refreshListenable: widget.supabaseProvider,
|
||||||
routes: [
|
routes: [
|
||||||
HomePage.route,
|
LoginPage.route,
|
||||||
|
VerifyEmailPage.route,
|
||||||
|
home_v2.HomePage.rootRoute,
|
||||||
|
home_v2.HomePage.channelRoute,
|
||||||
|
legacy_home.HomePage.route,
|
||||||
StationSelectionPage.route,
|
StationSelectionPage.route,
|
||||||
TripListPage.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
|
||||||
routerConfig: _router,
|
AdaptiveScaling? adaptiveScaling;
|
||||||
title: "Bus Running Record",
|
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||||
|
adaptiveScaling = AdaptiveScaling(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: widget.supabaseProvider),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) =>
|
||||||
|
CollaborationProvider(context.read<SupabaseProvider>())
|
||||||
|
..initialize(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: ShadcnApp.router(
|
||||||
|
routerConfig: _routerConfig,
|
||||||
|
scaling: adaptiveScaling,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: ColorSchemes.darkNeutral,
|
||||||
primary: Color(0xFF00A9CE),
|
|
||||||
onPrimary: Colors.black,
|
|
||||||
surface: Color(0xFF1E1E1E),
|
|
||||||
onSurface: Color(0xFFEEEEEE),
|
|
||||||
surfaceContainerHighest: Color(0xFF2A2A2A),
|
|
||||||
error: Color(0xFFCF6679),
|
|
||||||
),
|
),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
backgroundColor: Color(0xFF1A1A1A),
|
|
||||||
foregroundColor: Color(0xFFEEEEEE),
|
|
||||||
elevation: 0,
|
|
||||||
titleTextStyle: TextStyle(
|
|
||||||
color: Color(0xFFEEEEEE),
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
inputDecorationTheme: const InputDecorationTheme(
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
|
||||||
borderSide: BorderSide(color: Color(0xFF3A3A3A)),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
|
||||||
borderSide: BorderSide(color: Color(0xFF3A3A3A)),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
|
||||||
borderSide: BorderSide(color: Color(0xFF00A9CE), width: 1.5),
|
|
||||||
),
|
|
||||||
filled: true,
|
|
||||||
fillColor: Color(0xFF252525),
|
|
||||||
labelStyle: TextStyle(color: Color(0xFF999999)),
|
|
||||||
hintStyle: TextStyle(color: Color(0xFF555555)),
|
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
),
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF00A9CE),
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
elevation: 0,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
|
||||||
),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 14,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
dividerTheme: const DividerThemeData(
|
|
||||||
color: Color(0xFF2E2E2E),
|
|
||||||
thickness: 1,
|
|
||||||
),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import "trip.dart";
|
import "operations/trip.dart";
|
||||||
|
|
||||||
class BRRState {
|
class BRRState {
|
||||||
final List<Trip> trips;
|
final List<Trip> trips;
|
||||||
|
|
|
||||||
67
lib/models/channels/base_channel.dart
Normal file
67
lib/models/channels/base_channel.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import "package:supabase_flutter/supabase_flutter.dart";
|
||||||
|
|
||||||
|
enum ChannelKind { text, operations, voice, announcement, unknown }
|
||||||
|
|
||||||
|
abstract class BaseChannel {
|
||||||
|
BaseChannel({
|
||||||
|
required this.client,
|
||||||
|
required this.id,
|
||||||
|
required this.organizationId,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.slug,
|
||||||
|
required this.kind,
|
||||||
|
required this.isPrivate,
|
||||||
|
required this.position,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SupabaseClient client;
|
||||||
|
final String id;
|
||||||
|
final String organizationId;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final String slug;
|
||||||
|
final ChannelKind kind;
|
||||||
|
final bool isPrivate;
|
||||||
|
final int position;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"organization_id": organizationId,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"slug": slug,
|
||||||
|
"type": kind.name,
|
||||||
|
"is_private": isPrivate,
|
||||||
|
"position": position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static ChannelKind channelKindFromApi(Object? rawType) {
|
||||||
|
final value = (rawType ?? "").toString().trim().toLowerCase();
|
||||||
|
switch (value) {
|
||||||
|
case "text":
|
||||||
|
return ChannelKind.text;
|
||||||
|
case "operations":
|
||||||
|
return ChannelKind.operations;
|
||||||
|
case "voice":
|
||||||
|
return ChannelKind.voice;
|
||||||
|
case "announcement":
|
||||||
|
return ChannelKind.announcement;
|
||||||
|
default:
|
||||||
|
return ChannelKind.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> asMap(Object? value) {
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return value.cast<String, dynamic>();
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<dynamic> asList(Object? value) {
|
||||||
|
if (value is List) return value;
|
||||||
|
return const <dynamic>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
436
lib/models/channels/operations_channel.dart
Normal file
436
lib/models/channels/operations_channel.dart
Normal file
|
|
@ -0,0 +1,436 @@
|
||||||
|
import "package:bus_running_record/models/channels/base_channel.dart";
|
||||||
|
import "package:bus_running_record/models/operations/stop.dart";
|
||||||
|
import "package:supabase_flutter/supabase_flutter.dart";
|
||||||
|
|
||||||
|
class OperationsSchedule {
|
||||||
|
const OperationsSchedule({
|
||||||
|
required this.id,
|
||||||
|
required this.version,
|
||||||
|
required this.sourceFileName,
|
||||||
|
required this.uploadedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final int version;
|
||||||
|
final String sourceFileName;
|
||||||
|
final DateTime? uploadedAt;
|
||||||
|
|
||||||
|
factory OperationsSchedule.fromMap(Map<String, dynamic> map) {
|
||||||
|
return OperationsSchedule(
|
||||||
|
id: (map["id"] ?? "").toString(),
|
||||||
|
version: (map["version"] as num?)?.toInt() ?? 1,
|
||||||
|
sourceFileName: (map["source_file_name"] ?? "").toString(),
|
||||||
|
uploadedAt: DateTime.tryParse((map["uploaded_at"] ?? "").toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsTrip {
|
||||||
|
const OperationsTrip({
|
||||||
|
required this.id,
|
||||||
|
required this.scheduleId,
|
||||||
|
required this.tripNumber,
|
||||||
|
required this.dutyNumber,
|
||||||
|
required this.busWorkNumber,
|
||||||
|
required this.direction,
|
||||||
|
required this.sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String scheduleId;
|
||||||
|
final String tripNumber;
|
||||||
|
final String dutyNumber;
|
||||||
|
final String busWorkNumber;
|
||||||
|
final String direction;
|
||||||
|
final int sortOrder;
|
||||||
|
String get dutyKey => "$dutyNumber::$busWorkNumber";
|
||||||
|
|
||||||
|
factory OperationsTrip.fromMap(Map<String, dynamic> map) {
|
||||||
|
return OperationsTrip(
|
||||||
|
id: (map["id"] ?? "").toString(),
|
||||||
|
scheduleId: (map["schedule_id"] ?? "").toString(),
|
||||||
|
tripNumber: (map["trip_number"] ?? "").toString(),
|
||||||
|
dutyNumber: (map["duty_number"] ?? "").toString(),
|
||||||
|
busWorkNumber: (map["bus_work_number"] ?? "").toString(),
|
||||||
|
direction: (map["direction"] ?? "").toString(),
|
||||||
|
sortOrder: (map["sort_order"] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsScheduledStop extends Stop {
|
||||||
|
const OperationsScheduledStop({
|
||||||
|
required this.id,
|
||||||
|
required this.tripId,
|
||||||
|
required this.sequence,
|
||||||
|
required super.name,
|
||||||
|
required this.scheduledTime,
|
||||||
|
super.alias,
|
||||||
|
super.aliasSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String tripId;
|
||||||
|
final int sequence;
|
||||||
|
final String? scheduledTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsStopUpdate {
|
||||||
|
const OperationsStopUpdate({
|
||||||
|
required this.status,
|
||||||
|
required this.actualTime,
|
||||||
|
required this.vehicleId,
|
||||||
|
required this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? status;
|
||||||
|
final String? actualTime;
|
||||||
|
final String? vehicleId;
|
||||||
|
final String? notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsStop {
|
||||||
|
const OperationsStop({required this.scheduledStop, this.update});
|
||||||
|
|
||||||
|
final OperationsScheduledStop scheduledStop;
|
||||||
|
final OperationsStopUpdate? update;
|
||||||
|
|
||||||
|
String get id => scheduledStop.id;
|
||||||
|
String get tripId => scheduledStop.tripId;
|
||||||
|
int get sequence => scheduledStop.sequence;
|
||||||
|
String get name => scheduledStop.name;
|
||||||
|
String get displayName => scheduledStop.displayName;
|
||||||
|
String? get aliasSource => scheduledStop.aliasSource;
|
||||||
|
String? get scheduledTime => scheduledStop.scheduledTime;
|
||||||
|
String? get status => update?.status;
|
||||||
|
String? get actualTime => update?.actualTime;
|
||||||
|
String? get vehicleId => update?.vehicleId;
|
||||||
|
String? get notes => update?.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsTripSnapshot {
|
||||||
|
const OperationsTripSnapshot({
|
||||||
|
required this.trip,
|
||||||
|
required this.stops,
|
||||||
|
required this.scheduledStops,
|
||||||
|
});
|
||||||
|
|
||||||
|
final OperationsTrip trip;
|
||||||
|
final List<OperationsStop> stops;
|
||||||
|
final List<OperationsScheduledStop> scheduledStops;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsDutySnapshot {
|
||||||
|
const OperationsDutySnapshot({
|
||||||
|
required this.dutyNumber,
|
||||||
|
required this.busWorkNumber,
|
||||||
|
required this.trips,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String dutyNumber;
|
||||||
|
final String busWorkNumber;
|
||||||
|
final List<OperationsTripSnapshot> trips;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsStopAlias {
|
||||||
|
const OperationsStopAlias({
|
||||||
|
required this.rawStopName,
|
||||||
|
required this.aliasStopName,
|
||||||
|
required this.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String rawStopName;
|
||||||
|
final String aliasStopName;
|
||||||
|
final String source;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsSnapshot {
|
||||||
|
const OperationsSnapshot({
|
||||||
|
required this.schedule,
|
||||||
|
required this.trips,
|
||||||
|
required this.duties,
|
||||||
|
required this.scheduledStops,
|
||||||
|
required this.stopAliasesByRawName,
|
||||||
|
required this.stopAliases,
|
||||||
|
});
|
||||||
|
|
||||||
|
final OperationsSchedule? schedule;
|
||||||
|
final List<OperationsTripSnapshot> trips;
|
||||||
|
final List<OperationsDutySnapshot> duties;
|
||||||
|
final List<OperationsScheduledStop> scheduledStops;
|
||||||
|
final Map<String, String> stopAliasesByRawName;
|
||||||
|
final List<OperationsStopAlias> stopAliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsChannel extends BaseChannel {
|
||||||
|
OperationsChannel({
|
||||||
|
required super.client,
|
||||||
|
required super.id,
|
||||||
|
required super.organizationId,
|
||||||
|
required super.name,
|
||||||
|
required super.description,
|
||||||
|
required super.slug,
|
||||||
|
required super.isPrivate,
|
||||||
|
required super.position,
|
||||||
|
}) : super(kind: ChannelKind.operations);
|
||||||
|
|
||||||
|
factory OperationsChannel.fromApi({
|
||||||
|
required SupabaseClient client,
|
||||||
|
required Map<String, dynamic> map,
|
||||||
|
}) {
|
||||||
|
return OperationsChannel(
|
||||||
|
client: client,
|
||||||
|
id: (map["id"] ?? "").toString(),
|
||||||
|
organizationId: (map["organization_id"] ?? "").toString(),
|
||||||
|
name: (map["name"] ?? "").toString(),
|
||||||
|
description: (map["description"] ?? map["topic"] ?? "").toString(),
|
||||||
|
slug: (map["slug"] ?? "").toString(),
|
||||||
|
isPrivate: map["is_private"] == true,
|
||||||
|
position: (map["position"] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<OperationsSnapshot> loadSnapshot() async {
|
||||||
|
final scheduleRow = await client
|
||||||
|
.from("operations_schedules")
|
||||||
|
.select("id, version, source_file_name, uploaded_at")
|
||||||
|
.eq("channel_id", id)
|
||||||
|
.eq("is_active", true)
|
||||||
|
.order("uploaded_at", ascending: false)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (scheduleRow == null) {
|
||||||
|
return const OperationsSnapshot(
|
||||||
|
schedule: null,
|
||||||
|
trips: <OperationsTripSnapshot>[],
|
||||||
|
duties: <OperationsDutySnapshot>[],
|
||||||
|
scheduledStops: <OperationsScheduledStop>[],
|
||||||
|
stopAliasesByRawName: <String, String>{},
|
||||||
|
stopAliases: <OperationsStopAlias>[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final stopAliases = await listStopAliases();
|
||||||
|
final stopAliasesByRawName = _aliasesByRawStopName(stopAliases);
|
||||||
|
final aliasesByNormalizedName = <String, String>{};
|
||||||
|
final aliasSourceByNormalizedName = <String, String>{};
|
||||||
|
for (final entry in stopAliasesByRawName.entries) {
|
||||||
|
aliasesByNormalizedName[Stop.normalizeName(entry.key)] = entry.value;
|
||||||
|
}
|
||||||
|
for (final alias in stopAliases) {
|
||||||
|
aliasSourceByNormalizedName[Stop.normalizeName(alias.rawStopName)] =
|
||||||
|
alias.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
final schedule = OperationsSchedule.fromMap(BaseChannel.asMap(scheduleRow));
|
||||||
|
final tripRows = await client
|
||||||
|
.from("operations_trips")
|
||||||
|
.select(
|
||||||
|
"id, schedule_id, trip_number, duty_number, bus_work_number, direction, sort_order",
|
||||||
|
)
|
||||||
|
.eq("schedule_id", schedule.id)
|
||||||
|
.order("sort_order", ascending: true);
|
||||||
|
|
||||||
|
final trips = BaseChannel.asList(
|
||||||
|
tripRows,
|
||||||
|
).map((row) => OperationsTrip.fromMap(BaseChannel.asMap(row))).toList();
|
||||||
|
|
||||||
|
if (trips.isEmpty) {
|
||||||
|
return OperationsSnapshot(
|
||||||
|
schedule: schedule,
|
||||||
|
trips: const <OperationsTripSnapshot>[],
|
||||||
|
duties: const <OperationsDutySnapshot>[],
|
||||||
|
scheduledStops: const <OperationsScheduledStop>[],
|
||||||
|
stopAliasesByRawName: stopAliasesByRawName,
|
||||||
|
stopAliases: stopAliases,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tripIds = trips.map((trip) => trip.id).toList();
|
||||||
|
final stopRows = await client
|
||||||
|
.from("operations_trip_stops")
|
||||||
|
.select("id, trip_id, stop_sequence, stop_name, scheduled_time")
|
||||||
|
.filter("trip_id", "in", "(${tripIds.join(",")})")
|
||||||
|
.order("trip_id", ascending: true)
|
||||||
|
.order("stop_sequence", ascending: true);
|
||||||
|
|
||||||
|
final stopIds = BaseChannel.asList(stopRows)
|
||||||
|
.map((row) => (BaseChannel.asMap(row)["id"] ?? "").toString())
|
||||||
|
.where((value) => value.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<dynamic> updateRows = const <dynamic>[];
|
||||||
|
if (stopIds.isNotEmpty) {
|
||||||
|
updateRows = await client
|
||||||
|
.from("operations_stop_updates")
|
||||||
|
.select("trip_stop_id, status, actual_time, vehicle_id, notes")
|
||||||
|
.filter("trip_stop_id", "in", "(${stopIds.join(",")})");
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatesByStopId = <String, Map<String, dynamic>>{};
|
||||||
|
for (final row in BaseChannel.asList(updateRows)) {
|
||||||
|
final map = BaseChannel.asMap(row);
|
||||||
|
final tripStopId = (map["trip_stop_id"] ?? "").toString();
|
||||||
|
if (tripStopId.isEmpty) continue;
|
||||||
|
updatesByStopId[tripStopId] = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
final scheduledStopsByTripId = <String, List<OperationsScheduledStop>>{};
|
||||||
|
final allScheduledStops = <OperationsScheduledStop>[];
|
||||||
|
for (final row in BaseChannel.asList(stopRows)) {
|
||||||
|
final map = BaseChannel.asMap(row);
|
||||||
|
final stopId = (map["id"] ?? "").toString();
|
||||||
|
final tripId = (map["trip_id"] ?? "").toString();
|
||||||
|
final scheduledStop = OperationsScheduledStop(
|
||||||
|
id: stopId,
|
||||||
|
tripId: tripId,
|
||||||
|
sequence: (map["stop_sequence"] as num?)?.toInt() ?? 0,
|
||||||
|
name: (map["stop_name"] ?? "").toString().trim(),
|
||||||
|
alias: aliasesByNormalizedName[
|
||||||
|
Stop.normalizeName((map["stop_name"] ?? "").toString())],
|
||||||
|
aliasSource: aliasSourceByNormalizedName[
|
||||||
|
Stop.normalizeName((map["stop_name"] ?? "").toString())],
|
||||||
|
scheduledTime: (map["scheduled_time"] ?? "").toString().trim().isEmpty
|
||||||
|
? null
|
||||||
|
: (map["scheduled_time"] ?? "").toString(),
|
||||||
|
);
|
||||||
|
allScheduledStops.add(scheduledStop);
|
||||||
|
scheduledStopsByTripId
|
||||||
|
.putIfAbsent(tripId, () => <OperationsScheduledStop>[])
|
||||||
|
.add(scheduledStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
final snapshots = trips.map((trip) {
|
||||||
|
final scheduledStops =
|
||||||
|
scheduledStopsByTripId[trip.id] ?? const <OperationsScheduledStop>[];
|
||||||
|
final stops = scheduledStops
|
||||||
|
.map((scheduledStop) {
|
||||||
|
final update = updatesByStopId[scheduledStop.id];
|
||||||
|
final hasUpdate = update != null;
|
||||||
|
return OperationsStop(
|
||||||
|
scheduledStop: scheduledStop,
|
||||||
|
update: !hasUpdate
|
||||||
|
? null
|
||||||
|
: OperationsStopUpdate(
|
||||||
|
status: (update["status"] ?? "").toString().trim().isEmpty
|
||||||
|
? null
|
||||||
|
: (update["status"] ?? "").toString(),
|
||||||
|
actualTime:
|
||||||
|
(update["actual_time"] ?? "")
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.isEmpty
|
||||||
|
? null
|
||||||
|
: (update["actual_time"] ?? "").toString(),
|
||||||
|
vehicleId:
|
||||||
|
(update["vehicle_id"] ?? "").toString().trim().isEmpty
|
||||||
|
? null
|
||||||
|
: (update["vehicle_id"] ?? "").toString(),
|
||||||
|
notes: (update["notes"] ?? "").toString().trim().isEmpty
|
||||||
|
? null
|
||||||
|
: (update["notes"] ?? "").toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
return OperationsTripSnapshot(
|
||||||
|
trip: trip,
|
||||||
|
stops: stops,
|
||||||
|
scheduledStops: scheduledStops,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final groupedByDuty = <String, List<OperationsTripSnapshot>>{};
|
||||||
|
for (final snapshot in snapshots) {
|
||||||
|
groupedByDuty
|
||||||
|
.putIfAbsent(snapshot.trip.dutyKey, () => <OperationsTripSnapshot>[])
|
||||||
|
.add(snapshot);
|
||||||
|
}
|
||||||
|
final duties = groupedByDuty.entries
|
||||||
|
.map((entry) {
|
||||||
|
final dutyTrips = entry.value;
|
||||||
|
final firstTrip = dutyTrips.first.trip;
|
||||||
|
return OperationsDutySnapshot(
|
||||||
|
dutyNumber: firstTrip.dutyNumber,
|
||||||
|
busWorkNumber: firstTrip.busWorkNumber,
|
||||||
|
trips: dutyTrips,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
return OperationsSnapshot(
|
||||||
|
schedule: schedule,
|
||||||
|
trips: snapshots,
|
||||||
|
duties: duties,
|
||||||
|
scheduledStops: allScheduledStops,
|
||||||
|
stopAliasesByRawName: stopAliasesByRawName,
|
||||||
|
stopAliases: stopAliases,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<OperationsStopAlias>> listStopAliases() async {
|
||||||
|
final rows = await client
|
||||||
|
.from("operations_stop_aliases")
|
||||||
|
.select("raw_stop_name, alias_stop_name, source")
|
||||||
|
.eq("channel_id", id);
|
||||||
|
|
||||||
|
final aliases = <OperationsStopAlias>[];
|
||||||
|
for (final row in BaseChannel.asList(rows)) {
|
||||||
|
final map = BaseChannel.asMap(row);
|
||||||
|
final raw = (map["raw_stop_name"] ?? "").toString().trim();
|
||||||
|
final alias = (map["alias_stop_name"] ?? "").toString().trim();
|
||||||
|
final source = (map["source"] ?? "").toString().trim();
|
||||||
|
if (raw.isEmpty || alias.isEmpty) continue;
|
||||||
|
aliases.add(
|
||||||
|
OperationsStopAlias(
|
||||||
|
rawStopName: raw,
|
||||||
|
aliasStopName: alias,
|
||||||
|
source: source.isEmpty ? "user" : source,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _aliasesByRawStopName(List<OperationsStopAlias> aliases) {
|
||||||
|
final byRawStopName = <String, String>{};
|
||||||
|
for (final alias in aliases) {
|
||||||
|
byRawStopName[alias.rawStopName] = alias.aliasStopName;
|
||||||
|
}
|
||||||
|
return byRawStopName;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markStopCompleted({
|
||||||
|
required String tripStopId,
|
||||||
|
required bool completed,
|
||||||
|
String? actualTime,
|
||||||
|
String? vehicleId,
|
||||||
|
String? notes,
|
||||||
|
}) async {
|
||||||
|
final userId = client.auth.currentUser?.id;
|
||||||
|
if (userId == null || userId.isEmpty) {
|
||||||
|
throw StateError(
|
||||||
|
"Cannot update stop status without an authenticated user.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? persistedActualTime = actualTime;
|
||||||
|
if (completed &&
|
||||||
|
(persistedActualTime == null || persistedActualTime.isEmpty)) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final hour = now.hour.toString().padLeft(2, "0");
|
||||||
|
final minute = now.minute.toString().padLeft(2, "0");
|
||||||
|
persistedActualTime = "$hour:$minute";
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.from("operations_stop_updates").upsert({
|
||||||
|
"trip_stop_id": tripStopId,
|
||||||
|
"status": completed ? "completed" : null,
|
||||||
|
"actual_time": completed ? persistedActualTime : null,
|
||||||
|
"vehicle_id": vehicleId,
|
||||||
|
"notes": notes,
|
||||||
|
"updated_by": userId,
|
||||||
|
}, onConflict: "trip_stop_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
129
lib/models/channels/text_channel.dart
Normal file
129
lib/models/channels/text_channel.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import "package:bus_running_record/models/channels/base_channel.dart";
|
||||||
|
import "package:supabase_flutter/supabase_flutter.dart";
|
||||||
|
|
||||||
|
class TextChannelMessage {
|
||||||
|
const TextChannelMessage({
|
||||||
|
required this.id,
|
||||||
|
required this.channelId,
|
||||||
|
required this.authorUserId,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String channelId;
|
||||||
|
final String authorUserId;
|
||||||
|
final String content;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
factory TextChannelMessage.fromMap(Map<String, dynamic> map) {
|
||||||
|
return TextChannelMessage(
|
||||||
|
id: (map["id"] ?? "").toString(),
|
||||||
|
channelId: (map["channel_id"] ?? "").toString(),
|
||||||
|
authorUserId: (map["author_user_id"] ?? "").toString(),
|
||||||
|
content: (map["content"] ?? "").toString(),
|
||||||
|
createdAt: DateTime.tryParse((map["created_at"] ?? "").toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"channel_id": channelId,
|
||||||
|
"author_user_id": authorUserId,
|
||||||
|
"content": content,
|
||||||
|
"created_at": createdAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextChannel extends BaseChannel {
|
||||||
|
TextChannel({
|
||||||
|
required super.client,
|
||||||
|
required super.id,
|
||||||
|
required super.organizationId,
|
||||||
|
required super.name,
|
||||||
|
required super.description,
|
||||||
|
required super.slug,
|
||||||
|
required super.isPrivate,
|
||||||
|
required super.position,
|
||||||
|
}) : super(kind: ChannelKind.text);
|
||||||
|
|
||||||
|
factory TextChannel.fromApi({
|
||||||
|
required SupabaseClient client,
|
||||||
|
required Map<String, dynamic> map,
|
||||||
|
}) {
|
||||||
|
return TextChannel(
|
||||||
|
client: client,
|
||||||
|
id: (map["id"] ?? "").toString(),
|
||||||
|
organizationId: (map["organization_id"] ?? "").toString(),
|
||||||
|
name: (map["name"] ?? "").toString(),
|
||||||
|
description: (map["description"] ?? map["topic"] ?? "").toString(),
|
||||||
|
slug: (map["slug"] ?? "").toString(),
|
||||||
|
isPrivate: map["is_private"] == true,
|
||||||
|
position: (map["position"] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<TextChannelMessage>> listMessages({int limit = 50}) async {
|
||||||
|
final rows = await client
|
||||||
|
.from("messages")
|
||||||
|
.select("id, channel_id, author_user_id, content, created_at")
|
||||||
|
.eq("channel_id", id)
|
||||||
|
.order("created_at", ascending: true)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return BaseChannel.asList(rows)
|
||||||
|
.map((row) => TextChannelMessage.fromMap(BaseChannel.asMap(row)))
|
||||||
|
.where((message) => message.id.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TextChannelMessage?> sendMessage(String content) async {
|
||||||
|
final trimmed = content.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
|
||||||
|
final authorUserId = client.auth.currentUser?.id;
|
||||||
|
if (authorUserId == null || authorUserId.isEmpty) {
|
||||||
|
throw StateError("Cannot send message without an authenticated user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final inserted = await client
|
||||||
|
.from("messages")
|
||||||
|
.insert({
|
||||||
|
"channel_id": id,
|
||||||
|
"author_user_id": authorUserId,
|
||||||
|
"content": trimmed,
|
||||||
|
})
|
||||||
|
.select("id, channel_id, author_user_id, content, created_at")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return TextChannelMessage.fromMap(BaseChannel.asMap(inserted));
|
||||||
|
}
|
||||||
|
|
||||||
|
RealtimeChannel subscribeToMessages({
|
||||||
|
required void Function() onMessageChanged,
|
||||||
|
void Function(RealtimeSubscribeStatus status, Object? error)? onStatus,
|
||||||
|
}) {
|
||||||
|
final topic = "messages:$id";
|
||||||
|
return client.channel(topic)
|
||||||
|
..onPostgresChanges(
|
||||||
|
event: PostgresChangeEvent.all,
|
||||||
|
schema: "public",
|
||||||
|
table: "messages",
|
||||||
|
filter: PostgresChangeFilter(
|
||||||
|
type: PostgresChangeFilterType.eq,
|
||||||
|
column: "channel_id",
|
||||||
|
value: id,
|
||||||
|
),
|
||||||
|
callback: (_) => onMessageChanged(),
|
||||||
|
)
|
||||||
|
..subscribe((status, [error]) {
|
||||||
|
onStatus?.call(status, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unsubscribe(RealtimeChannel channel) async {
|
||||||
|
await client.removeChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/models/operations/duty.dart
Normal file
24
lib/models/operations/duty.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
class Duty {
|
||||||
|
const Duty({required this.dutyNumber, required this.busWorkNumber});
|
||||||
|
|
||||||
|
final String dutyNumber;
|
||||||
|
final String busWorkNumber;
|
||||||
|
|
||||||
|
Duty copyWith({String? dutyNumber, String? busWorkNumber}) {
|
||||||
|
return Duty(
|
||||||
|
dutyNumber: dutyNumber ?? this.dutyNumber,
|
||||||
|
busWorkNumber: busWorkNumber ?? this.busWorkNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {"dutyNumber": dutyNumber, "busWorkNumber": busWorkNumber};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Duty.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Duty(
|
||||||
|
dutyNumber: (json["dutyNumber"] ?? "").toString(),
|
||||||
|
busWorkNumber: (json["busWorkNumber"] ?? "").toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
lib/models/operations/scheduled_stop.dart
Normal file
58
lib/models/operations/scheduled_stop.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import "package:bus_running_record/models/operations/stop.dart";
|
||||||
|
|
||||||
|
class ScheduledStop extends Stop {
|
||||||
|
const ScheduledStop({
|
||||||
|
required super.name,
|
||||||
|
required this.sequence,
|
||||||
|
this.scheduledTime,
|
||||||
|
super.alias,
|
||||||
|
super.aliasSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int sequence;
|
||||||
|
final String? scheduledTime;
|
||||||
|
|
||||||
|
ScheduledStop copyWith({
|
||||||
|
String? name,
|
||||||
|
String? alias,
|
||||||
|
String? aliasSource,
|
||||||
|
int? sequence,
|
||||||
|
String? scheduledTime,
|
||||||
|
}) {
|
||||||
|
return ScheduledStop(
|
||||||
|
name: name ?? this.name,
|
||||||
|
alias: alias ?? this.alias,
|
||||||
|
aliasSource: aliasSource ?? this.aliasSource,
|
||||||
|
sequence: sequence ?? this.sequence,
|
||||||
|
scheduledTime: scheduledTime ?? this.scheduledTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"alias": alias,
|
||||||
|
"aliasSource": aliasSource,
|
||||||
|
"sequence": sequence,
|
||||||
|
"scheduledTime": scheduledTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ScheduledStop.fromJson(Map<String, dynamic> json) {
|
||||||
|
final raw = (json["scheduledTime"] ?? "").toString().trim();
|
||||||
|
return ScheduledStop(
|
||||||
|
name: (json["name"] ?? "").toString(),
|
||||||
|
alias: (json["alias"] ?? "").toString().trim().isEmpty
|
||||||
|
? null
|
||||||
|
: (json["alias"] ?? "").toString(),
|
||||||
|
aliasSource: (json["aliasSource"] ?? json["source"] ?? "")
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.isEmpty
|
||||||
|
? null
|
||||||
|
: (json["aliasSource"] ?? json["source"] ?? "").toString(),
|
||||||
|
sequence: (json["sequence"] as num?)?.toInt() ?? 0,
|
||||||
|
scheduledTime: raw.isEmpty ? null : raw,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
lib/models/operations/stop.dart
Normal file
15
lib/models/operations/stop.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
class Stop {
|
||||||
|
const Stop({required this.name, this.alias, this.aliasSource});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String? alias;
|
||||||
|
final String? aliasSource;
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
final value = (alias ?? "").trim();
|
||||||
|
if (value.isEmpty) return name;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeName(String name) => name.trim().toLowerCase();
|
||||||
|
}
|
||||||
206
lib/models/operations/trip.dart
Normal file
206
lib/models/operations/trip.dart
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import "package:bus_running_record/models/operations/duty.dart";
|
||||||
|
import "package:bus_running_record/models/operations/scheduled_stop.dart";
|
||||||
|
import "package:bus_running_record/models/operations/stop.dart";
|
||||||
|
|
||||||
|
class Trip {
|
||||||
|
final String scheduledTime; // "15:31" - default departure time
|
||||||
|
final String tripNumber; // "112"
|
||||||
|
final Duty duty;
|
||||||
|
final String tripType; // "", "N", "R", "F"
|
||||||
|
final bool isFinishing;
|
||||||
|
final List<ScheduledStop> scheduledStops;
|
||||||
|
final String direction; // "outbound" or "inbound"
|
||||||
|
|
||||||
|
String? actualDepartureTime; // "15:33" (nullable - user input)
|
||||||
|
String? actualFleetNumber; // "33523" (nullable - user input)
|
||||||
|
|
||||||
|
Trip({
|
||||||
|
required this.scheduledTime,
|
||||||
|
required this.tripNumber,
|
||||||
|
String? dutyNumber,
|
||||||
|
String? busWorkNumber,
|
||||||
|
Duty? duty,
|
||||||
|
this.tripType = "",
|
||||||
|
this.isFinishing = false,
|
||||||
|
Map<String, String> stationTimes = const {},
|
||||||
|
List<String> stationOrder = const [],
|
||||||
|
List<ScheduledStop>? scheduledStops,
|
||||||
|
this.direction = "outbound",
|
||||||
|
this.actualDepartureTime,
|
||||||
|
this.actualFleetNumber,
|
||||||
|
}) : duty =
|
||||||
|
duty ??
|
||||||
|
Duty(
|
||||||
|
dutyNumber: dutyNumber ?? "",
|
||||||
|
busWorkNumber: busWorkNumber ?? "",
|
||||||
|
),
|
||||||
|
scheduledStops =
|
||||||
|
scheduledStops ?? _buildScheduledStops(stationTimes, stationOrder);
|
||||||
|
|
||||||
|
bool get isComplete =>
|
||||||
|
actualDepartureTime != null && actualFleetNumber != null;
|
||||||
|
|
||||||
|
String get dutyNumber => duty.dutyNumber;
|
||||||
|
String get busWorkNumber => duty.busWorkNumber;
|
||||||
|
List<String> get stationOrder =>
|
||||||
|
scheduledStops.map((stop) => stop.name).toList(growable: false);
|
||||||
|
Map<String, String> get stationTimes {
|
||||||
|
final result = <String, String>{};
|
||||||
|
for (final stop in scheduledStops) {
|
||||||
|
final time = (stop.scheduledTime ?? "").trim();
|
||||||
|
if (time.isEmpty) continue;
|
||||||
|
result[stop.name] = time;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Trip copyWith({
|
||||||
|
String? scheduledTime,
|
||||||
|
String? tripNumber,
|
||||||
|
String? dutyNumber,
|
||||||
|
String? busWorkNumber,
|
||||||
|
Duty? duty,
|
||||||
|
String? tripType,
|
||||||
|
bool? isFinishing,
|
||||||
|
Map<String, String>? stationTimes,
|
||||||
|
List<String>? stationOrder,
|
||||||
|
List<ScheduledStop>? scheduledStops,
|
||||||
|
String? direction,
|
||||||
|
String? actualDepartureTime,
|
||||||
|
String? actualFleetNumber,
|
||||||
|
}) {
|
||||||
|
return Trip(
|
||||||
|
scheduledTime: scheduledTime ?? this.scheduledTime,
|
||||||
|
tripNumber: tripNumber ?? this.tripNumber,
|
||||||
|
duty:
|
||||||
|
duty ??
|
||||||
|
this.duty.copyWith(
|
||||||
|
dutyNumber: dutyNumber,
|
||||||
|
busWorkNumber: busWorkNumber,
|
||||||
|
),
|
||||||
|
tripType: tripType ?? this.tripType,
|
||||||
|
isFinishing: isFinishing ?? this.isFinishing,
|
||||||
|
scheduledStops:
|
||||||
|
scheduledStops ??
|
||||||
|
(stationTimes != null || stationOrder != null
|
||||||
|
? _buildScheduledStops(
|
||||||
|
stationTimes ?? this.stationTimes,
|
||||||
|
stationOrder ?? this.stationOrder,
|
||||||
|
)
|
||||||
|
: this.scheduledStops),
|
||||||
|
direction: direction ?? this.direction,
|
||||||
|
actualDepartureTime: actualDepartureTime ?? this.actualDepartureTime,
|
||||||
|
actualFleetNumber: actualFleetNumber ?? this.actualFleetNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Trip withStopAliases(
|
||||||
|
Map<String, String> aliasesByRawName, {
|
||||||
|
String aliasSource = "ai",
|
||||||
|
}) {
|
||||||
|
if (aliasesByRawName.isEmpty) return this;
|
||||||
|
|
||||||
|
final aliasesByNormalizedName = <String, String>{};
|
||||||
|
for (final entry in aliasesByRawName.entries) {
|
||||||
|
aliasesByNormalizedName[Stop.normalizeName(entry.key)] = entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyWith(
|
||||||
|
scheduledStops: scheduledStops
|
||||||
|
.map(
|
||||||
|
(stop) => stop.copyWith(
|
||||||
|
alias: aliasesByNormalizedName[Stop.normalizeName(stop.name)],
|
||||||
|
aliasSource:
|
||||||
|
aliasesByNormalizedName[Stop.normalizeName(stop.name)] == null
|
||||||
|
? stop.aliasSource
|
||||||
|
: aliasSource,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"scheduledTime": scheduledTime,
|
||||||
|
"tripNumber": tripNumber,
|
||||||
|
"duty": duty.toJson(),
|
||||||
|
"dutyNumber": dutyNumber,
|
||||||
|
"busWorkNumber": busWorkNumber,
|
||||||
|
"tripType": tripType,
|
||||||
|
"isFinishing": isFinishing,
|
||||||
|
"scheduledStops": scheduledStops.map((stop) => stop.toJson()).toList(),
|
||||||
|
"stationTimes": stationTimes,
|
||||||
|
"stationOrder": stationOrder,
|
||||||
|
"direction": direction,
|
||||||
|
"actualDepartureTime": actualDepartureTime,
|
||||||
|
"actualFleetNumber": actualFleetNumber,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Trip.fromJson(Map<String, dynamic> json) {
|
||||||
|
final dutyJson = json["duty"];
|
||||||
|
final duty = dutyJson is Map<String, dynamic>
|
||||||
|
? Duty.fromJson(dutyJson)
|
||||||
|
: Duty(
|
||||||
|
dutyNumber: (json["dutyNumber"] ?? "").toString(),
|
||||||
|
busWorkNumber:
|
||||||
|
(json["busWorkNumber"] ?? json["runningNumber"] ?? "")
|
||||||
|
.toString(),
|
||||||
|
);
|
||||||
|
final scheduledStopsJson = json["scheduledStops"];
|
||||||
|
final scheduledStops = scheduledStopsJson is List
|
||||||
|
? scheduledStopsJson
|
||||||
|
.whereType<Map>()
|
||||||
|
.map(
|
||||||
|
(stopJson) =>
|
||||||
|
ScheduledStop.fromJson(Map<String, dynamic>.from(stopJson)),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Trip(
|
||||||
|
scheduledTime: json["scheduledTime"] as String,
|
||||||
|
tripNumber: json["tripNumber"] as String,
|
||||||
|
duty: duty,
|
||||||
|
tripType: json["tripType"] as String? ?? "",
|
||||||
|
isFinishing: json["isFinishing"] as bool? ?? false,
|
||||||
|
scheduledStops: scheduledStops,
|
||||||
|
stationTimes: Map<String, String>.from(
|
||||||
|
json["stationTimes"] as Map? ?? {},
|
||||||
|
),
|
||||||
|
stationOrder: List<String>.from(json["stationOrder"] as List? ?? []),
|
||||||
|
direction: json["direction"] as String? ?? "outbound",
|
||||||
|
actualDepartureTime: json["actualDepartureTime"] as String?,
|
||||||
|
actualFleetNumber: json["actualFleetNumber"] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ScheduledStop> _buildScheduledStops(
|
||||||
|
Map<String, String> stationTimes,
|
||||||
|
List<String> stationOrder,
|
||||||
|
) {
|
||||||
|
if (stationOrder.isEmpty) {
|
||||||
|
var index = 0;
|
||||||
|
return stationTimes.entries
|
||||||
|
.map(
|
||||||
|
(entry) => ScheduledStop(
|
||||||
|
name: entry.key,
|
||||||
|
sequence: ++index,
|
||||||
|
scheduledTime: entry.value.trim().isEmpty ? null : entry.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<ScheduledStop>.generate(stationOrder.length, (i) {
|
||||||
|
final stopName = stationOrder[i];
|
||||||
|
final time = (stationTimes[stopName] ?? "").trim();
|
||||||
|
return ScheduledStop(
|
||||||
|
name: stopName,
|
||||||
|
sequence: i + 1,
|
||||||
|
scheduledTime: time.isEmpty ? null : time,
|
||||||
|
);
|
||||||
|
}, growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
class Trip {
|
|
||||||
final String scheduledTime; // "15:31" - default departure time (first non-empty time)
|
|
||||||
final String tripNumber; // "112"
|
|
||||||
final String dutyNumber; // "518"
|
|
||||||
final String runningNumber; // "518"
|
|
||||||
final String tripType; // "", "N", "R", "F"
|
|
||||||
final bool isFinishing;
|
|
||||||
final Map<String, String> stationTimes; // {"UXBG": "15:31", "HILL": "15:42", ...}
|
|
||||||
final List<String> stationOrder; // ["UXBG", "UXBG", "HILL", "ICKE", ...]
|
|
||||||
final String direction; // "outbound" or "inbound"
|
|
||||||
|
|
||||||
String? actualDepartureTime; // "15:33" (nullable - user input)
|
|
||||||
String? actualFleetNumber; // "33523" (nullable - user input)
|
|
||||||
|
|
||||||
Trip({
|
|
||||||
required this.scheduledTime,
|
|
||||||
required this.tripNumber,
|
|
||||||
required this.dutyNumber,
|
|
||||||
required this.runningNumber,
|
|
||||||
this.tripType = "",
|
|
||||||
this.isFinishing = false,
|
|
||||||
this.stationTimes = const {},
|
|
||||||
this.stationOrder = const [],
|
|
||||||
this.direction = "outbound",
|
|
||||||
this.actualDepartureTime,
|
|
||||||
this.actualFleetNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool get isComplete =>
|
|
||||||
actualDepartureTime != null && actualFleetNumber != null;
|
|
||||||
|
|
||||||
Trip copyWith({
|
|
||||||
String? scheduledTime,
|
|
||||||
String? tripNumber,
|
|
||||||
String? dutyNumber,
|
|
||||||
String? runningNumber,
|
|
||||||
String? tripType,
|
|
||||||
bool? isFinishing,
|
|
||||||
Map<String, String>? stationTimes,
|
|
||||||
List<String>? stationOrder,
|
|
||||||
String? direction,
|
|
||||||
String? actualDepartureTime,
|
|
||||||
String? actualFleetNumber,
|
|
||||||
}) {
|
|
||||||
return Trip(
|
|
||||||
scheduledTime: scheduledTime ?? this.scheduledTime,
|
|
||||||
tripNumber: tripNumber ?? this.tripNumber,
|
|
||||||
dutyNumber: dutyNumber ?? this.dutyNumber,
|
|
||||||
runningNumber: runningNumber ?? this.runningNumber,
|
|
||||||
tripType: tripType ?? this.tripType,
|
|
||||||
isFinishing: isFinishing ?? this.isFinishing,
|
|
||||||
stationTimes: stationTimes ?? this.stationTimes,
|
|
||||||
stationOrder: stationOrder ?? this.stationOrder,
|
|
||||||
direction: direction ?? this.direction,
|
|
||||||
actualDepartureTime: actualDepartureTime ?? this.actualDepartureTime,
|
|
||||||
actualFleetNumber: actualFleetNumber ?? this.actualFleetNumber,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
"scheduledTime": scheduledTime,
|
|
||||||
"tripNumber": tripNumber,
|
|
||||||
"dutyNumber": dutyNumber,
|
|
||||||
"runningNumber": runningNumber,
|
|
||||||
"tripType": tripType,
|
|
||||||
"isFinishing": isFinishing,
|
|
||||||
"stationTimes": stationTimes,
|
|
||||||
"stationOrder": stationOrder,
|
|
||||||
"direction": direction,
|
|
||||||
"actualDepartureTime": actualDepartureTime,
|
|
||||||
"actualFleetNumber": actualFleetNumber,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
factory Trip.fromJson(Map<String, dynamic> json) {
|
|
||||||
return Trip(
|
|
||||||
scheduledTime: json["scheduledTime"] as String,
|
|
||||||
tripNumber: json["tripNumber"] as String,
|
|
||||||
dutyNumber: json["dutyNumber"] as String,
|
|
||||||
runningNumber: json["runningNumber"] as String,
|
|
||||||
tripType: json["tripType"] as String? ?? "",
|
|
||||||
isFinishing: json["isFinishing"] as bool? ?? false,
|
|
||||||
stationTimes: Map<String, String>.from(json["stationTimes"] as Map? ?? {}),
|
|
||||||
stationOrder: List<String>.from(json["stationOrder"] as List? ?? []),
|
|
||||||
direction: json["direction"] as String? ?? "outbound",
|
|
||||||
actualDepartureTime: json["actualDepartureTime"] as String?,
|
|
||||||
actualFleetNumber: json["actualFleetNumber"] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
963
lib/pages/auth/page.dart
Normal file
963
lib/pages/auth/page.dart
Normal file
|
|
@ -0,0 +1,963 @@
|
||||||
|
import "dart:async";
|
||||||
|
import "dart:math" as math;
|
||||||
|
|
||||||
|
import "package:bus_running_record/pages/auth/verify_email_page.dart";
|
||||||
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
|
import "package:flutter/material.dart" as material;
|
||||||
|
import "package:flutter/services.dart"
|
||||||
|
as flutter_services
|
||||||
|
show TextInput, TextInputAction;
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:provider/provider.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
class LoginPage extends StatefulWidget {
|
||||||
|
static const String routePath = "/login";
|
||||||
|
static final GoRoute route = GoRoute(
|
||||||
|
path: routePath,
|
||||||
|
builder: (context, state) => const LoginPage(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const LoginPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends State<LoginPage> {
|
||||||
|
static const _loginEmailKey = TextFieldKey("login_email");
|
||||||
|
static const _loginPasswordKey = TextFieldKey("login_password");
|
||||||
|
static const _signUpEmailKey = TextFieldKey("signup_email");
|
||||||
|
static const _signUpPasswordKey = TextFieldKey("signup_password");
|
||||||
|
static const _confirmPasswordKey = TextFieldKey("signup_confirm_password");
|
||||||
|
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
bool _isExitingScreen = false;
|
||||||
|
bool _isSignUpMode = false;
|
||||||
|
bool _isSignUpPasswordStep = false;
|
||||||
|
bool _desktopChecklistEntered = false;
|
||||||
|
String? _error;
|
||||||
|
int _signInShakeNonce = 0;
|
||||||
|
String _loginEmailDraft = "";
|
||||||
|
String _loginPasswordDraft = "";
|
||||||
|
String _signUpPasswordDraft = "";
|
||||||
|
String _signUpEmailDraft = "";
|
||||||
|
String _signUpConfirmPasswordDraft = "";
|
||||||
|
final FocusNode _loginEmailFocusNode = FocusNode();
|
||||||
|
final FocusNode _loginPasswordFocusNode = FocusNode();
|
||||||
|
final FocusNode _signUpEmailFocusNode = FocusNode();
|
||||||
|
final FocusNode _signUpPasswordFocusNode = FocusNode();
|
||||||
|
final FocusNode _signUpConfirmPasswordFocusNode = FocusNode();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_signUpPasswordFocusNode.addListener(_onSignUpPasswordFocusChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_loginEmailFocusNode.dispose();
|
||||||
|
_loginPasswordFocusNode.dispose();
|
||||||
|
_signUpEmailFocusNode.dispose();
|
||||||
|
_signUpPasswordFocusNode.removeListener(_onSignUpPasswordFocusChanged);
|
||||||
|
_signUpPasswordFocusNode.dispose();
|
||||||
|
_signUpConfirmPasswordFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSignUpPasswordFocusChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _submitAuth({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final supabase = context.read<SupabaseProvider>();
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_isSignUpMode) {
|
||||||
|
await supabase.signUpWithPassword(email: email, password: password);
|
||||||
|
if (mounted) {
|
||||||
|
flutter_services.TextInput.finishAutofillContext(shouldSave: true);
|
||||||
|
// Force token-based verification flow after sign-up.
|
||||||
|
await supabase.signOut();
|
||||||
|
if (!mounted) return false;
|
||||||
|
final encodedEmail = Uri.encodeQueryComponent(email);
|
||||||
|
await _animateOutAndGo(
|
||||||
|
"${VerifyEmailPage.routePath}?email=$encodedEmail",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
await supabase.signInWithPassword(email: email, password: password);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
flutter_services.TextInput.finishAutofillContext(shouldSave: true);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logAuthError("submitAuth", error, stackTrace);
|
||||||
|
if (!_isSignUpMode && _isEmailNotConfirmedError(error)) {
|
||||||
|
try {
|
||||||
|
await supabase.signOut();
|
||||||
|
await supabase.resendSignUpOtp(email: email);
|
||||||
|
if (mounted) {
|
||||||
|
final encodedEmail = Uri.encodeQueryComponent(email);
|
||||||
|
await _animateOutAndGo(
|
||||||
|
"${VerifyEmailPage.routePath}?email=$encodedEmail",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (resendError, resendStackTrace) {
|
||||||
|
_logAuthError(
|
||||||
|
"submitAuth/signInEmailNotConfirmedResend",
|
||||||
|
resendError,
|
||||||
|
resendStackTrace,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(
|
||||||
|
() => _error =
|
||||||
|
"Email is not confirmed yet. We couldn't resend a token right now.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_isSignUpMode && _isExistingAccountError(error)) {
|
||||||
|
if (mounted) {
|
||||||
|
await supabase.signOut();
|
||||||
|
if (!mounted) return false;
|
||||||
|
final encodedEmail = Uri.encodeQueryComponent(email);
|
||||||
|
await _animateOutAndGo(
|
||||||
|
"${VerifyEmailPage.routePath}?email=$encodedEmail",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
if (!_isSignUpMode) {
|
||||||
|
_triggerSignInShake();
|
||||||
|
}
|
||||||
|
setState(() => _error = _formatAuthError(error));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logAuthError(String operation, Object error, StackTrace stackTrace) {
|
||||||
|
debugPrint("[AuthPage] $operation failed (${error.runtimeType}): $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _triggerSignInShake() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_signInShakeNonce += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _animateOutAndGo(String location) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_isExitingScreen) return;
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
setState(() {
|
||||||
|
_isExitingScreen = true;
|
||||||
|
});
|
||||||
|
await Future.delayed(const Duration(milliseconds: 260));
|
||||||
|
if (mounted) {
|
||||||
|
context.go(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isExistingAccountError(Object error) {
|
||||||
|
final message = error.toString().toLowerCase();
|
||||||
|
return message.contains("already registered") ||
|
||||||
|
message.contains("already exists") ||
|
||||||
|
message.contains("user already exists") ||
|
||||||
|
message.contains("database error saving new user") ||
|
||||||
|
(message.contains("unexpected_failure") &&
|
||||||
|
message.contains("saving new user")) ||
|
||||||
|
message.contains("duplicate key value violates unique constraint");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isEmailNotConfirmedError(Object error) {
|
||||||
|
final message = error.toString().toLowerCase();
|
||||||
|
return message.contains("email not confirmed") ||
|
||||||
|
message.contains("email_not_confirmed");
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAuthError(Object error) {
|
||||||
|
final raw = error.toString().trim();
|
||||||
|
|
||||||
|
final wrapped = RegExp(
|
||||||
|
r'^[A-Za-z_]\w*\(message:\s*(.*?)(?:,\s*[A-Za-z_]\w*:\s*.*)?\)$',
|
||||||
|
dotAll: true,
|
||||||
|
).firstMatch(raw);
|
||||||
|
|
||||||
|
final message = wrapped?.group(1) ?? raw;
|
||||||
|
return message.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitLoginFromKeyboard(BuildContext fieldContext) {
|
||||||
|
try {
|
||||||
|
fieldContext.submitForm();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[AuthPage] submitLoginFromKeyboard fallback: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
final email = _loginEmailDraft.trim();
|
||||||
|
final password = _loginPasswordDraft;
|
||||||
|
if (email.isNotEmpty && password.isNotEmpty) {
|
||||||
|
unawaited(_submitAuth(email: email, password: password));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
FocusScope.of(fieldContext).unfocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitSignUpEmailFromKeyboard(BuildContext fieldContext) {
|
||||||
|
try {
|
||||||
|
fieldContext.submitForm();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[AuthPage] submitSignUpEmailFromKeyboard fallback: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
final email = _signUpEmailDraft.trim();
|
||||||
|
if (email.isNotEmpty) {
|
||||||
|
_handleSignUpEmailContinue(email);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
FocusScope.of(fieldContext).unfocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitSignUpPasswordFromKeyboard(BuildContext fieldContext) {
|
||||||
|
try {
|
||||||
|
fieldContext.submitForm();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[AuthPage] submitSignUpPasswordFromKeyboard fallback: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
final email = _signUpEmailDraft.trim();
|
||||||
|
final password = _signUpPasswordDraft;
|
||||||
|
if (email.isNotEmpty && password.isNotEmpty) {
|
||||||
|
unawaited(_submitAuth(email: email, password: password));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
FocusScope.of(fieldContext).unfocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ValidationResult?> _validateLoginSubmission(String? value) async {
|
||||||
|
final email = _loginEmailDraft.trim();
|
||||||
|
final password = value ?? "";
|
||||||
|
final success = await _submitAuth(email: email, password: password);
|
||||||
|
if (success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return InvalidResult(
|
||||||
|
_error ?? "Unable to sign in. Please try again.",
|
||||||
|
state: FormValidationMode.submitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ValidationResult?> _validateSignUpSubmission(String? value) async {
|
||||||
|
final email = _signUpEmailDraft.trim();
|
||||||
|
final password = value ?? "";
|
||||||
|
final success = await _submitAuth(email: email, password: password);
|
||||||
|
if (success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return InvalidResult(
|
||||||
|
_error ?? "Unable to create account. Please try again.",
|
||||||
|
state: FormValidationMode.submitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleMode() {
|
||||||
|
if (_isSubmitting || _isExitingScreen) return;
|
||||||
|
setState(() {
|
||||||
|
_isSignUpMode = !_isSignUpMode;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _continueToPasswordStep(String email) {
|
||||||
|
setState(() {
|
||||||
|
_signUpEmailDraft = email;
|
||||||
|
_isSignUpPasswordStep = true;
|
||||||
|
_desktopChecklistEntered = false;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_signUpPasswordFocusNode.requestFocus();
|
||||||
|
setState(() {
|
||||||
|
_desktopChecklistEntered = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSignUpEmailContinue(String email) {
|
||||||
|
_continueToPasswordStep(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _backToEmailStep() async {
|
||||||
|
final isDesktop = MediaQuery.of(context).size.width >= 600;
|
||||||
|
if (isDesktop && _desktopChecklistEntered) {
|
||||||
|
setState(() {
|
||||||
|
_desktopChecklistEntered = false;
|
||||||
|
});
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
if (!mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSignUpPasswordStep = false;
|
||||||
|
_desktopChecklistEntered = false;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_signUpEmailFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorBlock(String? initializationError) {
|
||||||
|
final messages = <String>[
|
||||||
|
if (initializationError != null)
|
||||||
|
"Supabase init failed: $initializationError",
|
||||||
|
if (_error != null) _error!,
|
||||||
|
];
|
||||||
|
return AnimatedCrossFade(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
sizeCurve: Curves.easeInOutCubic,
|
||||||
|
firstCurve: Curves.easeOutCubic,
|
||||||
|
secondCurve: Curves.easeInCubic,
|
||||||
|
crossFadeState: messages.isEmpty
|
||||||
|
? CrossFadeState.showFirst
|
||||||
|
: CrossFadeState.showSecond,
|
||||||
|
firstChild: const SizedBox(key: ValueKey("no-error"), height: 24),
|
||||||
|
secondChild: Column(
|
||||||
|
key: const ValueKey("with-error"),
|
||||||
|
children: [
|
||||||
|
const Gap(24),
|
||||||
|
for (var i = 0; i < messages.length; i++) ...[
|
||||||
|
Text(
|
||||||
|
messages[i],
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.destructive,
|
||||||
|
),
|
||||||
|
).small.semiBold,
|
||||||
|
if (i < messages.length - 1) const Gap(8),
|
||||||
|
],
|
||||||
|
const Gap(24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPasswordRequirement({required bool met, required String label}) {
|
||||||
|
final color = met
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(context).colorScheme.mutedForeground;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
met ? LucideIcons.circleCheck : LucideIcons.circle,
|
||||||
|
color: color,
|
||||||
|
size: 14,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Text(label, style: TextStyle(color: color)).small,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoginForm(String? initializationError) {
|
||||||
|
return AutofillGroup(
|
||||||
|
child: Form(
|
||||||
|
key: const ValueKey("login-form"),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("Welcome back").x3Large.black,
|
||||||
|
Text("Sign in with your account.").xSmall.muted,
|
||||||
|
const Gap(26),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FormField(
|
||||||
|
key: _loginEmailKey,
|
||||||
|
label: const Text("Email"),
|
||||||
|
validator:
|
||||||
|
const NotEmptyValidator(message: "Email is required") &
|
||||||
|
const EmailValidator(message: "Enter a valid email"),
|
||||||
|
showErrors: const {
|
||||||
|
FormValidationMode.changed,
|
||||||
|
FormValidationMode.submitted,
|
||||||
|
},
|
||||||
|
child: TextField(
|
||||||
|
initialValue: _loginEmailDraft,
|
||||||
|
placeholder: const Text("Enter your email"),
|
||||||
|
focusNode: _loginEmailFocusNode,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
textInputAction: flutter_services.TextInputAction.next,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
features: const [
|
||||||
|
InputFeature.leading(Icon(LucideIcons.mail)),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
_loginEmailDraft = value;
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _loginPasswordFocusNode.requestFocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FormField(
|
||||||
|
key: _loginPasswordKey,
|
||||||
|
label: const Text("Password"),
|
||||||
|
validator:
|
||||||
|
const NotEmptyValidator(message: "Password is required") &
|
||||||
|
const LengthValidator(
|
||||||
|
min: 8,
|
||||||
|
message: "Minimum 8 characters",
|
||||||
|
) &
|
||||||
|
ValidationMode(
|
||||||
|
ValidatorBuilder<String>(_validateLoginSubmission),
|
||||||
|
mode: {FormValidationMode.submitted},
|
||||||
|
),
|
||||||
|
showErrors: const {
|
||||||
|
FormValidationMode.changed,
|
||||||
|
FormValidationMode.submitted,
|
||||||
|
},
|
||||||
|
child: TextField(
|
||||||
|
initialValue: _loginPasswordDraft,
|
||||||
|
placeholder: const Text("Your password"),
|
||||||
|
focusNode: _loginPasswordFocusNode,
|
||||||
|
obscureText: true,
|
||||||
|
textInputAction: flutter_services.TextInputAction.done,
|
||||||
|
features: const [
|
||||||
|
InputFeature.leading(Icon(LucideIcons.lock)),
|
||||||
|
],
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
onChanged: (value) {
|
||||||
|
_loginPasswordDraft = value;
|
||||||
|
},
|
||||||
|
onEditingComplete: () => _submitLoginFromKeyboard(context),
|
||||||
|
onSubmitted: (_) => _submitLoginFromKeyboard(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildErrorBlock(initializationError),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: SubmitButton(
|
||||||
|
loading: const Text("Signing in..."),
|
||||||
|
loadingTrailing: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
onSurface: true,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Text("Sign in"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Button.text(
|
||||||
|
onPressed: _isSubmitting ? null : _toggleMode,
|
||||||
|
child: const Text("Need an account? Create one"),
|
||||||
|
),
|
||||||
|
const Gap(24),
|
||||||
|
material.ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.go("/deprecated");
|
||||||
|
},
|
||||||
|
child: const Text("Use Legacy System"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSignUpForm(String? initializationError) {
|
||||||
|
final isDesktop = MediaQuery.of(context).size.width >= 600;
|
||||||
|
final showPasswordChecklist = isDesktop
|
||||||
|
? _desktopChecklistEntered
|
||||||
|
: _signUpPasswordFocusNode.hasFocus;
|
||||||
|
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
layoutBuilder: (currentChild, previousChildren) {
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
...previousChildren,
|
||||||
|
if (currentChild != null) currentChild,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final slide =
|
||||||
|
Tween<Offset>(
|
||||||
|
begin: const Offset(0.05, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
|
||||||
|
);
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: slide, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _isSignUpPasswordStep
|
||||||
|
? AutofillGroup(
|
||||||
|
child: Form(
|
||||||
|
key: const ValueKey("signup-password-step"),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("Create account").x3Large.black,
|
||||||
|
Text(
|
||||||
|
"Set a password for\n$_signUpEmailDraft.",
|
||||||
|
).xSmall.muted.textCenter,
|
||||||
|
const Gap(26),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FormField(
|
||||||
|
key: _signUpPasswordKey,
|
||||||
|
label: const Text("Password"),
|
||||||
|
validator:
|
||||||
|
const NotEmptyValidator(
|
||||||
|
message: "Password is required",
|
||||||
|
) &
|
||||||
|
const LengthValidator(
|
||||||
|
min: 8,
|
||||||
|
message: "Minimum 8 characters",
|
||||||
|
) &
|
||||||
|
const SafePasswordValidator(
|
||||||
|
requireDigit: true,
|
||||||
|
requireLowercase: true,
|
||||||
|
requireUppercase: true,
|
||||||
|
requireSpecialChar: true,
|
||||||
|
message:
|
||||||
|
"Password must include lowercase, uppercase, number, and symbol.",
|
||||||
|
),
|
||||||
|
showErrors: const {
|
||||||
|
FormValidationMode.changed,
|
||||||
|
FormValidationMode.submitted,
|
||||||
|
},
|
||||||
|
child: TextField(
|
||||||
|
initialValue: _signUpPasswordDraft,
|
||||||
|
placeholder: const Text("Create a password"),
|
||||||
|
obscureText: true,
|
||||||
|
features: const [
|
||||||
|
InputFeature.leading(Icon(LucideIcons.lock)),
|
||||||
|
],
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
focusNode: _signUpPasswordFocusNode,
|
||||||
|
textInputAction:
|
||||||
|
flutter_services.TextInputAction.next,
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
_signUpConfirmPasswordFocusNode.requestFocus(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_signUpPasswordDraft = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
child: showPasswordChecklist
|
||||||
|
? Column(
|
||||||
|
children: [
|
||||||
|
const Gap(12),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
opacity: 1,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildPasswordRequirement(
|
||||||
|
met: RegExp(
|
||||||
|
r"[A-Z]",
|
||||||
|
).hasMatch(_signUpPasswordDraft),
|
||||||
|
label: "Uppercase letter",
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
_buildPasswordRequirement(
|
||||||
|
met: RegExp(
|
||||||
|
r"[a-z]",
|
||||||
|
).hasMatch(_signUpPasswordDraft),
|
||||||
|
label: "Lowercase letter",
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
_buildPasswordRequirement(
|
||||||
|
met: RegExp(
|
||||||
|
r"\d",
|
||||||
|
).hasMatch(_signUpPasswordDraft),
|
||||||
|
label: "Number",
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
_buildPasswordRequirement(
|
||||||
|
met: RegExp(
|
||||||
|
r"[\W_]",
|
||||||
|
).hasMatch(_signUpPasswordDraft),
|
||||||
|
label:
|
||||||
|
"Special character (e.g. !?<>@#\$%)",
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
_buildPasswordRequirement(
|
||||||
|
met: _signUpPasswordDraft.length >= 8,
|
||||||
|
label: "8 characters or more",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FormField(
|
||||||
|
key: _confirmPasswordKey,
|
||||||
|
label: const Text("Confirm Password"),
|
||||||
|
validator:
|
||||||
|
CompareWith.equal(
|
||||||
|
_signUpPasswordKey,
|
||||||
|
message: "Passwords do not match",
|
||||||
|
) &
|
||||||
|
ValidationMode(
|
||||||
|
ValidatorBuilder<String>(
|
||||||
|
_validateSignUpSubmission,
|
||||||
|
),
|
||||||
|
mode: {FormValidationMode.submitted},
|
||||||
|
),
|
||||||
|
showErrors: const {
|
||||||
|
FormValidationMode.changed,
|
||||||
|
FormValidationMode.submitted,
|
||||||
|
},
|
||||||
|
child: TextField(
|
||||||
|
initialValue: _signUpConfirmPasswordDraft,
|
||||||
|
placeholder: const Text("Confirm password"),
|
||||||
|
focusNode: _signUpConfirmPasswordFocusNode,
|
||||||
|
obscureText: true,
|
||||||
|
textInputAction:
|
||||||
|
flutter_services.TextInputAction.done,
|
||||||
|
features: const [
|
||||||
|
InputFeature.leading(Icon(LucideIcons.lockKeyhole)),
|
||||||
|
],
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
onChanged: (value) {
|
||||||
|
_signUpConfirmPasswordDraft = value;
|
||||||
|
},
|
||||||
|
onEditingComplete: () =>
|
||||||
|
_submitSignUpPasswordFromKeyboard(context),
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
_submitSignUpPasswordFromKeyboard(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildErrorBlock(initializationError),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: SubmitButton(
|
||||||
|
loading: const Text("Creating account..."),
|
||||||
|
loadingTrailing: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
onSurface: true,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Text("Create account"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Button.text(
|
||||||
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () => unawaited(_backToEmailStep()),
|
||||||
|
child: const Text("Back"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: AutofillGroup(
|
||||||
|
child: Form(
|
||||||
|
key: const ValueKey("signup-email-step"),
|
||||||
|
onSubmit: (context, values) {
|
||||||
|
final email = (_signUpEmailKey[values] ?? "").trim();
|
||||||
|
_handleSignUpEmailContinue(email);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("Create account").x3Large.black,
|
||||||
|
Text(
|
||||||
|
"Start with your email address",
|
||||||
|
).xSmall.muted.textCenter,
|
||||||
|
const Gap(26),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FormField(
|
||||||
|
key: _signUpEmailKey,
|
||||||
|
label: const Text("Email"),
|
||||||
|
validator:
|
||||||
|
const NotEmptyValidator(
|
||||||
|
message: "Email is required",
|
||||||
|
) &
|
||||||
|
const EmailValidator(
|
||||||
|
message: "Enter a valid email",
|
||||||
|
),
|
||||||
|
showErrors: const {
|
||||||
|
FormValidationMode.changed,
|
||||||
|
FormValidationMode.submitted,
|
||||||
|
},
|
||||||
|
child: TextField(
|
||||||
|
initialValue: _signUpEmailDraft,
|
||||||
|
placeholder: const Text("Enter your email"),
|
||||||
|
focusNode: _signUpEmailFocusNode,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
textInputAction:
|
||||||
|
flutter_services.TextInputAction.done,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
features: const [
|
||||||
|
InputFeature.leading(Icon(LucideIcons.mail)),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
_signUpEmailDraft = value;
|
||||||
|
},
|
||||||
|
onEditingComplete: () =>
|
||||||
|
_submitSignUpEmailFromKeyboard(context),
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
_submitSignUpEmailFromKeyboard(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildErrorBlock(initializationError),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: SubmitButton(
|
||||||
|
loading: const Text("Continuing..."),
|
||||||
|
loadingTrailing: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
onSurface: true,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Text("Continue"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Button.text(
|
||||||
|
onPressed: _isSubmitting ? null : _toggleMode,
|
||||||
|
child: const Text("Already have an account? Sign in"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final supabase = context.watch<SupabaseProvider>();
|
||||||
|
const String? initializationError = null;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
|
bool isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
|
||||||
|
final header = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text("ROADBOUND", style: TextStyle(height: 0.9, fontSize: 24)).black,
|
||||||
|
Text(
|
||||||
|
"by IMBENJI.NET LTD",
|
||||||
|
style: TextStyle(height: 0.9, fontSize: 12),
|
||||||
|
).bold.muted,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final showValidatingState = !_isSignUpMode && supabase.isValidatingSession;
|
||||||
|
final shouldHideForExit = _isExitingScreen && !showValidatingState;
|
||||||
|
|
||||||
|
final content = Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: _isSignUpMode || shouldHideForExit,
|
||||||
|
child: ExcludeFocus(
|
||||||
|
excluding: _isSignUpMode,
|
||||||
|
child: AnimatedSlide(
|
||||||
|
duration: const Duration(milliseconds: 320),
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
offset: _isSignUpMode ? const Offset(-1.0, 0) : Offset.zero,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
opacity: _isSignUpMode ? 0 : 1,
|
||||||
|
child: _signInShakeNonce == 0
|
||||||
|
? _buildLoginForm(initializationError)
|
||||||
|
: TweenAnimationBuilder<double>(
|
||||||
|
key: ValueKey("login-shake-$_signInShakeNonce"),
|
||||||
|
tween: Tween(begin: 0, end: 1),
|
||||||
|
duration: const Duration(milliseconds: 700),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
final amplitude = 12 * (1 - value);
|
||||||
|
final dx =
|
||||||
|
math.sin(value * math.pi * 6) * amplitude;
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(dx, 0),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _buildLoginForm(initializationError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: !_isSignUpMode || shouldHideForExit,
|
||||||
|
child: ExcludeFocus(
|
||||||
|
excluding: !_isSignUpMode,
|
||||||
|
child: AnimatedSlide(
|
||||||
|
duration: const Duration(milliseconds: 320),
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
offset: _isSignUpMode ? Offset.zero : const Offset(1.0, 0),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
opacity: _isSignUpMode ? 1 : 0,
|
||||||
|
child: _buildSignUpForm(initializationError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showValidatingState)
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.background.withValues(alpha: 0.85),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
Gap(12),
|
||||||
|
Text("Validating session..."),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
bool isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Gap(topPadding + 16),
|
||||||
|
|
||||||
|
header,
|
||||||
|
|
||||||
|
if (false) ...[
|
||||||
|
content,
|
||||||
|
const Spacer(),
|
||||||
|
] else ...[
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedSlide(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeInCubic,
|
||||||
|
offset: _isExitingScreen
|
||||||
|
? const Offset(0, -0.08)
|
||||||
|
: Offset.zero,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeInCubic,
|
||||||
|
opacity: shouldHideForExit ? 0 : 1,
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
Visibility(
|
||||||
|
visible: !isKeyboardOpen,
|
||||||
|
maintainState: true,
|
||||||
|
maintainAnimation: true,
|
||||||
|
maintainSize: true,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"IMBENJI.NET LTD is a private limited company registered in England & Wales. Company number: 16955294.",
|
||||||
|
).xSmall.muted.textCenter,
|
||||||
|
|
||||||
|
Gap(bottomPadding + 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
lib/pages/auth/verify_email_page.dart
Normal file
143
lib/pages/auth/verify_email_page.dart
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:bus_running_record/pages/auth/page.dart";
|
||||||
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:provider/provider.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
class VerifyEmailPage extends StatefulWidget {
|
||||||
|
static const String routePath = "/verify-email";
|
||||||
|
static final GoRoute route = GoRoute(
|
||||||
|
path: routePath,
|
||||||
|
builder: (context, state) {
|
||||||
|
final email = state.uri.queryParameters["email"] ?? "";
|
||||||
|
return VerifyEmailPage(email: email);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const VerifyEmailPage({required this.email, super.key});
|
||||||
|
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VerifyEmailPage> createState() => _VerifyEmailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VerifyEmailPageState extends State<VerifyEmailPage> {
|
||||||
|
static const int _tokenLength = 6;
|
||||||
|
String _token = "";
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
Future<void> _verifyToken(String token) async {
|
||||||
|
if (widget.email.isEmpty) {
|
||||||
|
setState(() => _error = "Missing email address for verification.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token.length != _tokenLength) {
|
||||||
|
setState(() => _error = "Enter the $_tokenLength-digit token from your email.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.read<SupabaseProvider>().verifySignUpOtp(
|
||||||
|
email: widget.email,
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
if (mounted) context.go("/");
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
"[VerifyEmailPage] verifyToken failed (${error.runtimeType}): $error",
|
||||||
|
);
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = error.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Gap(topPadding + 16),
|
||||||
|
Text("You're almost there!").x2Large.semiBold,
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
"We sent a $_tokenLength-digit token to your email.\n(${widget.email})",
|
||||||
|
).xSmall.muted.textCenter,
|
||||||
|
const Gap(14),
|
||||||
|
InputOTP(
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_token = value.otpToString();
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (value) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
unawaited(_verifyToken(value.otpToString()));
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
InputOTPChild.character(allowDigit: true),
|
||||||
|
InputOTPChild.character(allowDigit: true),
|
||||||
|
InputOTPChild.character(allowDigit: true),
|
||||||
|
InputOTPChild.character(allowDigit: true),
|
||||||
|
InputOTPChild.character(allowDigit: true),
|
||||||
|
InputOTPChild.character(allowDigit: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(20),
|
||||||
|
Button.primary(
|
||||||
|
enabled: !_isSubmitting && _token.length == _tokenLength,
|
||||||
|
onPressed: () => unawaited(_verifyToken(_token)),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
_isSubmitting ? "Verifying..." : "Verify token",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Button.text(
|
||||||
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () => context.go(LoginPage.routePath),
|
||||||
|
child: const Text("Back to sign in"),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.destructive,
|
||||||
|
),
|
||||||
|
).xSmall.textCenter,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/pages/home/channels/operations_channel_view.dart
Normal file
42
lib/pages/home/channels/operations_channel_view.dart
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
299
lib/pages/home/channels/text_channel_view.dart
Normal file
299
lib/pages/home/channels/text_channel_view.dart
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:bus_running_record/models/channels/text_channel.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
import "package:supabase_flutter/supabase_flutter.dart";
|
||||||
|
|
||||||
|
class TextChannelView extends StatefulWidget {
|
||||||
|
const TextChannelView({required this.channel, super.key});
|
||||||
|
|
||||||
|
final TextChannel channel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TextChannelView> createState() => _TextChannelViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TextChannelViewState extends State<TextChannelView> {
|
||||||
|
RealtimeChannel? _messagesRealtimeChannel;
|
||||||
|
bool _loadingMessages = false;
|
||||||
|
bool _sendingMessage = false;
|
||||||
|
String? _messageError;
|
||||||
|
String _draftMessage = "";
|
||||||
|
int _composerNonce = 0;
|
||||||
|
List<TextChannelMessage> _messages = const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
unawaited(_initializeChannel());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant TextChannelView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.channel.id == widget.channel.id) return;
|
||||||
|
unawaited(_initializeChannel());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeChannel() async {
|
||||||
|
await _unsubscribeFromRealtimeMessages();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messages = const [];
|
||||||
|
_messageError = null;
|
||||||
|
_draftMessage = "";
|
||||||
|
_composerNonce += 1;
|
||||||
|
});
|
||||||
|
await _subscribeToRealtimeMessages();
|
||||||
|
await _loadMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMessages() async {
|
||||||
|
if (_loadingMessages) return;
|
||||||
|
setState(() {
|
||||||
|
_loadingMessages = true;
|
||||||
|
_messageError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final messages = await widget.channel.listMessages();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messages = messages;
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[TextChannelView] loadMessages failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messageError = error.toString();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_loadingMessages = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _subscribeToRealtimeMessages() async {
|
||||||
|
if (_messagesRealtimeChannel != null) return;
|
||||||
|
|
||||||
|
final realtime = widget.channel.subscribeToMessages(
|
||||||
|
onMessageChanged: () {
|
||||||
|
if (!mounted) return;
|
||||||
|
unawaited(_loadMessages());
|
||||||
|
},
|
||||||
|
onStatus: (status, error) {
|
||||||
|
if (status == RealtimeSubscribeStatus.subscribed) return;
|
||||||
|
if (status == RealtimeSubscribeStatus.channelError ||
|
||||||
|
status == RealtimeSubscribeStatus.timedOut) {
|
||||||
|
debugPrint(
|
||||||
|
"[TextChannelView] realtime subscribe issue ($status) for channel ${widget.channel.id}: $error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_messagesRealtimeChannel = realtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unsubscribeFromRealtimeMessages() async {
|
||||||
|
final realtime = _messagesRealtimeChannel;
|
||||||
|
_messagesRealtimeChannel = null;
|
||||||
|
if (realtime == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.channel.unsubscribe(realtime);
|
||||||
|
debugPrint("[TextChannelView] realtime unsubscribed");
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[TextChannelView] realtime unsubscribe failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMessage() async {
|
||||||
|
final content = _draftMessage.trim();
|
||||||
|
if (content.isEmpty || _sendingMessage) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_sendingMessage = true;
|
||||||
|
_messageError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.channel.sendMessage(content);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_draftMessage = "";
|
||||||
|
_composerNonce += 1;
|
||||||
|
});
|
||||||
|
await _loadMessages();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[TextChannelView] sendMessage failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messageError = error.toString();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_sendingMessage = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageList() {
|
||||||
|
if (_loadingMessages) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_messages.isEmpty) {
|
||||||
|
return Center(child: Text("No messages yet. Say hi.").small.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentUserId = widget.channel.client.auth.currentUser?.id ?? "";
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < _messages.length; i++) ...[
|
||||||
|
MessageBubble(message: _messages[i], currentUserId: currentUserId),
|
||||||
|
if (i != _messages.length - 1) const Gap(2),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
unawaited(_unsubscribeFromRealtimeMessages());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
|
||||||
|
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildMessageList()),
|
||||||
|
if (_messageError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12, right: 12),
|
||||||
|
child: Text(
|
||||||
|
_messageError!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.destructive,
|
||||||
|
),
|
||||||
|
).small,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
height: 60,
|
||||||
|
child: TextField(
|
||||||
|
key: ValueKey("composer-$_composerNonce"),
|
||||||
|
initialValue: "",
|
||||||
|
placeholder: Text("Message #${widget.channel.slug}"),
|
||||||
|
enabled: !_sendingMessage,
|
||||||
|
onChanged: (value) {
|
||||||
|
_draftMessage = value;
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => unawaited(_sendMessage()),
|
||||||
|
features: [
|
||||||
|
InputFeature.leading(
|
||||||
|
IconButton.ghost(
|
||||||
|
icon: const Icon(LucideIcons.plus).iconSmall,
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Gap(bottomPadding + 12),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageBubble extends StatelessWidget {
|
||||||
|
const MessageBubble({
|
||||||
|
required this.message,
|
||||||
|
required this.currentUserId,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextChannelMessage message;
|
||||||
|
final String currentUserId;
|
||||||
|
|
||||||
|
String _formatTime() {
|
||||||
|
final createdAt = message.createdAt?.toLocal();
|
||||||
|
if (createdAt == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
final paddedHour = createdAt.hour.toString().padLeft(2, "0");
|
||||||
|
final paddedMinute = createdAt.minute.toString().padLeft(2, "0");
|
||||||
|
return "$paddedHour:$paddedMinute";
|
||||||
|
}
|
||||||
|
|
||||||
|
String _senderLabel() {
|
||||||
|
final authorUserId = message.authorUserId;
|
||||||
|
final isCurrentUser =
|
||||||
|
currentUserId.isNotEmpty && authorUserId == currentUserId;
|
||||||
|
if (isCurrentUser) {
|
||||||
|
return "You";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorUserId.length >= 8) {
|
||||||
|
return "User ${authorUserId.substring(0, 8)}";
|
||||||
|
}
|
||||||
|
return "User $authorUserId";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final timeText = _formatTime();
|
||||||
|
final senderLabel = _senderLabel();
|
||||||
|
final shouldShowTime = timeText.isNotEmpty;
|
||||||
|
final senderInitials = Avatar.getInitials(senderLabel);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Avatar(initials: senderInitials),
|
||||||
|
const Gap(10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(senderLabel).small.semiBold,
|
||||||
|
if (shouldShowTime) ...[
|
||||||
|
const Gap(8),
|
||||||
|
Text(timeText).xSmall.muted,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(2),
|
||||||
|
Text(message.content).small,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
192
lib/pages/home/page.dart
Normal file
192
lib/pages/home/page.dart
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:bus_running_record/models/channels/base_channel.dart";
|
||||||
|
import "package:bus_running_record/models/channels/operations_channel.dart";
|
||||||
|
import "package:bus_running_record/models/channels/text_channel.dart";
|
||||||
|
import "package:bus_running_record/pages/home/channels/operations_channel_view.dart";
|
||||||
|
import "package:bus_running_record/pages/home/channels/text_channel_view.dart";
|
||||||
|
import "package:bus_running_record/pages/home/widgets/swiper.dart";
|
||||||
|
import "package:bus_running_record/pages/home/widgets/channel_header.dart";
|
||||||
|
import "package:bus_running_record/pages/home/widgets/home_left_sidebar.dart";
|
||||||
|
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||||
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:provider/provider.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
class HomePage extends StatefulWidget {
|
||||||
|
static GoRoute rootRoute = GoRoute(
|
||||||
|
path: "/",
|
||||||
|
builder: (context, state) => const HomePage(),
|
||||||
|
);
|
||||||
|
|
||||||
|
static GoRoute channelRoute = GoRoute(
|
||||||
|
path: "/channel/:orgId/:channelId",
|
||||||
|
builder: (context, state) => HomePage(
|
||||||
|
organizationId: state.pathParameters["orgId"],
|
||||||
|
channelId: state.pathParameters["channelId"],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const HomePage({this.organizationId, this.channelId, super.key});
|
||||||
|
|
||||||
|
final String? organizationId;
|
||||||
|
final String? channelId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomePage> createState() => _HomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomePageState extends State<HomePage> {
|
||||||
|
String? _lastSyncedRouteKey;
|
||||||
|
|
||||||
|
Future<void> _syncRouteSelection() async {
|
||||||
|
final orgId = widget.organizationId;
|
||||||
|
final channelId = widget.channelId;
|
||||||
|
if (orgId == null || channelId == null) return;
|
||||||
|
|
||||||
|
final routeKey = "$orgId/$channelId";
|
||||||
|
if (_lastSyncedRouteKey == routeKey) return;
|
||||||
|
_lastSyncedRouteKey = routeKey;
|
||||||
|
|
||||||
|
final collab = context.read<CollaborationProvider>();
|
||||||
|
if (collab.selectedOrganizationId != orgId) {
|
||||||
|
await collab.selectOrganization(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final channels = collab.channelsForOrganization(orgId);
|
||||||
|
if (channels.any((channel) => channel.id == channelId)) {
|
||||||
|
collab.selectChannel(channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.organizationId != null && widget.channelId != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
unawaited(_syncRouteSelection());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_lastSyncedRouteKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return const SidebarSwiper(
|
||||||
|
sidebar: HomeLeftSidebar(),
|
||||||
|
child: Scaffold(
|
||||||
|
child: Row(children: [Expanded(child: _HomeChannelPane())]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Scaffold(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
HomeLeftSidebar(),
|
||||||
|
VerticalDivider(),
|
||||||
|
Expanded(child: _HomeChannelPane()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeChannelPane extends StatelessWidget {
|
||||||
|
const _HomeChannelPane();
|
||||||
|
|
||||||
|
ChannelSummary? _findChannel(
|
||||||
|
CollaborationProvider collab,
|
||||||
|
String? organizationId,
|
||||||
|
String? channelId,
|
||||||
|
) {
|
||||||
|
if (organizationId == null || channelId == null) return null;
|
||||||
|
final channels = collab.channelsForOrganization(organizationId);
|
||||||
|
for (final channel in channels) {
|
||||||
|
if (channel.id == channelId) {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseChannel? _buildChannelModel(
|
||||||
|
BuildContext context,
|
||||||
|
ChannelSummary? selectedChannel,
|
||||||
|
) {
|
||||||
|
if (selectedChannel == null) return null;
|
||||||
|
final client = context.read<SupabaseProvider>().client;
|
||||||
|
|
||||||
|
if (selectedChannel.type == "operations") {
|
||||||
|
return OperationsChannel(
|
||||||
|
client: client,
|
||||||
|
id: selectedChannel.id,
|
||||||
|
organizationId: selectedChannel.organizationId,
|
||||||
|
name: selectedChannel.name,
|
||||||
|
description: selectedChannel.description,
|
||||||
|
slug: selectedChannel.slug,
|
||||||
|
isPrivate: selectedChannel.isPrivate,
|
||||||
|
position: selectedChannel.position,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextChannel(
|
||||||
|
client: client,
|
||||||
|
id: selectedChannel.id,
|
||||||
|
organizationId: selectedChannel.organizationId,
|
||||||
|
name: selectedChannel.name,
|
||||||
|
description: selectedChannel.description,
|
||||||
|
slug: selectedChannel.slug,
|
||||||
|
isPrivate: selectedChannel.isPrivate,
|
||||||
|
position: selectedChannel.position,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelContent({
|
||||||
|
required BaseChannel? channel,
|
||||||
|
}) {
|
||||||
|
if (channel == null) {
|
||||||
|
return Center(
|
||||||
|
child: Text("Pick a channel to start chatting.").small.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel is OperationsChannel) {
|
||||||
|
return OperationsChannelView(channel: channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
final textChannel = channel as TextChannel;
|
||||||
|
return TextChannelView(
|
||||||
|
key: ValueKey("text-channel-${textChannel.id}"),
|
||||||
|
channel: textChannel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final collab = context.watch<CollaborationProvider>();
|
||||||
|
final selectedOrg = collab.selectedOrganizationId;
|
||||||
|
final selectedChannelId = collab.selectedChannelId;
|
||||||
|
final selectedChannel = _findChannel(
|
||||||
|
collab,
|
||||||
|
selectedOrg,
|
||||||
|
selectedChannelId,
|
||||||
|
);
|
||||||
|
final channel = _buildChannelModel(context, selectedChannel);
|
||||||
|
final channelBody = _buildChannelContent(
|
||||||
|
channel: channel,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (channel != null) ...[
|
||||||
|
ChannelHeader(channel: channel),
|
||||||
|
],
|
||||||
|
Expanded(child: channelBody),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
lib/pages/home/widgets/channel_header.dart
Normal file
52
lib/pages/home/widgets/channel_header.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import "package:bus_running_record/models/channels/base_channel.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
class ChannelHeader extends StatelessWidget {
|
||||||
|
const ChannelHeader({required this.channel, super.key});
|
||||||
|
|
||||||
|
final BaseChannel channel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
|
||||||
|
double topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
|
||||||
|
Gap(topPadding),
|
||||||
|
|
||||||
|
SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.hash,
|
||||||
|
).iconSmall,
|
||||||
|
Gap(4),
|
||||||
|
Text(channel.slug).textSmall,
|
||||||
|
Icon(LucideIcons.dot).iconMutedForeground,
|
||||||
|
Text(channel.description),
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
376
lib/pages/home/widgets/home_dialogs.dart
Normal file
376
lib/pages/home/widgets/home_dialogs.dart
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
import "dart:convert";
|
||||||
|
|
||||||
|
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||||
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
|
import "package:provider/provider.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
Future<void> showCreateOrganizationDialog(BuildContext context) async {
|
||||||
|
final result = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
String name = "";
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text("Create Organization"),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text("Name your new organization."),
|
||||||
|
const Gap(12),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
placeholder: const Text("Enter organization name"),
|
||||||
|
onChanged: (value) {
|
||||||
|
name = value;
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
Navigator.of(dialogContext).pop(name);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Button.text(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(name),
|
||||||
|
child: const Text("Create"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final name = (result ?? "").trim();
|
||||||
|
if (name.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await context.read<CollaborationProvider>().createOrganization(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showCreateChannelDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String organizationId,
|
||||||
|
}) async {
|
||||||
|
final result = await showDialog<Map<String, String>>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => const _CreateChannelDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final channelName = (result?["name"] ?? "").trim();
|
||||||
|
final channelDescription = (result?["description"] ?? "").trim();
|
||||||
|
final channelType = (result?["type"] ?? "text").trim();
|
||||||
|
if (channelName.isEmpty) return;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
try {
|
||||||
|
await context.read<CollaborationProvider>().createChannel(
|
||||||
|
organizationId: organizationId,
|
||||||
|
name: channelName,
|
||||||
|
description: channelDescription,
|
||||||
|
type: channelType,
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[HomePage] createChannel dialog failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text("Create Channel Failed"),
|
||||||
|
content: Text(error.toString()),
|
||||||
|
actions: [
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text("Close"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String extractInviteToken(String input) {
|
||||||
|
final trimmed = input.trim();
|
||||||
|
if (trimmed.isEmpty) return "";
|
||||||
|
|
||||||
|
final invitePathMatch = RegExp(r"/invite/([0-9a-fA-F]+)").firstMatch(trimmed);
|
||||||
|
if (invitePathMatch != null) {
|
||||||
|
return (invitePathMatch.group(1) ?? "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
final hashInviteMatch = RegExp(
|
||||||
|
r"#/invite/([0-9a-fA-F]+)",
|
||||||
|
).firstMatch(trimmed);
|
||||||
|
if (hashInviteMatch != null) {
|
||||||
|
return (hashInviteMatch.group(1) ?? "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showJoinOrganizationDialog(BuildContext context) async {
|
||||||
|
final result = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
String inviteInput = "";
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text("Join Organization"),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text("Paste an invite link or invite token."),
|
||||||
|
const Gap(12),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
placeholder: const Text("https://.../#/invite/<token>"),
|
||||||
|
onChanged: (value) {
|
||||||
|
inviteInput = value;
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
Navigator.of(dialogContext).pop(inviteInput);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Button.text(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(inviteInput),
|
||||||
|
child: const Text("Join"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final token = extractInviteToken(result ?? "");
|
||||||
|
if (token.isEmpty) return;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
try {
|
||||||
|
await context.read<CollaborationProvider>().acceptInviteToken(token);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text("Joined"),
|
||||||
|
content: const Text("You have joined the organization."),
|
||||||
|
actions: [
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text("Close"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[HomePage] join organization failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text("Join Failed"),
|
||||||
|
content: Text(error.toString()),
|
||||||
|
actions: [
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text("Close"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> runAuthDebug(BuildContext context) async {
|
||||||
|
final client = context.read<SupabaseProvider>().client;
|
||||||
|
final encoder = const JsonEncoder.withIndent(" ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await client.functions.invoke("auth-debug", body: {});
|
||||||
|
final payload = response.data;
|
||||||
|
final formatted = payload is Map || payload is List
|
||||||
|
? encoder.convert(payload)
|
||||||
|
: payload.toString();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text("Auth Debug Response"),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 600,
|
||||||
|
child: SingleChildScrollView(child: Text(formatted).small),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text("Close"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[HomePage] auth-debug failed (${error.runtimeType}): $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text("Auth Debug Failed"),
|
||||||
|
content: Text(error.toString()),
|
||||||
|
actions: [
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text("Close"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateChannelDialog extends StatelessWidget {
|
||||||
|
const _CreateChannelDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selectedType = ValueNotifier<int>(1);
|
||||||
|
final channelName = ValueNotifier<String>("");
|
||||||
|
final channelDescription = ValueNotifier<String>("");
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text("Create Channel"),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: selectedType,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return RadioGroup<int>(
|
||||||
|
value: value,
|
||||||
|
onChanged: (v) {
|
||||||
|
selectedType.value = v;
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
RadioItem(
|
||||||
|
value: 1,
|
||||||
|
trailing: Basic(
|
||||||
|
title: Text("Text Channel").large,
|
||||||
|
subtitle: Text(
|
||||||
|
"Standard chat channel for communications and discussions.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioItem(
|
||||||
|
value: 2,
|
||||||
|
trailing: Basic(
|
||||||
|
title: Text("Operations Channel").large,
|
||||||
|
subtitle: Text(
|
||||||
|
"Upload a schedule document and interact with it in a way that matters.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
const Text("Channel Name").medium.semiBold,
|
||||||
|
const Gap(8),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
features: [
|
||||||
|
InputFeature.leading(
|
||||||
|
Icon(
|
||||||
|
LucideIcons.hash,
|
||||||
|
color: Theme.of(context).colorScheme.mutedForeground,
|
||||||
|
).iconSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
placeholder: const Text("general"),
|
||||||
|
onChanged: (value) {
|
||||||
|
channelName.value = value;
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
Navigator.of(context).pop({
|
||||||
|
"name": channelName.value,
|
||||||
|
"type": _channelTypeFromValue(selectedType.value),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
const Text("Channel Description").medium.semiBold,
|
||||||
|
const Gap(8),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
|
child: TextField(
|
||||||
|
placeholder: const Text("What this channel is for"),
|
||||||
|
onChanged: (value) {
|
||||||
|
channelDescription.value = value;
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
Navigator.of(context).pop({
|
||||||
|
"name": channelName.value,
|
||||||
|
"description": channelDescription.value,
|
||||||
|
"type": _channelTypeFromValue(selectedType.value),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Button.text(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => Navigator.of(context).pop({
|
||||||
|
"name": channelName.value,
|
||||||
|
"description": channelDescription.value,
|
||||||
|
"type": _channelTypeFromValue(selectedType.value),
|
||||||
|
}),
|
||||||
|
child: const Text("Create"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _channelTypeFromValue(int value) {
|
||||||
|
switch (value) {
|
||||||
|
case 2:
|
||||||
|
return "operations";
|
||||||
|
default:
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
}
|
||||||
613
lib/pages/home/widgets/home_left_sidebar.dart
Normal file
613
lib/pages/home/widgets/home_left_sidebar.dart
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:bus_running_record/pages/home/widgets/home_dialogs.dart";
|
||||||
|
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||||
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:provider/provider.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
class HomeLeftSidebar extends StatelessWidget {
|
||||||
|
const HomeLeftSidebar({super.key});
|
||||||
|
|
||||||
|
String _buildInitials(String displayName) {
|
||||||
|
if (displayName.isEmpty) {
|
||||||
|
return "U";
|
||||||
|
}
|
||||||
|
return displayName.substring(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final collab = context.watch<CollaborationProvider>();
|
||||||
|
final supabase = context.watch<SupabaseProvider>();
|
||||||
|
final organizations = collab.organizations;
|
||||||
|
final user = supabase.session?.user;
|
||||||
|
final displayName = user?.email ?? "Signed in user";
|
||||||
|
final initials = _buildInitials(displayName);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 70,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _ServerRail(
|
||||||
|
organizations: organizations,
|
||||||
|
selectedOrganizationId: collab.selectedOrganizationId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
width: 100,
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: IconButton.outline(
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(showCreateOrganizationDialog(context));
|
||||||
|
},
|
||||||
|
shape: ButtonShape.circle,
|
||||||
|
size: ButtonSize.normal,
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
RotatedBox(
|
||||||
|
quarterTurns: 3,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"ROADBOUND",
|
||||||
|
style: const TextStyle(height: 1),
|
||||||
|
).extraBold.x3Large,
|
||||||
|
Text(
|
||||||
|
"by IMBENJI.NET LTD",
|
||||||
|
style: const TextStyle(height: 1),
|
||||||
|
).small.muted,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const VerticalDivider(),
|
||||||
|
Container(
|
||||||
|
color: Theme.of(context).colorScheme.background,
|
||||||
|
width: 300,
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width - 50,
|
||||||
|
),
|
||||||
|
child: IntrinsicWidth(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Button.ghost(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
leading: const Icon(Icons.add),
|
||||||
|
child: const Text("Create Organization"),
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(showCreateOrganizationDialog(context));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Button.ghost(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
leading: const Icon(LucideIcons.userRoundPlus),
|
||||||
|
child: const Text("Join Organization"),
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(showJoinOrganizationDialog(context));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Button.secondary(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
leading: const Icon(LucideIcons.bug),
|
||||||
|
child: const Text("Auth Debug"),
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(runAuthDebug(context));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
right: 16,
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
child: _OrganizationList(
|
||||||
|
organizations: organizations,
|
||||||
|
isLoading: collab.isLoadingOrganizations,
|
||||||
|
errorMessage: collab.errorMessage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Button.ghost(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Avatar(initials: initials),
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(child: Basic(title: Text(displayName))),
|
||||||
|
const Icon(LucideIcons.logOut).iconSmall,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(context.read<SupabaseProvider>().signOut());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrganizationList extends StatelessWidget {
|
||||||
|
const _OrganizationList({
|
||||||
|
required this.organizations,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<OrganizationSummary> organizations;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isLoading && organizations.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage != null && organizations.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(errorMessage!, textAlign: TextAlign.center).small,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organizations.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text("No organizations yet. Create one to get started.").small,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final collab = context.watch<CollaborationProvider>();
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final org = organizations[index];
|
||||||
|
return _OrganizationGroup(
|
||||||
|
organization: org,
|
||||||
|
channels: collab.channelsForOrganization(org.id),
|
||||||
|
selectedOrganizationId: collab.selectedOrganizationId,
|
||||||
|
selectedChannelId: collab.selectedChannelId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) => const Gap(4),
|
||||||
|
itemCount: organizations.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerRail extends StatelessWidget {
|
||||||
|
const _ServerRail({
|
||||||
|
required this.organizations,
|
||||||
|
required this.selectedOrganizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<OrganizationSummary> organizations;
|
||||||
|
final String? selectedOrganizationId;
|
||||||
|
|
||||||
|
Future<void> _openOrganization(
|
||||||
|
BuildContext context,
|
||||||
|
String organizationId,
|
||||||
|
) async {
|
||||||
|
final collab = context.read<CollaborationProvider>();
|
||||||
|
await collab.selectOrganization(organizationId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final channelId = collab.selectedChannelId;
|
||||||
|
if (channelId == null || channelId.isEmpty) {
|
||||||
|
context.go("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.go("/channel/$organizationId/$channelId");
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openOrganizationSettings(BuildContext context, String organizationId) {
|
||||||
|
context.go("/org/$organizationId/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fallbackInitial(String organizationName) {
|
||||||
|
final trimmedName = organizationName.trim();
|
||||||
|
if (trimmedName.isEmpty) {
|
||||||
|
return "O";
|
||||||
|
}
|
||||||
|
return trimmedName.substring(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOrganizationAvatar({
|
||||||
|
required OrganizationSummary organization,
|
||||||
|
required String fallbackInitial,
|
||||||
|
}) {
|
||||||
|
final iconUrl = organization.iconUrl;
|
||||||
|
if (iconUrl == null || iconUrl.isEmpty) {
|
||||||
|
return Center(child: Text(fallbackInitial).semiBold.small);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: Image.network(
|
||||||
|
iconUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Center(child: Text(fallbackInitial).semiBold.small);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonStyle _organizationButtonStyle(bool isSelected) {
|
||||||
|
if (isSelected) {
|
||||||
|
return ButtonStyle.ghost(density: ButtonDensity.compact);
|
||||||
|
}
|
||||||
|
return ButtonStyle.ghost();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (organizations.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Column(
|
||||||
|
children: organizations.map((organization) {
|
||||||
|
final isSelected = selectedOrganizationId == organization.id;
|
||||||
|
final fallbackInitial = _fallbackInitial(organization.name);
|
||||||
|
final buttonStyle = _organizationButtonStyle(isSelected);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
|
child: HoverCard(
|
||||||
|
hoverBuilder: (context) {
|
||||||
|
return SurfaceCard(
|
||||||
|
child: Basic(title: Text(organization.name).medium),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
anchorAlignment: Alignment.centerLeft,
|
||||||
|
popoverAlignment: Alignment.centerRight,
|
||||||
|
popoverOffset: const Offset(-4, 0),
|
||||||
|
child: ContextMenu(
|
||||||
|
items: [
|
||||||
|
MenuLabel(child: Text(organization.name)),
|
||||||
|
const MenuDivider(),
|
||||||
|
MenuButton(
|
||||||
|
leading: const Icon(LucideIcons.settings2).iconSmall,
|
||||||
|
onPressed: (_) {
|
||||||
|
_openOrganizationSettings(context, organization.id);
|
||||||
|
},
|
||||||
|
child: const Text("Server Settings"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
_buildOrganizationAvatar(
|
||||||
|
organization: organization,
|
||||||
|
fallbackInitial: fallbackInitial,
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 54,
|
||||||
|
height: 54,
|
||||||
|
child: Button(
|
||||||
|
style: buttonStyle,
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(
|
||||||
|
_openOrganization(context, organization.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrganizationGroup extends StatefulWidget {
|
||||||
|
const _OrganizationGroup({
|
||||||
|
required this.organization,
|
||||||
|
required this.channels,
|
||||||
|
required this.selectedOrganizationId,
|
||||||
|
required this.selectedChannelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final OrganizationSummary organization;
|
||||||
|
final List<ChannelSummary> channels;
|
||||||
|
final String? selectedOrganizationId;
|
||||||
|
final String? selectedChannelId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_OrganizationGroup> createState() => _OrganizationGroupState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrganizationGroupState extends State<_OrganizationGroup> {
|
||||||
|
bool _headerHover = false;
|
||||||
|
late bool _isExpanded;
|
||||||
|
|
||||||
|
bool get _isSelected =>
|
||||||
|
widget.selectedOrganizationId == widget.organization.id;
|
||||||
|
|
||||||
|
IconData _expansionIcon(bool expanded) {
|
||||||
|
if (expanded) {
|
||||||
|
return LucideIcons.chevronDown;
|
||||||
|
}
|
||||||
|
return LucideIcons.chevronRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleOrSelectOrganization() {
|
||||||
|
if (_isSelected) {
|
||||||
|
setState(() {
|
||||||
|
_isExpanded = !_isExpanded;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isExpanded = true;
|
||||||
|
});
|
||||||
|
unawaited(
|
||||||
|
context.read<CollaborationProvider>().selectOrganization(
|
||||||
|
widget.organization.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_isExpanded = _isSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _OrganizationGroup oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
final wasSelected =
|
||||||
|
oldWidget.selectedOrganizationId == oldWidget.organization.id;
|
||||||
|
if (!wasSelected && _isSelected) {
|
||||||
|
_isExpanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final expanded = _isExpanded;
|
||||||
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
|
||||||
|
final header = Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onLongPress: isMobile ? () => _showOrganizationMenu(context) : null,
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _headerHover = true),
|
||||||
|
onExit: (_) => setState(() => _headerHover = false),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Button.text(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
marginAlignment: Alignment.centerLeft,
|
||||||
|
style: ButtonStyle.text(density: ButtonDensity.dense),
|
||||||
|
leading: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
child: Icon(_expansionIcon(expanded)).iconSmall,
|
||||||
|
),
|
||||||
|
onPressed: _toggleOrSelectOrganization,
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Text(widget.organization.name).normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Visibility.maintain(
|
||||||
|
visible: _headerHover,
|
||||||
|
child: Builder(
|
||||||
|
builder: (buttonContext) => Button.text(
|
||||||
|
style: ButtonStyle.text(density: ButtonDensity.dense),
|
||||||
|
onPressed: () {
|
||||||
|
_showOrganizationMenu(buttonContext);
|
||||||
|
},
|
||||||
|
child: Icon(LucideIcons.ellipsis).iconSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!expanded) return header;
|
||||||
|
|
||||||
|
final channels = widget.channels;
|
||||||
|
if (channels.isEmpty) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
header,
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(left: 28, top: 4, bottom: 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text("No channels yet").small.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
header,
|
||||||
|
const Gap(2),
|
||||||
|
...channels.map(
|
||||||
|
(channel) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: _ChannelButton(
|
||||||
|
channel: channel,
|
||||||
|
active: widget.selectedChannelId == channel.id,
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(
|
||||||
|
context.read<CollaborationProvider>().selectOrganization(
|
||||||
|
widget.organization.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.read<CollaborationProvider>().selectChannel(channel.id);
|
||||||
|
context.go("/channel/${widget.organization.id}/${channel.id}");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showOrganizationMenu(BuildContext context) {
|
||||||
|
showDropdown<void>(
|
||||||
|
context: context,
|
||||||
|
anchorAlignment: Alignment.bottomRight,
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
builder: (menuContext) {
|
||||||
|
return DropdownMenu(
|
||||||
|
children: [
|
||||||
|
MenuLabel(child: Text(widget.organization.name).small.semiBold),
|
||||||
|
const MenuDivider(),
|
||||||
|
MenuButton(
|
||||||
|
leading: const Icon(LucideIcons.plus).iconSmall,
|
||||||
|
onPressed: (_) {
|
||||||
|
unawaited(
|
||||||
|
showCreateChannelDialog(
|
||||||
|
context,
|
||||||
|
organizationId: widget.organization.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text("Add Channel"),
|
||||||
|
),
|
||||||
|
const MenuDivider(),
|
||||||
|
MenuButton(
|
||||||
|
leading: const Icon(LucideIcons.settings2).iconSmall,
|
||||||
|
onPressed: (_) {
|
||||||
|
context.go("/org/${widget.organization.id}/settings");
|
||||||
|
},
|
||||||
|
child: const Text("Settings"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelButton extends StatelessWidget {
|
||||||
|
const _ChannelButton({
|
||||||
|
required this.channel,
|
||||||
|
required this.active,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ChannelSummary channel;
|
||||||
|
final bool active;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
IconData _iconForType() {
|
||||||
|
switch (channel.type) {
|
||||||
|
case "voice":
|
||||||
|
return LucideIcons.mic;
|
||||||
|
case "operations":
|
||||||
|
return LucideIcons.clipboardList;
|
||||||
|
default:
|
||||||
|
return LucideIcons.notebookPen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _labelColor(BuildContext context) {
|
||||||
|
if (active) {
|
||||||
|
return Theme.of(context).colorScheme.primary;
|
||||||
|
}
|
||||||
|
return Theme.of(context).colorScheme.mutedForeground;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = _labelColor(context);
|
||||||
|
return Button.ghost(
|
||||||
|
marginAlignment: Alignment.centerLeft,
|
||||||
|
style: ButtonStyle.ghost(density: ButtonDensity.dense),
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||||
|
width: double.infinity,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(_iconForType(), color: color).iconSmall,
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(channel.name, style: TextStyle(color: color)).normal,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lib/pages/home/widgets/swiper.dart
Normal file
126
lib/pages/home/widgets/swiper.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import "dart:math" as math;
|
||||||
|
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
class SidebarSwiper extends StatefulWidget {
|
||||||
|
const SidebarSwiper({
|
||||||
|
required this.sidebar,
|
||||||
|
required this.child,
|
||||||
|
this.maxSidebarWidth = 360,
|
||||||
|
this.sidebarWidthFactor = 0.92,
|
||||||
|
this.edgeDragWidth = 44,
|
||||||
|
this.animationDuration = const Duration(milliseconds: 220),
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget sidebar;
|
||||||
|
final Widget child;
|
||||||
|
final double maxSidebarWidth;
|
||||||
|
final double sidebarWidthFactor;
|
||||||
|
final double edgeDragWidth;
|
||||||
|
final Duration animationDuration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SidebarSwiper> createState() => _SidebarSwiperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SidebarSwiperState extends State<SidebarSwiper> {
|
||||||
|
static const double _closedExtraOffset = 12;
|
||||||
|
|
||||||
|
double _progress = 0; // 0 = closed, 1 = fully open
|
||||||
|
bool _isDragging = false;
|
||||||
|
bool _canDrag = false;
|
||||||
|
double _dragStartGlobalX = 0;
|
||||||
|
double _dragStartProgress = 0;
|
||||||
|
|
||||||
|
void _setOpen(bool value) {
|
||||||
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
_progress = value ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragStart(DragStartDetails details, double sidebarWidth) {
|
||||||
|
final isOpen = _progress > 0.001;
|
||||||
|
final fromEdge = details.globalPosition.dx <= widget.edgeDragWidth;
|
||||||
|
final fromSidebarZone = details.globalPosition.dx <= sidebarWidth;
|
||||||
|
_canDrag = fromEdge || (isOpen && fromSidebarZone);
|
||||||
|
if (!_canDrag) return;
|
||||||
|
setState(() {
|
||||||
|
_isDragging = true;
|
||||||
|
_dragStartGlobalX = details.globalPosition.dx;
|
||||||
|
_dragStartProgress = _progress;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragUpdate(DragUpdateDetails details, double sidebarWidth) {
|
||||||
|
if (!_canDrag || !_isDragging) return;
|
||||||
|
if (sidebarWidth <= 0) return;
|
||||||
|
final movedX = details.globalPosition.dx - _dragStartGlobalX;
|
||||||
|
setState(() {
|
||||||
|
_progress = (_dragStartProgress + (movedX / sidebarWidth)).clamp(0, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragEnd(DragEndDetails details) {
|
||||||
|
if (!_canDrag) return;
|
||||||
|
_canDrag = false;
|
||||||
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
|
final shouldOpen = velocity > 250
|
||||||
|
? true
|
||||||
|
: (velocity < -250 ? false : _progress >= 0.35);
|
||||||
|
_setOpen(shouldOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final sidebarWidth = math.min(
|
||||||
|
widget.maxSidebarWidth,
|
||||||
|
screenWidth * widget.sidebarWidthFactor,
|
||||||
|
);
|
||||||
|
final leftOffset =
|
||||||
|
-(sidebarWidth + _closedExtraOffset) +
|
||||||
|
((sidebarWidth + _closedExtraOffset) * _progress);
|
||||||
|
final showScrim = _progress > 0;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
widget.child,
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onHorizontalDragStart: (details) =>
|
||||||
|
_handleDragStart(details, sidebarWidth),
|
||||||
|
onHorizontalDragUpdate: (details) =>
|
||||||
|
_handleDragUpdate(details, sidebarWidth),
|
||||||
|
onHorizontalDragEnd: _handleDragEnd,
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showScrim)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => _setOpen(false),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.45 * _progress),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: _isDragging ? Duration.zero : widget.animationDuration,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
left: leftOffset,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: sidebarWidth,
|
||||||
|
child: Material(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: widget.sidebar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:file_picker/file_picker.dart";
|
import "package:file_picker/file_picker.dart";
|
||||||
import "package:go_router/go_router.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/arriva_schedule_parser.dart";
|
||||||
import "../parsers/stagecoach_schedule_parser.dart";
|
import "../parsers/stagecoach_schedule_parser.dart";
|
||||||
import "../services/brr_export_service.dart";
|
import "../services/brr_export_service.dart";
|
||||||
|
|
@ -9,9 +9,10 @@ import "../services/storage_service.dart";
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
|
static const routePath = "/deprecated";
|
||||||
|
|
||||||
static GoRoute route = GoRoute(
|
static GoRoute route = GoRoute(
|
||||||
path: "/",
|
path: routePath,
|
||||||
builder: (context, state) => const HomePage(),
|
builder: (context, state) => const HomePage(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -102,8 +103,9 @@ class _HomePageState extends State<HomePage> {
|
||||||
if (routeName != null) "routeName": routeName,
|
if (routeName != null) "routeName": routeName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
print("Error: $e");
|
print("Error: $e");
|
||||||
|
print(stackTrace);
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
132
lib/pages/invite/page.dart
Normal file
132
lib/pages/invite/page.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||||
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:provider/provider.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
class InvitePage extends StatefulWidget {
|
||||||
|
const InvitePage({required this.token, super.key});
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
static final GoRoute route = GoRoute(
|
||||||
|
path: "/invite/:token",
|
||||||
|
builder: (context, state) {
|
||||||
|
final token = state.pathParameters["token"] ?? "";
|
||||||
|
return InvitePage(token: token);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InvitePage> createState() => _InvitePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvitePageState extends State<InvitePage> {
|
||||||
|
bool _accepting = false;
|
||||||
|
bool _accepted = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final isLoggedIn = context.read<SupabaseProvider>().isAuthenticated;
|
||||||
|
if (isLoggedIn) {
|
||||||
|
unawaited(_acceptInvite());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final supabase = context.watch<SupabaseProvider>();
|
||||||
|
final isLoggedIn = supabase.isAuthenticated;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text("Organization Invite").x2Large.semiBold,
|
||||||
|
const Gap(8),
|
||||||
|
Text("Token: ${widget.token}").xSmall.muted,
|
||||||
|
const Gap(16),
|
||||||
|
if (!isLoggedIn) ...[
|
||||||
|
Text("Sign in to accept this invite.").small,
|
||||||
|
const Gap(12),
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () {
|
||||||
|
final next = Uri.encodeComponent("/invite/${widget.token}");
|
||||||
|
context.go("/login?next=$next");
|
||||||
|
},
|
||||||
|
child: const Text("Sign in"),
|
||||||
|
),
|
||||||
|
] else if (_accepting) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const Gap(10),
|
||||||
|
Text("Accepting invite...").small,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] else if (_accepted) ...[
|
||||||
|
Text("Invite accepted.").small,
|
||||||
|
const Gap(12),
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => context.go("/"),
|
||||||
|
child: const Text("Open workspace"),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
if (_error != null) Text(_error!).small,
|
||||||
|
const Gap(12),
|
||||||
|
Button.primary(
|
||||||
|
onPressed: () => unawaited(_acceptInvite()),
|
||||||
|
child: const Text("Accept invite"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _acceptInvite() async {
|
||||||
|
if (_accepting) return;
|
||||||
|
setState(() {
|
||||||
|
_accepting = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.read<CollaborationProvider>().acceptInviteToken(widget.token);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_accepted = true;
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[InvitePage] acceptInvite failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error.toString();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_accepting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
761
lib/pages/operations_upload/page.dart
Normal file
761
lib/pages/operations_upload/page.dart
Normal file
|
|
@ -0,0 +1,761 @@
|
||||||
|
import "dart:async";
|
||||||
|
import "dart:math";
|
||||||
|
|
||||||
|
import "package:bus_running_record/models/operations/scheduled_stop.dart";
|
||||||
|
import "package:bus_running_record/models/operations/trip.dart";
|
||||||
|
import "package:bus_running_record/parsers/arriva_schedule_parser.dart";
|
||||||
|
import "package:bus_running_record/parsers/stagecoach_schedule_parser.dart";
|
||||||
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
|
import "package:bus_running_record/widgets/trip_diagram.dart";
|
||||||
|
import "package:file_picker/file_picker.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:provider/provider.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
class OperationsUploadPage extends StatefulWidget {
|
||||||
|
const OperationsUploadPage({
|
||||||
|
required this.organizationId,
|
||||||
|
required this.channelId,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String organizationId;
|
||||||
|
final String channelId;
|
||||||
|
|
||||||
|
static final GoRoute route = GoRoute(
|
||||||
|
path: "/channel/:orgId/:channelId/upload",
|
||||||
|
builder: (context, state) => OperationsUploadPage(
|
||||||
|
organizationId: state.pathParameters["orgId"] ?? "",
|
||||||
|
channelId: state.pathParameters["channelId"] ?? "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OperationsUploadPage> createState() => _OperationsUploadPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||||
|
bool _parsing = false;
|
||||||
|
bool _saving = false;
|
||||||
|
bool _enhancing = false;
|
||||||
|
bool _enhanced = false;
|
||||||
|
String? _error;
|
||||||
|
List<Trip> _parsedTrips = const [];
|
||||||
|
String? _fileName;
|
||||||
|
String? _parserType;
|
||||||
|
String? _sourceMime;
|
||||||
|
|
||||||
|
Future<void> _pickAndParse() async {
|
||||||
|
if (_parsing) return;
|
||||||
|
setState(() {
|
||||||
|
_parsing = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
allowMultiple: false,
|
||||||
|
withData: true,
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: const ["docx", "pdf"],
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.bytes == null) {
|
||||||
|
throw StateError("Could not read schedule file bytes.");
|
||||||
|
}
|
||||||
|
final ext = (file.extension ?? "").toLowerCase();
|
||||||
|
final parserType = ext == "pdf" ? "stagecoach" : "arriva";
|
||||||
|
final sourceMime = ext == "pdf"
|
||||||
|
? "application/pdf"
|
||||||
|
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
final trips = await _parseTrips(file.bytes!, ext);
|
||||||
|
if (trips.isEmpty) {
|
||||||
|
throw StateError("No trips parsed from schedule.");
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_parsedTrips = trips;
|
||||||
|
_fileName = file.name;
|
||||||
|
_parserType = parserType;
|
||||||
|
_sourceMime = sourceMime;
|
||||||
|
_enhanced = false;
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[OperationsUploadPage] parse failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error.toString();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_parsing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Trip>> _parseTrips(Uint8List bytes, String extension) async {
|
||||||
|
if (extension == "pdf") return StagecoachScheduleParser().parseBytes(bytes);
|
||||||
|
if (extension == "docx") return ArrivaScheduleParser().parseBytes(bytes);
|
||||||
|
throw UnsupportedError("Unsupported schedule extension: $extension");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Trip> _sortedTrips() {
|
||||||
|
final sorted = List<Trip>.from(_parsedTrips);
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
final aNum = int.tryParse(a.tripNumber);
|
||||||
|
final bNum = int.tryParse(b.tripNumber);
|
||||||
|
if (aNum != null && bNum != null) {
|
||||||
|
final byNumber = aNum.compareTo(bNum);
|
||||||
|
if (byNumber != 0) return byNumber;
|
||||||
|
} else {
|
||||||
|
final byText = a.tripNumber.compareTo(b.tripNumber);
|
||||||
|
if (byText != 0) return byText;
|
||||||
|
}
|
||||||
|
return a.scheduledTime.compareTo(b.scheduledTime);
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveToChannel() async {
|
||||||
|
if (_saving || _parsedTrips.isEmpty) return;
|
||||||
|
final parserType = _parserType;
|
||||||
|
final fileName = _fileName;
|
||||||
|
final sourceMime = _sourceMime;
|
||||||
|
if (parserType == null || fileName == null || sourceMime == null) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_saving = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final client = context.read<SupabaseProvider>().client;
|
||||||
|
final userId = client.auth.currentUser?.id;
|
||||||
|
if (userId == null || userId.isEmpty) {
|
||||||
|
throw StateError("No authenticated user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final current = await client
|
||||||
|
.from("operations_schedules")
|
||||||
|
.select("version")
|
||||||
|
.eq("channel_id", widget.channelId)
|
||||||
|
.order("version", ascending: false)
|
||||||
|
.limit(1);
|
||||||
|
final latestVersion = (current as List).isEmpty
|
||||||
|
? 0
|
||||||
|
: (((current.first as Map)["version"] as num?)?.toInt() ?? 0);
|
||||||
|
final nextVersion = latestVersion + 1;
|
||||||
|
|
||||||
|
await client
|
||||||
|
.from("operations_schedules")
|
||||||
|
.update({"is_active": false})
|
||||||
|
.eq("channel_id", widget.channelId)
|
||||||
|
.eq("is_active", true);
|
||||||
|
|
||||||
|
final scheduleRow = await client
|
||||||
|
.from("operations_schedules")
|
||||||
|
.insert({
|
||||||
|
"channel_id": widget.channelId,
|
||||||
|
"version": nextVersion,
|
||||||
|
"source_file_name": fileName,
|
||||||
|
"source_mime": sourceMime,
|
||||||
|
"parser": parserType,
|
||||||
|
"parse_status": "parsed",
|
||||||
|
"uploaded_by": userId,
|
||||||
|
"is_active": true,
|
||||||
|
"parsed_at": DateTime.now().toUtc().toIso8601String(),
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
final scheduleId = (scheduleRow["id"] ?? "").toString();
|
||||||
|
if (scheduleId.isEmpty) throw StateError("Created schedule missing id.");
|
||||||
|
|
||||||
|
final sortedTrips = _sortedTrips();
|
||||||
|
for (var i = 0; i < sortedTrips.length; i++) {
|
||||||
|
final trip = sortedTrips[i];
|
||||||
|
final tripRow = await client
|
||||||
|
.from("operations_trips")
|
||||||
|
.insert({
|
||||||
|
"schedule_id": scheduleId,
|
||||||
|
"trip_number": trip.tripNumber,
|
||||||
|
"duty_number": trip.dutyNumber,
|
||||||
|
"bus_work_number": trip.busWorkNumber,
|
||||||
|
"direction": trip.direction,
|
||||||
|
"service_code": trip.tripType,
|
||||||
|
"sort_order": i,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
final tripId = (tripRow["id"] ?? "").toString();
|
||||||
|
if (tripId.isEmpty) continue;
|
||||||
|
|
||||||
|
final stopRows = <Map<String, dynamic>>[];
|
||||||
|
final stationOrder = trip.stationOrder.isEmpty
|
||||||
|
? trip.stationTimes.keys.toList()
|
||||||
|
: trip.stationOrder;
|
||||||
|
for (var s = 0; s < stationOrder.length; s++) {
|
||||||
|
final stopName = stationOrder[s].trim();
|
||||||
|
if (stopName.isEmpty) continue;
|
||||||
|
final scheduled = (trip.stationTimes[stopName] ?? "").trim();
|
||||||
|
stopRows.add({
|
||||||
|
"trip_id": tripId,
|
||||||
|
"stop_sequence": s + 1,
|
||||||
|
"stop_name": stopName,
|
||||||
|
"scheduled_time": scheduled.isEmpty ? null : scheduled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (stopRows.isNotEmpty) {
|
||||||
|
await client.from("operations_trip_stops").insert(stopRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go("/channel/${widget.organizationId}/${widget.channelId}");
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[OperationsUploadPage] save failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error.toString();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enhanceStops() async {
|
||||||
|
if (_enhancing || _parsedTrips.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_enhancing = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final stopNames = _parsedTrips
|
||||||
|
.expand((trip) => trip.scheduledStops.map((stop) => stop.name.trim()))
|
||||||
|
.where((name) => name.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
if (stopNames.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_enhancing = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _invokeAuthedFunction(
|
||||||
|
"operations-stop-alias-enhance",
|
||||||
|
body: {
|
||||||
|
"channel_id": widget.channelId,
|
||||||
|
"stop_names": stopNames,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = response.data;
|
||||||
|
if (data is! Map) {
|
||||||
|
throw StateError("Enhance function returned unexpected response.");
|
||||||
|
}
|
||||||
|
final aliasesRaw = data["aliases"];
|
||||||
|
if (aliasesRaw is! List) {
|
||||||
|
throw StateError("Enhance function returned invalid aliases.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final aliasesByRawStopName = <String, String>{};
|
||||||
|
for (final row in aliasesRaw) {
|
||||||
|
if (row is! Map) continue;
|
||||||
|
final rawStopName = (row["raw_stop_name"] ?? "").toString().trim();
|
||||||
|
final aliasStopName = (row["alias_stop_name"] ?? "").toString().trim();
|
||||||
|
if (rawStopName.isEmpty || aliasStopName.isEmpty) continue;
|
||||||
|
aliasesByRawStopName[rawStopName] = aliasStopName;
|
||||||
|
}
|
||||||
|
|
||||||
|
final enhancedTrips = _parsedTrips
|
||||||
|
.map((trip) => trip.withStopAliases(aliasesByRawStopName))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_parsedTrips = enhancedTrips;
|
||||||
|
_enhanced = true;
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[OperationsUploadPage] enhance failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error.toString();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_enhancing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _invokeAuthedFunction(
|
||||||
|
String functionName, {
|
||||||
|
Object? body,
|
||||||
|
}) async {
|
||||||
|
final client = context.read<SupabaseProvider>().client;
|
||||||
|
var token = await _getFreshAccessToken();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw StateError("No valid access token available for edge function call.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> invokeOnce(String accessToken) {
|
||||||
|
client.functions.setAuth(accessToken);
|
||||||
|
return client.functions.invoke(
|
||||||
|
functionName,
|
||||||
|
body: body,
|
||||||
|
headers: <String, String>{"Authorization": "Bearer $accessToken"},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await invokeOnce(token);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
"[OperationsUploadPage] invokeAuthedFunction/$functionName initial attempt failed: $error",
|
||||||
|
);
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!_isUnauthorizedFunctionError(error)) rethrow;
|
||||||
|
|
||||||
|
final refreshed = await client.auth.refreshSession();
|
||||||
|
token =
|
||||||
|
refreshed.session?.accessToken ??
|
||||||
|
client.auth.currentSession?.accessToken;
|
||||||
|
if (token == null || token.isEmpty) rethrow;
|
||||||
|
return invokeOnce(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getFreshAccessToken() async {
|
||||||
|
final client = context.read<SupabaseProvider>().client;
|
||||||
|
var session = client.auth.currentSession;
|
||||||
|
final nowUnix = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final expiresAt = session?.expiresAt;
|
||||||
|
|
||||||
|
final shouldRefresh =
|
||||||
|
session != null && expiresAt != null && expiresAt <= nowUnix + 30;
|
||||||
|
|
||||||
|
if (shouldRefresh) {
|
||||||
|
try {
|
||||||
|
final refreshed = await client.auth.refreshSession();
|
||||||
|
session = refreshed.session ?? client.auth.currentSession;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
"[OperationsUploadPage] getFreshAccessToken/refreshSession failed: $error",
|
||||||
|
);
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
session = client.auth.currentSession;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return session?.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isUnauthorizedFunctionError(Object error) {
|
||||||
|
final text = error.toString();
|
||||||
|
return text.contains("status: 401") || text.contains("code: 401");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
|
||||||
|
double topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
|
||||||
|
Gap(topPadding),
|
||||||
|
|
||||||
|
SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton.ghost(
|
||||||
|
density: ButtonDensity.iconDense,
|
||||||
|
icon: const Icon(LucideIcons.arrowLeft),
|
||||||
|
onPressed: () => context.go(
|
||||||
|
"/channel/${widget.organizationId}/${widget.channelId}",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Gap(8),
|
||||||
|
Text(
|
||||||
|
"Operations Schedule Upload",
|
||||||
|
).textSmall,
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: _parsedTrips.isEmpty
|
||||||
|
? _buildBeforeUpload(context)
|
||||||
|
: _buildParsedPreview(context),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBeforeUpload(BuildContext context){
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("Upload Operations Schedule").h4,
|
||||||
|
const Gap(10),
|
||||||
|
Button.primary(
|
||||||
|
onPressed: _parsing ? null : () => unawaited(_pickAndParse()),
|
||||||
|
child: _parsing
|
||||||
|
? const Text("Parsing...")
|
||||||
|
: const Text("Choose File"),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.destructive,
|
||||||
|
),
|
||||||
|
).small,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? filterBy;
|
||||||
|
Map<String, Object?> filterValue = {};
|
||||||
|
|
||||||
|
Trip? _selectedTripForFilter() {
|
||||||
|
if (filterBy != "By Trip") return null;
|
||||||
|
final selectedTripNumber = (filterValue["By Trip"] ?? "").toString();
|
||||||
|
if (selectedTripNumber.isEmpty) return null;
|
||||||
|
for (final trip in _parsedTrips) {
|
||||||
|
if (trip.tripNumber == selectedTripNumber) {
|
||||||
|
return trip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TripDiagramEntry> _tripDiagramEntries(Trip trip) {
|
||||||
|
final orderedStops = List<ScheduledStop>.from(trip.scheduledStops)
|
||||||
|
..sort((a, b) => a.sequence.compareTo(b.sequence));
|
||||||
|
return orderedStops
|
||||||
|
.map((stop) => TripDiagramEntry(
|
||||||
|
label: stop.displayName,
|
||||||
|
labelIcon: stop.aliasSource == "ai" ? LucideIcons.sparkles : null,
|
||||||
|
subtitle: stop.displayName == stop.name ? null : stop.name,
|
||||||
|
time: stop.scheduledTime
|
||||||
|
))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildParsedPreview(BuildContext context) {
|
||||||
|
|
||||||
|
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
bool isMobile = defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final selectedTrip = _selectedTripForFilter();
|
||||||
|
if (selectedTrip == null) {
|
||||||
|
return Text(
|
||||||
|
"Select 'By Trip' and choose a trip to preview it.",
|
||||||
|
).small.muted;
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
|
||||||
|
Gap(8),
|
||||||
|
|
||||||
|
Divider(),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
LucideIcons.bus,
|
||||||
|
),
|
||||||
|
|
||||||
|
Gap(8),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Trip ${selectedTrip.tripNumber} • Duty ${selectedTrip.dutyNumber}",
|
||||||
|
).semiBold.textSmall,
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Divider(),
|
||||||
|
|
||||||
|
TripDiagram(
|
||||||
|
lineColor: Colors.red,
|
||||||
|
entries: _tripDiagramEntries(selectedTrip),
|
||||||
|
leftOffset: 12,
|
||||||
|
rowHeight: 48,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Divider(),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Select<String>(
|
||||||
|
itemBuilder: (context, item) {
|
||||||
|
return Text("Filter by: $item");
|
||||||
|
},
|
||||||
|
popupConstraints: BoxConstraints(
|
||||||
|
maxHeight: 300,
|
||||||
|
maxWidth: !isMobile ? 200 : double.infinity,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
filterBy = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
value: filterBy,
|
||||||
|
popup: SelectPopup(
|
||||||
|
items: SelectItemList(
|
||||||
|
children: [
|
||||||
|
SelectItemButton(
|
||||||
|
value: "By Duty",
|
||||||
|
child: Text(
|
||||||
|
"By Duty"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
SelectItemButton(
|
||||||
|
value: "By Trip",
|
||||||
|
child: Text(
|
||||||
|
"By Trip"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
SelectItemButton(
|
||||||
|
value: "By Stop",
|
||||||
|
child: Text(
|
||||||
|
"By Stop"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Gap(16),
|
||||||
|
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Select<Object>(
|
||||||
|
itemBuilder: (context, item) {
|
||||||
|
|
||||||
|
if (filterBy == "By Duty") {
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
"Duty $item"
|
||||||
|
);
|
||||||
|
|
||||||
|
} else if (filterBy == "By Trip") {
|
||||||
|
final tripNumber = item.toString();
|
||||||
|
final matchingTrip = _parsedTrips.where(
|
||||||
|
(trip) => trip.tripNumber == tripNumber,
|
||||||
|
);
|
||||||
|
if (matchingTrip.isEmpty) return Text("Trip $tripNumber");
|
||||||
|
final trip = matchingTrip.first;
|
||||||
|
return Text("Trip ${trip.tripNumber} • Duty ${trip.dutyNumber}");
|
||||||
|
|
||||||
|
} else if (filterBy == "By Stop") {
|
||||||
|
|
||||||
|
ScheduledStop stop = item as ScheduledStop;
|
||||||
|
|
||||||
|
return Text(stop.name);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text("Undefined");
|
||||||
|
},
|
||||||
|
popupConstraints: BoxConstraints(
|
||||||
|
maxHeight: 300,
|
||||||
|
maxWidth: !isMobile ? 200 : double.infinity,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
|
||||||
|
if (filterBy == null) return;
|
||||||
|
|
||||||
|
filterValue[filterBy!] = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
value: filterValue[filterBy],
|
||||||
|
popup: SelectPopup.builder(
|
||||||
|
searchPlaceholder: Text(
|
||||||
|
"Search ${filterBy?.toLowerCase()}"
|
||||||
|
),
|
||||||
|
builder: (context, searchQuery) {
|
||||||
|
|
||||||
|
List<SelectItemButton> items = [];
|
||||||
|
|
||||||
|
if (filterBy == "By Duty") {
|
||||||
|
final duties = _parsedTrips
|
||||||
|
.map((t) => t.dutyNumber)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
items = duties.map((d) => SelectItemButton(
|
||||||
|
value: d,
|
||||||
|
child: Text(d)
|
||||||
|
)).toList();
|
||||||
|
} else if (filterBy == "By Trip") {
|
||||||
|
|
||||||
|
final trips = _parsedTrips
|
||||||
|
.map((t) => t.tripNumber)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
|
||||||
|
// Sort trips by number if possible, otherwise by text
|
||||||
|
trips.sort((a, b) {
|
||||||
|
final aNum = int.tryParse(a);
|
||||||
|
final bNum = int.tryParse(b);
|
||||||
|
if (aNum != null && bNum != null) {
|
||||||
|
return aNum.compareTo(bNum);
|
||||||
|
}
|
||||||
|
return a.compareTo(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
items = trips.map((t) => SelectItemButton(
|
||||||
|
value: t,
|
||||||
|
child: Text("Trip $t")
|
||||||
|
)).toList();
|
||||||
|
} else if (filterBy == "By Stop") {
|
||||||
|
final stops = _parsedTrips
|
||||||
|
.expand((t) => t.stationTimes.keys)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
items = stops.map((s) => SelectItemButton(
|
||||||
|
value: s,
|
||||||
|
child: Text(s)
|
||||||
|
)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return SelectItemList(
|
||||||
|
children: items,
|
||||||
|
);
|
||||||
|
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
Divider(),
|
||||||
|
|
||||||
|
Gap(8),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
|
||||||
|
Button.secondary(
|
||||||
|
trailing: Icon(
|
||||||
|
LucideIcons.sparkle
|
||||||
|
).iconSmall,
|
||||||
|
child: Text(
|
||||||
|
_enhanced
|
||||||
|
? "Enhanced"
|
||||||
|
: _enhancing
|
||||||
|
? "Enhancing..."
|
||||||
|
: "Enhance"
|
||||||
|
),
|
||||||
|
onPressed: _enhancing || _enhanced
|
||||||
|
? null
|
||||||
|
: () => unawaited(_enhanceStops()),
|
||||||
|
),
|
||||||
|
|
||||||
|
Spacer(),
|
||||||
|
|
||||||
|
Button.secondary(
|
||||||
|
trailing: Icon(
|
||||||
|
LucideIcons.upload
|
||||||
|
).iconSmall,
|
||||||
|
child: Text(
|
||||||
|
"Upload"
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Gap(max(bottomPadding, 16)),
|
||||||
|
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
566
lib/pages/org_settings/page.dart
Normal file
566
lib/pages/org_settings/page.dart
Normal file
|
|
@ -0,0 +1,566 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||||
|
import "package:file_picker/file_picker.dart";
|
||||||
|
import "package:flutter/services.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:provider/provider.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
|
||||||
|
class OrganizationSettingsPage extends StatefulWidget {
|
||||||
|
const OrganizationSettingsPage({required this.organizationId, super.key});
|
||||||
|
|
||||||
|
final String organizationId;
|
||||||
|
|
||||||
|
static final GoRoute route = GoRoute(
|
||||||
|
path: "/org/:orgId/settings",
|
||||||
|
builder: (context, state) {
|
||||||
|
final organizationId = state.pathParameters["orgId"] ?? "";
|
||||||
|
return OrganizationSettingsPage(organizationId: organizationId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OrganizationSettingsPage> createState() =>
|
||||||
|
_OrganizationSettingsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrganizationSettingsPageState extends State<OrganizationSettingsPage> {
|
||||||
|
String _organizationName = "";
|
||||||
|
String _newChannelName = "";
|
||||||
|
String _newChannelDescription = "";
|
||||||
|
bool _savingOrganization = false;
|
||||||
|
bool _uploadingIcon = false;
|
||||||
|
bool _creatingChannel = false;
|
||||||
|
String? _deletingChannelId;
|
||||||
|
bool _creatingInvite = false;
|
||||||
|
String? _message;
|
||||||
|
String? _inviteLink;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
unawaited(
|
||||||
|
context.read<CollaborationProvider>().selectOrganization(
|
||||||
|
widget.organizationId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final collab = context.watch<CollaborationProvider>();
|
||||||
|
OrganizationSummary? organization;
|
||||||
|
for (final org in collab.organizations) {
|
||||||
|
if (org.id == widget.organizationId) {
|
||||||
|
organization = org;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final channels = collab.channelsForOrganization(widget.organizationId);
|
||||||
|
|
||||||
|
if (_organizationName.isEmpty && organization != null) {
|
||||||
|
_organizationName = organization.name;
|
||||||
|
}
|
||||||
|
final organizationId = organization?.id;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 820),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Button.text(
|
||||||
|
leading: const Icon(LucideIcons.arrowLeft),
|
||||||
|
onPressed: () => context.go("/"),
|
||||||
|
child: const Text("Back to workspace"),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Text("Organization Settings").x2Large.semiBold,
|
||||||
|
Text(organization?.name ?? "Loading organization...").muted,
|
||||||
|
const Gap(20),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.border,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text("Organization name").semiBold,
|
||||||
|
const Gap(10),
|
||||||
|
TextField(
|
||||||
|
initialValue: _organizationName,
|
||||||
|
placeholder: const Text("Enter organization name"),
|
||||||
|
enabled: !_savingOrganization,
|
||||||
|
onChanged: (value) {
|
||||||
|
_organizationName = value;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
Button.primary(
|
||||||
|
onPressed:
|
||||||
|
(_savingOrganization || organizationId == null)
|
||||||
|
? null
|
||||||
|
: () => unawaited(
|
||||||
|
_saveOrganizationName(context, organizationId),
|
||||||
|
),
|
||||||
|
child: _savingOrganization
|
||||||
|
? const Text("Saving...")
|
||||||
|
: const Text("Save name"),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Text("Organization icon").semiBold,
|
||||||
|
const Gap(10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.muted,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child:
|
||||||
|
(organization?.iconUrl != null &&
|
||||||
|
organization!.iconUrl!.isNotEmpty)
|
||||||
|
? Image.network(
|
||||||
|
organization.iconUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder:
|
||||||
|
(context, error, stackTrace) => Icon(
|
||||||
|
LucideIcons.imageOff,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.mutedForeground,
|
||||||
|
).iconSmall,
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
LucideIcons.image,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.mutedForeground,
|
||||||
|
).iconSmall,
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
Button.secondary(
|
||||||
|
onPressed:
|
||||||
|
(_uploadingIcon || organizationId == null)
|
||||||
|
? null
|
||||||
|
: () => unawaited(
|
||||||
|
_uploadOrganizationIcon(organizationId),
|
||||||
|
),
|
||||||
|
child: _uploadingIcon
|
||||||
|
? const Text("Uploading...")
|
||||||
|
: const Text("Upload icon"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.border,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text("Create channel").semiBold,
|
||||||
|
const Gap(10),
|
||||||
|
TextField(
|
||||||
|
initialValue: _newChannelName,
|
||||||
|
placeholder: const Text("Channel name"),
|
||||||
|
enabled: !_creatingChannel,
|
||||||
|
onChanged: (value) {
|
||||||
|
_newChannelName = value;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
TextField(
|
||||||
|
initialValue: _newChannelDescription,
|
||||||
|
placeholder: const Text("Channel description"),
|
||||||
|
enabled: !_creatingChannel,
|
||||||
|
onChanged: (value) {
|
||||||
|
_newChannelDescription = value;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
Button.secondary(
|
||||||
|
onPressed: (_creatingChannel || organizationId == null)
|
||||||
|
? null
|
||||||
|
: () => unawaited(
|
||||||
|
_createChannel(context, organizationId),
|
||||||
|
),
|
||||||
|
child: _creatingChannel
|
||||||
|
? const Text("Creating...")
|
||||||
|
: const Text("Add channel"),
|
||||||
|
),
|
||||||
|
if (channels.isNotEmpty) ...[
|
||||||
|
const Gap(14),
|
||||||
|
Text("Existing channels").small.muted,
|
||||||
|
const Gap(8),
|
||||||
|
...channels.map(
|
||||||
|
(channel) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.hash,
|
||||||
|
size: 14,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.mutedForeground,
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
Expanded(child: Text(channel.name).small),
|
||||||
|
Button.text(
|
||||||
|
style: ButtonStyle.text(
|
||||||
|
density: ButtonDensity.dense,
|
||||||
|
),
|
||||||
|
onPressed:
|
||||||
|
(_deletingChannelId != null ||
|
||||||
|
organizationId == null)
|
||||||
|
? null
|
||||||
|
: () => unawaited(
|
||||||
|
_confirmDeleteChannel(
|
||||||
|
organizationId,
|
||||||
|
channel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _deletingChannelId == channel.id
|
||||||
|
? const Text("Deleting...")
|
||||||
|
: Text(
|
||||||
|
"Delete",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.destructive,
|
||||||
|
),
|
||||||
|
).small,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.border,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text("Invite links").semiBold,
|
||||||
|
const Gap(10),
|
||||||
|
Text(
|
||||||
|
"Generate a one-time invite link to join this organization.",
|
||||||
|
).small.muted,
|
||||||
|
const Gap(10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Button.secondary(
|
||||||
|
onPressed:
|
||||||
|
(_creatingInvite || organizationId == null)
|
||||||
|
? null
|
||||||
|
: () => unawaited(
|
||||||
|
_createInviteLink(context, organizationId),
|
||||||
|
),
|
||||||
|
child: _creatingInvite
|
||||||
|
? const Text("Generating...")
|
||||||
|
: const Text("Generate invite link"),
|
||||||
|
),
|
||||||
|
if (_inviteLink != null) ...[
|
||||||
|
const Gap(8),
|
||||||
|
Button.outline(
|
||||||
|
onPressed: () => unawaited(_copyInviteLink()),
|
||||||
|
child: const Text("Copy"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_inviteLink != null) ...[
|
||||||
|
const Gap(12),
|
||||||
|
SelectableText(_inviteLink!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_message != null) ...[const Gap(12), Text(_message!).small],
|
||||||
|
if (collab.errorMessage != null) ...[
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
collab.errorMessage!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.destructive,
|
||||||
|
),
|
||||||
|
).small,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveOrganizationName(
|
||||||
|
BuildContext context,
|
||||||
|
String organizationId,
|
||||||
|
) async {
|
||||||
|
final name = _organizationName.trim();
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_savingOrganization = true;
|
||||||
|
_message = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await context.read<CollaborationProvider>().updateOrganization(
|
||||||
|
organizationId: organizationId,
|
||||||
|
name: name,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Organization name updated.";
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
"[OrganizationSettingsPage] saveOrganizationName failed: $error",
|
||||||
|
);
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Could not update organization name.";
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _savingOrganization = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadOrganizationIcon(String organizationId) async {
|
||||||
|
setState(() {
|
||||||
|
_uploadingIcon = true;
|
||||||
|
_message = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.image,
|
||||||
|
allowMultiple: false,
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final file = result.files.first;
|
||||||
|
final bytes = file.bytes;
|
||||||
|
if (bytes == null) {
|
||||||
|
throw StateError("Could not read image bytes.");
|
||||||
|
}
|
||||||
|
final extension = (file.extension ?? "png").toLowerCase();
|
||||||
|
final contentType = _mimeTypeForExtension(extension);
|
||||||
|
if (!mounted) return;
|
||||||
|
await context.read<CollaborationProvider>().uploadOrganizationIcon(
|
||||||
|
organizationId: organizationId,
|
||||||
|
bytes: bytes,
|
||||||
|
contentType: contentType,
|
||||||
|
extension: extension,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Organization icon updated.";
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
"[OrganizationSettingsPage] uploadOrganizationIcon failed: $error",
|
||||||
|
);
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Could not upload organization icon.";
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _uploadingIcon = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mimeTypeForExtension(String extension) {
|
||||||
|
switch (extension.toLowerCase()) {
|
||||||
|
case "jpg":
|
||||||
|
case "jpeg":
|
||||||
|
return "image/jpeg";
|
||||||
|
case "webp":
|
||||||
|
return "image/webp";
|
||||||
|
case "gif":
|
||||||
|
return "image/gif";
|
||||||
|
case "png":
|
||||||
|
default:
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createChannel(
|
||||||
|
BuildContext context,
|
||||||
|
String organizationId,
|
||||||
|
) async {
|
||||||
|
final name = _newChannelName.trim();
|
||||||
|
final description = _newChannelDescription.trim();
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_creatingChannel = true;
|
||||||
|
_message = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await context.read<CollaborationProvider>().createChannel(
|
||||||
|
organizationId: organizationId,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_newChannelName = "";
|
||||||
|
_newChannelDescription = "";
|
||||||
|
_message = "Channel created.";
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[OrganizationSettingsPage] createChannel failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Could not create channel.";
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _creatingChannel = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteChannel(
|
||||||
|
String organizationId,
|
||||||
|
ChannelSummary channel,
|
||||||
|
) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text("Delete Channel"),
|
||||||
|
content: Text(
|
||||||
|
"Delete #${channel.name}? This removes messages and operations data for this channel.",
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Button.text(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
Button.destructive(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text("Delete"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_deletingChannelId = channel.id;
|
||||||
|
_message = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await context.read<CollaborationProvider>().deleteChannel(
|
||||||
|
organizationId: organizationId,
|
||||||
|
channelId: channel.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Channel deleted.";
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[OrganizationSettingsPage] deleteChannel failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Could not delete channel.";
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _deletingChannelId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createInviteLink(
|
||||||
|
BuildContext context,
|
||||||
|
String organizationId,
|
||||||
|
) async {
|
||||||
|
setState(() {
|
||||||
|
_creatingInvite = true;
|
||||||
|
_message = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final token = await context.read<CollaborationProvider>().createInvite(
|
||||||
|
organizationId: organizationId,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final base = Uri.base;
|
||||||
|
final origin = base.hasAuthority
|
||||||
|
? "${base.scheme}://${base.authority}"
|
||||||
|
: "";
|
||||||
|
final inviteLink = "$origin/#/invite/$token";
|
||||||
|
setState(() {
|
||||||
|
_inviteLink = inviteLink;
|
||||||
|
_message = "Invite link generated.";
|
||||||
|
});
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[OrganizationSettingsPage] createInviteLink failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Could not generate invite link.";
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _creatingInvite = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _copyInviteLink() async {
|
||||||
|
final link = _inviteLink;
|
||||||
|
if (link == null || link.isEmpty) return;
|
||||||
|
await Clipboard.setData(ClipboardData(text: link));
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_message = "Invite link copied.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:go_router/go_router.dart";
|
import "package:go_router/go_router.dart";
|
||||||
import "../models/trip.dart";
|
import "../models/operations/trip.dart";
|
||||||
import "../services/brr_export_service.dart";
|
import "../services/brr_export_service.dart";
|
||||||
|
|
||||||
class StationSelectionPage extends StatefulWidget {
|
class StationSelectionPage extends StatefulWidget {
|
||||||
|
static const routePath = "/station-selection";
|
||||||
|
static const legacyHomePath = "/deprecated";
|
||||||
final List<Trip> trips;
|
final List<Trip> trips;
|
||||||
final String fileName;
|
final String fileName;
|
||||||
final BRROperator operator;
|
final BRROperator operator;
|
||||||
|
|
@ -18,12 +20,12 @@ class StationSelectionPage extends StatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
static GoRoute route = GoRoute(
|
static GoRoute route = GoRoute(
|
||||||
path: "/station-selection",
|
path: routePath,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final extra = state.extra as Map<String, dynamic>?;
|
final extra = state.extra as Map<String, dynamic>?;
|
||||||
if (extra == null) {
|
if (extra == null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.go("/");
|
context.go(legacyHomePath);
|
||||||
});
|
});
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +168,7 @@ class _StationSelectionPageState extends State<StationSelectionPage> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, size: 20),
|
icon: const Icon(Icons.arrow_back, size: 20),
|
||||||
onPressed: () => context.go("/"),
|
onPressed: () => context.go(StationSelectionPage.legacyHomePath),
|
||||||
),
|
),
|
||||||
title: const Text("SELECT STATION"),
|
title: const Text("SELECT STATION"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@ import "package:file_saver/file_saver.dart";
|
||||||
import "package:go_router/go_router.dart";
|
import "package:go_router/go_router.dart";
|
||||||
import "package:path_provider/path_provider.dart";
|
import "package:path_provider/path_provider.dart";
|
||||||
import "package:share_plus/share_plus.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_metadata.dart";
|
||||||
import "../models/brr_state.dart";
|
import "../models/brr_state.dart";
|
||||||
import "../services/brr_export_service.dart";
|
import "../services/brr_export_service.dart";
|
||||||
import "../services/storage_service.dart";
|
import "../services/storage_service.dart";
|
||||||
|
|
||||||
class TripListPage extends StatefulWidget {
|
class TripListPage extends StatefulWidget {
|
||||||
|
static const routePath = "/trips";
|
||||||
final List<Trip> trips;
|
final List<Trip> trips;
|
||||||
final String fileName;
|
final String fileName;
|
||||||
final bool fromStationSelection;
|
final bool fromStationSelection;
|
||||||
|
|
@ -29,7 +30,7 @@ class TripListPage extends StatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
static GoRoute route = GoRoute(
|
static GoRoute route = GoRoute(
|
||||||
path: "/trips",
|
path: routePath,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final extra = state.extra as Map<String, dynamic>?;
|
final extra = state.extra as Map<String, dynamic>?;
|
||||||
if (extra == null) {
|
if (extra == null) {
|
||||||
|
|
@ -478,7 +479,7 @@ class _TripCardState extends State<TripCard> {
|
||||||
children: [
|
children: [
|
||||||
_InfoCell("DUTY", widget.trip.dutyNumber),
|
_InfoCell("DUTY", widget.trip.dutyNumber),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_InfoCell("RUNNING", widget.trip.runningNumber),
|
_InfoCell("RUNNING", widget.trip.busWorkNumber),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
import "package:docx_to_text/docx_to_text.dart";
|
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 "../exceptions/schedule_parse_exception.dart";
|
||||||
import "schedule_parser.dart";
|
import "schedule_parser.dart";
|
||||||
|
|
||||||
|
|
@ -50,7 +50,9 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
|
|
||||||
print("=== FOUND ${documentSections.length} SECTIONS ===");
|
print("=== FOUND ${documentSections.length} SECTIONS ===");
|
||||||
for (var section in documentSections) {
|
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) {
|
if (documentSections.isEmpty) {
|
||||||
|
|
@ -62,7 +64,9 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
for (var section in documentSections) {
|
for (var section in documentSections) {
|
||||||
final sectionTrips = _parseSectionTrips(section);
|
final sectionTrips = _parseSectionTrips(section);
|
||||||
trips.addAll(sectionTrips);
|
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
|
// Step 4: Sort by scheduled time
|
||||||
|
|
@ -74,12 +78,13 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
String _extractTextFromDocx(Uint8List bytes) {
|
String _extractTextFromDocx(Uint8List bytes) {
|
||||||
try {
|
try {
|
||||||
return docxToText(bytes);
|
return docxToText(bytes);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
|
print("Arriva parser document read failed: $e");
|
||||||
|
print(stackTrace);
|
||||||
throw ScheduleParseException("Failed to read document: $e");
|
throw ScheduleParseException("Failed to read document: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
String _formatTime(String rawTime) {
|
String _formatTime(String rawTime) {
|
||||||
if (rawTime.length != 4) {
|
if (rawTime.length != 4) {
|
||||||
throw FormatException("Invalid time format: $rawTime");
|
throw FormatException("Invalid time format: $rawTime");
|
||||||
|
|
@ -109,14 +114,17 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
tripLines: [],
|
tripLines: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
print("Found station header at line $i with ${stations.length} stations");
|
print(
|
||||||
print(" Stations: ${stations.take(3).join(", ")} ... ${stations.skip(stations.length - 2).join(", ")}");
|
"Found station header at line $i with ${stations.length} stations",
|
||||||
|
);
|
||||||
|
print(
|
||||||
|
" Stations: ${stations.take(3).join(", ")} ... ${stations.skip(stations.length - 2).join(", ")}",
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a trip line
|
// Check if this is a trip line
|
||||||
if (currentSection != null && _isTripLine(line)) {
|
if (currentSection != null && _isTripLine(line)) {
|
||||||
|
|
||||||
// Infer direction from first trip line if not yet set
|
// Infer direction from first trip line if not yet set
|
||||||
if (currentSection.direction == "unknown") {
|
if (currentSection.direction == "unknown") {
|
||||||
currentSection.direction = _inferDirectionFromTripLine(line);
|
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)
|
// Split by whitespace and filter for potential station codes (3-8 uppercase letters)
|
||||||
final parts = line.split(RegExp(r"\s+"));
|
final parts = line.split(RegExp(r"\s+"));
|
||||||
final potentialStations = parts
|
final potentialStations = parts
|
||||||
.where((part) => part.length >= 3 &&
|
.where(
|
||||||
|
(part) =>
|
||||||
|
part.length >= 3 &&
|
||||||
part.length <= 8 &&
|
part.length <= 8 &&
|
||||||
RegExp(r"^[A-Z]+$").hasMatch(part))
|
RegExp(r"^[A-Z]+$").hasMatch(part),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (potentialStations.length < 8) return null;
|
if (potentialStations.length < 8) return null;
|
||||||
|
|
||||||
// Filter out common metadata words that appear in headers
|
// Filter out common metadata words that appear in headers
|
||||||
const nonStationWords = {
|
const nonStationWords = {
|
||||||
"TRP", "DUTY", "BUS", "START", "END", "GAR", "DEP", "ARR",
|
"TRP",
|
||||||
"DENOTES", "FINISHES", "RELIEF", "TRIP", "NEXT", "NO", "AT",
|
"DUTY",
|
||||||
"SPELL", "HOURS", "TOTAL", "LAYOVER", "MILES", "LIVE", "DEAD",
|
"BUS",
|
||||||
"MILEAGE", "TIME", "SIGN", "FORM", "NXT", "THIS", "HAS", "OR",
|
"START",
|
||||||
"FOR", "CHANGE", "SERVICE", "POINT", "LSN", "MAN", "RUI", "SN",
|
"END",
|
||||||
"ROUTE", "RUNNING", "PREV", "FIN", "ENTOD", "SOALL", "USHRS",
|
"GAR",
|
||||||
"ADDTL", "CASH", "TODAYS", "REL", "IEF",
|
"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
|
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"
|
// Note: running number might be "N503 EC" (with spaces) or "N 503 EC" or just "503 EC"
|
||||||
// Outbound: starts with HHMM_HHMM
|
// Outbound: starts with HHMM_HHMM
|
||||||
final isOutboundFormat = RegExp(r"^\d{4}_\d{4}").hasMatch(line);
|
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) {
|
if (isOutboundFormat) {
|
||||||
trip = _parseOutboundTrip(line, section.stations);
|
trip = _parseOutboundTrip(line, section.stations);
|
||||||
|
|
@ -210,25 +266,28 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
if (trip != null) {
|
if (trip != null) {
|
||||||
trips.add(trip);
|
trips.add(trip);
|
||||||
} else {
|
} else {
|
||||||
final format = isOutboundFormat ? "outbound" : isInboundFormat ? "inbound" : "unknown";
|
final format = isOutboundFormat
|
||||||
print("Failed to parse $format line: ${line.substring(0, line.length > 80 ? 80 : line.length)}...");
|
? "outbound"
|
||||||
|
: isInboundFormat
|
||||||
|
? "inbound"
|
||||||
|
: "unknown";
|
||||||
|
print(
|
||||||
|
"Failed to parse $format line: ${line.substring(0, line.length > 80 ? 80 : line.length)}...",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return trips;
|
return trips;
|
||||||
}
|
}
|
||||||
|
|
||||||
Trip? _parseInboundTrip(
|
Trip? _parseInboundTrip(String line, List<String> stations) {
|
||||||
String line,
|
|
||||||
List<String> stations,
|
|
||||||
) {
|
|
||||||
// INBOUND: trip duty running EC### ... HHMM_HHMM times...
|
// INBOUND: trip duty running EC### ... HHMM_HHMM times...
|
||||||
var match = _inboundPattern.firstMatch(line);
|
var match = _inboundPattern.firstMatch(line);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
final tripNumber = match.group(1)!;
|
final tripNumber = match.group(1)!;
|
||||||
final dutyNumber = match.group(2)!;
|
final dutyNumber = match.group(2)!;
|
||||||
final tripType = match.group(3) ?? "";
|
final tripType = match.group(3) ?? "";
|
||||||
final runningNumber = match.group(4)!;
|
final busWorkNumber = match.group(4)!;
|
||||||
final firstTime = match.group(6)!;
|
final firstTime = match.group(6)!;
|
||||||
final secondTime = match.group(7)!;
|
final secondTime = match.group(7)!;
|
||||||
final timesString = match.group(8) ?? "";
|
final timesString = match.group(8) ?? "";
|
||||||
|
|
@ -244,13 +303,15 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
return Trip(
|
return Trip(
|
||||||
tripNumber: tripNumber,
|
tripNumber: tripNumber,
|
||||||
dutyNumber: dutyNumber,
|
dutyNumber: dutyNumber,
|
||||||
runningNumber: runningNumber,
|
busWorkNumber: busWorkNumber,
|
||||||
scheduledTime: scheduledTime,
|
scheduledTime: scheduledTime,
|
||||||
tripType: tripType,
|
tripType: tripType,
|
||||||
isFinishing: false,
|
isFinishing: false,
|
||||||
stationTimes: stationTimes,
|
stationTimes: stationTimes,
|
||||||
stationOrder: stations,
|
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(
|
return Trip(
|
||||||
tripNumber: tripNumber,
|
tripNumber: tripNumber,
|
||||||
dutyNumber: dutyNumber,
|
dutyNumber: dutyNumber,
|
||||||
runningNumber: dutyNumber,
|
busWorkNumber: dutyNumber,
|
||||||
scheduledTime: scheduledTime,
|
scheduledTime: scheduledTime,
|
||||||
tripType: "F",
|
tripType: "F",
|
||||||
isFinishing: true,
|
isFinishing: true,
|
||||||
stationTimes: stationTimes,
|
stationTimes: stationTimes,
|
||||||
stationOrder: stations,
|
stationOrder: stations,
|
||||||
direction: (int.tryParse(tripNumber) ?? 0).isOdd ? "inbound" : "outbound",
|
direction: (int.tryParse(tripNumber) ?? 0).isOdd
|
||||||
|
? "inbound"
|
||||||
|
: "outbound",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Trip? _parseOutboundTrip(
|
Trip? _parseOutboundTrip(String line, List<String> stations) {
|
||||||
String line,
|
|
||||||
List<String> stations,
|
|
||||||
) {
|
|
||||||
// OUTBOUND: HHMM_HHMM times... EC### duty running trip
|
// OUTBOUND: HHMM_HHMM times... EC### duty running trip
|
||||||
var match = _outboundPattern.firstMatch(line);
|
var match = _outboundPattern.firstMatch(line);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
|
|
@ -298,7 +358,7 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
final timesString = match.group(3) ?? "";
|
final timesString = match.group(3) ?? "";
|
||||||
final dutyNumber = match.group(5)!;
|
final dutyNumber = match.group(5)!;
|
||||||
final tripType = match.group(6) ?? "";
|
final tripType = match.group(6) ?? "";
|
||||||
final runningNumber = match.group(7)!;
|
final busWorkNumber = match.group(7)!;
|
||||||
final tripNumber = match.group(8)!;
|
final tripNumber = match.group(8)!;
|
||||||
|
|
||||||
// Build complete time array: first_time, second_time, then remaining times
|
// Build complete time array: first_time, second_time, then remaining times
|
||||||
|
|
@ -311,13 +371,15 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
return Trip(
|
return Trip(
|
||||||
tripNumber: tripNumber,
|
tripNumber: tripNumber,
|
||||||
dutyNumber: dutyNumber,
|
dutyNumber: dutyNumber,
|
||||||
runningNumber: runningNumber,
|
busWorkNumber: busWorkNumber,
|
||||||
scheduledTime: scheduledTime,
|
scheduledTime: scheduledTime,
|
||||||
tripType: tripType,
|
tripType: tripType,
|
||||||
isFinishing: false,
|
isFinishing: false,
|
||||||
stationTimes: stationTimes,
|
stationTimes: stationTimes,
|
||||||
stationOrder: stations,
|
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);
|
final scheduledTime = _formatTime(firstTime);
|
||||||
|
|
||||||
return Trip(
|
return Trip(
|
||||||
tripNumber: dutyNumber, // Finishing trips may not have separate trip number
|
tripNumber:
|
||||||
|
dutyNumber, // Finishing trips may not have separate trip number
|
||||||
dutyNumber: dutyNumber,
|
dutyNumber: dutyNumber,
|
||||||
runningNumber: dutyNumber,
|
busWorkNumber: dutyNumber,
|
||||||
scheduledTime: scheduledTime,
|
scheduledTime: scheduledTime,
|
||||||
tripType: "F",
|
tripType: "F",
|
||||||
isFinishing: true,
|
isFinishing: true,
|
||||||
stationTimes: stationTimes,
|
stationTimes: stationTimes,
|
||||||
stationOrder: stations,
|
stationOrder: stations,
|
||||||
direction: (int.tryParse(dutyNumber) ?? 0).isOdd ? "inbound" : "outbound",
|
direction: (int.tryParse(dutyNumber) ?? 0).isOdd
|
||||||
|
? "inbound"
|
||||||
|
: "outbound",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,10 +419,7 @@ class ArrivaScheduleParser implements ScheduleParser {
|
||||||
List<String> _extractTimesFromString(String timesString) {
|
List<String> _extractTimesFromString(String timesString) {
|
||||||
// Extract all 4-digit times from the string
|
// Extract all 4-digit times from the string
|
||||||
final pattern = RegExp(r"\b(\d{4})\b");
|
final pattern = RegExp(r"\b(\d{4})\b");
|
||||||
return pattern
|
return pattern.allMatches(timesString).map((m) => m.group(1)!).toList();
|
||||||
.allMatches(timesString)
|
|
||||||
.map((m) => m.group(1)!)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _extractAllTimes(String line) {
|
List<String> _extractAllTimes(String line) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
import "../models/trip.dart";
|
import "../models/operations/trip.dart";
|
||||||
|
|
||||||
abstract class ScheduleParser {
|
abstract class ScheduleParser {
|
||||||
Future<List<Trip>> parseBytes(Uint8List bytes);
|
Future<List<Trip>> parseBytes(Uint8List bytes);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
import "package:syncfusion_flutter_pdf/pdf.dart";
|
import "package:syncfusion_flutter_pdf/pdf.dart";
|
||||||
import "../models/trip.dart";
|
import "../models/operations/trip.dart";
|
||||||
import "../exceptions/schedule_parse_exception.dart";
|
import "../exceptions/schedule_parse_exception.dart";
|
||||||
import "schedule_parser.dart";
|
import "schedule_parser.dart";
|
||||||
|
|
||||||
|
|
@ -20,14 +20,24 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
|
|
||||||
// Syncfusion splits text with newlines everywhere.
|
// Syncfusion splits text with newlines everywhere.
|
||||||
// Collapse each page into single line, normalize all whitespace runs to single space
|
// Collapse each page into single line, normalize all whitespace runs to single space
|
||||||
final pages = rawPages.map((p) =>
|
final pages = rawPages
|
||||||
p.replaceAll("\n", " ").replaceAll(RegExp(r"\s+"), " ").trim()
|
.map(
|
||||||
).toList();
|
(p) => p.replaceAll("\n", " ").replaceAll(RegExp(r"\s+"), " ").trim(),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
print("=== STAGECOACH: ${pages.length} pages ===");
|
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..."
|
// 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) {
|
if (routeMatch != null) {
|
||||||
parsedRouteName = routeMatch.group(1);
|
parsedRouteName = routeMatch.group(1);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -57,7 +67,9 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
if (seenKeys.contains(key)) continue;
|
if (seenKeys.contains(key)) continue;
|
||||||
seenKeys.add(key);
|
seenKeys.add(key);
|
||||||
allTrips.add(t);
|
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 doc = PdfDocument(inputBytes: bytes);
|
||||||
final pages = <String>[];
|
final pages = <String>[];
|
||||||
for (int i = 0; i < doc.pages.count; i++) {
|
for (int i = 0; i < doc.pages.count; i++) {
|
||||||
pages.add(PdfTextExtractor(doc).extractText(
|
pages.add(
|
||||||
startPageIndex: i, endPageIndex: i,
|
PdfTextExtractor(doc).extractText(startPageIndex: i, endPageIndex: i),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
doc.dispose();
|
doc.dispose();
|
||||||
return pages;
|
return pages;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
|
print("Stagecoach parser PDF read failed: $e");
|
||||||
|
print(stackTrace);
|
||||||
throw ScheduleParseException("Failed to read PDF: $e");
|
throw ScheduleParseException("Failed to read PDF: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +113,9 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
|
|
||||||
if (isOutbound) {
|
if (isOutbound) {
|
||||||
// Between "DEPT GAR" and "RLF DEPT TIME" (or just before the first duty ID)
|
// 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);
|
if (match != null) stationBlock = match.group(1);
|
||||||
} else {
|
} else {
|
||||||
// Between "RLF STS:" and "ARR GAR"
|
// Between "RLF STS:" and "ARR GAR"
|
||||||
|
|
@ -122,8 +138,16 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
|
|
||||||
var block = match.group(1)!;
|
var block = match.group(1)!;
|
||||||
// Remove known non-station words
|
// Remove known non-station words
|
||||||
for (final word in ["DEPT GAR", "ARR GAR", "RLF DEPT TIME", "DUTY END",
|
for (final word in [
|
||||||
"NEXT BUS", "NEXT TRIP", "DRV SPELL", "RLF"]) {
|
"DEPT GAR",
|
||||||
|
"ARR GAR",
|
||||||
|
"RLF DEPT TIME",
|
||||||
|
"DUTY END",
|
||||||
|
"NEXT BUS",
|
||||||
|
"NEXT TRIP",
|
||||||
|
"DRV SPELL",
|
||||||
|
"RLF",
|
||||||
|
]) {
|
||||||
block = block.replaceAll(word, " ");
|
block = block.replaceAll(word, " ");
|
||||||
}
|
}
|
||||||
return _pairStationTokens(block.trim());
|
return _pairStationTokens(block.trim());
|
||||||
|
|
@ -163,13 +187,18 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
return stations;
|
return stations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Trip> _parseTripsFromPage(
|
||||||
List<Trip> _parseTripsFromPage(String pageText, String direction, List<String> stations) {
|
String pageText,
|
||||||
|
String direction,
|
||||||
|
List<String> stations,
|
||||||
|
) {
|
||||||
final trips = <Trip>[];
|
final trips = <Trip>[];
|
||||||
|
|
||||||
// Find all trip-start positions: DUTY_ID followed by RUN(digits) and BUS(3 digits)
|
// 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
|
// 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();
|
final starts = tripStartPattern.allMatches(pageText).toList();
|
||||||
|
|
||||||
print(" Trip-start matches: ${starts.length}");
|
print(" Trip-start matches: ${starts.length}");
|
||||||
|
|
@ -180,20 +209,31 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
for (var i = 0; i < starts.length; i++) {
|
for (var i = 0; i < starts.length; i++) {
|
||||||
final segStart = starts[i].start;
|
final segStart = starts[i].start;
|
||||||
// segment ends where the next trip starts, or at end of text
|
// 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 segment = pageText.substring(segStart, segEnd).trim();
|
||||||
|
|
||||||
final dutyId = starts[i].group(1)!;
|
final dutyId = starts[i].group(1)!;
|
||||||
final runNumber = starts[i].group(3)!;
|
final runNumber = starts[i].group(3)!;
|
||||||
final busWorkingNo = starts[i].group(4)!;
|
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) {
|
if (trip != null) {
|
||||||
trips.add(trip);
|
trips.add(trip);
|
||||||
parsed++;
|
parsed++;
|
||||||
} else {
|
} else {
|
||||||
failed++;
|
failed++;
|
||||||
final preview = segment.length > 120 ? segment.substring(0, 120) : segment;
|
final preview = segment.length > 120
|
||||||
|
? segment.substring(0, 120)
|
||||||
|
: segment;
|
||||||
print(" Skip: $preview");
|
print(" Skip: $preview");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,20 +244,32 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
Trip? _parseTripFromSegment(
|
Trip? _parseTripFromSegment(
|
||||||
String segment, String dutyId, String runNumber, String busWorkingNo,
|
String segment,
|
||||||
String direction, List<String> stations,
|
String dutyId,
|
||||||
|
String runNumber,
|
||||||
|
String busWorkingNo,
|
||||||
|
String direction,
|
||||||
|
List<String> stations,
|
||||||
) {
|
) {
|
||||||
// Skip header junk
|
// Skip header junk
|
||||||
if (segment.contains("TRIP NO") ||
|
if (segment.contains("TRIP NO") ||
|
||||||
segment.contains("DUTY START") || segment.contains("SCHEDULE DATE")) {
|
segment.contains("DUTY START") ||
|
||||||
|
segment.contains("SCHEDULE DATE")) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip deadhead/light runs — but only if LIGHT appears early in the segment
|
// 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
|
// (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) {
|
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 LIGHT is within 40 chars of the header, its a genuine deadhead
|
||||||
if (lightMatch.start - headerEnd < 40) {
|
if (lightMatch.start - headerEnd < 40) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -225,14 +277,21 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip the leading "DUTY RUN BUS" we already extracted
|
// Strip the leading "DUTY RUN BUS" we already extracted
|
||||||
final afterHeader = segment.substring(
|
final afterHeader = segment
|
||||||
RegExp(RegExp.escape(dutyId) + r"\s+\d+\s+\d{3}").firstMatch(segment)!.end
|
.substring(
|
||||||
).trim();
|
RegExp(
|
||||||
|
RegExp.escape(dutyId) + r"\s+\d+\s+\d{3}",
|
||||||
|
).firstMatch(segment)!.end,
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
|
||||||
// Strip relief/garage prefix to get to the times
|
// Strip relief/garage prefix to get to the times
|
||||||
var timesSection = afterHeader;
|
var timesSection = afterHeader;
|
||||||
// remove leading relief markers and garage name
|
// 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*"), "");
|
timesSection = timesSection.replaceFirst(RegExp(r"^HomeGara\s*"), "");
|
||||||
// Also remove leading text from inbound like just "R " or "F "
|
// Also remove leading text from inbound like just "R " or "F "
|
||||||
timesSection = timesSection.replaceFirst(RegExp(r"^(F|R)\s+"), "");
|
timesSection = timesSection.replaceFirst(RegExp(r"^(F|R)\s+"), "");
|
||||||
|
|
@ -249,8 +308,18 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
|
|
||||||
if (times.isEmpty) return null;
|
if (times.isEmpty) return null;
|
||||||
|
|
||||||
// For garage trips, first time is the garage departure — skip it
|
// For garage-prefixed trips, first time is often a non-route garage departure.
|
||||||
final isFromGarage = afterHeader.contains("HomeGara");
|
// 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;
|
int firstStationIdx = 0;
|
||||||
if (isFromGarage && times.length > 1) {
|
if (isFromGarage && times.length > 1) {
|
||||||
firstStationIdx = 1;
|
firstStationIdx = 1;
|
||||||
|
|
@ -271,7 +340,8 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
final scheduledTime = _formatTime(times[firstStationIdx]);
|
final scheduledTime = _formatTime(times[firstStationIdx]);
|
||||||
|
|
||||||
String tripType = "";
|
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;
|
final actualDirection = dutyId.startsWith("2/") ? "inbound" : direction;
|
||||||
|
|
||||||
|
|
@ -283,9 +353,9 @@ class StagecoachScheduleParser implements ScheduleParser {
|
||||||
|
|
||||||
return Trip(
|
return Trip(
|
||||||
scheduledTime: scheduledTime,
|
scheduledTime: scheduledTime,
|
||||||
tripNumber: dutyId,
|
tripNumber: runNumber,
|
||||||
dutyNumber: busWorkingNo,
|
dutyNumber: dutyId,
|
||||||
runningNumber: runNumber,
|
busWorkNumber: busWorkingNo,
|
||||||
tripType: tripType,
|
tripType: tripType,
|
||||||
isFinishing: segment.contains("BUS FIN"),
|
isFinishing: segment.contains("BUS FIN"),
|
||||||
stationTimes: stationTimes,
|
stationTimes: stationTimes,
|
||||||
|
|
|
||||||
536
lib/provider/collaboration_state.dart
Normal file
536
lib/provider/collaboration_state.dart
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
|
import "package:bus_running_record/constants.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:supabase_flutter/supabase_flutter.dart";
|
||||||
|
|
||||||
|
class OrganizationSummary {
|
||||||
|
const OrganizationSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.slug,
|
||||||
|
required this.iconUrl,
|
||||||
|
required this.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String slug;
|
||||||
|
final String? iconUrl;
|
||||||
|
final String role;
|
||||||
|
|
||||||
|
factory OrganizationSummary.fromApi(Map<String, dynamic> map) {
|
||||||
|
final org =
|
||||||
|
(map["organization"] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||||
|
final iconUrlRaw = (org["icon_url"] ?? "").toString().trim();
|
||||||
|
return OrganizationSummary(
|
||||||
|
id: (org["id"] ?? "").toString(),
|
||||||
|
name: (org["name"] ?? "").toString(),
|
||||||
|
slug: (org["slug"] ?? "").toString(),
|
||||||
|
iconUrl: iconUrlRaw.isEmpty ? null : iconUrlRaw,
|
||||||
|
role: (map["role"] ?? "member").toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChannelSummary {
|
||||||
|
const ChannelSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.organizationId,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.slug,
|
||||||
|
required this.type,
|
||||||
|
required this.isPrivate,
|
||||||
|
required this.position,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String organizationId;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final String slug;
|
||||||
|
final String type;
|
||||||
|
final bool isPrivate;
|
||||||
|
final int position;
|
||||||
|
|
||||||
|
factory ChannelSummary.fromApi(Map<String, dynamic> map) {
|
||||||
|
final description = (map["description"] ?? map["topic"] ?? "").toString();
|
||||||
|
return ChannelSummary(
|
||||||
|
id: (map["id"] ?? "").toString(),
|
||||||
|
organizationId: (map["organization_id"] ?? "").toString(),
|
||||||
|
name: (map["name"] ?? "").toString(),
|
||||||
|
description: description,
|
||||||
|
slug: (map["slug"] ?? "").toString(),
|
||||||
|
type: (map["type"] ?? "text").toString(),
|
||||||
|
isPrivate: map["is_private"] == true,
|
||||||
|
position: (map["position"] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageSummary {
|
||||||
|
const MessageSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.channelId,
|
||||||
|
required this.authorUserId,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String channelId;
|
||||||
|
final String authorUserId;
|
||||||
|
final String content;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
factory MessageSummary.fromApi(Map<String, dynamic> map) {
|
||||||
|
final createdAtRaw = (map["created_at"] ?? "").toString();
|
||||||
|
return MessageSummary(
|
||||||
|
id: (map["id"] ?? "").toString(),
|
||||||
|
channelId: (map["channel_id"] ?? "").toString(),
|
||||||
|
authorUserId: (map["author_user_id"] ?? "").toString(),
|
||||||
|
content: (map["content"] ?? "").toString(),
|
||||||
|
createdAt: DateTime.tryParse(createdAtRaw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollaborationProvider extends ChangeNotifier {
|
||||||
|
CollaborationProvider(this._supabaseProvider) {
|
||||||
|
_supabaseProvider.addListener(_handleSupabaseStateChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
final SupabaseProvider _supabaseProvider;
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
bool _isLoadingOrganizations = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
List<OrganizationSummary> _organizations = const [];
|
||||||
|
final Map<String, List<ChannelSummary>> _channelsByOrganization = {};
|
||||||
|
String? _selectedOrganizationId;
|
||||||
|
String? _selectedChannelId;
|
||||||
|
|
||||||
|
bool get isLoadingOrganizations => _isLoadingOrganizations;
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
List<OrganizationSummary> get organizations => _organizations;
|
||||||
|
String? get selectedOrganizationId => _selectedOrganizationId;
|
||||||
|
String? get selectedChannelId => _selectedChannelId;
|
||||||
|
|
||||||
|
List<ChannelSummary> channelsForOrganization(String orgId) =>
|
||||||
|
_channelsByOrganization[orgId] ?? const [];
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
if (!_supabaseProvider.isAuthenticated) return;
|
||||||
|
await refreshOrganizations();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshOrganizations() async {
|
||||||
|
_isLoadingOrganizations = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final previousOrgId = _selectedOrganizationId;
|
||||||
|
final previousChannelId = _selectedChannelId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _invokeAuthedFunction("org-list", body: {});
|
||||||
|
final payload = _asMap(response.data);
|
||||||
|
final orgRows = _asList(payload["organizations"]);
|
||||||
|
_organizations = orgRows
|
||||||
|
.map((row) => OrganizationSummary.fromApi(_asMap(row)))
|
||||||
|
.where((org) => org.id.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (_organizations.isEmpty) {
|
||||||
|
_selectedOrganizationId = null;
|
||||||
|
_selectedChannelId = null;
|
||||||
|
_channelsByOrganization.clear();
|
||||||
|
} else {
|
||||||
|
final orgStillExists = _organizations.any((o) => o.id == previousOrgId);
|
||||||
|
_selectedOrganizationId = orgStillExists
|
||||||
|
? previousOrgId
|
||||||
|
: _organizations.first.id;
|
||||||
|
await _loadChannelsForOrganization(
|
||||||
|
_selectedOrganizationId!,
|
||||||
|
preferredChannelId: previousChannelId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logError("refreshOrganizations/org-list", error, stackTrace);
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
} finally {
|
||||||
|
_isLoadingOrganizations = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSupabaseStateChange() {
|
||||||
|
if (!_initialized) return;
|
||||||
|
if (!_supabaseProvider.isAuthenticated) {
|
||||||
|
_organizations = const [];
|
||||||
|
_channelsByOrganization.clear();
|
||||||
|
_selectedOrganizationId = null;
|
||||||
|
_selectedChannelId = null;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshOrganizations();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createOrganization(String name) async {
|
||||||
|
try {
|
||||||
|
final response = await _invokeAuthedFunction(
|
||||||
|
"org-create",
|
||||||
|
body: {"name": name},
|
||||||
|
);
|
||||||
|
final createdOrg = _asMap(_asMap(response.data)["organization"]);
|
||||||
|
final orgId = (createdOrg["id"] ?? "").toString();
|
||||||
|
if (orgId.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await _invokeAuthedFunction(
|
||||||
|
"channel-create",
|
||||||
|
body: {
|
||||||
|
"organization_id": orgId,
|
||||||
|
"name": "general",
|
||||||
|
"slug": "general",
|
||||||
|
"type": "text",
|
||||||
|
"position": 0,
|
||||||
|
"is_private": false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logError("createOrganization/channel-create", error, stackTrace);
|
||||||
|
// Best effort; org creation is the critical action.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refreshOrganizations();
|
||||||
|
if (orgId.isNotEmpty) {
|
||||||
|
_selectedOrganizationId = orgId;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logError("createOrganization/org-create", error, stackTrace);
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateOrganization({
|
||||||
|
required String organizationId,
|
||||||
|
String? name,
|
||||||
|
String? iconUrl,
|
||||||
|
}) async {
|
||||||
|
final trimmedName = name?.trim();
|
||||||
|
try {
|
||||||
|
final body = <String, Object?>{"organization_id": organizationId};
|
||||||
|
if (trimmedName != null && trimmedName.isNotEmpty) {
|
||||||
|
body["name"] = trimmedName;
|
||||||
|
}
|
||||||
|
if (iconUrl != null) {
|
||||||
|
body["icon_url"] = iconUrl;
|
||||||
|
}
|
||||||
|
await _invokeAuthedFunction("org-update", body: body);
|
||||||
|
await refreshOrganizations();
|
||||||
|
_selectedOrganizationId = organizationId;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logError("updateOrganization/org-update", error, stackTrace);
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> uploadOrganizationIcon({
|
||||||
|
required String organizationId,
|
||||||
|
required Uint8List bytes,
|
||||||
|
required String contentType,
|
||||||
|
String extension = "png",
|
||||||
|
}) async {
|
||||||
|
final ext = extension.toLowerCase();
|
||||||
|
final safeExtension = RegExp(r"^[a-z0-9]+$").hasMatch(ext) ? ext : "png";
|
||||||
|
final path =
|
||||||
|
"$organizationId/${DateTime.now().millisecondsSinceEpoch}.$safeExtension";
|
||||||
|
final client = _supabaseProvider.client;
|
||||||
|
try {
|
||||||
|
await client.storage
|
||||||
|
.from("organization-icons")
|
||||||
|
.uploadBinary(
|
||||||
|
path,
|
||||||
|
bytes,
|
||||||
|
fileOptions: FileOptions(upsert: true, contentType: contentType),
|
||||||
|
);
|
||||||
|
var publicUrl = client.storage
|
||||||
|
.from("organization-icons")
|
||||||
|
.getPublicUrl(path);
|
||||||
|
if (Uri.tryParse(publicUrl)?.hasScheme != true) {
|
||||||
|
publicUrl =
|
||||||
|
"$kSupabaseEndpoint/storage/v1/object/public/organization-icons/$path";
|
||||||
|
}
|
||||||
|
await updateOrganization(
|
||||||
|
organizationId: organizationId,
|
||||||
|
iconUrl: publicUrl,
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logError("uploadOrganizationIcon/storage-upload", error, stackTrace);
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createChannel({
|
||||||
|
required String organizationId,
|
||||||
|
required String name,
|
||||||
|
String description = "",
|
||||||
|
String type = "text",
|
||||||
|
bool isPrivate = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _invokeAuthedFunction(
|
||||||
|
"channel-create",
|
||||||
|
body: {
|
||||||
|
"organization_id": organizationId,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"type": type,
|
||||||
|
"is_private": isPrivate,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await _loadChannelsForOrganization(organizationId);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logError("createChannel/channel-create", error, stackTrace);
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteChannel({
|
||||||
|
required String organizationId,
|
||||||
|
required String channelId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _invokeAuthedFunction(
|
||||||
|
"channel-delete",
|
||||||
|
body: {"channel_id": channelId},
|
||||||
|
);
|
||||||
|
await _loadChannelsForOrganization(organizationId);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logError("deleteChannel/channel-delete", error, stackTrace);
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MessageSummary>> listMessages({
|
||||||
|
required String channelId,
|
||||||
|
int limit = 50,
|
||||||
|
}) async {
|
||||||
|
final response = await _invokeAuthedFunction(
|
||||||
|
"message-list",
|
||||||
|
body: {"channel_id": channelId, "limit": limit},
|
||||||
|
);
|
||||||
|
final payload = _asMap(response.data);
|
||||||
|
final rows = _asList(payload["messages"]);
|
||||||
|
return rows
|
||||||
|
.map((row) => MessageSummary.fromApi(_asMap(row)))
|
||||||
|
.where((message) => message.id.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendMessage({
|
||||||
|
required String channelId,
|
||||||
|
required String content,
|
||||||
|
}) async {
|
||||||
|
await _invokeAuthedFunction(
|
||||||
|
"message-send",
|
||||||
|
body: {"channel_id": channelId, "content": content},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> createInvite({
|
||||||
|
required String organizationId,
|
||||||
|
int maxUses = 1,
|
||||||
|
int expiresInDays = 7,
|
||||||
|
}) async {
|
||||||
|
final response = await _invokeAuthedFunction(
|
||||||
|
"org-invite-create",
|
||||||
|
body: {
|
||||||
|
"organization_id": organizationId,
|
||||||
|
"max_uses": maxUses,
|
||||||
|
"expires_in_days": expiresInDays,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final payload = _asMap(response.data);
|
||||||
|
final invite = _asMap(payload["invite"]);
|
||||||
|
final token = (invite["token"] ?? "").toString();
|
||||||
|
if (token.isEmpty) {
|
||||||
|
throw StateError("Invite token missing from response.");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> acceptInviteToken(String token) async {
|
||||||
|
final response = await _invokeAuthedFunction(
|
||||||
|
"org-invite-accept",
|
||||||
|
body: {"token": token},
|
||||||
|
);
|
||||||
|
final payload = _asMap(response.data);
|
||||||
|
final organizationId = (payload["organization_id"] ?? "").toString();
|
||||||
|
if (organizationId.isEmpty) {
|
||||||
|
throw StateError("Invite did not return organization_id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshOrganizations();
|
||||||
|
_selectedOrganizationId = organizationId;
|
||||||
|
await _loadChannelsForOrganization(organizationId);
|
||||||
|
notifyListeners();
|
||||||
|
return organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> selectOrganization(String organizationId) async {
|
||||||
|
if (_selectedOrganizationId == organizationId) return;
|
||||||
|
_selectedOrganizationId = organizationId;
|
||||||
|
_selectedChannelId = null;
|
||||||
|
notifyListeners();
|
||||||
|
await _loadChannelsForOrganization(organizationId);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectChannel(String channelId) {
|
||||||
|
_selectedChannelId = channelId;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadChannelsForOrganization(
|
||||||
|
String organizationId, {
|
||||||
|
String? preferredChannelId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _invokeAuthedFunction(
|
||||||
|
"channel-list",
|
||||||
|
body: {"organization_id": organizationId},
|
||||||
|
);
|
||||||
|
final payload = _asMap(response.data);
|
||||||
|
final channelRows = _asList(payload["channels"]);
|
||||||
|
final channels = channelRows
|
||||||
|
.map((row) => ChannelSummary.fromApi(_asMap(row)))
|
||||||
|
.where((channel) => channel.id.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
_channelsByOrganization[organizationId] = channels;
|
||||||
|
|
||||||
|
if (channels.isEmpty) {
|
||||||
|
_selectedChannelId = null;
|
||||||
|
} else if (preferredChannelId != null &&
|
||||||
|
channels.any((c) => c.id == preferredChannelId)) {
|
||||||
|
_selectedChannelId = preferredChannelId;
|
||||||
|
} else {
|
||||||
|
_selectedChannelId = channels.first.id;
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logError("loadChannels/channel-list", error, stackTrace);
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logError(String operation, Object error, StackTrace stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
"[CollaborationProvider] $operation failed (${error.runtimeType}): $error",
|
||||||
|
);
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _invokeAuthedFunction(
|
||||||
|
String functionName, {
|
||||||
|
Object? body,
|
||||||
|
}) async {
|
||||||
|
final client = _supabaseProvider.client;
|
||||||
|
var token = await _getFreshAccessToken();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw StateError(
|
||||||
|
"No valid access token available for edge function call.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> invokeOnce(String accessToken) {
|
||||||
|
client.functions.setAuth(accessToken);
|
||||||
|
return client.functions.invoke(
|
||||||
|
functionName,
|
||||||
|
body: body,
|
||||||
|
headers: <String, String>{"Authorization": "Bearer $accessToken"},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await invokeOnce(token);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
"[CollaborationProvider] invokeAuthedFunction/$functionName initial attempt failed: $error",
|
||||||
|
);
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
if (!_isUnauthorizedFunctionError(error)) rethrow;
|
||||||
|
|
||||||
|
// One forced refresh + retry for token rotation races.
|
||||||
|
final refreshed = await client.auth.refreshSession();
|
||||||
|
token =
|
||||||
|
refreshed.session?.accessToken ??
|
||||||
|
client.auth.currentSession?.accessToken;
|
||||||
|
if (token == null || token.isEmpty) rethrow;
|
||||||
|
return invokeOnce(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getFreshAccessToken() async {
|
||||||
|
var session = _supabaseProvider.session;
|
||||||
|
final nowUnix = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final expiresAt = session?.expiresAt;
|
||||||
|
|
||||||
|
final shouldRefresh =
|
||||||
|
session != null && expiresAt != null && expiresAt <= nowUnix + 30;
|
||||||
|
|
||||||
|
if (shouldRefresh) {
|
||||||
|
try {
|
||||||
|
final refreshed = await _supabaseProvider.client.auth.refreshSession();
|
||||||
|
session =
|
||||||
|
refreshed.session ?? _supabaseProvider.client.auth.currentSession;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
"[CollaborationProvider] getFreshAccessToken/refreshSession failed: $error",
|
||||||
|
);
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
session = _supabaseProvider.client.auth.currentSession;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return session?.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isUnauthorizedFunctionError(Object error) {
|
||||||
|
final text = error.toString();
|
||||||
|
return text.contains("status: 401") || text.contains("code: 401");
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _asMap(Object? value) {
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return value.cast<String, dynamic>();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<dynamic> _asList(Object? value) {
|
||||||
|
if (value is List) return value;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_supabaseProvider.removeListener(_handleSupabaseStateChange);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
lib/provider/supabase_state.dart
Normal file
131
lib/provider/supabase_state.dart
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:supabase_flutter/supabase_flutter.dart";
|
||||||
|
|
||||||
|
SupabaseClient get supabase => Supabase.instance.client;
|
||||||
|
|
||||||
|
class SupabaseProvider extends ChangeNotifier {
|
||||||
|
Session? _session;
|
||||||
|
StreamSubscription<AuthState>? _authSub;
|
||||||
|
bool _isSessionValidated = false;
|
||||||
|
bool _isValidatingSession = false;
|
||||||
|
|
||||||
|
SupabaseProvider() {
|
||||||
|
_session = supabase.auth.currentSession;
|
||||||
|
unawaited(_validateSession(_session));
|
||||||
|
|
||||||
|
_authSub = supabase.auth.onAuthStateChange.listen((data) {
|
||||||
|
unawaited(_validateSession(data.session));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Session? get session => _session;
|
||||||
|
bool get isAuthenticated => _session != null && _isSessionValidated;
|
||||||
|
bool get isValidatingSession => _isValidatingSession;
|
||||||
|
SupabaseClient get client => supabase;
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> signInWithPassword({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
await supabase.auth.signInWithPassword(email: email, password: password);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> signUpWithPassword({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final res = await supabase.auth.signUp(email: email, password: password);
|
||||||
|
|
||||||
|
// returns true if email confirmation is needed
|
||||||
|
return res.session == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> verifySignUpOtp({
|
||||||
|
required String email,
|
||||||
|
required String token,
|
||||||
|
}) async {
|
||||||
|
await supabase.auth.verifyOTP(
|
||||||
|
email: email,
|
||||||
|
token: token,
|
||||||
|
type: OtpType.signup,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resendSignUpOtp({required String email}) async {
|
||||||
|
await supabase.auth.resend(type: OtpType.signup, email: email);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signOut() async {
|
||||||
|
try {
|
||||||
|
await supabase.auth.signOut(scope: SignOutScope.local);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[SupabaseProvider] signOut failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
_session = null;
|
||||||
|
_isSessionValidated = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _validateSession(Session? incomingSession) async {
|
||||||
|
_session = incomingSession ?? supabase.auth.currentSession;
|
||||||
|
|
||||||
|
if (_session == null) {
|
||||||
|
_isSessionValidated = false;
|
||||||
|
_isValidatingSession = false;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isValidatingSession = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final userResponse = await supabase.auth.getUser();
|
||||||
|
if (userResponse.user != null) {
|
||||||
|
_session = supabase.auth.currentSession ?? _session;
|
||||||
|
_isSessionValidated = true;
|
||||||
|
_isValidatingSession = false;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[SupabaseProvider] validateSession/getUser failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final refreshed = await supabase.auth.refreshSession();
|
||||||
|
_session = refreshed.session ?? supabase.auth.currentSession;
|
||||||
|
final refreshedUser = await supabase.auth.getUser();
|
||||||
|
_isSessionValidated = refreshedUser.user != null;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[SupabaseProvider] validateSession/refresh failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
_isSessionValidated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isSessionValidated) {
|
||||||
|
try {
|
||||||
|
await supabase.auth.signOut(scope: SignOutScope.local);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("[SupabaseProvider] validateSession/signOut failed: $error");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isValidatingSession = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_authSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
import "../models/trip.dart";
|
import "package:flutter/foundation.dart";
|
||||||
|
import "../models/operations/trip.dart";
|
||||||
import "../models/brr_metadata.dart";
|
import "../models/brr_metadata.dart";
|
||||||
import "../exporters/brr_exporter.dart";
|
import "../exporters/brr_exporter.dart";
|
||||||
import "../exporters/arriva_brr_exporter.dart";
|
import "../exporters/arriva_brr_exporter.dart";
|
||||||
|
|
@ -37,7 +38,9 @@ class BRRExportService {
|
||||||
try {
|
try {
|
||||||
final bytes = await _exporter.export(trips, metadata);
|
final bytes = await _exporter.export(trips, metadata);
|
||||||
return ExportResult.success(bytes);
|
return ExportResult.success(bytes);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint("[BRRExportService] exportBRR failed: $e");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
return ExportResult.error(["Export failed: $e"]);
|
return ExportResult.error(["Export failed: $e"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
import "package:hive_flutter/hive_flutter.dart";
|
import "package:hive_flutter/hive_flutter.dart";
|
||||||
import "../models/brr_state.dart";
|
import "../models/brr_state.dart";
|
||||||
|
|
||||||
|
|
@ -21,7 +22,9 @@ class StorageService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return BRRState.fromJson(jsonDecode(jsonString) as Map<String, dynamic>);
|
return BRRState.fromJson(jsonDecode(jsonString) as Map<String, dynamic>);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint("[StorageService] loadState failed: $e");
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import "../models/trip.dart";
|
import "../models/operations/trip.dart";
|
||||||
|
|
||||||
class TripValidator {
|
class TripValidator {
|
||||||
static List<String> validate(Trip trip) {
|
static List<String> validate(Trip trip) {
|
||||||
|
|
@ -14,15 +14,17 @@ class TripValidator {
|
||||||
errors.add("Missing trip number");
|
errors.add("Missing trip number");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate duty/running numbers
|
// Validate duty/bus-work numbers
|
||||||
if (trip.dutyNumber.isEmpty || trip.runningNumber.isEmpty) {
|
if (trip.dutyNumber.isEmpty || trip.busWorkNumber.isEmpty) {
|
||||||
errors.add("Missing duty or running number");
|
errors.add("Missing duty or bus work number");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate actual departure time if provided
|
// Validate actual departure time if provided
|
||||||
if (trip.actualDepartureTime != null &&
|
if (trip.actualDepartureTime != null &&
|
||||||
!RegExp(r"^\d{2}:\d{2}$").hasMatch(trip.actualDepartureTime!)) {
|
!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;
|
return errors;
|
||||||
|
|
|
||||||
280
lib/widgets/trip_diagram.dart
Normal file
280
lib/widgets/trip_diagram.dart
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
class TripDiagramEntry {
|
||||||
|
const TripDiagramEntry({required this.label, this.labelIcon, this.subtitle, this.time});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData? labelIcon;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? time;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TripDiagram extends StatelessWidget {
|
||||||
|
const TripDiagram({
|
||||||
|
required this.entries,
|
||||||
|
super.key,
|
||||||
|
this.leftOffset = 0,
|
||||||
|
this.rowHeight = 32,
|
||||||
|
this.lineWidth = 7,
|
||||||
|
this.lineColor,
|
||||||
|
this.highlightLastEntry = false,
|
||||||
|
this.labelOffset = 26,
|
||||||
|
this.rightPadding = 16,
|
||||||
|
this.oddRowColor,
|
||||||
|
this.terminalRowColor,
|
||||||
|
this.stationTextStyle,
|
||||||
|
this.timeTextStyle,
|
||||||
|
this.missingTimeTextStyle,
|
||||||
|
this.emptyMessage = "No stations found.",
|
||||||
|
this.emptyTextStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<TripDiagramEntry> entries;
|
||||||
|
final double leftOffset;
|
||||||
|
final double rowHeight;
|
||||||
|
final double lineWidth;
|
||||||
|
final Color? lineColor;
|
||||||
|
final bool highlightLastEntry;
|
||||||
|
final double labelOffset;
|
||||||
|
final double rightPadding;
|
||||||
|
final Color? oddRowColor;
|
||||||
|
final Color? terminalRowColor;
|
||||||
|
final TextStyle? stationTextStyle;
|
||||||
|
final TextStyle? timeTextStyle;
|
||||||
|
final TextStyle? missingTimeTextStyle;
|
||||||
|
final String emptyMessage;
|
||||||
|
final TextStyle? emptyTextStyle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final resolvedLineColor = lineColor ?? Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
|
final deduped = entries;
|
||||||
|
|
||||||
|
if (deduped.isEmpty) {
|
||||||
|
return Text(
|
||||||
|
emptyMessage,
|
||||||
|
style:
|
||||||
|
emptyTextStyle ??
|
||||||
|
TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalHeight = deduped.length * rowHeight;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: totalHeight,
|
||||||
|
width: double.infinity,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _TripDiagramPainter(
|
||||||
|
count: deduped.length,
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
lineWidth: lineWidth,
|
||||||
|
lineColor: resolvedLineColor,
|
||||||
|
leftOffset: leftOffset,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Column(
|
||||||
|
children: List.generate(deduped.length, (index) {
|
||||||
|
final entry = deduped[index];
|
||||||
|
final time = entry.time;
|
||||||
|
final isTerminalRow = highlightLastEntry && index == deduped.length - 1;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: rowHeight,
|
||||||
|
color: isTerminalRow
|
||||||
|
? (terminalRowColor ?? resolvedLineColor.withValues(alpha: 0.12))
|
||||||
|
: index.isOdd
|
||||||
|
? (oddRowColor ?? Colors.white.withValues(alpha: 0.03))
|
||||||
|
: null,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: leftOffset + labelOffset,
|
||||||
|
right: rightPadding,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.label,
|
||||||
|
style:
|
||||||
|
stationTextStyle ??
|
||||||
|
const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFDDDDDD),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
|
||||||
|
if (entry.labelIcon != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
entry.labelIcon,
|
||||||
|
size: 12,
|
||||||
|
color: const Color(0xFFBBBBBB),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (entry.subtitle != null)
|
||||||
|
Text(
|
||||||
|
entry.subtitle!,
|
||||||
|
style:
|
||||||
|
stationTextStyle ??
|
||||||
|
const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFFDDDDDD),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
time ?? "--:--",
|
||||||
|
style: time != null
|
||||||
|
? (timeTextStyle ??
|
||||||
|
const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFFEEEEEE),
|
||||||
|
))
|
||||||
|
: (missingTimeTextStyle ??
|
||||||
|
const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFF555555),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TripDiagramPainter extends CustomPainter {
|
||||||
|
const _TripDiagramPainter({
|
||||||
|
required this.count,
|
||||||
|
required this.rowHeight,
|
||||||
|
required this.lineWidth,
|
||||||
|
required this.lineColor,
|
||||||
|
this.leftOffset = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int count;
|
||||||
|
final double rowHeight;
|
||||||
|
final double lineWidth;
|
||||||
|
final Color lineColor;
|
||||||
|
final double leftOffset;
|
||||||
|
|
||||||
|
static const double _linePad = 4.0;
|
||||||
|
static const double _blobPad = 4.0;
|
||||||
|
static const double _centerSize = 4.0;
|
||||||
|
static const double _cornerFactor = 0.3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (count <= 0) return;
|
||||||
|
|
||||||
|
final strokeWidth = lineWidth / 2.0;
|
||||||
|
final ringSize = _centerSize + strokeWidth;
|
||||||
|
final paddingWidth = strokeWidth + _blobPad;
|
||||||
|
final centerX = leftOffset + ringSize / 2 + paddingWidth / 2 + 2;
|
||||||
|
final firstY = rowHeight / 2;
|
||||||
|
final lastY = (count - 1) * rowHeight + rowHeight / 2;
|
||||||
|
|
||||||
|
final nodeRects = List.generate(count, (index) {
|
||||||
|
final centerY = index * rowHeight + rowHeight / 2;
|
||||||
|
final rect = Rect.fromCenter(
|
||||||
|
center: Offset(centerX, centerY),
|
||||||
|
width: ringSize,
|
||||||
|
height: ringSize,
|
||||||
|
);
|
||||||
|
return RRect.fromRectAndRadius(
|
||||||
|
rect,
|
||||||
|
Radius.circular(ringSize * _cornerFactor),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final rect in nodeRects) {
|
||||||
|
canvas.drawRRect(
|
||||||
|
rect,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..strokeWidth = paddingWidth
|
||||||
|
..style = PaintingStyle.stroke,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(centerX, firstY),
|
||||||
|
Offset(centerX, lastY),
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..strokeWidth = lineWidth + _linePad
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..style = PaintingStyle.stroke,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(centerX, firstY),
|
||||||
|
Offset(centerX, lastY),
|
||||||
|
Paint()
|
||||||
|
..color = lineColor
|
||||||
|
..strokeWidth = lineWidth
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..style = PaintingStyle.stroke,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final rect in nodeRects) {
|
||||||
|
final centerRect = rect.deflate(strokeWidth / 2);
|
||||||
|
canvas.drawRRect(
|
||||||
|
centerRect,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..style = PaintingStyle.fill,
|
||||||
|
);
|
||||||
|
canvas.drawRRect(
|
||||||
|
rect,
|
||||||
|
Paint()
|
||||||
|
..color = lineColor
|
||||||
|
..strokeWidth = strokeWidth
|
||||||
|
..style = PaintingStyle.stroke,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _TripDiagramPainter oldDelegate) {
|
||||||
|
return oldDelegate.count != count ||
|
||||||
|
oldDelegate.rowHeight != rowHeight ||
|
||||||
|
oldDelegate.lineWidth != lineWidth ||
|
||||||
|
oldDelegate.lineColor != lineColor ||
|
||||||
|
oldDelegate.leftOffset != leftOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,12 +7,16 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <file_saver/file_saver_plugin.h>
|
#include <file_saver/file_saver_plugin.h>
|
||||||
|
#include <gtk/gtk_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) file_saver_registrar =
|
g_autoptr(FlPluginRegistrar) file_saver_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin");
|
||||||
file_saver_plugin_register_with_registrar(file_saver_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_saver
|
file_saver
|
||||||
|
gtk
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,20 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import app_links
|
||||||
import file_picker
|
import file_picker
|
||||||
import file_saver
|
import file_saver
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import share_plus
|
import share_plus
|
||||||
|
import shared_preferences_foundation
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
|
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,12 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.files.downloads.read-write</key>
|
<key>com.apple.security.files.downloads.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.files.downloads.read-write</key>
|
<key>com.apple.security.files.downloads.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
|
||||||
389
pubspec.lock
389
pubspec.lock
|
|
@ -1,6 +1,46 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
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:
|
archive:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -9,6 +49,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.6.1"
|
version: "3.6.1"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -57,6 +105,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -73,6 +129,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
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:
|
dio:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -97,6 +169,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
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:
|
equatable:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -113,6 +193,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.6"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -174,6 +262,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -192,6 +285,22 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
go_router:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -200,6 +309,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.8.1"
|
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:
|
hive:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -236,10 +361,34 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -312,6 +461,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
nested:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nested
|
||||||
|
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -376,6 +533,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
phonecodes:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: phonecodes
|
||||||
|
sha256: d963c19d35914cd83620e64125689a0c09047e25046639f2a124142ccf5868bb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.4"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -392,6 +557,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -408,6 +637,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.2"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -429,6 +722,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
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:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -445,22 +746,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
syncfusion_flutter_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: syncfusion_flutter_core
|
name: syncfusion_flutter_core
|
||||||
sha256: "325f519ce4ad8edd81811c21b853d72018529e353584490824da0555156ba076"
|
sha256: cdc9f865a2447b75446c6583e68b2800411687cc1a36df83769b66f96ed70df1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "27.2.5"
|
version: "33.1.45"
|
||||||
syncfusion_flutter_pdf:
|
syncfusion_flutter_pdf:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: syncfusion_flutter_pdf
|
name: syncfusion_flutter_pdf
|
||||||
sha256: da7fb9d156fafdce7099dc711c1a2dc522c48361188bd4be78520b22c99bbafd
|
sha256: bb8f5fbe35b79ad418f376448c889fb8fdbaf6d2d42b6c6460985657d9008cc0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "27.2.5"
|
version: "33.1.45"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -485,6 +802,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -493,6 +834,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
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:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -549,6 +898,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -573,6 +938,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
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:
|
sdks:
|
||||||
dart: ">=3.10.0-75.1.beta <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.1"
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
shadcn_flutter: ^0.0.52
|
||||||
|
provider: ^6.1.5
|
||||||
|
supabase_flutter: ^2.12.2
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^14.6.2
|
go_router: ^14.6.2
|
||||||
|
|
||||||
|
|
@ -42,7 +46,7 @@ dependencies:
|
||||||
file_saver: ^0.2.14
|
file_saver: ^0.2.14
|
||||||
share_plus: ^10.1.3
|
share_plus: ^10.1.3
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
syncfusion_flutter_pdf: ^27.2.5
|
syncfusion_flutter_pdf: ^33.1.45
|
||||||
|
|
||||||
# Local storage
|
# Local storage
|
||||||
hive: ^2.2.3
|
hive: ^2.2.3
|
||||||
|
|
|
||||||
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.84.2
|
||||||
1
supabase/.temp/gotrue-version
Normal file
1
supabase/.temp/gotrue-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.188.1
|
||||||
1
supabase/.temp/pooler-url
Normal file
1
supabase/.temp/pooler-url
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
postgresql://postgres.fbgvisimvgeksfxpemuk@aws-1-eu-west-2.pooler.supabase.com:5432/postgres
|
||||||
1
supabase/.temp/postgres-version
Normal file
1
supabase/.temp/postgres-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
17.6.1.084
|
||||||
1
supabase/.temp/project-ref
Normal file
1
supabase/.temp/project-ref
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
fbgvisimvgeksfxpemuk
|
||||||
1
supabase/.temp/rest-version
Normal file
1
supabase/.temp/rest-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v14.4
|
||||||
1
supabase/.temp/storage-migration
Normal file
1
supabase/.temp/storage-migration
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
fix-optimized-search-function
|
||||||
1
supabase/.temp/storage-version
Normal file
1
supabase/.temp/storage-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v1.43.3
|
||||||
35
supabase/config.toml
Normal file
35
supabase/config.toml
Normal file
|
|
@ -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
|
||||||
24
supabase/functions/_shared/http.ts
Normal file
24
supabase/functions/_shared/http.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
43
supabase/functions/_shared/supabase.ts
Normal file
43
supabase/functions/_shared/supabase.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
114
supabase/functions/auth-debug/index.ts
Normal file
114
supabase/functions/auth-debug/index.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
95
supabase/functions/channel-create/index.ts
Normal file
95
supabase/functions/channel-create/index.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
52
supabase/functions/channel-delete/index.ts
Normal file
52
supabase/functions/channel-delete/index.ts
Normal file
|
|
@ -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 });
|
||||||
|
});
|
||||||
42
supabase/functions/channel-list/index.ts
Normal file
42
supabase/functions/channel-list/index.ts
Normal file
|
|
@ -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 ?? [] });
|
||||||
|
});
|
||||||
48
supabase/functions/message-list/index.ts
Normal file
48
supabase/functions/message-list/index.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
43
supabase/functions/message-send/index.ts
Normal file
43
supabase/functions/message-send/index.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
155
supabase/functions/operations-stop-alias-enhance/index.ts
Normal file
155
supabase/functions/operations-stop-alias-enhance/index.ts
Normal file
|
|
@ -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":"<code>","estimated":"<name>"}]}.
|
||||||
|
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<string, Set<string>>();
|
||||||
|
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<string>();
|
||||||
|
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<string, string>();
|
||||||
|
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 });
|
||||||
|
});
|
||||||
53
supabase/functions/org-create/index.ts
Normal file
53
supabase/functions/org-create/index.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
88
supabase/functions/org-invite-accept/index.ts
Normal file
88
supabase/functions/org-invite-accept/index.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
98
supabase/functions/org-invite-create/index.ts
Normal file
98
supabase/functions/org-invite-create/index.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
32
supabase/functions/org-list/index.ts
Normal file
32
supabase/functions/org-list/index.ts
Normal file
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
76
supabase/functions/org-update/index.ts
Normal file
76
supabase/functions/org-update/index.ts
Normal file
|
|
@ -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 });
|
||||||
|
});
|
||||||
308
supabase/migrations/20260325120000_collab_schema.sql
Normal file
308
supabase/migrations/20260325120000_collab_schema.sql
Normal file
|
|
@ -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')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -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()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -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 $$;
|
||||||
322
supabase/migrations/20260326173000_reset_collab_to_hash_ids.sql
Normal file
322
supabase/migrations/20260326173000_reset_collab_to_hash_ids.sql
Normal file
|
|
@ -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')
|
||||||
|
)
|
||||||
|
);
|
||||||
39
supabase/migrations/20260326190000_add_org_invites.sql
Normal file
39
supabase/migrations/20260326190000_add_org_invites.sql
Normal file
|
|
@ -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'));
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
||||||
|
|
@ -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')
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table public.operations_trips
|
||||||
|
rename column running_number to bus_work_number;
|
||||||
|
|
@ -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, '') <> '';
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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'));
|
||||||
|
|
@ -6,11 +6,14 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <app_links/app_links_plugin_c_api.h>
|
||||||
#include <file_saver/file_saver_plugin.h>
|
#include <file_saver/file_saver_plugin.h>
|
||||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
AppLinksPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||||
FileSaverPluginRegisterWithRegistrar(
|
FileSaverPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
||||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
app_links
|
||||||
file_saver
|
file_saver
|
||||||
share_plus
|
share_plus
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue