Add version files and update imports for trip model; enhance error handling
This commit is contained in:
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user