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(); final supabase = context.watch(); 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().signOut()); }, ), ), ], ), ), ), ], ), ); } } class _OrganizationList extends StatelessWidget { const _OrganizationList({ required this.organizations, required this.isLoading, required this.errorMessage, }); final List 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(); 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 organizations; final String? selectedOrganizationId; Future _openOrganization( BuildContext context, String organizationId, ) async { final collab = context.read(); 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 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().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().selectOrganization( widget.organization.id, ), ); context.read().selectChannel(channel.id); context.go("/channel/${widget.organization.id}/${channel.id}"); }, ), ), ), const Gap(4), ], ); } void _showOrganizationMenu(BuildContext context) { showDropdown( 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, ), ], ), ), ); } }