569 lines
20 KiB
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.";
|
|
});
|
|
}
|
|
}
|