Add version files and update imports for trip model; enhance error handling
This commit is contained in:
@@ -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.";
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user