Add version files and update imports for trip model; enhance error handling

This commit is contained in:
ImBenji
2026-03-27 21:17:56 +00:00
parent e41e14e252
commit 427bcadc77
89 changed files with 9455 additions and 395 deletions
@@ -0,0 +1,42 @@
import "package:bus_running_record/models/channels/operations_channel.dart";
import "package:go_router/go_router.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class OperationsChannelView extends StatelessWidget {
const OperationsChannelView({required this.channel, super.key});
final OperationsChannel channel;
@override
Widget build(BuildContext context) {
final isScheduleUploaded = channel.id.isEmpty;
if (!isScheduleUploaded) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"No schedule uploaded yet.",
).h4,
Gap(8),
Button.secondary(
child: Text(
"Upload Schedule",
),
onPressed: () {
context.go(
"/channel/${channel.organizationId}/${channel.id}/upload",
);
},
)
],
)
);
}
return const SizedBox.expand();
}
}
@@ -0,0 +1,299 @@
import "dart:async";
import "package:bus_running_record/models/channels/text_channel.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "package:supabase_flutter/supabase_flutter.dart";
class TextChannelView extends StatefulWidget {
const TextChannelView({required this.channel, super.key});
final TextChannel channel;
@override
State<TextChannelView> createState() => _TextChannelViewState();
}
class _TextChannelViewState extends State<TextChannelView> {
RealtimeChannel? _messagesRealtimeChannel;
bool _loadingMessages = false;
bool _sendingMessage = false;
String? _messageError;
String _draftMessage = "";
int _composerNonce = 0;
List<TextChannelMessage> _messages = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_initializeChannel());
});
}
@override
void didUpdateWidget(covariant TextChannelView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.channel.id == widget.channel.id) return;
unawaited(_initializeChannel());
}
Future<void> _initializeChannel() async {
await _unsubscribeFromRealtimeMessages();
if (!mounted) return;
setState(() {
_messages = const [];
_messageError = null;
_draftMessage = "";
_composerNonce += 1;
});
await _subscribeToRealtimeMessages();
await _loadMessages();
}
Future<void> _loadMessages() async {
if (_loadingMessages) return;
setState(() {
_loadingMessages = true;
_messageError = null;
});
try {
final messages = await widget.channel.listMessages();
if (!mounted) return;
setState(() {
_messages = messages;
});
} catch (error, stackTrace) {
debugPrint("[TextChannelView] loadMessages failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_messageError = error.toString();
});
} finally {
if (mounted) {
setState(() {
_loadingMessages = false;
});
}
}
}
Future<void> _subscribeToRealtimeMessages() async {
if (_messagesRealtimeChannel != null) return;
final realtime = widget.channel.subscribeToMessages(
onMessageChanged: () {
if (!mounted) return;
unawaited(_loadMessages());
},
onStatus: (status, error) {
if (status == RealtimeSubscribeStatus.subscribed) return;
if (status == RealtimeSubscribeStatus.channelError ||
status == RealtimeSubscribeStatus.timedOut) {
debugPrint(
"[TextChannelView] realtime subscribe issue ($status) for channel ${widget.channel.id}: $error",
);
}
},
);
_messagesRealtimeChannel = realtime;
}
Future<void> _unsubscribeFromRealtimeMessages() async {
final realtime = _messagesRealtimeChannel;
_messagesRealtimeChannel = null;
if (realtime == null) return;
try {
await widget.channel.unsubscribe(realtime);
debugPrint("[TextChannelView] realtime unsubscribed");
} catch (error, stackTrace) {
debugPrint("[TextChannelView] realtime unsubscribe failed: $error");
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> _sendMessage() async {
final content = _draftMessage.trim();
if (content.isEmpty || _sendingMessage) return;
setState(() {
_sendingMessage = true;
_messageError = null;
});
try {
await widget.channel.sendMessage(content);
if (!mounted) return;
setState(() {
_draftMessage = "";
_composerNonce += 1;
});
await _loadMessages();
} catch (error, stackTrace) {
debugPrint("[TextChannelView] sendMessage failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_messageError = error.toString();
});
} finally {
if (mounted) {
setState(() {
_sendingMessage = false;
});
}
}
}
Widget _buildMessageList() {
if (_loadingMessages) {
return const Center(child: CircularProgressIndicator());
}
if (_messages.isEmpty) {
return Center(child: Text("No messages yet. Say hi.").small.muted);
}
final currentUserId = widget.channel.client.auth.currentUser?.id ?? "";
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
for (var i = 0; i < _messages.length; i++) ...[
MessageBubble(message: _messages[i], currentUserId: currentUserId),
if (i != _messages.length - 1) const Gap(2),
],
],
),
);
}
@override
void dispose() {
unawaited(_unsubscribeFromRealtimeMessages());
super.dispose();
}
@override
Widget build(BuildContext context) {
double bottomPadding = MediaQuery.of(context).padding.bottom;
return Column(
children: [
Expanded(child: _buildMessageList()),
if (_messageError != null)
Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: Text(
_messageError!,
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).small,
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
clipBehavior: Clip.none,
height: 60,
child: TextField(
key: ValueKey("composer-$_composerNonce"),
initialValue: "",
placeholder: Text("Message #${widget.channel.slug}"),
enabled: !_sendingMessage,
onChanged: (value) {
_draftMessage = value;
},
onSubmitted: (_) => unawaited(_sendMessage()),
features: [
InputFeature.leading(
IconButton.ghost(
icon: const Icon(LucideIcons.plus).iconSmall,
onPressed: () {},
),
),
],
),
),
Gap(bottomPadding + 12),
],
);
}
}
class MessageBubble extends StatelessWidget {
const MessageBubble({
required this.message,
required this.currentUserId,
super.key,
});
final TextChannelMessage message;
final String currentUserId;
String _formatTime() {
final createdAt = message.createdAt?.toLocal();
if (createdAt == null) {
return "";
}
final paddedHour = createdAt.hour.toString().padLeft(2, "0");
final paddedMinute = createdAt.minute.toString().padLeft(2, "0");
return "$paddedHour:$paddedMinute";
}
String _senderLabel() {
final authorUserId = message.authorUserId;
final isCurrentUser =
currentUserId.isNotEmpty && authorUserId == currentUserId;
if (isCurrentUser) {
return "You";
}
if (authorUserId.length >= 8) {
return "User ${authorUserId.substring(0, 8)}";
}
return "User $authorUserId";
}
@override
Widget build(BuildContext context) {
final timeText = _formatTime();
final senderLabel = _senderLabel();
final shouldShowTime = timeText.isNotEmpty;
final senderInitials = Avatar.getInitials(senderLabel);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(initials: senderInitials),
const Gap(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(senderLabel).small.semiBold,
if (shouldShowTime) ...[
const Gap(8),
Text(timeText).xSmall.muted,
],
],
),
const Gap(2),
Text(message.content).small,
],
),
),
],
),
);
}
}
+192
View File
@@ -0,0 +1,192 @@
import "dart:async";
import "package:bus_running_record/models/channels/base_channel.dart";
import "package:bus_running_record/models/channels/operations_channel.dart";
import "package:bus_running_record/models/channels/text_channel.dart";
import "package:bus_running_record/pages/home/channels/operations_channel_view.dart";
import "package:bus_running_record/pages/home/channels/text_channel_view.dart";
import "package:bus_running_record/pages/home/widgets/swiper.dart";
import "package:bus_running_record/pages/home/widgets/channel_header.dart";
import "package:bus_running_record/pages/home/widgets/home_left_sidebar.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 HomePage extends StatefulWidget {
static GoRoute rootRoute = GoRoute(
path: "/",
builder: (context, state) => const HomePage(),
);
static GoRoute channelRoute = GoRoute(
path: "/channel/:orgId/:channelId",
builder: (context, state) => HomePage(
organizationId: state.pathParameters["orgId"],
channelId: state.pathParameters["channelId"],
),
);
const HomePage({this.organizationId, this.channelId, super.key});
final String? organizationId;
final String? channelId;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
String? _lastSyncedRouteKey;
Future<void> _syncRouteSelection() async {
final orgId = widget.organizationId;
final channelId = widget.channelId;
if (orgId == null || channelId == null) return;
final routeKey = "$orgId/$channelId";
if (_lastSyncedRouteKey == routeKey) return;
_lastSyncedRouteKey = routeKey;
final collab = context.read<CollaborationProvider>();
if (collab.selectedOrganizationId != orgId) {
await collab.selectOrganization(orgId);
}
final channels = collab.channelsForOrganization(orgId);
if (channels.any((channel) => channel.id == channelId)) {
collab.selectChannel(channelId);
}
}
@override
Widget build(BuildContext context) {
if (widget.organizationId != null && widget.channelId != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_syncRouteSelection());
});
} else {
_lastSyncedRouteKey = null;
}
final isMobile = MediaQuery.of(context).size.width < 600;
if (isMobile) {
return const SidebarSwiper(
sidebar: HomeLeftSidebar(),
child: Scaffold(
child: Row(children: [Expanded(child: _HomeChannelPane())]),
),
);
}
return const Scaffold(
child: Row(
children: [
HomeLeftSidebar(),
VerticalDivider(),
Expanded(child: _HomeChannelPane()),
],
),
);
}
}
class _HomeChannelPane extends StatelessWidget {
const _HomeChannelPane();
ChannelSummary? _findChannel(
CollaborationProvider collab,
String? organizationId,
String? channelId,
) {
if (organizationId == null || channelId == null) return null;
final channels = collab.channelsForOrganization(organizationId);
for (final channel in channels) {
if (channel.id == channelId) {
return channel;
}
}
return null;
}
BaseChannel? _buildChannelModel(
BuildContext context,
ChannelSummary? selectedChannel,
) {
if (selectedChannel == null) return null;
final client = context.read<SupabaseProvider>().client;
if (selectedChannel.type == "operations") {
return OperationsChannel(
client: client,
id: selectedChannel.id,
organizationId: selectedChannel.organizationId,
name: selectedChannel.name,
description: selectedChannel.description,
slug: selectedChannel.slug,
isPrivate: selectedChannel.isPrivate,
position: selectedChannel.position,
);
}
return TextChannel(
client: client,
id: selectedChannel.id,
organizationId: selectedChannel.organizationId,
name: selectedChannel.name,
description: selectedChannel.description,
slug: selectedChannel.slug,
isPrivate: selectedChannel.isPrivate,
position: selectedChannel.position,
);
}
Widget _buildChannelContent({
required BaseChannel? channel,
}) {
if (channel == null) {
return Center(
child: Text("Pick a channel to start chatting.").small.muted,
);
}
if (channel is OperationsChannel) {
return OperationsChannelView(channel: channel);
}
final textChannel = channel as TextChannel;
return TextChannelView(
key: ValueKey("text-channel-${textChannel.id}"),
channel: textChannel,
);
}
@override
Widget build(BuildContext context) {
final collab = context.watch<CollaborationProvider>();
final selectedOrg = collab.selectedOrganizationId;
final selectedChannelId = collab.selectedChannelId;
final selectedChannel = _findChannel(
collab,
selectedOrg,
selectedChannelId,
);
final channel = _buildChannelModel(context, selectedChannel);
final channelBody = _buildChannelContent(
channel: channel,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (channel != null) ...[
ChannelHeader(channel: channel),
],
Expanded(child: channelBody),
],
);
}
}
@@ -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(),
],
),
),
],
);
}
}
+376
View File
@@ -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,
),
],
),
),
);
}
}
+126
View File
@@ -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,
),
),
],
);
}
}