Add version files and update imports for trip model; enhance error handling
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import "package:bus_running_record/models/channels/base_channel.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class ChannelHeader extends StatelessWidget {
|
||||
const ChannelHeader({required this.channel, super.key});
|
||||
|
||||
final BaseChannel channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
double topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
Gap(topPadding),
|
||||
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.hash,
|
||||
).iconSmall,
|
||||
Gap(4),
|
||||
Text(channel.slug).textSmall,
|
||||
Icon(LucideIcons.dot).iconMutedForeground,
|
||||
Text(channel.description),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import "dart:convert";
|
||||
|
||||
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||
import "package:bus_running_record/provider/supabase_state.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
Future<void> showCreateOrganizationDialog(BuildContext context) async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
String name = "";
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text("Create Organization"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Name your new organization."),
|
||||
const Gap(12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
placeholder: const Text("Enter organization name"),
|
||||
onChanged: (value) {
|
||||
name = value;
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
Navigator.of(dialogContext).pop(name);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(name),
|
||||
child: const Text("Create"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final name = (result ?? "").trim();
|
||||
if (name.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await context.read<CollaborationProvider>().createOrganization(name);
|
||||
}
|
||||
|
||||
Future<void> showCreateChannelDialog(
|
||||
BuildContext context, {
|
||||
required String organizationId,
|
||||
}) async {
|
||||
final result = await showDialog<Map<String, String>>(
|
||||
context: context,
|
||||
builder: (dialogContext) => const _CreateChannelDialog(),
|
||||
);
|
||||
|
||||
final channelName = (result?["name"] ?? "").trim();
|
||||
final channelDescription = (result?["description"] ?? "").trim();
|
||||
final channelType = (result?["type"] ?? "text").trim();
|
||||
if (channelName.isEmpty) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await context.read<CollaborationProvider>().createChannel(
|
||||
organizationId: organizationId,
|
||||
name: channelName,
|
||||
description: channelDescription,
|
||||
type: channelType,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[HomePage] createChannel dialog failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Create Channel Failed"),
|
||||
content: Text(error.toString()),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String extractInviteToken(String input) {
|
||||
final trimmed = input.trim();
|
||||
if (trimmed.isEmpty) return "";
|
||||
|
||||
final invitePathMatch = RegExp(r"/invite/([0-9a-fA-F]+)").firstMatch(trimmed);
|
||||
if (invitePathMatch != null) {
|
||||
return (invitePathMatch.group(1) ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
final hashInviteMatch = RegExp(
|
||||
r"#/invite/([0-9a-fA-F]+)",
|
||||
).firstMatch(trimmed);
|
||||
if (hashInviteMatch != null) {
|
||||
return (hashInviteMatch.group(1) ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
Future<void> showJoinOrganizationDialog(BuildContext context) async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
String inviteInput = "";
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text("Join Organization"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Paste an invite link or invite token."),
|
||||
const Gap(12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
placeholder: const Text("https://.../#/invite/<token>"),
|
||||
onChanged: (value) {
|
||||
inviteInput = value;
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
Navigator.of(dialogContext).pop(inviteInput);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(inviteInput),
|
||||
child: const Text("Join"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final token = extractInviteToken(result ?? "");
|
||||
if (token.isEmpty) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await context.read<CollaborationProvider>().acceptInviteToken(token);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Joined"),
|
||||
content: const Text("You have joined the organization."),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[HomePage] join organization failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Join Failed"),
|
||||
content: Text(error.toString()),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> runAuthDebug(BuildContext context) async {
|
||||
final client = context.read<SupabaseProvider>().client;
|
||||
final encoder = const JsonEncoder.withIndent(" ");
|
||||
|
||||
try {
|
||||
final response = await client.functions.invoke("auth-debug", body: {});
|
||||
final payload = response.data;
|
||||
final formatted = payload is Map || payload is List
|
||||
? encoder.convert(payload)
|
||||
: payload.toString();
|
||||
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Auth Debug Response"),
|
||||
content: SizedBox(
|
||||
width: 600,
|
||||
child: SingleChildScrollView(child: Text(formatted).small),
|
||||
),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[HomePage] auth-debug failed (${error.runtimeType}): $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Auth Debug Failed"),
|
||||
content: Text(error.toString()),
|
||||
actions: [
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateChannelDialog extends StatelessWidget {
|
||||
const _CreateChannelDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedType = ValueNotifier<int>(1);
|
||||
final channelName = ValueNotifier<String>("");
|
||||
final channelDescription = ValueNotifier<String>("");
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text("Create Channel"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: selectedType,
|
||||
builder: (context, value, child) {
|
||||
return RadioGroup<int>(
|
||||
value: value,
|
||||
onChanged: (v) {
|
||||
selectedType.value = v;
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
RadioItem(
|
||||
value: 1,
|
||||
trailing: Basic(
|
||||
title: Text("Text Channel").large,
|
||||
subtitle: Text(
|
||||
"Standard chat channel for communications and discussions.",
|
||||
),
|
||||
),
|
||||
),
|
||||
RadioItem(
|
||||
value: 2,
|
||||
trailing: Basic(
|
||||
title: Text("Operations Channel").large,
|
||||
subtitle: Text(
|
||||
"Upload a schedule document and interact with it in a way that matters.",
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
const Text("Channel Name").medium.semiBold,
|
||||
const Gap(8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
features: [
|
||||
InputFeature.leading(
|
||||
Icon(
|
||||
LucideIcons.hash,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
).iconSmall,
|
||||
),
|
||||
],
|
||||
placeholder: const Text("general"),
|
||||
onChanged: (value) {
|
||||
channelName.value = value;
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
Navigator.of(context).pop({
|
||||
"name": channelName.value,
|
||||
"type": _channelTypeFromValue(selectedType.value),
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
const Text("Channel Description").medium.semiBold,
|
||||
const Gap(8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: TextField(
|
||||
placeholder: const Text("What this channel is for"),
|
||||
onChanged: (value) {
|
||||
channelDescription.value = value;
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
Navigator.of(context).pop({
|
||||
"name": channelName.value,
|
||||
"description": channelDescription.value,
|
||||
"type": _channelTypeFromValue(selectedType.value),
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
Button.primary(
|
||||
onPressed: () => Navigator.of(context).pop({
|
||||
"name": channelName.value,
|
||||
"description": channelDescription.value,
|
||||
"type": _channelTypeFromValue(selectedType.value),
|
||||
}),
|
||||
child: const Text("Create"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _channelTypeFromValue(int value) {
|
||||
switch (value) {
|
||||
case 2:
|
||||
return "operations";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import "dart:math" as math;
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class SidebarSwiper extends StatefulWidget {
|
||||
const SidebarSwiper({
|
||||
required this.sidebar,
|
||||
required this.child,
|
||||
this.maxSidebarWidth = 360,
|
||||
this.sidebarWidthFactor = 0.92,
|
||||
this.edgeDragWidth = 44,
|
||||
this.animationDuration = const Duration(milliseconds: 220),
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget sidebar;
|
||||
final Widget child;
|
||||
final double maxSidebarWidth;
|
||||
final double sidebarWidthFactor;
|
||||
final double edgeDragWidth;
|
||||
final Duration animationDuration;
|
||||
|
||||
@override
|
||||
State<SidebarSwiper> createState() => _SidebarSwiperState();
|
||||
}
|
||||
|
||||
class _SidebarSwiperState extends State<SidebarSwiper> {
|
||||
static const double _closedExtraOffset = 12;
|
||||
|
||||
double _progress = 0; // 0 = closed, 1 = fully open
|
||||
bool _isDragging = false;
|
||||
bool _canDrag = false;
|
||||
double _dragStartGlobalX = 0;
|
||||
double _dragStartProgress = 0;
|
||||
|
||||
void _setOpen(bool value) {
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
_progress = value ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details, double sidebarWidth) {
|
||||
final isOpen = _progress > 0.001;
|
||||
final fromEdge = details.globalPosition.dx <= widget.edgeDragWidth;
|
||||
final fromSidebarZone = details.globalPosition.dx <= sidebarWidth;
|
||||
_canDrag = fromEdge || (isOpen && fromSidebarZone);
|
||||
if (!_canDrag) return;
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragStartGlobalX = details.globalPosition.dx;
|
||||
_dragStartProgress = _progress;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details, double sidebarWidth) {
|
||||
if (!_canDrag || !_isDragging) return;
|
||||
if (sidebarWidth <= 0) return;
|
||||
final movedX = details.globalPosition.dx - _dragStartGlobalX;
|
||||
setState(() {
|
||||
_progress = (_dragStartProgress + (movedX / sidebarWidth)).clamp(0, 1);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
if (!_canDrag) return;
|
||||
_canDrag = false;
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
final shouldOpen = velocity > 250
|
||||
? true
|
||||
: (velocity < -250 ? false : _progress >= 0.35);
|
||||
_setOpen(shouldOpen);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final sidebarWidth = math.min(
|
||||
widget.maxSidebarWidth,
|
||||
screenWidth * widget.sidebarWidthFactor,
|
||||
);
|
||||
final leftOffset =
|
||||
-(sidebarWidth + _closedExtraOffset) +
|
||||
((sidebarWidth + _closedExtraOffset) * _progress);
|
||||
final showScrim = _progress > 0;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (details) =>
|
||||
_handleDragStart(details, sidebarWidth),
|
||||
onHorizontalDragUpdate: (details) =>
|
||||
_handleDragUpdate(details, sidebarWidth),
|
||||
onHorizontalDragEnd: _handleDragEnd,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
if (showScrim)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _setOpen(false),
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.45 * _progress),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: _isDragging ? Duration.zero : widget.animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
left: leftOffset,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: sidebarWidth,
|
||||
child: Material(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: widget.sidebar,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user