# Flutter prompt: Kick Kings support staff app Paste everything below this line into the Flutter agent. Do not add Firebase project ids, API keys, or service-account JSON to the app repo. The human will place `google-services.json` (Android) and `GoogleService-Info.plist` (iOS) in the Flutter project after creating those apps in the Firebase console. --- Build a Flutter staff app for Kick Kings customer support. It is an inbox for people who already have an admin-panel account. Players do not use this app. Do not implement player login or the `/api/support/player/` routes. ## Stack - Flutter, null-safe Dart - `firebase_core` and `firebase_messaging` for push - `http` or `dio` for JSON - Persist the bearer token in secure storage (`flutter_secure_storage`) - Android and iOS The admin server base URL is a compile-time or settings value, for example `https://kickkings.playpoolstudios.com`. No trailing slash. Call it `baseUrl`. ## Auth `POST {baseUrl}/api/auth/login` Headers: `Content-Type: application/json` ```json { "username": "ada", "password": "secret" } ``` 200: ```json { "ok": true, "token": "", "account": { "id": "", "username": "ada", "isAdmin": false, "permissions": { "support": { "read": true, "write": true } } } } ``` `permissions` also includes the other admin pages. This app only cares about `support`. 401: `{ "error": "Invalid credentials" }` 429: `{ "error": "Too many failed login attempts. Try again later.", "retryAfterSeconds": 900 }` After login, if `account.isAdmin` is false and `account.permissions.support.read` is not true, show “This account does not have support access” and do not enter the inbox. `isAdmin: true` can read and write. Send the token on every later request: `Authorization: Bearer ` The token is also an HttpOnly cookie on web. Ignore cookies in Flutter. The token does not expire until the server secret changes; still offer logout. Logout: 1. `DELETE {baseUrl}/api/support/devices` with `{ "token": "" }` if you have one 2. Delete the bearer token locally 3. Return to the login screen Do not call `/api/auth/logout`. That only clears the browser cookie. ## Inbox API All staff routes require the bearer token. 401 means the token is invalid: return to login. 403 means the account cannot do that action. List: `GET {baseUrl}/api/support/tickets?status=open&category=bug` `status` is optional: `open` or `closed`. Omit it for both. `category` is optional. Slugs: | slug | label | | --- | --- | | bug | Report Bug | | cheating | Report cheating | | withdrawal | Request withdrawal | | crypto | Pay with crypto | | other | Other | 200: `{ "tickets": [ SupportTicket ] }` Open tickets are already sorted before closed ones, then by latest message. Thread: `GET {baseUrl}/api/support/tickets/{ticketId}` 200: ```json { "ticket": {}, "messages": [] } ``` Reply (requires support write, or admin): `POST {baseUrl}/api/support/tickets/{ticketId}/messages` ```json { "body": "We are looking into this." } ``` 201 returns the same thread shape. Body is plain text, 1 to 4000 characters after trim. Close or reopen (requires write): `POST {baseUrl}/api/support/tickets/{ticketId}/status` ```json { "status": "closed" } ``` `status` is `open` or `closed`. 200 returns the thread. Ticket: ```json { "id": "uuid", "userId": 42, "username": "playername", "category": "bug", "categoryLabel": "Report Bug", "status": "open", "createdAt": "2026-10-15T12:00:00.000Z", "updatedAt": "2026-10-15T12:00:00.000Z", "lastMessageAt": "2026-10-15T12:00:00.000Z", "lastMessagePreview": "The match froze" } ``` Message: ```json { "id": "uuid", "ticketId": "uuid", "authorKind": "player", "authorName": "playername", "body": "The match froze", "createdAt": "2026-10-15T12:00:00.000Z" } ``` `authorKind` is `player` or `staff`. Show player messages on the left and staff messages on the right. Use `categoryLabel` in the UI, not the slug, except when you need the slug for filters. Errors are `{ "error": "..." }` with 400, 401, 403, 404, or 503. ## Screens 1. Login: username, password, error text, submit. 2. Inbox: filter by status (open by default, plus closed and all) and by category. List shows category label, player username, status, and preview. Pull to refresh. 3. Thread: messages, reply box hidden when the account cannot write, and a Close / Reopen button hidden the same way. 4. Tapping a push notification opens that ticket (`ticketId` in the data payload). Poll the open list and the open thread every 5 seconds while the app is in the foreground, in addition to push. ## Push (FCM) Initialize Firebase with the local `google-services.json` / `GoogleService-Info.plist`. Do not hardcode a server key. On login, after notification permission: 1. `FirebaseMessaging.instance.getToken()` 2. `PUT {baseUrl}/api/support/devices` ```json { "token": "", "platform": "android" } ``` `platform` is `android` or `ios`. 200: `{ "ok": true }`. 3. Listen to `onTokenRefresh` and PUT again. Register a background handler with `FirebaseMessaging.onBackgroundMessage`. In the foreground, `FirebaseMessaging.onMessage` should refresh the inbox and, if the payload `ticketId` is the open thread, refresh that thread. Notification title is the category label. Data always includes `ticketId` and `category`. Request alert permission on iOS before `getToken`. On Android 13+, request `POST_NOTIFICATIONS`. If the user denies permission, the inbox still works; skip device registration. ## Out of scope - Player ticket creation - Any admin page other than support - Sending pushes from the phone (the server sends them) - Storing the Firebase service account in the app