This commit is contained in:
2026-10-16 01:25:50 +05:30
commit c60ea2d370
89 changed files with 4661 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
import 'package:dio/dio.dart';
import '../config.dart';
import '../models/api_exception.dart';
import '../models/login_result.dart';
import '../models/support_message.dart';
import '../models/support_ticket.dart';
class SupportApi {
SupportApi({Dio? dio, String? origin, this.onUnauthorized})
: dio =
dio ??
Dio(
BaseOptions(
baseUrl: origin ?? baseUrl,
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
),
) {
this.dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
final token = authToken;
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) {
final apiError = _exceptionFor(error);
final sentAuth =
error.requestOptions.headers['Authorization'] != null;
if (apiError.isUnauthorized && sentAuth) {
onUnauthorized?.call();
}
handler.reject(
DioException(
requestOptions: error.requestOptions,
response: error.response,
type: error.type,
error: apiError,
message: apiError.message,
),
);
},
),
);
}
final Dio dio;
String? authToken;
void Function()? onUnauthorized;
Future<LoginResult> login(String username, String password) {
return _send(
dio.post<dynamic>(
'/api/auth/login',
data: {'username': username, 'password': password},
),
(data) {
if (data is! Map) {
throw const FormatException('Unexpected login payload');
}
return LoginResult.fromJson(Map<String, dynamic>.from(data));
},
);
}
Future<List<SupportTicket>> listTickets({String? status, String? category}) {
return _send(
dio.get<dynamic>(
'/api/support/tickets',
queryParameters: ticketListQuery(status: status, category: category),
),
(data) {
if (data is! Map || data['tickets'] is! List) {
throw const FormatException('Unexpected ticket list');
}
return [
for (final item in data['tickets'] as List)
if (item is Map)
SupportTicket.fromJson(Map<String, dynamic>.from(item)),
];
},
);
}
Future<SupportThread> getThread(String ticketId) {
return _send(dio.get<dynamic>('/api/support/tickets/$ticketId'), _thread);
}
Future<SupportThread> reply(String ticketId, String body) {
return _send(
dio.post<dynamic>(
'/api/support/tickets/$ticketId/messages',
data: {'body': body},
),
_thread,
);
}
Future<SupportThread> setStatus(String ticketId, String status) {
return _send(
dio.post<dynamic>(
'/api/support/tickets/$ticketId/status',
data: {'status': status},
),
_thread,
);
}
Future<void> registerDevice({
required String token,
required String platform,
}) {
return _send(
dio.put<dynamic>(
'/api/support/devices',
data: {'token': token, 'platform': platform},
),
(_) {},
);
}
Future<void> unregisterDevice(String token) {
return _send(
dio.delete<dynamic>('/api/support/devices', data: {'token': token}),
(_) {},
);
}
SupportThread _thread(Object? data) {
if (data is! Map) {
throw const FormatException('Unexpected thread payload');
}
return SupportThread.fromJson(Map<String, dynamic>.from(data));
}
Future<T> _send<T>(
Future<Response<dynamic>> call,
T Function(Object? data) parse,
) async {
try {
final response = await call;
return parse(response.data);
} on DioException catch (error) {
final wrapped = error.error;
if (wrapped is ApiException) {
throw wrapped;
}
throw _exceptionFor(error);
} on FormatException {
throw const ApiException(message: 'Unexpected response from the server.');
}
}
ApiException _exceptionFor(DioException error) {
final offline =
error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout;
return ApiException.fromBody(
statusCode: error.response?.statusCode,
body: error.response?.data,
fallback: offline
? 'Could not reach the server.'
: 'Something went wrong.',
);
}
}
+211
View File
@@ -0,0 +1,211 @@
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'api/support_api.dart';
import 'app_scope.dart';
import 'inbox.dart';
import 'push.dart';
import 'screens/inbox_screen.dart';
import 'screens/login_screen.dart';
import 'screens/thread_screen.dart';
import 'session.dart';
class KickKingsApp extends StatefulWidget {
const KickKingsApp({
super.key,
required this.firebaseReady,
this.session,
this.inbox,
this.push,
this.api,
});
final bool firebaseReady;
final SessionController? session;
final InboxController? inbox;
final PushService? push;
final SupportApi? api;
@override
State<KickKingsApp> createState() => _KickKingsAppState();
}
class _KickKingsAppState extends State<KickKingsApp> {
final _navigatorKey = GlobalKey<NavigatorState>();
final _messengerKey = GlobalKey<ScaffoldMessengerState>();
late final RouteObserver<ModalRoute<void>> _routeObserver;
late final SupportApi _api;
late final SessionController _session;
late final InboxController _inbox;
late final PushService _push;
var _wasSignedIn = false;
var _openScheduled = false;
@override
void initState() {
super.initState();
_routeObserver = RouteObserver<ModalRoute<void>>();
_api = widget.api ?? SupportApi();
_session =
widget.session ?? SessionController(store: SessionStore(), api: _api);
_inbox = widget.inbox ?? InboxController(api: _api);
_push = widget.push ?? PushService(firebaseReady: widget.firebaseReady);
_api.onUnauthorized = _session.handleUnauthorized;
_session.addListener(_onSession);
unawaited(_boot());
}
Future<void> _boot() async {
await _session.restore();
await _push.listen(
onOpenTicket: _rememberTicket,
onForegroundMessage: _onForegroundMessage,
onToken: _registerToken,
isSignedIn: () => _session.isSignedIn,
);
if (_session.isSignedIn) {
await _registerCurrentDevice();
}
_openPendingTicket();
}
void _onSession() {
final signedIn = _session.isSignedIn;
if (signedIn && !_wasSignedIn) {
unawaited(_registerCurrentDevice());
_openPendingTicket();
}
if (!signedIn && _wasSignedIn) {
_inbox.reset();
WidgetsBinding.instance.addPostFrameCallback((_) {
_navigatorKey.currentState?.popUntil((route) => route.isFirst);
});
}
_wasSignedIn = signedIn;
}
Future<void> _registerCurrentDevice() async {
if (!_session.isSignedIn) {
return;
}
try {
await _push.register(_registerToken);
} on Object {
// A denied permission or a missing Firebase config leaves the inbox usable.
}
}
Future<void> _registerToken(String token, String platform) async {
if (!_session.isSignedIn) {
return;
}
await _api.registerDevice(token: token, platform: platform);
await _session.saveFcmToken(token);
}
void _onForegroundMessage(RemoteMessage message) {
if (!_session.isSignedIn) {
return;
}
unawaited(_inbox.refreshList());
final ticketId = message.data['ticketId'];
if (ticketId is String &&
ticketId.isNotEmpty &&
ticketId == _inbox.activeTicketId) {
unawaited(_inbox.refreshThread());
}
final title = message.notification?.title;
if (title != null && title.isNotEmpty) {
_messengerKey.currentState?.showSnackBar(SnackBar(content: Text(title)));
}
}
void _rememberTicket(String ticketId) {
_session.pendingTicketId = ticketId;
_openPendingTicket();
}
void _openPendingTicket() {
if (_openScheduled) {
return;
}
final ticketId = _session.pendingTicketId;
if (ticketId == null || !_session.isSignedIn) {
return;
}
_openScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_openScheduled = false;
if (!mounted ||
_session.pendingTicketId != ticketId ||
!_session.isSignedIn) {
return;
}
if (_inbox.activeTicketId == ticketId) {
_session.pendingTicketId = null;
unawaited(_inbox.refreshThread());
return;
}
_session.pendingTicketId = null;
_navigatorKey.currentState?.push(
MaterialPageRoute<void>(
builder: (_) => ThreadScreen(ticketId: ticketId),
),
);
});
}
@override
void dispose() {
_session.removeListener(_onSession);
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScope(
session: _session,
inbox: _inbox,
routeObserver: _routeObserver,
child: MaterialApp(
title: 'Kick Kings',
navigatorKey: _navigatorKey,
scaffoldMessengerKey: _messengerKey,
navigatorObservers: [_routeObserver],
theme: _theme(Brightness.light),
darkTheme: _theme(Brightness.dark),
home: ListenableBuilder(
listenable: _session,
builder: (context, _) {
if (!_session.ready) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (!_session.isSignedIn) {
return const LoginScreen();
}
return const InboxScreen();
},
),
),
);
}
}
ThemeData _theme(Brightness brightness) {
final scheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF0F6B4C),
brightness: brightness,
);
return ThemeData(
colorScheme: scheme,
useMaterial3: true,
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
),
);
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'inbox.dart';
import 'session.dart';
class AppScope extends InheritedWidget {
const AppScope({
super.key,
required this.session,
required this.inbox,
required this.routeObserver,
required super.child,
});
final SessionController session;
final InboxController inbox;
final RouteObserver<ModalRoute<void>> routeObserver;
static AppScope of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope is missing');
return scope!;
}
@override
bool updateShouldNotify(AppScope oldWidget) {
return session != oldWidget.session ||
inbox != oldWidget.inbox ||
routeObserver != oldWidget.routeObserver;
}
}
+32
View File
@@ -0,0 +1,32 @@
const defaultBaseUrl = 'https://kickkings.playpoolstudios.com';
const supportCategoryLabels = <String, String>{
'bug': 'Report Bug',
'cheating': 'Report cheating',
'withdrawal': 'Request withdrawal',
'crypto': 'Pay with crypto',
'other': 'Other',
};
const _rawBaseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: defaultBaseUrl,
);
/// Admin server origin with no trailing slash.
final String baseUrl = normalizeBaseUrl(_rawBaseUrl);
String normalizeBaseUrl(String value) {
var url = value.trim();
while (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
return url;
}
Map<String, String> ticketListQuery({String? status, String? category}) {
return {
if (status != null && status.isNotEmpty) 'status': status,
if (category != null && category.isNotEmpty) 'category': category,
};
}
+21
View File
@@ -0,0 +1,21 @@
const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
String formatTimestamp(DateTime value) {
final local = value.toLocal();
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '${local.day} ${_months[local.month - 1]} $hour:$minute';
}
+201
View File
@@ -0,0 +1,201 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'api/support_api.dart';
import 'models/api_exception.dart';
import 'models/support_message.dart';
import 'models/support_ticket.dart';
enum TicketStatusFilter { open, closed, all }
class InboxController extends ChangeNotifier {
InboxController({required this.api});
final SupportApi api;
TicketStatusFilter statusFilter = TicketStatusFilter.open;
String? category;
List<SupportTicket> tickets = [];
bool loading = false;
String? error;
String? activeTicketId;
SupportTicket? threadTicket;
List<SupportMessage> messages = [];
bool threadLoading = false;
String? threadError;
int _listGeneration = 0;
int _threadGeneration = 0;
String? get statusQuery {
switch (statusFilter) {
case TicketStatusFilter.open:
return 'open';
case TicketStatusFilter.closed:
return 'closed';
case TicketStatusFilter.all:
return null;
}
}
Future<void> refreshList({bool showLoading = false}) async {
final generation = ++_listGeneration;
if (showLoading) {
loading = true;
error = null;
notifyListeners();
}
try {
final next = await api.listTickets(
status: statusQuery,
category: category,
);
if (generation != _listGeneration) {
return;
}
tickets = next;
error = null;
} on ApiException catch (caught) {
if (generation != _listGeneration || caught.isUnauthorized) {
return;
}
error = caught.message;
} finally {
if (generation == _listGeneration) {
loading = false;
notifyListeners();
}
}
}
Future<void> refreshThread({bool showLoading = false}) async {
final ticketId = activeTicketId;
if (ticketId == null) {
return;
}
final generation = ++_threadGeneration;
if (showLoading) {
threadLoading = true;
threadError = null;
notifyListeners();
}
try {
final thread = await api.getThread(ticketId);
if (generation != _threadGeneration || activeTicketId != ticketId) {
return;
}
_applyThread(thread);
threadError = null;
} on ApiException catch (caught) {
if (generation != _threadGeneration || caught.isUnauthorized) {
return;
}
threadError = caught.message;
} finally {
if (generation == _threadGeneration) {
threadLoading = false;
notifyListeners();
}
}
}
Future<String?> sendReply(String raw) async {
final ticketId = activeTicketId;
if (ticketId == null) {
return 'No ticket is open.';
}
final validation = validateReplyBody(raw);
if (validation != null) {
return validation;
}
final generation = ++_threadGeneration;
try {
final thread = await api.reply(ticketId, raw.trim());
if (generation != _threadGeneration || activeTicketId != ticketId) {
return null;
}
_applyThread(thread);
threadError = null;
notifyListeners();
return null;
} on ApiException catch (caught) {
if (caught.isUnauthorized) {
return null;
}
return caught.message;
}
}
Future<String?> setStatus(String status) async {
final ticketId = activeTicketId;
if (ticketId == null) {
return 'No ticket is open.';
}
final generation = ++_threadGeneration;
try {
final thread = await api.setStatus(ticketId, status);
if (generation != _threadGeneration || activeTicketId != ticketId) {
return null;
}
_applyThread(thread);
threadError = null;
notifyListeners();
return null;
} on ApiException catch (caught) {
if (caught.isUnauthorized) {
return null;
}
return caught.message;
}
}
void setStatusFilter(TicketStatusFilter value) {
if (statusFilter == value) {
return;
}
statusFilter = value;
tickets = [];
unawaited(refreshList(showLoading: true));
}
void setCategory(String? value) {
final next = (value == null || value.isEmpty) ? null : value;
if (category == next) {
return;
}
category = next;
tickets = [];
unawaited(refreshList(showLoading: true));
}
void reset() {
_listGeneration++;
_threadGeneration++;
statusFilter = TicketStatusFilter.open;
category = null;
tickets = [];
loading = false;
error = null;
activeTicketId = null;
threadTicket = null;
messages = [];
threadLoading = false;
threadError = null;
notifyListeners();
}
void _applyThread(SupportThread thread) {
threadTicket = thread.ticket;
messages = thread.messages;
final index = tickets.indexWhere((ticket) => ticket.id == thread.ticket.id);
if (index >= 0) {
tickets = [
for (var i = 0; i < tickets.length; i++)
if (i == index) thread.ticket else tickets[i],
];
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/widgets.dart';
import 'app.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage _) async {
try {
await Firebase.initializeApp();
} on Object {
// The system still shows the notification when Firebase is not configured.
}
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final firebaseReady = await _initializeFirebase();
runApp(KickKingsApp(firebaseReady: firebaseReady));
}
Future<bool> _initializeFirebase() async {
try {
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
return true;
} on Object {
return false;
}
}
+50
View File
@@ -0,0 +1,50 @@
class Account {
const Account({
required this.id,
required this.username,
required this.isAdmin,
required this.permissions,
});
final String id;
final String username;
final bool isAdmin;
final Map<String, dynamic> permissions;
bool get canReadSupport => isAdmin || _supportFlag('read');
bool get canWriteSupport => isAdmin || _supportFlag('write');
bool _supportFlag(String key) {
final support = permissions['support'];
if (support is Map) {
return support[key] == true;
}
return false;
}
factory Account.fromJson(Map<String, dynamic> json) {
return Account(
id: json['id']?.toString() ?? '',
username: json['username'] as String? ?? '',
isAdmin: json['isAdmin'] == true,
permissions: _stringKeyMap(json['permissions']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'isAdmin': isAdmin,
'permissions': permissions,
};
}
}
Map<String, dynamic> _stringKeyMap(Object? value) {
if (value is! Map) {
return {};
}
return value.map((key, item) => MapEntry(key.toString(), item));
}
+45
View File
@@ -0,0 +1,45 @@
class ApiException implements Exception {
const ApiException({
this.statusCode,
required this.message,
this.retryAfterSeconds,
});
final int? statusCode;
final String message;
final int? retryAfterSeconds;
bool get isUnauthorized => statusCode == 401;
@override
String toString() => message;
static ApiException fromBody({
int? statusCode,
Object? body,
String fallback = 'Something went wrong.',
}) {
var message = fallback;
int? retryAfterSeconds;
if (body is Map) {
final error = body['error'];
if (error is String && error.trim().isNotEmpty) {
message = error.trim();
}
final retry = body['retryAfterSeconds'];
if (retry is int) {
retryAfterSeconds = retry;
} else if (retry is num) {
retryAfterSeconds = retry.toInt();
}
}
if (retryAfterSeconds != null) {
message = '$message Retry after $retryAfterSeconds seconds.';
}
return ApiException(
statusCode: statusCode,
message: message,
retryAfterSeconds: retryAfterSeconds,
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'account.dart';
class LoginResult {
const LoginResult({required this.token, required this.account});
final String token;
final Account account;
factory LoginResult.fromJson(Map<String, dynamic> json) {
final token = json['token'];
final account = json['account'];
if (token is! String || token.isEmpty || account is! Map) {
throw const FormatException('Unexpected login payload');
}
return LoginResult(
token: token,
account: Account.fromJson(Map<String, dynamic>.from(account)),
);
}
}
+69
View File
@@ -0,0 +1,69 @@
import 'support_ticket.dart';
const maxReplyLength = 4000;
class SupportMessage {
const SupportMessage({
required this.id,
required this.ticketId,
required this.authorKind,
required this.authorName,
required this.body,
required this.createdAt,
});
final String id;
final String ticketId;
final String authorKind;
final String authorName;
final String body;
final DateTime createdAt;
bool get isStaff => authorKind == 'staff';
factory SupportMessage.fromJson(Map<String, dynamic> json) {
return SupportMessage(
id: json['id'] as String,
ticketId: json['ticketId'] as String,
authorKind: json['authorKind'] as String? ?? 'player',
authorName: json['authorName'] as String? ?? '',
body: json['body'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
class SupportThread {
const SupportThread({required this.ticket, required this.messages});
final SupportTicket ticket;
final List<SupportMessage> messages;
factory SupportThread.fromJson(Map<String, dynamic> json) {
final ticketJson = json['ticket'];
final messagesJson = json['messages'];
if (ticketJson is! Map || messagesJson is! List) {
throw const FormatException('Unexpected thread payload');
}
return SupportThread(
ticket: SupportTicket.fromJson(Map<String, dynamic>.from(ticketJson)),
messages: [
for (final item in messagesJson)
if (item is Map)
SupportMessage.fromJson(Map<String, dynamic>.from(item)),
],
);
}
}
/// Returns an error message when [raw] is not 1–4000 characters after trim.
String? validateReplyBody(String raw) {
final body = raw.trim();
if (body.isEmpty) {
return 'Enter a message.';
}
if (body.length > maxReplyLength) {
return 'Message must be $maxReplyLength characters or fewer.';
}
return null;
}
+47
View File
@@ -0,0 +1,47 @@
class SupportTicket {
const SupportTicket({
required this.id,
required this.userId,
required this.username,
required this.category,
required this.categoryLabel,
required this.status,
required this.createdAt,
required this.updatedAt,
required this.lastMessageAt,
required this.lastMessagePreview,
});
final String id;
final int userId;
final String username;
final String category;
final String categoryLabel;
final String status;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime lastMessageAt;
final String lastMessagePreview;
bool get isClosed => status == 'closed';
String get displayCategory =>
categoryLabel.isNotEmpty ? categoryLabel : category;
String get statusLabel => isClosed ? 'Closed' : 'Open';
factory SupportTicket.fromJson(Map<String, dynamic> json) {
return SupportTicket(
id: json['id'] as String,
userId: (json['userId'] as num).toInt(),
username: json['username'] as String? ?? '',
category: json['category'] as String? ?? '',
categoryLabel: json['categoryLabel'] as String? ?? '',
status: json['status'] as String? ?? 'open',
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
lastMessageAt: DateTime.parse(json['lastMessageAt'] as String),
lastMessagePreview: json['lastMessagePreview'] as String? ?? '',
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'dart:async';
import 'package:flutter/material.dart';
const pollInterval = Duration(seconds: 5);
/// Calls [onPoll] every five seconds while the app is resumed.
class ForegroundPoll extends StatefulWidget {
const ForegroundPoll({super.key, required this.onPoll, required this.child});
final Future<void> Function() onPoll;
final Widget child;
@override
State<ForegroundPoll> createState() => _ForegroundPollState();
}
class _ForegroundPollState extends State<ForegroundPoll>
with WidgetsBindingObserver {
Timer? _timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_start();
}
@override
void dispose() {
_stop();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
_start();
unawaited(widget.onPoll());
case AppLifecycleState.paused:
case AppLifecycleState.hidden:
case AppLifecycleState.detached:
_stop();
case AppLifecycleState.inactive:
break;
}
}
void _start() {
_timer ??= Timer.periodic(pollInterval, (_) {
unawaited(widget.onPoll());
});
}
void _stop() {
_timer?.cancel();
_timer = null;
}
@override
Widget build(BuildContext context) => widget.child;
}
+102
View File
@@ -0,0 +1,102 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
typedef FcmTokenCallback = Future<void> Function(String token, String platform);
class PushService {
PushService({required this.firebaseReady});
final bool firebaseReady;
final _subscriptions = <StreamSubscription<dynamic>>[];
bool _listening = false;
Future<void> listen({
required void Function(String ticketId) onOpenTicket,
required void Function(RemoteMessage message) onForegroundMessage,
required FcmTokenCallback onToken,
required bool Function() isSignedIn,
}) async {
if (!firebaseReady || _listening) {
return;
}
_listening = true;
_subscriptions
..add(
FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
try {
if (!isSignedIn()) {
return;
}
final settings = await FirebaseMessaging.instance
.getNotificationSettings();
if (!_allowsNotifications(settings.authorizationStatus)) {
return;
}
await onToken(token, _platform);
} on Object {
// Keep the previous registration when a token refresh cannot be saved.
}
}),
)
..add(FirebaseMessaging.onMessage.listen(onForegroundMessage))
..add(
FirebaseMessaging.onMessageOpenedApp.listen((message) {
final ticketId = _ticketId(message);
if (ticketId != null) {
onOpenTicket(ticketId);
}
}),
);
try {
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) {
final ticketId = _ticketId(initial);
if (ticketId != null) {
onOpenTicket(ticketId);
}
}
} on Object {
// Opening from a notification can wait until the next tap.
}
}
Future<void> register(FcmTokenCallback onToken) async {
if (!firebaseReady) {
return;
}
try {
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (!_allowsNotifications(settings.authorizationStatus)) {
return;
}
final token = await FirebaseMessaging.instance.getToken();
if (token == null || token.isEmpty) {
return;
}
await onToken(token, _platform);
} on Object {
// Denied permission or a missing Firebase file should not block the inbox.
}
}
String? _ticketId(RemoteMessage message) {
final value = message.data['ticketId'];
if (value is String && value.isNotEmpty) {
return value;
}
return null;
}
bool _allowsNotifications(AuthorizationStatus status) {
return status == AuthorizationStatus.authorized ||
status == AuthorizationStatus.provisional;
}
String get _platform => Platform.isIOS ? 'ios' : 'android';
}
+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)),
),
],
),
),
),
),
);
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'api/support_api.dart';
import 'models/account.dart';
import 'models/api_exception.dart';
class SessionStore {
SessionStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
static const tokenKey = 'auth_token';
static const accountKey = 'account_json';
static const fcmTokenKey = 'fcm_token';
final FlutterSecureStorage _storage;
Future<String?> readToken() => _storage.read(key: tokenKey);
Future<Account?> readAccount() async {
final raw = await _storage.read(key: accountKey);
if (raw == null || raw.isEmpty) {
return null;
}
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return null;
}
return Account.fromJson(Map<String, dynamic>.from(decoded));
}
Future<String?> readFcmToken() => _storage.read(key: fcmTokenKey);
Future<void> writeSession(String token, Account account) async {
await _storage.write(key: tokenKey, value: token);
await _storage.write(key: accountKey, value: jsonEncode(account.toJson()));
}
Future<void> writeFcmToken(String token) {
return _storage.write(key: fcmTokenKey, value: token);
}
Future<void> clear() async {
await _storage.delete(key: tokenKey);
await _storage.delete(key: accountKey);
await _storage.delete(key: fcmTokenKey);
}
}
class SessionController extends ChangeNotifier {
SessionController({required this.store, required this.api});
final SessionStore store;
final SupportApi api;
Account? account;
String? token;
String? fcmToken;
String? pendingTicketId;
bool ready = false;
bool get isSignedIn =>
token != null && account != null && account!.canReadSupport;
Future<void> restore() async {
try {
token = await store.readToken();
account = await store.readAccount();
fcmToken = await store.readFcmToken();
if (token == null || account == null || !account!.canReadSupport) {
token = null;
account = null;
}
api.authToken = token;
} finally {
ready = true;
notifyListeners();
}
}
/// Returns an error message, or null when the inbox can open.
Future<String?> signIn(String username, String password) async {
try {
final result = await api.login(username.trim(), password);
if (!result.account.canReadSupport) {
return 'This account does not have support access';
}
await store.writeSession(result.token, result.account);
token = result.token;
account = result.account;
api.authToken = token;
notifyListeners();
return null;
} on ApiException catch (error) {
return error.message;
} on Object {
return 'Could not save this session.';
}
}
Future<void> saveFcmToken(String value) async {
fcmToken = value;
await store.writeFcmToken(value);
}
Future<void> signOut() async {
final deviceToken = fcmToken;
if (deviceToken != null && deviceToken.isNotEmpty && token != null) {
try {
await api.unregisterDevice(deviceToken);
} on ApiException {
// Local logout still proceeds when the device delete fails.
}
}
token = null;
account = null;
fcmToken = null;
api.authToken = null;
await store.clear();
notifyListeners();
}
void handleUnauthorized() {
if (token == null && account == null) {
return;
}
token = null;
account = null;
fcmToken = null;
api.authToken = null;
notifyListeners();
unawaited(store.clear());
}
}