This commit is contained in:
2026-10-16 01:25:50 +05:30
commit c60ea2d370
89 changed files with 4661 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../app_scope.dart';
import '../config.dart';
import '../format.dart';
import '../inbox.dart';
import '../polling.dart';
import 'thread_screen.dart';
class InboxScreen extends StatefulWidget {
const InboxScreen({super.key});
@override
State<InboxScreen> createState() => _InboxScreenState();
}
class _InboxScreenState extends State<InboxScreen> {
var _started = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_started) {
return;
}
_started = true;
unawaited(AppScope.of(context).inbox.refreshList(showLoading: true));
}
Future<void> _logout() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Log out?'),
content: const Text(
'This device will stop receiving support notifications.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Log out'),
),
],
);
},
);
if (confirmed != true || !mounted) {
return;
}
final session = AppScope.of(context).session;
await session.signOut();
}
@override
Widget build(BuildContext context) {
final inbox = AppScope.of(context).inbox;
return ForegroundPoll(
onPoll: () => inbox.refreshList(),
child: Scaffold(
appBar: AppBar(
title: const Text('Inbox'),
actions: [
IconButton(
tooltip: 'Log out',
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
),
body: Column(
children: [
_Filters(inbox: inbox),
const Divider(height: 1),
Expanded(
child: ListenableBuilder(
listenable: inbox,
builder: (context, _) {
return RefreshIndicator(
onRefresh: () => inbox.refreshList(),
child: _TicketList(inbox: inbox),
);
},
),
),
],
),
),
);
}
}
class _Filters extends StatelessWidget {
const _Filters({required this.inbox});
final InboxController inbox;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: inbox,
builder: (context, _) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<TicketStatusFilter>(
segments: const [
ButtonSegment(
value: TicketStatusFilter.open,
label: Text('Open'),
),
ButtonSegment(
value: TicketStatusFilter.closed,
label: Text('Closed'),
),
ButtonSegment(
value: TicketStatusFilter.all,
label: Text('All'),
),
],
selected: {inbox.statusFilter},
onSelectionChanged: (selection) {
inbox.setStatusFilter(selection.first);
},
),
const SizedBox(height: 12),
DropdownButton<String?>(
isExpanded: true,
value: inbox.category,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('All categories'),
),
for (final entry in supportCategoryLabels.entries)
DropdownMenuItem<String?>(
value: entry.key,
child: Text(entry.value),
),
],
onChanged: inbox.setCategory,
),
],
),
);
},
);
}
}
class _TicketList extends StatelessWidget {
const _TicketList({required this.inbox});
final InboxController inbox;
@override
Widget build(BuildContext context) {
if (inbox.loading && inbox.tickets.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (inbox.error != null && inbox.tickets.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(inbox.error!, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => inbox.refreshList(showLoading: true),
child: const Text('Try again'),
),
],
),
),
],
);
}
if (inbox.tickets.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
Padding(
padding: EdgeInsets.all(24),
child: Text('No tickets', textAlign: TextAlign.center),
),
],
);
}
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: inbox.tickets.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final ticket = inbox.tickets[index];
return ListTile(
title: Text(ticket.displayCategory),
subtitle: Text(
'${ticket.username}\n${ticket.lastMessagePreview}',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
isThreeLine: true,
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_StatusChip(closed: ticket.isClosed, label: ticket.statusLabel),
const SizedBox(height: 6),
Text(
formatTimestamp(ticket.lastMessageAt),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadScreen(ticketId: ticket.id),
),
);
},
);
},
);
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.closed, required this.label});
final bool closed;
final String label;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: closed
? scheme.surfaceContainerHighest
: scheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Text(label, style: Theme.of(context).textTheme.labelSmall),
),
);
}
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import '../app_scope.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
var _submitting = false;
String? _error;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
final username = _usernameController.text.trim();
final password = _passwordController.text;
if (username.isEmpty || password.isEmpty) {
setState(() => _error = 'Enter your username and password.');
return;
}
setState(() {
_submitting = true;
_error = null;
});
final session = AppScope.of(context).session;
final error = await session.signIn(username, password);
if (!mounted) {
return;
}
setState(() {
_submitting = false;
_error = error;
});
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(Icons.support_agent, size: 48, color: scheme.primary),
const SizedBox(height: 12),
Text(
'Kick Kings',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'Support staff',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: scheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextField(
controller: _usernameController,
autofillHints: const [AutofillHints.username],
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
autofillHints: const [AutofillHints.password],
obscureText: true,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _submit(),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(_error!, style: TextStyle(color: scheme.error)),
],
const SizedBox(height: 20),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign in'),
),
],
),
),
),
),
),
);
}
}
+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)),
),
],
),
),
),
),
);
}
}