Roadbound-BRR/lib/pages/home/channels/text_channel_view.dart

299 lines
8 KiB
Dart

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,
],
),
),
],
),
);
}
}