613 lines
19 KiB
Dart
613 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);
|
|
|
|
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<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();
|
|
}
|
|
|
|
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<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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|