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 createState() => _ThreadScreenState(); } class _ThreadScreenState extends State with RouteAware { final _replyController = TextEditingController(); final _scrollController = ScrollController(); InboxController? _inbox; RouteObserver>? _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) { 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 _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 _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 [] : 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 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)), ), ], ), ), ), ), ); } }