import "dart:async"; import "package:bus_running_record/constants.dart"; 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 createState() => _OrganizationSettingsPageState(); } class _OrganizationSettingsPageState extends State { 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().selectOrganization( widget.organizationId, ), ); }); } @override Widget build(BuildContext context) { final collab = context.watch(); 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 _saveOrganizationName( BuildContext context, String organizationId, ) async { final name = _organizationName.trim(); if (name.isEmpty) return; setState(() { _savingOrganization = true; _message = null; }); try { await context.read().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 _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().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 _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().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 _confirmDeleteChannel( String organizationId, ChannelSummary channel, ) async { final confirmed = await showDialog( 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().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 _createInviteLink( BuildContext context, String organizationId, ) async { setState(() { _creatingInvite = true; _message = null; }); try { final token = await context.read().createInvite( organizationId: organizationId, ); if (!mounted) return; final inviteLink = "https://$kAppHostname/#/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 _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."; }); } }