299 lines
8 KiB
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,
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|