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 createState() => _TextChannelViewState(); } class _TextChannelViewState extends State { RealtimeChannel? _messagesRealtimeChannel; bool _loadingMessages = false; bool _sendingMessage = false; String? _messageError; String _draftMessage = ""; int _composerNonce = 0; List _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 _initializeChannel() async { await _unsubscribeFromRealtimeMessages(); if (!mounted) return; setState(() { _messages = const []; _messageError = null; _draftMessage = ""; _composerNonce += 1; }); await _subscribeToRealtimeMessages(); await _loadMessages(); } Future _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 _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 _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 _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, ], ), ), ], ), ); } }