Roadbound-BRR/lib/pages/org_settings/page.dart

569 lines
20 KiB
Dart

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;
double topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(topPadding),
Button.text(
leading: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.go("/"),
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.";
});
}
}