This commit is contained in:
2026-10-16 01:25:50 +05:30
commit c60ea2d370
89 changed files with 4661 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../app_scope.dart';
import '../format.dart';
import '../inbox.dart';
import '../models/support_message.dart';
import '../polling.dart';
class ThreadScreen extends StatefulWidget {
const ThreadScreen({super.key, required this.ticketId});
final String ticketId;
@override
State<ThreadScreen> createState() => _ThreadScreenState();
}
class _ThreadScreenState extends State<ThreadScreen> with RouteAware {
final _replyController = TextEditingController();
final _scrollController = ScrollController();
InboxController? _inbox;
RouteObserver<ModalRoute<void>>? _routeObserver;
var _started = false;
var _sending = false;
var _updatingStatus = false;
var _stickToBottom = true;
String? _actionError;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final scope = AppScope.of(context);
final route = ModalRoute.of(context);
_routeObserver ??= scope.routeObserver;
if (route is ModalRoute<void>) {
scope.routeObserver.subscribe(this, route);
}
_inbox ??= scope.inbox;
if (_started) {
return;
}
_started = true;
_inbox!.activeTicketId = widget.ticketId;
_inbox!.addListener(_onInbox);
unawaited(_inbox!.refreshThread(showLoading: true));
}
@override
void didPopNext() {
_inbox?.activeTicketId = widget.ticketId;
final refresh = _inbox?.refreshThread();
if (refresh != null) {
unawaited(refresh);
}
}
@override
void dispose() {
_routeObserver?.unsubscribe(this);
_inbox?.removeListener(_onInbox);
if (_inbox?.activeTicketId == widget.ticketId) {
_inbox?.activeTicketId = null;
}
_replyController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onInbox() {
if (!mounted) {
return;
}
final stick = _stickToBottom || _nearBottom();
setState(() {});
if (stick) {
_stickToBottom = false;
_jumpToBottom();
}
}
bool _nearBottom() {
if (!_scrollController.hasClients) {
return true;
}
final position = _scrollController.position;
return position.maxScrollExtent - position.pixels < 80;
}
void _jumpToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
}
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
});
}
Future<void> _send() async {
final inbox = _inbox;
if (inbox == null || _sending) {
return;
}
setState(() {
_sending = true;
_actionError = null;
});
_stickToBottom = true;
final error = await inbox.sendReply(_replyController.text);
if (!mounted) {
return;
}
setState(() {
_sending = false;
_actionError = error;
if (error == null) {
_replyController.clear();
}
});
}
Future<void> _toggleStatus() async {
final inbox = _inbox;
final ticket = inbox?.threadTicket;
if (inbox == null || ticket == null || _updatingStatus) {
return;
}
setState(() {
_updatingStatus = true;
_actionError = null;
});
final error = await inbox.setStatus(ticket.isClosed ? 'open' : 'closed');
if (!mounted) {
return;
}
setState(() {
_updatingStatus = false;
_actionError = error;
});
}
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
final inbox = scope.inbox;
final canWrite = scope.session.account?.canWriteSupport ?? false;
final ticket = inbox.threadTicket?.id == widget.ticketId
? inbox.threadTicket
: null;
final messages = ticket == null ? const <SupportMessage>[] : inbox.messages;
return ForegroundPoll(
onPoll: () => inbox.refreshThread(),
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ticket?.displayCategory ?? 'Ticket'),
if (ticket != null)
Text(
ticket.username,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
actions: [
if (canWrite && ticket != null)
TextButton(
onPressed: _updatingStatus ? null : _toggleStatus,
child: Text(ticket.isClosed ? 'Reopen' : 'Close'),
),
],
),
body: Column(
children: [
Expanded(
child: _messages(
context,
inbox,
messages,
hasTicket: ticket != null,
),
),
if (_actionError != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
_actionError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
if (canWrite && ticket != null) _composer(context),
],
),
),
);
}
Widget _messages(
BuildContext context,
InboxController inbox,
List<SupportMessage> messages, {
required bool hasTicket,
}) {
if ((!hasTicket && inbox.threadError == null) ||
(inbox.threadLoading && messages.isEmpty)) {
return const Center(child: CircularProgressIndicator());
}
if (inbox.threadError != null && messages.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(inbox.threadError!, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => inbox.refreshThread(showLoading: true),
child: const Text('Try again'),
),
],
),
),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length,
itemBuilder: (context, index) {
return _Bubble(message: messages[index]);
},
);
}
Widget _composer(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _replyController,
minLines: 1,
maxLines: 5,
maxLength: maxReplyLength,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: 'Reply',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
IconButton.filled(
tooltip: 'Send',
onPressed: _sending ? null : _send,
icon: _sending
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
),
],
),
),
);
}
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.message});
final SupportMessage message;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final staff = message.isStaff;
final alignment = staff ? Alignment.centerRight : Alignment.centerLeft;
final color = staff
? scheme.primaryContainer
: scheme.surfaceContainerHighest;
final onColor = staff ? scheme.onPrimaryContainer : scheme.onSurface;
return Align(
alignment: alignment,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.78,
),
child: Card(
color: color,
elevation: 0,
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.authorName,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(color: onColor),
),
const SizedBox(height: 4),
Text(message.body, style: TextStyle(color: onColor)),
const SizedBox(height: 6),
Text(
formatTimestamp(message.createdAt),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: onColor.withValues(alpha: 0.7)),
),
],
),
),
),
),
);
}
}