Roadbound-BRR/lib/pages/home/widgets/home_left_sidebar.dart

619 lines
19 KiB
Dart

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<CollaborationProvider>();
final supabase = context.watch<SupabaseProvider>();
final organizations = collab.organizations;
final user = supabase.session?.user;
final displayName = user?.email ?? "Signed in user";
final initials = _buildInitials(displayName);
double topPadding = MediaQuery.of(context).padding.top;
double bottomPadding = MediaQuery.of(context).padding.bottom;
return 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,
],
),
),
),
Gap(bottomPadding)
],
),
),
const VerticalDivider(),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.background,
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(topPadding),
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<SupabaseProvider>().signOut());
},
),
),
],
),
),
),
),
],
);
}
}
class _OrganizationList extends StatelessWidget {
const _OrganizationList({
required this.organizations,
required this.isLoading,
required this.errorMessage,
});
final List<OrganizationSummary> 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<CollaborationProvider>();
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<OrganizationSummary> organizations;
final String? selectedOrganizationId;
Future<void> _openOrganization(
BuildContext context,
String organizationId,
) async {
final collab = context.read<CollaborationProvider>();
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();
}
double topPadding = MediaQuery.of(context).padding.top;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Column(
children: [
Gap(topPadding),
...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(),
),
),
],
),
),
),
);
})
],
),
);
}
}
class _OrganizationGroup extends StatefulWidget {
const _OrganizationGroup({
required this.organization,
required this.channels,
required this.selectedOrganizationId,
required this.selectedChannelId,
});
final OrganizationSummary organization;
final List<ChannelSummary> 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<CollaborationProvider>().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<CollaborationProvider>().selectOrganization(
widget.organization.id,
),
);
context.read<CollaborationProvider>().selectChannel(channel.id);
context.go("/channel/${widget.organization.id}/${channel.id}");
},
),
),
),
const Gap(4),
],
);
}
void _showOrganizationMenu(BuildContext context) {
showDropdown<void>(
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,
),
],
),
),
);
}
}