init
This commit is contained in:
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user