Add version files and update imports for trip model; enhance error handling
This commit is contained in:
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:bus_running_record/pages/auth/page.dart";
|
||||
import "package:bus_running_record/provider/supabase_state.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class VerifyEmailPage extends StatefulWidget {
|
||||
static const String routePath = "/verify-email";
|
||||
static final GoRoute route = GoRoute(
|
||||
path: routePath,
|
||||
builder: (context, state) {
|
||||
final email = state.uri.queryParameters["email"] ?? "";
|
||||
return VerifyEmailPage(email: email);
|
||||
},
|
||||
);
|
||||
|
||||
const VerifyEmailPage({required this.email, super.key});
|
||||
|
||||
final String email;
|
||||
|
||||
@override
|
||||
State<VerifyEmailPage> createState() => _VerifyEmailPageState();
|
||||
}
|
||||
|
||||
class _VerifyEmailPageState extends State<VerifyEmailPage> {
|
||||
static const int _tokenLength = 6;
|
||||
String _token = "";
|
||||
bool _isSubmitting = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _verifyToken(String token) async {
|
||||
if (widget.email.isEmpty) {
|
||||
setState(() => _error = "Missing email address for verification.");
|
||||
return;
|
||||
}
|
||||
if (token.length != _tokenLength) {
|
||||
setState(() => _error = "Enter the $_tokenLength-digit token from your email.");
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<SupabaseProvider>().verifySignUpOtp(
|
||||
email: widget.email,
|
||||
token: token,
|
||||
);
|
||||
if (mounted) context.go("/");
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
"[VerifyEmailPage] verifyToken failed (${error.runtimeType}): $error",
|
||||
);
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = error.toString();
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Gap(topPadding + 16),
|
||||
Text("You're almost there!").x2Large.semiBold,
|
||||
const Gap(8),
|
||||
Text(
|
||||
"We sent a $_tokenLength-digit token to your email.\n(${widget.email})",
|
||||
).xSmall.muted.textCenter,
|
||||
const Gap(14),
|
||||
InputOTP(
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_token = value.otpToString();
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
FocusScope.of(context).unfocus();
|
||||
unawaited(_verifyToken(value.otpToString()));
|
||||
},
|
||||
children: [
|
||||
InputOTPChild.character(allowDigit: true),
|
||||
InputOTPChild.character(allowDigit: true),
|
||||
InputOTPChild.character(allowDigit: true),
|
||||
InputOTPChild.character(allowDigit: true),
|
||||
InputOTPChild.character(allowDigit: true),
|
||||
InputOTPChild.character(allowDigit: true),
|
||||
],
|
||||
),
|
||||
const Gap(20),
|
||||
Button.primary(
|
||||
enabled: !_isSubmitting && _token.length == _tokenLength,
|
||||
onPressed: () => unawaited(_verifyToken(_token)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_isSubmitting ? "Verifying..." : "Verify token",
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Button.text(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => context.go(LoginPage.routePath),
|
||||
child: const Text("Back to sign in"),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const Gap(8),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
).xSmall.textCenter,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import "package:bus_running_record/models/channels/operations_channel.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class OperationsChannelView extends StatelessWidget {
|
||||
const OperationsChannelView({required this.channel, super.key});
|
||||
|
||||
final OperationsChannel channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
final isScheduleUploaded = channel.id.isEmpty;
|
||||
|
||||
if (!isScheduleUploaded) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"No schedule uploaded yet.",
|
||||
).h4,
|
||||
|
||||
Gap(8),
|
||||
|
||||
Button.secondary(
|
||||
child: Text(
|
||||
"Upload Schedule",
|
||||
),
|
||||
onPressed: () {
|
||||
context.go(
|
||||
"/channel/${channel.organizationId}/${channel.id}/upload",
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:bus_running_record/models/channels/text_channel.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "package:supabase_flutter/supabase_flutter.dart";
|
||||
|
||||
class TextChannelView extends StatefulWidget {
|
||||
const TextChannelView({required this.channel, super.key});
|
||||
|
||||
final TextChannel channel;
|
||||
|
||||
@override
|
||||
State<TextChannelView> createState() => _TextChannelViewState();
|
||||
}
|
||||
|
||||
class _TextChannelViewState extends State<TextChannelView> {
|
||||
RealtimeChannel? _messagesRealtimeChannel;
|
||||
bool _loadingMessages = false;
|
||||
bool _sendingMessage = false;
|
||||
String? _messageError;
|
||||
String _draftMessage = "";
|
||||
int _composerNonce = 0;
|
||||
List<TextChannelMessage> _messages = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
unawaited(_initializeChannel());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TextChannelView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.channel.id == widget.channel.id) return;
|
||||
unawaited(_initializeChannel());
|
||||
}
|
||||
|
||||
Future<void> _initializeChannel() async {
|
||||
await _unsubscribeFromRealtimeMessages();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = const [];
|
||||
_messageError = null;
|
||||
_draftMessage = "";
|
||||
_composerNonce += 1;
|
||||
});
|
||||
await _subscribeToRealtimeMessages();
|
||||
await _loadMessages();
|
||||
}
|
||||
|
||||
Future<void> _loadMessages() async {
|
||||
if (_loadingMessages) return;
|
||||
setState(() {
|
||||
_loadingMessages = true;
|
||||
_messageError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final messages = await widget.channel.listMessages();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = messages;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[TextChannelView] loadMessages failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messageError = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadingMessages = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _subscribeToRealtimeMessages() async {
|
||||
if (_messagesRealtimeChannel != null) return;
|
||||
|
||||
final realtime = widget.channel.subscribeToMessages(
|
||||
onMessageChanged: () {
|
||||
if (!mounted) return;
|
||||
unawaited(_loadMessages());
|
||||
},
|
||||
onStatus: (status, error) {
|
||||
if (status == RealtimeSubscribeStatus.subscribed) return;
|
||||
if (status == RealtimeSubscribeStatus.channelError ||
|
||||
status == RealtimeSubscribeStatus.timedOut) {
|
||||
debugPrint(
|
||||
"[TextChannelView] realtime subscribe issue ($status) for channel ${widget.channel.id}: $error",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_messagesRealtimeChannel = realtime;
|
||||
}
|
||||
|
||||
Future<void> _unsubscribeFromRealtimeMessages() async {
|
||||
final realtime = _messagesRealtimeChannel;
|
||||
_messagesRealtimeChannel = null;
|
||||
if (realtime == null) return;
|
||||
|
||||
try {
|
||||
await widget.channel.unsubscribe(realtime);
|
||||
debugPrint("[TextChannelView] realtime unsubscribed");
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[TextChannelView] realtime unsubscribe failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final content = _draftMessage.trim();
|
||||
if (content.isEmpty || _sendingMessage) return;
|
||||
|
||||
setState(() {
|
||||
_sendingMessage = true;
|
||||
_messageError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.channel.sendMessage(content);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_draftMessage = "";
|
||||
_composerNonce += 1;
|
||||
});
|
||||
await _loadMessages();
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[TextChannelView] sendMessage failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messageError = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_sendingMessage = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMessageList() {
|
||||
if (_loadingMessages) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_messages.isEmpty) {
|
||||
return Center(child: Text("No messages yet. Say hi.").small.muted);
|
||||
}
|
||||
|
||||
final currentUserId = widget.channel.client.auth.currentUser?.id ?? "";
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < _messages.length; i++) ...[
|
||||
MessageBubble(message: _messages[i], currentUserId: currentUserId),
|
||||
if (i != _messages.length - 1) const Gap(2),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_unsubscribeFromRealtimeMessages());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: _buildMessageList()),
|
||||
if (_messageError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 12),
|
||||
child: Text(
|
||||
_messageError!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
).small,
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
clipBehavior: Clip.none,
|
||||
height: 60,
|
||||
child: TextField(
|
||||
key: ValueKey("composer-$_composerNonce"),
|
||||
initialValue: "",
|
||||
placeholder: Text("Message #${widget.channel.slug}"),
|
||||
enabled: !_sendingMessage,
|
||||
onChanged: (value) {
|
||||
_draftMessage = value;
|
||||
},
|
||||
onSubmitted: (_) => unawaited(_sendMessage()),
|
||||
features: [
|
||||
InputFeature.leading(
|
||||
IconButton.ghost(
|
||||
icon: const Icon(LucideIcons.plus).iconSmall,
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Gap(bottomPadding + 12),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({
|
||||
required this.message,
|
||||
required this.currentUserId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TextChannelMessage message;
|
||||
final String currentUserId;
|
||||
|
||||
String _formatTime() {
|
||||
final createdAt = message.createdAt?.toLocal();
|
||||
if (createdAt == null) {
|
||||
return "";
|
||||
}
|
||||
final paddedHour = createdAt.hour.toString().padLeft(2, "0");
|
||||
final paddedMinute = createdAt.minute.toString().padLeft(2, "0");
|
||||
return "$paddedHour:$paddedMinute";
|
||||
}
|
||||
|
||||
String _senderLabel() {
|
||||
final authorUserId = message.authorUserId;
|
||||
final isCurrentUser =
|
||||
currentUserId.isNotEmpty && authorUserId == currentUserId;
|
||||
if (isCurrentUser) {
|
||||
return "You";
|
||||
}
|
||||
|
||||
if (authorUserId.length >= 8) {
|
||||
return "User ${authorUserId.substring(0, 8)}";
|
||||
}
|
||||
return "User $authorUserId";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timeText = _formatTime();
|
||||
final senderLabel = _senderLabel();
|
||||
final shouldShowTime = timeText.isNotEmpty;
|
||||
final senderInitials = Avatar.getInitials(senderLabel);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Avatar(initials: senderInitials),
|
||||
const Gap(10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(senderLabel).small.semiBold,
|
||||
if (shouldShowTime) ...[
|
||||
const Gap(8),
|
||||
Text(timeText).xSmall.muted,
|
||||
],
|
||||
],
|
||||
),
|
||||
const Gap(2),
|
||||
Text(message.content).small,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:bus_running_record/models/channels/base_channel.dart";
|
||||
import "package:bus_running_record/models/channels/operations_channel.dart";
|
||||
import "package:bus_running_record/models/channels/text_channel.dart";
|
||||
import "package:bus_running_record/pages/home/channels/operations_channel_view.dart";
|
||||
import "package:bus_running_record/pages/home/channels/text_channel_view.dart";
|
||||
import "package:bus_running_record/pages/home/widgets/swiper.dart";
|
||||
import "package:bus_running_record/pages/home/widgets/channel_header.dart";
|
||||
import "package:bus_running_record/pages/home/widgets/home_left_sidebar.dart";
|
||||
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||
import "package:bus_running_record/provider/supabase_state.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
static GoRoute rootRoute = GoRoute(
|
||||
path: "/",
|
||||
builder: (context, state) => const HomePage(),
|
||||
);
|
||||
|
||||
static GoRoute channelRoute = GoRoute(
|
||||
path: "/channel/:orgId/:channelId",
|
||||
builder: (context, state) => HomePage(
|
||||
organizationId: state.pathParameters["orgId"],
|
||||
channelId: state.pathParameters["channelId"],
|
||||
),
|
||||
);
|
||||
|
||||
const HomePage({this.organizationId, this.channelId, super.key});
|
||||
|
||||
final String? organizationId;
|
||||
final String? channelId;
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
String? _lastSyncedRouteKey;
|
||||
|
||||
Future<void> _syncRouteSelection() async {
|
||||
final orgId = widget.organizationId;
|
||||
final channelId = widget.channelId;
|
||||
if (orgId == null || channelId == null) return;
|
||||
|
||||
final routeKey = "$orgId/$channelId";
|
||||
if (_lastSyncedRouteKey == routeKey) return;
|
||||
_lastSyncedRouteKey = routeKey;
|
||||
|
||||
final collab = context.read<CollaborationProvider>();
|
||||
if (collab.selectedOrganizationId != orgId) {
|
||||
await collab.selectOrganization(orgId);
|
||||
}
|
||||
|
||||
final channels = collab.channelsForOrganization(orgId);
|
||||
if (channels.any((channel) => channel.id == channelId)) {
|
||||
collab.selectChannel(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.organizationId != null && widget.channelId != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
unawaited(_syncRouteSelection());
|
||||
});
|
||||
} else {
|
||||
_lastSyncedRouteKey = null;
|
||||
}
|
||||
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
|
||||
if (isMobile) {
|
||||
return const SidebarSwiper(
|
||||
sidebar: HomeLeftSidebar(),
|
||||
child: Scaffold(
|
||||
child: Row(children: [Expanded(child: _HomeChannelPane())]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const Scaffold(
|
||||
child: Row(
|
||||
children: [
|
||||
HomeLeftSidebar(),
|
||||
VerticalDivider(),
|
||||
Expanded(child: _HomeChannelPane()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeChannelPane extends StatelessWidget {
|
||||
const _HomeChannelPane();
|
||||
|
||||
ChannelSummary? _findChannel(
|
||||
CollaborationProvider collab,
|
||||
String? organizationId,
|
||||
String? channelId,
|
||||
) {
|
||||
if (organizationId == null || channelId == null) return null;
|
||||
final channels = collab.channelsForOrganization(organizationId);
|
||||
for (final channel in channels) {
|
||||
if (channel.id == channelId) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
BaseChannel? _buildChannelModel(
|
||||
BuildContext context,
|
||||
ChannelSummary? selectedChannel,
|
||||
) {
|
||||
if (selectedChannel == null) return null;
|
||||
final client = context.read<SupabaseProvider>().client;
|
||||
|
||||
if (selectedChannel.type == "operations") {
|
||||
return OperationsChannel(
|
||||
client: client,
|
||||
id: selectedChannel.id,
|
||||
organizationId: selectedChannel.organizationId,
|
||||
name: selectedChannel.name,
|
||||
description: selectedChannel.description,
|
||||
slug: selectedChannel.slug,
|
||||
isPrivate: selectedChannel.isPrivate,
|
||||
position: selectedChannel.position,
|
||||
);
|
||||
}
|
||||
|
||||
return TextChannel(
|
||||
client: client,
|
||||
id: selectedChannel.id,
|
||||
organizationId: selectedChannel.organizationId,
|
||||
name: selectedChannel.name,
|
||||
description: selectedChannel.description,
|
||||
slug: selectedChannel.slug,
|
||||
isPrivate: selectedChannel.isPrivate,
|
||||
position: selectedChannel.position,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelContent({
|
||||
required BaseChannel? channel,
|
||||
}) {
|
||||
if (channel == null) {
|
||||
return Center(
|
||||
child: Text("Pick a channel to start chatting.").small.muted,
|
||||
);
|
||||
}
|
||||
|
||||
if (channel is OperationsChannel) {
|
||||
return OperationsChannelView(channel: channel);
|
||||
}
|
||||
|
||||
final textChannel = channel as TextChannel;
|
||||
return TextChannelView(
|
||||
key: ValueKey("text-channel-${textChannel.id}"),
|
||||
channel: textChannel,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collab = context.watch<CollaborationProvider>();
|
||||
final selectedOrg = collab.selectedOrganizationId;
|
||||
final selectedChannelId = collab.selectedChannelId;
|
||||
final selectedChannel = _findChannel(
|
||||
collab,
|
||||
selectedOrg,
|
||||
selectedChannelId,
|
||||
);
|
||||
final channel = _buildChannelModel(context, selectedChannel);
|
||||
final channelBody = _buildChannelContent(
|
||||
channel: channel,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (channel != null) ...[
|
||||
ChannelHeader(channel: channel),
|
||||
],
|
||||
Expanded(child: channelBody),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import "package:bus_running_record/models/channels/base_channel.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class ChannelHeader extends StatelessWidget {
|
||||
const ChannelHeader({required this.channel, super.key});
|
||||
|
||||
final BaseChannel channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
double topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
Gap(topPadding),
|
||||
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.hash,
|
||||
).iconSmall,
|
||||
Gap(4),
|
||||
Text(channel.slug).textSmall,
|
||||
Icon(LucideIcons.dot).iconMutedForeground,
|
||||
Text(channel.description),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import "dart:convert";
|
||||
|
||||
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||
import "package:bus_running_record/provider/supabase_state.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
Future<void> showCreateOrganizationDialog(BuildContext context) async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
String name = "";
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text("Create Organization"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Name your new organization."),
|
||||
const Gap(12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
placeholder: const Text("Enter organization name"),
|
||||
onChanged: (value) {
|
||||
name = value;
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
Navigator.of(dialogContext).pop(name);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(name),
|
||||
child: const Text("Create"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final name = (result ?? "").trim();
|
||||
if (name.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await context.read<CollaborationProvider>().createOrganization(name);
|
||||
}
|
||||
|
||||
Future<void> showCreateChannelDialog(
|
||||
BuildContext context, {
|
||||
required String organizationId,
|
||||
}) async {
|
||||
final result = await showDialog<Map<String, String>>(
|
||||
context: context,
|
||||
builder: (dialogContext) => const _CreateChannelDialog(),
|
||||
);
|
||||
|
||||
final channelName = (result?["name"] ?? "").trim();
|
||||
final channelDescription = (result?["description"] ?? "").trim();
|
||||
final channelType = (result?["type"] ?? "text").trim();
|
||||
if (channelName.isEmpty) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await context.read<CollaborationProvider>().createChannel(
|
||||
organizationId: organizationId,
|
||||
name: channelName,
|
||||
description: channelDescription,
|
||||
type: channelType,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[HomePage] createChannel dialog failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Create Channel Failed"),
|
||||
content: Text(error.toString()),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String extractInviteToken(String input) {
|
||||
final trimmed = input.trim();
|
||||
if (trimmed.isEmpty) return "";
|
||||
|
||||
final invitePathMatch = RegExp(r"/invite/([0-9a-fA-F]+)").firstMatch(trimmed);
|
||||
if (invitePathMatch != null) {
|
||||
return (invitePathMatch.group(1) ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
final hashInviteMatch = RegExp(
|
||||
r"#/invite/([0-9a-fA-F]+)",
|
||||
).firstMatch(trimmed);
|
||||
if (hashInviteMatch != null) {
|
||||
return (hashInviteMatch.group(1) ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
Future<void> showJoinOrganizationDialog(BuildContext context) async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
String inviteInput = "";
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text("Join Organization"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Paste an invite link or invite token."),
|
||||
const Gap(12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
placeholder: const Text("https://.../#/invite/<token>"),
|
||||
onChanged: (value) {
|
||||
inviteInput = value;
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
Navigator.of(dialogContext).pop(inviteInput);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(inviteInput),
|
||||
child: const Text("Join"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final token = extractInviteToken(result ?? "");
|
||||
if (token.isEmpty) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await context.read<CollaborationProvider>().acceptInviteToken(token);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Joined"),
|
||||
content: const Text("You have joined the organization."),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[HomePage] join organization failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Join Failed"),
|
||||
content: Text(error.toString()),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> runAuthDebug(BuildContext context) async {
|
||||
final client = context.read<SupabaseProvider>().client;
|
||||
final encoder = const JsonEncoder.withIndent(" ");
|
||||
|
||||
try {
|
||||
final response = await client.functions.invoke("auth-debug", body: {});
|
||||
final payload = response.data;
|
||||
final formatted = payload is Map || payload is List
|
||||
? encoder.convert(payload)
|
||||
: payload.toString();
|
||||
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Auth Debug Response"),
|
||||
content: SizedBox(
|
||||
width: 600,
|
||||
child: SingleChildScrollView(child: Text(formatted).small),
|
||||
),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[HomePage] auth-debug failed (${error.runtimeType}): $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Auth Debug Failed"),
|
||||
content: Text(error.toString()),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateChannelDialog extends StatelessWidget {
|
||||
const _CreateChannelDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedType = ValueNotifier<int>(1);
|
||||
final channelName = ValueNotifier<String>("");
|
||||
final channelDescription = ValueNotifier<String>("");
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text("Create Channel"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: selectedType,
|
||||
builder: (context, value, child) {
|
||||
return RadioGroup<int>(
|
||||
value: value,
|
||||
onChanged: (v) {
|
||||
selectedType.value = v;
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
RadioItem(
|
||||
value: 1,
|
||||
trailing: Basic(
|
||||
title: Text("Text Channel").large,
|
||||
subtitle: Text(
|
||||
"Standard chat channel for communications and discussions.",
|
||||
),
|
||||
),
|
||||
),
|
||||
RadioItem(
|
||||
value: 2,
|
||||
trailing: Basic(
|
||||
title: Text("Operations Channel").large,
|
||||
subtitle: Text(
|
||||
"Upload a schedule document and interact with it in a way that matters.",
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
const Text("Channel Name").medium.semiBold,
|
||||
const Gap(8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
features: [
|
||||
InputFeature.leading(
|
||||
Icon(
|
||||
LucideIcons.hash,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
).iconSmall,
|
||||
),
|
||||
],
|
||||
placeholder: const Text("general"),
|
||||
onChanged: (value) {
|
||||
channelName.value = value;
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
Navigator.of(context).pop({
|
||||
"name": channelName.value,
|
||||
"type": _channelTypeFromValue(selectedType.value),
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
const Text("Channel Description").medium.semiBold,
|
||||
const Gap(8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: TextField(
|
||||
placeholder: const Text("What this channel is for"),
|
||||
onChanged: (value) {
|
||||
channelDescription.value = value;
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
Navigator.of(context).pop({
|
||||
"name": channelName.value,
|
||||
"description": channelDescription.value,
|
||||
"type": _channelTypeFromValue(selectedType.value),
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(context).pop({
|
||||
"name": channelName.value,
|
||||
"description": channelDescription.value,
|
||||
"type": _channelTypeFromValue(selectedType.value),
|
||||
}),
|
||||
child: const Text("Create"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _channelTypeFromValue(int value) {
|
||||
switch (value) {
|
||||
case 2:
|
||||
return "operations";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:bus_running_record/pages/home/widgets/home_dialogs.dart";
|
||||
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||
import "package:bus_running_record/provider/supabase_state.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class HomeLeftSidebar extends StatelessWidget {
|
||||
const HomeLeftSidebar({super.key});
|
||||
|
||||
String _buildInitials(String displayName) {
|
||||
if (displayName.isEmpty) {
|
||||
return "U";
|
||||
}
|
||||
return displayName.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collab = context.watch<CollaborationProvider>();
|
||||
final supabase = context.watch<SupabaseProvider>();
|
||||
final organizations = collab.organizations;
|
||||
final user = supabase.session?.user;
|
||||
final displayName = user?.email ?? "Signed in user";
|
||||
final initials = _buildInitials(displayName);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ServerRail(
|
||||
organizations: organizations,
|
||||
selectedOrganizationId: collab.selectedOrganizationId,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
width: 100,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: IconButton.outline(
|
||||
onPressed: () {
|
||||
unawaited(showCreateOrganizationDialog(context));
|
||||
},
|
||||
shape: ButtonShape.circle,
|
||||
size: ButtonSize.normal,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
RotatedBox(
|
||||
quarterTurns: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"ROADBOUND",
|
||||
style: const TextStyle(height: 1),
|
||||
).extraBold.x3Large,
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: const TextStyle(height: 1),
|
||||
).small.muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const VerticalDivider(),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
width: 300,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width - 50,
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
alignment: Alignment.center,
|
||||
leading: const Icon(Icons.add),
|
||||
child: const Text("Create Organization"),
|
||||
onPressed: () {
|
||||
unawaited(showCreateOrganizationDialog(context));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
alignment: Alignment.center,
|
||||
leading: const Icon(LucideIcons.userRoundPlus),
|
||||
child: const Text("Join Organization"),
|
||||
onPressed: () {
|
||||
unawaited(showJoinOrganizationDialog(context));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.secondary(
|
||||
alignment: Alignment.center,
|
||||
leading: const Icon(LucideIcons.bug),
|
||||
child: const Text("Auth Debug"),
|
||||
onPressed: () {
|
||||
unawaited(runAuthDebug(context));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
right: 16,
|
||||
bottom: 8,
|
||||
),
|
||||
child: _OrganizationList(
|
||||
organizations: organizations,
|
||||
isLoading: collab.isLoadingOrganizations,
|
||||
errorMessage: collab.errorMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Button.ghost(
|
||||
child: Row(
|
||||
children: [
|
||||
Avatar(initials: initials),
|
||||
const Gap(8),
|
||||
Expanded(child: Basic(title: Text(displayName))),
|
||||
const Icon(LucideIcons.logOut).iconSmall,
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
unawaited(context.read<SupabaseProvider>().signOut());
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OrganizationList extends StatelessWidget {
|
||||
const _OrganizationList({
|
||||
required this.organizations,
|
||||
required this.isLoading,
|
||||
required this.errorMessage,
|
||||
});
|
||||
|
||||
final List<OrganizationSummary> organizations;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading && organizations.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (errorMessage != null && organizations.isEmpty) {
|
||||
return Center(
|
||||
child: Text(errorMessage!, textAlign: TextAlign.center).small,
|
||||
);
|
||||
}
|
||||
|
||||
if (organizations.isEmpty) {
|
||||
return Center(
|
||||
child: Text("No organizations yet. Create one to get started.").small,
|
||||
);
|
||||
}
|
||||
|
||||
final collab = context.watch<CollaborationProvider>();
|
||||
|
||||
return ListView.separated(
|
||||
itemBuilder: (context, index) {
|
||||
final org = organizations[index];
|
||||
return _OrganizationGroup(
|
||||
organization: org,
|
||||
channels: collab.channelsForOrganization(org.id),
|
||||
selectedOrganizationId: collab.selectedOrganizationId,
|
||||
selectedChannelId: collab.selectedChannelId,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Gap(4),
|
||||
itemCount: organizations.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerRail extends StatelessWidget {
|
||||
const _ServerRail({
|
||||
required this.organizations,
|
||||
required this.selectedOrganizationId,
|
||||
});
|
||||
|
||||
final List<OrganizationSummary> organizations;
|
||||
final String? selectedOrganizationId;
|
||||
|
||||
Future<void> _openOrganization(
|
||||
BuildContext context,
|
||||
String organizationId,
|
||||
) async {
|
||||
final collab = context.read<CollaborationProvider>();
|
||||
await collab.selectOrganization(organizationId);
|
||||
if (!context.mounted) return;
|
||||
final channelId = collab.selectedChannelId;
|
||||
if (channelId == null || channelId.isEmpty) {
|
||||
context.go("/");
|
||||
return;
|
||||
}
|
||||
context.go("/channel/$organizationId/$channelId");
|
||||
}
|
||||
|
||||
void _openOrganizationSettings(BuildContext context, String organizationId) {
|
||||
context.go("/org/$organizationId/settings");
|
||||
}
|
||||
|
||||
String _fallbackInitial(String organizationName) {
|
||||
final trimmedName = organizationName.trim();
|
||||
if (trimmedName.isEmpty) {
|
||||
return "O";
|
||||
}
|
||||
return trimmedName.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
Widget _buildOrganizationAvatar({
|
||||
required OrganizationSummary organization,
|
||||
required String fallbackInitial,
|
||||
}) {
|
||||
final iconUrl = organization.iconUrl;
|
||||
if (iconUrl == null || iconUrl.isEmpty) {
|
||||
return Center(child: Text(fallbackInitial).semiBold.small);
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(child: Text(fallbackInitial).semiBold.small);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ButtonStyle _organizationButtonStyle(bool isSelected) {
|
||||
if (isSelected) {
|
||||
return ButtonStyle.ghost(density: ButtonDensity.compact);
|
||||
}
|
||||
return ButtonStyle.ghost();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (organizations.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Column(
|
||||
children: organizations.map((organization) {
|
||||
final isSelected = selectedOrganizationId == organization.id;
|
||||
final fallbackInitial = _fallbackInitial(organization.name);
|
||||
final buttonStyle = _organizationButtonStyle(isSelected);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: HoverCard(
|
||||
hoverBuilder: (context) {
|
||||
return SurfaceCard(
|
||||
child: Basic(title: Text(organization.name).medium),
|
||||
);
|
||||
},
|
||||
anchorAlignment: Alignment.centerLeft,
|
||||
popoverAlignment: Alignment.centerRight,
|
||||
popoverOffset: const Offset(-4, 0),
|
||||
child: ContextMenu(
|
||||
items: [
|
||||
MenuLabel(child: Text(organization.name)),
|
||||
const MenuDivider(),
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.settings2).iconSmall,
|
||||
onPressed: (_) {
|
||||
_openOrganizationSettings(context, organization.id);
|
||||
},
|
||||
child: const Text("Server Settings"),
|
||||
),
|
||||
],
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildOrganizationAvatar(
|
||||
organization: organization,
|
||||
fallbackInitial: fallbackInitial,
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
height: 54,
|
||||
child: Button(
|
||||
style: buttonStyle,
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
_openOrganization(context, organization.id),
|
||||
);
|
||||
},
|
||||
child: Container(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OrganizationGroup extends StatefulWidget {
|
||||
const _OrganizationGroup({
|
||||
required this.organization,
|
||||
required this.channels,
|
||||
required this.selectedOrganizationId,
|
||||
required this.selectedChannelId,
|
||||
});
|
||||
|
||||
final OrganizationSummary organization;
|
||||
final List<ChannelSummary> channels;
|
||||
final String? selectedOrganizationId;
|
||||
final String? selectedChannelId;
|
||||
|
||||
@override
|
||||
State<_OrganizationGroup> createState() => _OrganizationGroupState();
|
||||
}
|
||||
|
||||
class _OrganizationGroupState extends State<_OrganizationGroup> {
|
||||
bool _headerHover = false;
|
||||
late bool _isExpanded;
|
||||
|
||||
bool get _isSelected =>
|
||||
widget.selectedOrganizationId == widget.organization.id;
|
||||
|
||||
IconData _expansionIcon(bool expanded) {
|
||||
if (expanded) {
|
||||
return LucideIcons.chevronDown;
|
||||
}
|
||||
return LucideIcons.chevronRight;
|
||||
}
|
||||
|
||||
void _toggleOrSelectOrganization() {
|
||||
if (_isSelected) {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isExpanded = true;
|
||||
});
|
||||
unawaited(
|
||||
context.read<CollaborationProvider>().selectOrganization(
|
||||
widget.organization.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isExpanded = _isSelected;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _OrganizationGroup oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final wasSelected =
|
||||
oldWidget.selectedOrganizationId == oldWidget.organization.id;
|
||||
if (!wasSelected && _isSelected) {
|
||||
_isExpanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = _isExpanded;
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
|
||||
final header = Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: isMobile ? () => _showOrganizationMenu(context) : null,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _headerHover = true),
|
||||
onExit: (_) => setState(() => _headerHover = false),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Button.text(
|
||||
alignment: Alignment.centerLeft,
|
||||
marginAlignment: Alignment.centerLeft,
|
||||
style: ButtonStyle.text(density: ButtonDensity.dense),
|
||||
leading: SizedBox(
|
||||
width: 16,
|
||||
child: Icon(_expansionIcon(expanded)).iconSmall,
|
||||
),
|
||||
onPressed: _toggleOrSelectOrganization,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(widget.organization.name).normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
Visibility.maintain(
|
||||
visible: _headerHover,
|
||||
child: Builder(
|
||||
builder: (buttonContext) => Button.text(
|
||||
style: ButtonStyle.text(density: ButtonDensity.dense),
|
||||
onPressed: () {
|
||||
_showOrganizationMenu(buttonContext);
|
||||
},
|
||||
child: Icon(LucideIcons.ellipsis).iconSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!expanded) return header;
|
||||
|
||||
final channels = widget.channels;
|
||||
if (channels.isEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
header,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 28, top: 4, bottom: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("No channels yet").small.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
header,
|
||||
const Gap(2),
|
||||
...channels.map(
|
||||
(channel) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: _ChannelButton(
|
||||
channel: channel,
|
||||
active: widget.selectedChannelId == channel.id,
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
context.read<CollaborationProvider>().selectOrganization(
|
||||
widget.organization.id,
|
||||
),
|
||||
);
|
||||
context.read<CollaborationProvider>().selectChannel(channel.id);
|
||||
context.go("/channel/${widget.organization.id}/${channel.id}");
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showOrganizationMenu(BuildContext context) {
|
||||
showDropdown<void>(
|
||||
context: context,
|
||||
anchorAlignment: Alignment.bottomRight,
|
||||
alignment: Alignment.topLeft,
|
||||
builder: (menuContext) {
|
||||
return DropdownMenu(
|
||||
children: [
|
||||
MenuLabel(child: Text(widget.organization.name).small.semiBold),
|
||||
const MenuDivider(),
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.plus).iconSmall,
|
||||
onPressed: (_) {
|
||||
unawaited(
|
||||
showCreateChannelDialog(
|
||||
context,
|
||||
organizationId: widget.organization.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text("Add Channel"),
|
||||
),
|
||||
const MenuDivider(),
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.settings2).iconSmall,
|
||||
onPressed: (_) {
|
||||
context.go("/org/${widget.organization.id}/settings");
|
||||
},
|
||||
child: const Text("Settings"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelButton extends StatelessWidget {
|
||||
const _ChannelButton({
|
||||
required this.channel,
|
||||
required this.active,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final ChannelSummary channel;
|
||||
final bool active;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
IconData _iconForType() {
|
||||
switch (channel.type) {
|
||||
case "voice":
|
||||
return LucideIcons.mic;
|
||||
case "operations":
|
||||
return LucideIcons.clipboardList;
|
||||
default:
|
||||
return LucideIcons.notebookPen;
|
||||
}
|
||||
}
|
||||
|
||||
Color _labelColor(BuildContext context) {
|
||||
if (active) {
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
return Theme.of(context).colorScheme.mutedForeground;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _labelColor(context);
|
||||
return Button.ghost(
|
||||
marginAlignment: Alignment.centerLeft,
|
||||
style: ButtonStyle.ghost(density: ButtonDensity.dense),
|
||||
onPressed: onPressed,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||
width: double.infinity,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_iconForType(), color: color).iconSmall,
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(channel.name, style: TextStyle(color: color)).normal,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:file_picker/file_picker.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../models/operations/trip.dart";
|
||||
import "../parsers/arriva_schedule_parser.dart";
|
||||
import "../parsers/stagecoach_schedule_parser.dart";
|
||||
import "../services/brr_export_service.dart";
|
||||
@@ -9,9 +9,10 @@ import "../services/storage_service.dart";
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
static const routePath = "/deprecated";
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/",
|
||||
path: routePath,
|
||||
builder: (context, state) => const HomePage(),
|
||||
);
|
||||
|
||||
@@ -102,8 +103,9 @@ class _HomePageState extends State<HomePage> {
|
||||
if (routeName != null) "routeName": routeName,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e, stackTrace) {
|
||||
print("Error: $e");
|
||||
print(stackTrace);
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
@@ -284,4 +286,4 @@ class _OperatorChip extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:go_router/go_router.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../models/operations/trip.dart";
|
||||
import "../services/brr_export_service.dart";
|
||||
|
||||
class StationSelectionPage extends StatefulWidget {
|
||||
static const routePath = "/station-selection";
|
||||
static const legacyHomePath = "/deprecated";
|
||||
final List<Trip> trips;
|
||||
final String fileName;
|
||||
final BRROperator operator;
|
||||
@@ -18,12 +20,12 @@ class StationSelectionPage extends StatefulWidget {
|
||||
});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/station-selection",
|
||||
path: routePath,
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
if (extra == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go("/");
|
||||
context.go(legacyHomePath);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -166,7 +168,7 @@ class _StationSelectionPageState extends State<StationSelectionPage> {
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: () => context.go("/"),
|
||||
onPressed: () => context.go(StationSelectionPage.legacyHomePath),
|
||||
),
|
||||
title: const Text("SELECT STATION"),
|
||||
),
|
||||
|
||||
@@ -6,13 +6,14 @@ import "package:file_saver/file_saver.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:share_plus/share_plus.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../models/operations/trip.dart";
|
||||
import "../models/brr_metadata.dart";
|
||||
import "../models/brr_state.dart";
|
||||
import "../services/brr_export_service.dart";
|
||||
import "../services/storage_service.dart";
|
||||
|
||||
class TripListPage extends StatefulWidget {
|
||||
static const routePath = "/trips";
|
||||
final List<Trip> trips;
|
||||
final String fileName;
|
||||
final bool fromStationSelection;
|
||||
@@ -29,7 +30,7 @@ class TripListPage extends StatefulWidget {
|
||||
});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/trips",
|
||||
path: routePath,
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
if (extra == null) {
|
||||
@@ -478,7 +479,7 @@ class _TripCardState extends State<TripCard> {
|
||||
children: [
|
||||
_InfoCell("DUTY", widget.trip.dutyNumber),
|
||||
const SizedBox(width: 12),
|
||||
_InfoCell("RUNNING", widget.trip.runningNumber),
|
||||
_InfoCell("RUNNING", widget.trip.busWorkNumber),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user